feat(mobile): align settings with PWA mobile flow
This commit is contained in:
1 parent
354eaed1e4
commit
39b2017f9c
3 files changed
+352
-301
No files matched your search
@@ -33,6 +33,7 @@ const mobileOnlyTranslations = new Map<string, string>([
|
||||
["笔记标题", "Note title"],
|
||||
["笔记标签", "Note tags"],
|
||||
["选择笔记本", "Choose notebook"],
|
||||
["刷新 Token", "Refresh tokens"],
|
||||
["没有正文预览", "No content preview"],
|
||||
["原生运行时启动", "Native runtime startup"],
|
||||
["启动至 JS 执行", "Launch to JavaScript execution"],
|
||||
|
||||
@@ -1,39 +1,34 @@
|
||||
import { ApiRequestError } from "@edgeever/client";
|
||||
import type { AuthUser } from "@edgeever/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Plus, UserRound, Users, X } from "../components/icons";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { KeyRound, Plus, UserRound } from "../components/icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Alert, Pressable, Text, TextInput } from "../components/LocalizedText";
|
||||
import { resolveMobileThemeStyles, useMobileTheme, type MobileResolvedTheme } from "../lib/mobile-theme";
|
||||
import { useSession } from "../lib/session";
|
||||
|
||||
type Section = "password" | "users";
|
||||
export type AccountSecuritySection = "password" | "users";
|
||||
|
||||
export const AccountSecurityModal = ({
|
||||
export const AccountSecurityPanel = ({
|
||||
active,
|
||||
currentUser,
|
||||
initialSection = "password",
|
||||
onClose,
|
||||
visible,
|
||||
section,
|
||||
}: {
|
||||
active: boolean;
|
||||
currentUser: AuthUser | null;
|
||||
initialSection?: Section;
|
||||
onClose: () => void;
|
||||
visible: boolean;
|
||||
section: AccountSecuritySection;
|
||||
}) => {
|
||||
const { resolvedTheme } = useMobileTheme();
|
||||
refreshAccountSecurityThemeStyles(resolvedTheme);
|
||||
const { client } = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
const [section, setSection] = useState<Section>("password");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
@@ -50,7 +45,7 @@ export const AccountSecurityModal = ({
|
||||
if (!client) throw new Error("Client is not ready");
|
||||
return client.listUsers();
|
||||
},
|
||||
enabled: Boolean(client && visible && currentUser?.role === "owner" && section === "users"),
|
||||
enabled: Boolean(client && active && currentUser?.role === "owner" && section === "users"),
|
||||
});
|
||||
|
||||
const passwordMutation = useMutation({
|
||||
@@ -92,140 +87,150 @@ export const AccountSecurityModal = ({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSection(initialSection);
|
||||
} else {
|
||||
setSection("password");
|
||||
if (!active) {
|
||||
passwordMutation.reset();
|
||||
createUserMutation.reset();
|
||||
updateUserMutation.reset();
|
||||
}
|
||||
}, [initialSection, visible]);
|
||||
}, [active]);
|
||||
|
||||
const errorMessage = (error: unknown) => {
|
||||
if (error instanceof ApiRequestError && error.code === "invalid_current_password") return "当前密码不正确";
|
||||
if (error instanceof ApiRequestError && error.code === "username_exists") return "该用户名已经存在";
|
||||
return error instanceof Error ? error.message : "操作失败,请稍后再试";
|
||||
};
|
||||
const resetUser = usersQuery.data?.users.find((user) => user.id === resetUserId) ?? null;
|
||||
const closeCreateDialog = () => {
|
||||
setCreateOpen(false);
|
||||
setUsername("");
|
||||
setDisplayName("");
|
||||
setMemberPassword("");
|
||||
createUserMutation.reset();
|
||||
};
|
||||
const closeResetDialog = () => {
|
||||
setResetUserId(null);
|
||||
setResetPasswordValue("");
|
||||
updateUserMutation.reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal animationType="slide" onRequestClose={onClose} presentationStyle="pageSheet" visible={visible}>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.header}>
|
||||
<Pressable accessibilityLabel="关闭" onPress={onClose} style={styles.iconButton}>
|
||||
<X color="#17211a" size={20} />
|
||||
</Pressable>
|
||||
<Text style={styles.title}>账户与安全</Text>
|
||||
<View style={styles.iconPlaceholder} />
|
||||
return section === "password" ? (
|
||||
<View style={styles.content}>
|
||||
<View style={styles.hero}>
|
||||
<KeyRound color="#15803d" size={22} />
|
||||
<View style={styles.flex}>
|
||||
<Text style={styles.cardTitle}>修改密码</Text>
|
||||
<Text style={styles.help}>修改后会保留当前设备登录,并退出其他设备上的登录会话。</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Field label="当前密码" onChangeText={setCurrentPassword} value={currentPassword} />
|
||||
<Field label="新密码" onChangeText={setNewPassword} value={newPassword} />
|
||||
<Field label="确认新密码" onChangeText={setConfirmPassword} value={confirmPassword} />
|
||||
{passwordMutation.error ? <Text style={styles.error}>{errorMessage(passwordMutation.error)}</Text> : null}
|
||||
<PrimaryButton
|
||||
disabled={passwordMutation.isPending || !currentPassword || !newPassword || !confirmPassword}
|
||||
label={passwordMutation.isPending ? "正在修改…" : "修改密码"}
|
||||
onPress={() => passwordMutation.mutate()}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.content}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<View style={styles.flex}>
|
||||
<Text style={styles.cardTitle}>成员管理</Text>
|
||||
<Text style={styles.help}>为家人或团队成员创建独立的个人笔记空间。实例不开放公开注册。</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => setCreateOpen(true)} style={styles.addButton}>
|
||||
<Plus color="#ffffff" size={16} />
|
||||
<Text style={styles.addButtonText}>添加成员</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{currentUser?.role === "owner" ? (
|
||||
<View style={styles.tabs}>
|
||||
<Tab active={section === "password"} icon={<KeyRound color={section === "password" ? "#ffffff" : "#475569"} size={16} />} label="修改密码" onPress={() => setSection("password")} />
|
||||
<Tab active={section === "users"} icon={<Users color={section === "users" ? "#ffffff" : "#475569"} size={16} />} label="用户管理" onPress={() => setSection("users")} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{section === "password" ? (
|
||||
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.hero}>
|
||||
<KeyRound color="#15803d" size={22} />
|
||||
<View style={styles.flex}>
|
||||
<Text style={styles.cardTitle}>修改登录密码</Text>
|
||||
<Text style={styles.help}>密码更新后,现有 App 会话继续有效。</Text>
|
||||
</View>
|
||||
{usersQuery.isLoading ? <ActivityIndicator color="#15803d" /> : null}
|
||||
{usersQuery.error ? <Text style={styles.error}>{errorMessage(usersQuery.error)}</Text> : null}
|
||||
{usersQuery.data?.users.map((user) => (
|
||||
<View key={user.id} style={styles.userBlock}>
|
||||
<View style={styles.userCard}>
|
||||
<View style={styles.userIcon}><UserRound color="#15803d" size={18} /></View>
|
||||
<View style={styles.flex}>
|
||||
<Text style={styles.userName}>{user.displayName || user.username}</Text>
|
||||
<Text style={styles.help}>@{user.username} · {user.role === "owner" ? "实例管理员" : user.isDisabled ? "已停用" : "已启用"}</Text>
|
||||
<Pressable onPress={() => { setResetUserId(user.id); setResetPasswordValue(""); }}><Text style={styles.link}>重置密码</Text></Pressable>
|
||||
</View>
|
||||
<Field label="当前密码" onChangeText={setCurrentPassword} value={currentPassword} />
|
||||
<Field label="新密码" onChangeText={setNewPassword} value={newPassword} />
|
||||
<Field label="确认新密码" onChangeText={setConfirmPassword} value={confirmPassword} />
|
||||
{passwordMutation.error ? <Text style={styles.error}>{errorMessage(passwordMutation.error)}</Text> : null}
|
||||
<PrimaryButton
|
||||
disabled={passwordMutation.isPending || !currentPassword || !newPassword || !confirmPassword}
|
||||
label={passwordMutation.isPending ? "正在修改…" : "修改密码"}
|
||||
onPress={() => passwordMutation.mutate()}
|
||||
/>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.sectionHeader}>
|
||||
<View style={styles.flex}>
|
||||
<Text style={styles.cardTitle}>实例用户</Text>
|
||||
<Text style={styles.help}>每个用户拥有隔离的笔记空间。</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => setCreateOpen((value) => !value)} style={styles.addButton}>
|
||||
<Plus color="#ffffff" size={16} />
|
||||
<Text style={styles.addButtonText}>添加</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{createOpen ? (
|
||||
<View style={styles.createCard}>
|
||||
<Field label="用户名" onChangeText={setUsername} secure={false} value={username} />
|
||||
<Field label="显示名称(可选)" onChangeText={setDisplayName} secure={false} value={displayName} />
|
||||
<Field label="初始密码" onChangeText={setMemberPassword} value={memberPassword} />
|
||||
{createUserMutation.error ? <Text style={styles.error}>{errorMessage(createUserMutation.error)}</Text> : null}
|
||||
<PrimaryButton
|
||||
disabled={createUserMutation.isPending || !username.trim() || memberPassword.length < 8}
|
||||
label={createUserMutation.isPending ? "正在创建…" : "创建用户"}
|
||||
onPress={() => createUserMutation.mutate()}
|
||||
/>
|
||||
</View>
|
||||
{user.role !== "owner" ? (
|
||||
<Switch
|
||||
disabled={updateUserMutation.isPending}
|
||||
onValueChange={(enabled) => updateUserMutation.mutate({ userId: user.id, input: { isDisabled: !enabled } })}
|
||||
value={!user.isDisabled}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{usersQuery.isLoading ? <ActivityIndicator color="#15803d" /> : null}
|
||||
{usersQuery.error ? <Text style={styles.error}>{errorMessage(usersQuery.error)}</Text> : null}
|
||||
{usersQuery.data?.users.map((user) => (
|
||||
<View key={user.id} style={styles.userBlock}>
|
||||
<View style={styles.userCard}>
|
||||
<View style={styles.userIcon}><UserRound color="#15803d" size={18} /></View>
|
||||
<View style={styles.flex}>
|
||||
<Text style={styles.userName}>{user.displayName || user.username}</Text>
|
||||
<Text style={styles.help}>@{user.username} · {user.role === "owner" ? "所有者" : user.isDisabled ? "已停用" : "已启用"}</Text>
|
||||
<Pressable onPress={() => { setResetUserId(user.id); setResetPasswordValue(""); }}><Text style={styles.link}>重置密码</Text></Pressable>
|
||||
</View>
|
||||
{user.role !== "owner" ? (
|
||||
<Switch
|
||||
disabled={updateUserMutation.isPending}
|
||||
onValueChange={(enabled) => updateUserMutation.mutate({ userId: user.id, input: { isDisabled: !enabled } })}
|
||||
value={!user.isDisabled}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{resetUserId === user.id ? (
|
||||
<View style={styles.resetCard}>
|
||||
<Field label={`@${user.username} 的新密码`} onChangeText={setResetPasswordValue} value={resetPasswordValue} />
|
||||
<View style={styles.resetActions}>
|
||||
<Pressable onPress={() => setResetUserId(null)}><Text style={styles.cancelText}>取消</Text></Pressable>
|
||||
<PrimaryButton
|
||||
disabled={updateUserMutation.isPending || resetPasswordValue.length < 8}
|
||||
label={updateUserMutation.isPending ? "正在重置…" : "确认重置"}
|
||||
onPress={() => updateUserMutation.mutate(
|
||||
{ userId: user.id, input: { password: resetPasswordValue } },
|
||||
{ onSuccess: () => { setResetUserId(null); setResetPasswordValue(""); } }
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
<Modal animationType="fade" onRequestClose={closeCreateDialog} transparent visible={createOpen}>
|
||||
<Pressable onPress={closeCreateDialog} style={styles.dialogBackdrop}>
|
||||
<Pressable style={styles.dialogCard}>
|
||||
<Text style={styles.cardTitle}>添加新成员</Text>
|
||||
<Text style={styles.help}>新账号会获得完全独立的笔记、附件、回收站和 MCP Token。</Text>
|
||||
<Field label="用户名" onChangeText={setUsername} placeholder="例如:xiaoming" secure={false} value={username} />
|
||||
<Field label="显示名称" onChangeText={setDisplayName} placeholder="选填,例如:小明" secure={false} value={displayName} />
|
||||
<Field help="成员首次登录后可以在个人中心修改密码。" label="初始密码" onChangeText={setMemberPassword} placeholder="请输入至少 8 位密码" value={memberPassword} />
|
||||
{createUserMutation.error ? <Text style={styles.error}>{errorMessage(createUserMutation.error)}</Text> : null}
|
||||
<View style={styles.dialogActions}>
|
||||
<Pressable onPress={closeCreateDialog} style={styles.cancelButton}><Text style={styles.cancelText}>取消</Text></Pressable>
|
||||
<PrimaryButton
|
||||
disabled={createUserMutation.isPending || !username.trim() || memberPassword.length < 8}
|
||||
label={createUserMutation.isPending ? "正在创建..." : "添加成员"}
|
||||
onPress={() => createUserMutation.mutate()}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
<Modal animationType="fade" onRequestClose={closeResetDialog} transparent visible={Boolean(resetUser)}>
|
||||
<Pressable onPress={closeResetDialog} style={styles.dialogBackdrop}>
|
||||
<Pressable style={styles.dialogCard}>
|
||||
<Text style={styles.cardTitle}>{`重置 ${resetUser?.username ?? ""} 的密码`}</Text>
|
||||
<Text style={styles.help}>重置后,该账号在其他设备上的登录会话将失效。</Text>
|
||||
<Field label="新密码(至少 8 位)" onChangeText={setResetPasswordValue} placeholder="请输入至少 8 位密码" value={resetPasswordValue} />
|
||||
<View style={styles.dialogActions}>
|
||||
<Pressable onPress={closeResetDialog} style={styles.cancelButton}><Text style={styles.cancelText}>取消</Text></Pressable>
|
||||
<PrimaryButton
|
||||
disabled={updateUserMutation.isPending || resetPasswordValue.length < 8 || !resetUser}
|
||||
label={updateUserMutation.isPending ? "正在重置..." : "重置密码"}
|
||||
onPress={() => resetUser && updateUserMutation.mutate(
|
||||
{ userId: resetUser.id, input: { password: resetPasswordValue } },
|
||||
{ onSuccess: closeResetDialog },
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const Tab = ({ active, icon, label, onPress }: { active: boolean; icon: ReactNode; label: string; onPress: () => void }) => (
|
||||
<Pressable onPress={onPress} style={[styles.tab, active && styles.tabActive]}>
|
||||
{icon}<Text style={[styles.tabText, active && styles.tabTextActive]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const Field = ({ label, onChangeText, secure = true, value }: { label: string; onChangeText: (value: string) => void; secure?: boolean; value: string }) => (
|
||||
const Field = ({ help, label, onChangeText, placeholder, secure = true, value }: {
|
||||
help?: string;
|
||||
label: string;
|
||||
onChangeText: (value: string) => void;
|
||||
placeholder?: string;
|
||||
secure?: boolean;
|
||||
value: string;
|
||||
}) => (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<TextInput autoCapitalize="none" autoCorrect={false} onChangeText={onChangeText} secureTextEntry={secure} style={styles.input} value={value} />
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#94a3b8"
|
||||
secureTextEntry={secure}
|
||||
style={styles.input}
|
||||
value={value}
|
||||
/>
|
||||
{help ? <Text style={styles.help}>{help}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -236,16 +241,6 @@ const PrimaryButton = ({ disabled, label, onPress }: { disabled: boolean; label:
|
||||
);
|
||||
|
||||
const baseAccountSecurityStyles = StyleSheet.create({
|
||||
safeArea: { backgroundColor: "#f7faf7", flex: 1 },
|
||||
header: { alignItems: "center", borderBottomColor: "#dce7dd", borderBottomWidth: 1, flexDirection: "row", justifyContent: "space-between", padding: 16 },
|
||||
title: { color: "#17211a", fontSize: 17, fontWeight: "800" },
|
||||
iconButton: { alignItems: "center", height: 40, justifyContent: "center", width: 40 },
|
||||
iconPlaceholder: { width: 40 },
|
||||
tabs: { flexDirection: "row", gap: 8, padding: 12 },
|
||||
tab: { alignItems: "center", backgroundColor: "#ffffff", borderColor: "#dce7dd", borderRadius: 10, borderWidth: 1, flex: 1, flexDirection: "row", gap: 7, justifyContent: "center", padding: 11 },
|
||||
tabActive: { backgroundColor: "#15803d", borderColor: "#15803d" },
|
||||
tabText: { color: "#475569", fontSize: 13, fontWeight: "700" },
|
||||
tabTextActive: { color: "#ffffff" },
|
||||
content: { gap: 14, padding: 16, paddingBottom: 40 },
|
||||
hero: { alignItems: "center", backgroundColor: "#ecfdf3", borderRadius: 14, flexDirection: "row", gap: 12, padding: 16 },
|
||||
sectionHeader: { alignItems: "center", flexDirection: "row", gap: 12 },
|
||||
@@ -255,17 +250,18 @@ const baseAccountSecurityStyles = StyleSheet.create({
|
||||
field: { gap: 7 },
|
||||
label: { color: "#334155", fontSize: 13, fontWeight: "700" },
|
||||
input: { backgroundColor: "#ffffff", borderColor: "#cad8cc", borderRadius: 10, borderWidth: 1, color: "#17211a", minHeight: 48, paddingHorizontal: 13 },
|
||||
primaryButton: { alignItems: "center", backgroundColor: "#15803d", borderRadius: 10, minHeight: 48, justifyContent: "center" },
|
||||
primaryButton: { alignItems: "center", backgroundColor: "#15803d", borderRadius: 10, minHeight: 48, justifyContent: "center", paddingHorizontal: 16 },
|
||||
primaryButtonText: { color: "#ffffff", fontSize: 14, fontWeight: "800" },
|
||||
disabled: { opacity: 0.45 },
|
||||
error: { color: "#be123c", fontSize: 13, lineHeight: 19 },
|
||||
addButton: { alignItems: "center", backgroundColor: "#15803d", borderRadius: 9, flexDirection: "row", gap: 6, paddingHorizontal: 12, paddingVertical: 9 },
|
||||
addButtonText: { color: "#ffffff", fontSize: 13, fontWeight: "800" },
|
||||
createCard: { backgroundColor: "#ffffff", borderColor: "#dce7dd", borderRadius: 14, borderWidth: 1, gap: 13, padding: 14 },
|
||||
userCard: { alignItems: "center", backgroundColor: "#ffffff", borderColor: "#dce7dd", borderRadius: 14, borderWidth: 1, flexDirection: "row", gap: 12, padding: 14 },
|
||||
userBlock: { gap: 8 },
|
||||
resetCard: { backgroundColor: "#f8fafc", borderColor: "#dce7dd", borderRadius: 12, borderWidth: 1, gap: 10, padding: 12 },
|
||||
resetActions: { alignItems: "center", flexDirection: "row", gap: 14, justifyContent: "flex-end" },
|
||||
dialogBackdrop: { alignItems: "center", backgroundColor: "rgba(15, 23, 42, 0.48)", flex: 1, justifyContent: "center", padding: 20 },
|
||||
dialogCard: { backgroundColor: "#ffffff", borderRadius: 14, gap: 14, maxWidth: 520, padding: 18, width: "100%" },
|
||||
dialogActions: { alignItems: "center", flexDirection: "row", gap: 10, justifyContent: "flex-end" },
|
||||
cancelButton: { alignItems: "center", borderColor: "#dce7dd", borderRadius: 10, borderWidth: 1, justifyContent: "center", minHeight: 48, paddingHorizontal: 16 },
|
||||
cancelText: { color: "#64748b", fontSize: 13, fontWeight: "700" },
|
||||
userIcon: { alignItems: "center", backgroundColor: "#ecfdf3", borderRadius: 20, height: 40, justifyContent: "center", width: 40 },
|
||||
userName: { color: "#17211a", fontSize: 14, fontWeight: "800" },
|
||||
|
||||
@@ -76,7 +76,7 @@ import {
|
||||
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Alert, Pressable, Text, TextInput } from "../components/LocalizedText";
|
||||
import Markdown from "react-native-markdown-display";
|
||||
import { createExcerpt, docToMarkdown, docToText, getNotebookDescendantIds, markdownToDoc, type ApiToken, type MemoDetail, type MemoRevision, type MemoSummary, type Notebook, type ResourceListItem, type TagSummary, type TiptapDoc } from "@edgeever/shared";
|
||||
import { createExcerpt, docToMarkdown, docToText, getNotebookDescendantIds, markdownToDoc, type ApiToken, type AuthUser, type MemoDetail, type MemoRevision, type MemoSummary, type Notebook, type ResourceListItem, type TagSummary, type TiptapDoc } from "@edgeever/shared";
|
||||
import { clearMobileMemoDraft, clearMobileNewMemoDraft, readMobileMemoDraft, readMobileNewMemoDraft, writeMobileMemoDraft, writeMobileNewMemoDraft } from "../lib/mobile-drafts";
|
||||
import {
|
||||
readMobileImageCompressionEnabled,
|
||||
@@ -116,8 +116,8 @@ import {
|
||||
syncMobileLocalMirror,
|
||||
upsertLocalMemo,
|
||||
} from "../lib/local-mirror";
|
||||
import { AccountSecurityModal } from "./AccountSecurityModal";
|
||||
import { getStartupPerformanceItems, markStartup, recordEditorStartup } from "../lib/startup-performance";
|
||||
import { AccountSecurityPanel } from "./AccountSecurityModal";
|
||||
import { markStartup, recordEditorStartup } from "../lib/startup-performance";
|
||||
import LocalTiptapEditor, { type LocalTiptapEditorRef } from "../components/LocalTiptapEditor";
|
||||
import { resolveMobileThemeStyles, useMobileTheme, type MobileResolvedTheme } from "../lib/mobile-theme";
|
||||
|
||||
@@ -339,10 +339,6 @@ export const WorkspaceScreen = () => {
|
||||
const [tagsManagerOpen, setTagsManagerOpen] = useState(false);
|
||||
const [resourcesOpen, setResourcesOpen] = useState(false);
|
||||
const [apiTokensOpen, setApiTokensOpen] = useState(false);
|
||||
const [advancedPlayOpen, setAdvancedPlayOpen] = useState(false);
|
||||
const [systemInfoOpen, setSystemInfoOpen] = useState(false);
|
||||
const [accountSecurityOpen, setAccountSecurityOpen] = useState(false);
|
||||
const [accountSecuritySection, setAccountSecuritySection] = useState<"password" | "users">("password");
|
||||
const [syncQueueOpen, setSyncQueueOpen] = useState(false);
|
||||
const [revisionMemo, setRevisionMemo] = useState<MemoDetail | null>(null);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
@@ -1152,18 +1148,9 @@ export const WorkspaceScreen = () => {
|
||||
{activeView === "account" ? <AccountView instance={session?.baseUrl ?? ""} userName={session?.user?.username ?? "owner"} onSignOut={signOut} /> : null}
|
||||
{activeView === "settings" ? (
|
||||
<SettingsView
|
||||
onOpenAdvancedPlay={() => setAdvancedPlayOpen(true)}
|
||||
baseUrl={session?.baseUrl ?? ""}
|
||||
currentUser={session?.user ?? null}
|
||||
onClose={() => setActiveView("notes")}
|
||||
onOpenPassword={() => {
|
||||
setAccountSecuritySection("password");
|
||||
setAccountSecurityOpen(true);
|
||||
}}
|
||||
onOpenUserManagement={() => {
|
||||
setAccountSecuritySection("users");
|
||||
setAccountSecurityOpen(true);
|
||||
}}
|
||||
onOpenApiTokens={() => setApiTokensOpen(true)}
|
||||
onOpenSystemInfo={() => setSystemInfoOpen(true)}
|
||||
localePreference={localePreference}
|
||||
onLocalePreferenceChange={handleLocalePreferenceChange}
|
||||
imageCompressionEnabled={imageCompressionEnabled}
|
||||
@@ -1233,7 +1220,6 @@ export const WorkspaceScreen = () => {
|
||||
{tagsManagerOpen ? <TagsManagerModal onClose={() => setTagsManagerOpen(false)} visible /> : null}
|
||||
{resourcesOpen ? <ResourcesModal activeMemo={selectedMemo} imageCompressionEnabled={imageCompressionEnabled} onClose={() => setResourcesOpen(false)} visible /> : null}
|
||||
{apiTokensOpen ? <ApiTokensModal baseUrl={session?.baseUrl ?? ""} onClose={() => setApiTokensOpen(false)} visible /> : null}
|
||||
{advancedPlayOpen ? <AdvancedPlayModal onClose={() => setAdvancedPlayOpen(false)} visible /> : null}
|
||||
{syncQueueOpen ? <SyncQueueModal
|
||||
onClose={() => setSyncQueueOpen(false)}
|
||||
onChanged={async () => setSyncQueueSummary(await loadMobileSyncQueueSummary(syncQueueScope))}
|
||||
@@ -1241,8 +1227,6 @@ export const WorkspaceScreen = () => {
|
||||
scope={syncQueueScope}
|
||||
visible
|
||||
/> : null}
|
||||
{systemInfoOpen ? <SystemInfoModal baseUrl={session?.baseUrl ?? ""} memoCount={memoCount} notebookCount={notebooks.length} onClose={() => setSystemInfoOpen(false)} visible /> : null}
|
||||
{accountSecurityOpen ? <AccountSecurityModal currentUser={session?.user ?? null} initialSection={accountSecuritySection} onClose={() => setAccountSecurityOpen(false)} visible /> : null}
|
||||
{revisionMemo ? <RevisionHistoryModal
|
||||
memo={revisionMemo}
|
||||
onClose={() => setRevisionMemo(null)}
|
||||
@@ -1383,9 +1367,10 @@ export const WorkspaceScreen = () => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View
|
||||
style={[styles.bottomNav, { height: 52 + safeAreaInsets.bottom, paddingBottom: safeAreaInsets.bottom }]}
|
||||
>
|
||||
{activeView !== "settings" ? (
|
||||
<View
|
||||
style={[styles.bottomNav, { height: 52 + safeAreaInsets.bottom, paddingBottom: safeAreaInsets.bottom }]}
|
||||
>
|
||||
<BottomNavItem
|
||||
active={activeView === "notes"}
|
||||
icon={<Home color={activeView === "notes" ? "#0f172a" : "#64748b"} size={20} />}
|
||||
@@ -1402,12 +1387,13 @@ export const WorkspaceScreen = () => {
|
||||
<Plus color={canCreateMemo ? "#ffffff" : "#e2e8f0"} size={28} />
|
||||
</Pressable>
|
||||
<BottomNavItem
|
||||
active={activeView === "settings"}
|
||||
icon={<UserRound color={activeView === "settings" ? "#0f172a" : "#64748b"} size={20} />}
|
||||
active={false}
|
||||
icon={<UserRound color="#64748b" size={20} />}
|
||||
label="我的"
|
||||
onPress={() => setActiveView("settings")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
@@ -1945,30 +1931,24 @@ const AccountInfoCopyRow = ({ instance, userName }: { instance: string; userName
|
||||
};
|
||||
|
||||
const SettingsView = ({
|
||||
baseUrl,
|
||||
currentUser,
|
||||
imageCompressionEnabled,
|
||||
isOwner,
|
||||
localePreference,
|
||||
onClose,
|
||||
onImageCompressionChange,
|
||||
onLocalePreferenceChange,
|
||||
onOpenAdvancedPlay,
|
||||
onOpenApiTokens,
|
||||
onOpenPassword,
|
||||
onOpenSystemInfo,
|
||||
onOpenUserManagement,
|
||||
onSignOut,
|
||||
}: {
|
||||
baseUrl: string;
|
||||
currentUser: AuthUser | null;
|
||||
imageCompressionEnabled: boolean;
|
||||
isOwner: boolean;
|
||||
localePreference: MobileLocaleMode;
|
||||
onClose: () => void;
|
||||
onImageCompressionChange: (enabled: boolean) => void;
|
||||
onLocalePreferenceChange: (locale: MobileLocaleMode) => void;
|
||||
onOpenAdvancedPlay: () => void;
|
||||
onOpenApiTokens: () => void;
|
||||
onOpenPassword: () => void;
|
||||
onOpenSystemInfo: () => void;
|
||||
onOpenUserManagement: () => void;
|
||||
onSignOut: () => void;
|
||||
}) => {
|
||||
const { resolvedTheme, toggleTheme } = useMobileTheme();
|
||||
@@ -1981,6 +1961,19 @@ const SettingsView = ({
|
||||
];
|
||||
const title = tabs.find((tab) => tab.key === activeTab)?.label ?? "我的";
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||
if (activeTab) {
|
||||
setActiveTab(null);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [activeTab, onClose]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (activeTab === "general") {
|
||||
return (
|
||||
@@ -2014,29 +2007,30 @@ const SettingsView = ({
|
||||
</View>
|
||||
</View>
|
||||
</SettingsGroup>
|
||||
<SettingsLinkGroup
|
||||
description="查看当前应用版本、构建标识和运行环境。"
|
||||
icon={<HardDrive color="#047857" size={16} />}
|
||||
onPress={onOpenSystemInfo}
|
||||
title="系统信息"
|
||||
/>
|
||||
<SystemInfoCard />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (activeTab === "users") {
|
||||
return <SettingsLinkGroup description="为家人或团队成员创建独立的个人笔记空间。实例不开放公开注册。" icon={<Users color="#047857" size={16} />} onPress={onOpenUserManagement} title="成员管理" />;
|
||||
return (
|
||||
<View style={styles.settingsGroup}>
|
||||
<AccountSecurityPanel active currentUser={currentUser} section="users" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (activeTab === "ai") {
|
||||
return (
|
||||
<View style={styles.settingsDetailList}>
|
||||
<SettingsLinkGroup description="搭配 AI Agent 的进阶玩法。" icon={<Sparkles color="#047857" size={16} />} onPress={onOpenAdvancedPlay} title="进阶玩法" />
|
||||
<SettingsLinkGroup description="让 AI Agent 可以读取和整理你的笔记。" icon={<KeyRound color="#047857" size={16} />} onPress={onOpenApiTokens} title="生成 MCP 配置" />
|
||||
<AdvancedPlayCard />
|
||||
<ApiTokensContent active baseUrl={baseUrl} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.settingsDetailList}>
|
||||
<SettingsLinkGroup description="修改后会保留当前设备登录,并退出其他设备上的登录会话。" icon={<ShieldCheck color="#047857" size={16} />} onPress={onOpenPassword} title="修改密码" />
|
||||
<View style={styles.settingsGroup}>
|
||||
<AccountSecurityPanel active currentUser={currentUser} section="password" />
|
||||
</View>
|
||||
<Pressable onPress={onSignOut} style={styles.settingsLogoutButton}>
|
||||
<LogOut color="#ffffff" size={17} />
|
||||
<Text style={styles.settingsLogoutText}>退出登录</Text>
|
||||
@@ -2090,18 +2084,6 @@ const SettingsGroup = ({ children, icon, title }: { children: ReactNode; icon?:
|
||||
</View>
|
||||
);
|
||||
|
||||
const SettingsLinkGroup = ({ description, icon, onPress, title }: { description: string; icon: ReactNode; onPress: () => void; title: string }) => (
|
||||
<Pressable onPress={onPress} style={styles.settingsGroup}>
|
||||
<View style={styles.settingsLinkHeader}>
|
||||
<View style={styles.settingsLinkCopy}>
|
||||
<View style={styles.settingsGroupHeader}>{icon}<Text style={styles.settingsGroupTitle}>{title}</Text></View>
|
||||
<Text style={styles.settingsLinkDescription}>{description}</Text>
|
||||
</View>
|
||||
<ChevronRight color="#94a3b8" size={17} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const CreateMemoModal = ({
|
||||
activeNotebookId,
|
||||
baseUrl,
|
||||
@@ -3082,14 +3064,18 @@ const TagsManagerModal = ({ onClose, visible }: { onClose: () => void; visible:
|
||||
);
|
||||
};
|
||||
|
||||
const ApiTokensModal = ({ baseUrl, onClose, visible }: { baseUrl: string; onClose: () => void; visible: boolean }) => {
|
||||
const ApiTokensContent = ({ active, baseUrl }: { active: boolean; baseUrl: string }) => {
|
||||
const { client } = useSession();
|
||||
const { translate } = useMobileLocale();
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState("MCP Agent");
|
||||
const [nameDefaultsSynced, setNameDefaultsSynced] = useState(false);
|
||||
const [selectedScopes, setSelectedScopes] = useState<Set<string>>(() => new Set(ALL_TOKEN_SCOPES));
|
||||
const [scopeDefaultsSynced, setScopeDefaultsSynced] = useState(false);
|
||||
const [scopesExpanded, setScopesExpanded] = useState(false);
|
||||
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
||||
const [copiedValue, setCopiedValue] = useState<string | null>(null);
|
||||
const [exampleOpen, setExampleOpen] = useState(false);
|
||||
|
||||
const tokensQuery = useQuery({
|
||||
queryKey: ["mobile", "api-tokens"],
|
||||
@@ -3100,12 +3086,24 @@ const ApiTokensModal = ({ baseUrl, onClose, visible }: { baseUrl: string; onClos
|
||||
|
||||
return client.listApiTokens();
|
||||
},
|
||||
enabled: Boolean(client && visible),
|
||||
enabled: Boolean(client && active),
|
||||
});
|
||||
|
||||
const availableScopes = tokensQuery.data?.availableScopes ?? ALL_TOKEN_SCOPES;
|
||||
const tokens = tokensQuery.data?.apiTokens ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (nameDefaultsSynced || !tokensQuery.data) {
|
||||
return;
|
||||
}
|
||||
const highestTokenNumber = tokens.reduce((highest, token) => {
|
||||
const match = token.name.match(/^MCP Token (\d+)$/i);
|
||||
return match ? Math.max(highest, Number(match[1])) : highest;
|
||||
}, 0);
|
||||
setName(`MCP Token ${highestTokenNumber + 1}`);
|
||||
setNameDefaultsSynced(true);
|
||||
}, [nameDefaultsSynced, tokens, tokensQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scopeDefaultsSynced || !tokensQuery.data?.availableScopes) {
|
||||
return;
|
||||
@@ -3189,21 +3187,22 @@ const ApiTokensModal = ({ baseUrl, onClose, visible }: { baseUrl: string; onClos
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal animationType="slide" onRequestClose={onClose} presentationStyle="pageSheet" visible={visible}>
|
||||
<SafeAreaView style={styles.modalSafeArea}>
|
||||
<View style={styles.modalHeader}>
|
||||
<IconButton onPress={onClose}>
|
||||
<X color="#0f172a" size={20} />
|
||||
</IconButton>
|
||||
<Text style={styles.modalTitle}>MCP 与 API Token</Text>
|
||||
<IconButton onPress={() => tokensQuery.refetch()}>
|
||||
{tokensQuery.isFetching ? <ActivityIndicator color="#0f172a" /> : <RefreshCw color="#0f172a" size={18} />}
|
||||
</IconButton>
|
||||
<View style={styles.settingsGroup}>
|
||||
<View style={styles.settingsInlineCardHeader}>
|
||||
<View style={styles.settingsLinkCopy}>
|
||||
<View style={styles.settingsGroupHeader}>
|
||||
<KeyRound color="#047857" size={16} />
|
||||
<Text style={styles.settingsGroupTitle}>生成 MCP 配置</Text>
|
||||
</View>
|
||||
<Text style={styles.settingsLinkDescription}>让 AI Agent 可以读取和整理你的笔记。</Text>
|
||||
</View>
|
||||
<IconButton accessibilityLabel="刷新 Token" onPress={() => tokensQuery.refetch()}>
|
||||
{tokensQuery.isFetching ? <ActivityIndicator color="#0f172a" /> : <RefreshCw color="#0f172a" size={18} />}
|
||||
</IconButton>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.editorForm}>
|
||||
<Text style={styles.sectionSubtitle}>创建远程 MCP 或 API 调用使用的 Bearer Token。</Text>
|
||||
<ActionButton label={copiedValue === "example-config" ? "已复制示例" : "复制示例配置"} onPress={() => copyText(buildMcpRemoteConfig(baseUrl, "YOUR_TOKEN_HERE"), "example-config")}>
|
||||
<View style={styles.settingsAccordionContent}>
|
||||
<ActionButton label="使用示例" onPress={() => setExampleOpen(true)}>
|
||||
<Copy color="#0f172a" size={16} />
|
||||
</ActionButton>
|
||||
|
||||
@@ -3211,7 +3210,7 @@ const ApiTokensModal = ({ baseUrl, onClose, visible }: { baseUrl: string; onClos
|
||||
<View style={styles.createdTokenPanel}>
|
||||
<View style={styles.assetsSummary}>
|
||||
<ShieldCheck color="#047857" size={18} />
|
||||
<Text style={styles.assetsSummaryText}>Token 已创建</Text>
|
||||
<Text style={styles.assetsSummaryText}>API Token 已成功生成</Text>
|
||||
</View>
|
||||
<Text selectable numberOfLines={2} style={styles.tokenValueText}>
|
||||
{createdToken}
|
||||
@@ -3224,25 +3223,30 @@ const ApiTokensModal = ({ baseUrl, onClose, visible }: { baseUrl: string; onClos
|
||||
<KeyRound color="#0f172a" size={16} />
|
||||
</ActionButton>
|
||||
</View>
|
||||
<Text style={styles.assetsHint}>请立即保存。离开后服务端不会再次显示完整 Token。</Text>
|
||||
<Text style={styles.assetsHint}>安全提醒:此 Token 属于高危凭证,请勿对外泄露。</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Text style={styles.label}>Token 名称</Text>
|
||||
<TextInput onChangeText={setName} placeholder="MCP Agent" placeholderTextColor="#94a3b8" style={styles.titleInput} value={name} />
|
||||
<TextInput onChangeText={setName} placeholder="Token 名称,例如:Codex 或 Claude Code" placeholderTextColor="#94a3b8" style={styles.titleInput} value={name} />
|
||||
|
||||
<Text style={styles.label}>权限范围</Text>
|
||||
<View style={styles.scopeGrid}>
|
||||
<Pressable accessibilityState={{ expanded: scopesExpanded }} onPress={() => setScopesExpanded((value) => !value)} style={styles.tokenScopeHeader}>
|
||||
<View>
|
||||
<Text style={styles.settingsRowTitle}>Token 权限范围</Text>
|
||||
<Text style={styles.settingsRowDescription}>{translate(`已选择 ${selectedScopes.size}/${availableScopes.length}`)}</Text>
|
||||
</View>
|
||||
{scopesExpanded ? <ChevronDown color="#94a3b8" size={17} /> : <ChevronRight color="#94a3b8" size={17} />}
|
||||
</Pressable>
|
||||
{scopesExpanded ? <View style={styles.scopeGrid}>
|
||||
{availableScopes.map((scope) => {
|
||||
const selected = selectedScopes.has(scope);
|
||||
|
||||
return (
|
||||
<Pressable key={scope} onPress={() => toggleScope(scope)} style={[styles.scopePill, selected && styles.scopePillActive]}>
|
||||
<Text style={[styles.scopePillText, selected && styles.scopePillTextActive]}>{getTokenScopeLabel(scope)}</Text>
|
||||
<Text style={[styles.scopePillText, selected && styles.scopePillTextActive]}>{translate(getTokenScopeLabel(scope))}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View> : null}
|
||||
|
||||
<Pressable
|
||||
disabled={createTokenMutation.isPending}
|
||||
@@ -3250,13 +3254,13 @@ const ApiTokensModal = ({ baseUrl, onClose, visible }: { baseUrl: string; onClos
|
||||
style={[styles.uploadButton, createTokenMutation.isPending && styles.buttonDisabled]}
|
||||
>
|
||||
{createTokenMutation.isPending ? <ActivityIndicator color="#ffffff" /> : <Plus color="#ffffff" size={18} />}
|
||||
<Text style={styles.uploadButtonText}>{createTokenMutation.isPending ? "创建中" : "创建 Token"}</Text>
|
||||
<Text style={styles.uploadButtonText}>{createTokenMutation.isPending ? "正在创建..." : "生成 Token"}</Text>
|
||||
</Pressable>
|
||||
{createTokenMutation.error ? (
|
||||
<Text style={styles.errorText}>{createTokenMutation.error instanceof Error ? createTokenMutation.error.message : "创建失败"}</Text>
|
||||
) : null}
|
||||
|
||||
<Text style={styles.label}>活跃 Token</Text>
|
||||
<Text style={styles.label}>Token 列表</Text>
|
||||
{tokensQuery.isLoading ? (
|
||||
<View style={styles.centerInline}>
|
||||
<ActivityIndicator color="#0f172a" />
|
||||
@@ -3264,7 +3268,7 @@ const ApiTokensModal = ({ baseUrl, onClose, visible }: { baseUrl: string; onClos
|
||||
) : tokens.length === 0 ? (
|
||||
<View style={styles.emptyInlinePanel}>
|
||||
<KeyRound color="#94a3b8" size={28} />
|
||||
<Text style={styles.mutedText}>暂无 Token</Text>
|
||||
<Text style={styles.mutedText}>暂无 API Token</Text>
|
||||
</View>
|
||||
) : (
|
||||
tokens.map((token) => (
|
||||
@@ -3282,12 +3286,44 @@ const ApiTokensModal = ({ baseUrl, onClose, visible }: { baseUrl: string; onClos
|
||||
{revokeTokenMutation.error ? (
|
||||
<Text style={styles.errorText}>{revokeTokenMutation.error instanceof Error ? revokeTokenMutation.error.message : "撤销失败"}</Text>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</View>
|
||||
<Modal animationType="fade" onRequestClose={() => setExampleOpen(false)} transparent visible={exampleOpen}>
|
||||
<Pressable onPress={() => setExampleOpen(false)} style={styles.settingsDialogBackdrop}>
|
||||
<Pressable style={styles.settingsExampleDialog}>
|
||||
<View style={styles.promptCardHeader}>
|
||||
<Text style={styles.settingsGroupTitle}>Remote MCP 示例</Text>
|
||||
<IconButton accessibilityLabel="关闭" onPress={() => setExampleOpen(false)}>
|
||||
<X color="#0f172a" size={18} />
|
||||
</IconButton>
|
||||
</View>
|
||||
<Text selectable style={styles.tokenValueText}>{buildMcpRemoteConfig(baseUrl, "YOUR_TOKEN_HERE")}</Text>
|
||||
<ActionButton label={copiedValue === "example-config" ? "已复制" : "复制示例"} onPress={() => copyText(buildMcpRemoteConfig(baseUrl, "YOUR_TOKEN_HERE"), "example-config")}>
|
||||
<Copy color="#0f172a" size={16} />
|
||||
</ActionButton>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const ApiTokensModal = ({ baseUrl, onClose, visible }: { baseUrl: string; onClose: () => void; visible: boolean }) => (
|
||||
<Modal animationType="slide" onRequestClose={onClose} presentationStyle="pageSheet" visible={visible}>
|
||||
<SafeAreaView style={styles.modalSafeArea}>
|
||||
<View style={styles.modalHeader}>
|
||||
<IconButton accessibilityLabel="关闭" onPress={onClose}>
|
||||
<X color="#0f172a" size={20} />
|
||||
</IconButton>
|
||||
<Text style={styles.modalTitle}>MCP 与 API Token</Text>
|
||||
<View style={styles.iconButtonPlaceholder} />
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.editorForm}>
|
||||
<ApiTokensContent active={visible} baseUrl={baseUrl} />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const ApiTokenRow = ({
|
||||
baseUrl,
|
||||
copiedValue,
|
||||
@@ -3303,6 +3339,7 @@ const ApiTokenRow = ({
|
||||
onDelete: (token: ApiToken) => void;
|
||||
token: ApiToken;
|
||||
}) => {
|
||||
const { resolvedLocale, translate } = useMobileLocale();
|
||||
const tokenCopyLabel = `token-${token.id}`;
|
||||
const configCopyLabel = `config-${token.id}`;
|
||||
const canCopyToken = Boolean(token.token && !token.isRevoked);
|
||||
@@ -3315,18 +3352,18 @@ const ApiTokenRow = ({
|
||||
{token.name}
|
||||
</Text>
|
||||
<Text numberOfLines={2} style={styles.panelLabel}>
|
||||
{token.scopes.map(getTokenScopeLabel).join("、") || "无权限"}
|
||||
{token.scopes.map((scope) => translate(getTokenScopeLabel(scope))).join(resolvedLocale === "en-US" ? ", " : "、") || translate("无权限")}
|
||||
</Text>
|
||||
<Text style={styles.panelLabel}>{token.lastUsedAt ? `最近使用 ${formatDate(token.lastUsedAt, localePreference)}` : "从未使用"}</Text>
|
||||
<Text style={styles.panelLabel}>{token.lastUsedAt ? `上次调用时间:${formatDate(token.lastUsedAt, localePreference)}` : "从未被调用"}</Text>
|
||||
</View>
|
||||
<View style={styles.apiTokenActions}>
|
||||
<IconButton disabled={!canCopyToken} onPress={() => token.token && onCopy(token.token, tokenCopyLabel)}>
|
||||
<IconButton accessibilityLabel={canCopyToken ? "复制 Token" : "旧 Token 无法复制"} disabled={!canCopyToken} onPress={() => token.token && onCopy(token.token, tokenCopyLabel)}>
|
||||
{copiedValue === tokenCopyLabel ? <ShieldCheck color="#047857" size={18} /> : <Copy color={canCopyToken ? "#0f172a" : "#cbd5e1"} size={18} />}
|
||||
</IconButton>
|
||||
<IconButton disabled={!canCopyToken} onPress={() => token.token && onCopy(buildMcpRemoteConfig(baseUrl, token.token), configCopyLabel)}>
|
||||
<IconButton accessibilityLabel={canCopyToken ? "复制完整 MCP 配置" : "旧 Token 无法复制 MCP 配置"} disabled={!canCopyToken} onPress={() => token.token && onCopy(buildMcpRemoteConfig(baseUrl, token.token), configCopyLabel)}>
|
||||
{copiedValue === configCopyLabel ? <ShieldCheck color="#047857" size={18} /> : <KeyRound color={canCopyToken ? "#0f172a" : "#cbd5e1"} size={18} />}
|
||||
</IconButton>
|
||||
<IconButton onPress={() => !isDeleting && onDelete(token)}>
|
||||
<IconButton accessibilityLabel="删除 Token" onPress={() => !isDeleting && onDelete(token)}>
|
||||
<Trash2 color="#b91c1c" size={18} />
|
||||
</IconButton>
|
||||
</View>
|
||||
@@ -3334,8 +3371,9 @@ const ApiTokenRow = ({
|
||||
);
|
||||
};
|
||||
|
||||
const AdvancedPlayModal = ({ onClose, visible }: { onClose: () => void; visible: boolean }) => {
|
||||
const AdvancedPlayCard = () => {
|
||||
const [copiedPromptId, setCopiedPromptId] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const localePreference = useMobileLocalePreference();
|
||||
const advancedPrompts = useMemo(() => getMobileAdvancedPrompts(localePreference), [localePreference]);
|
||||
|
||||
@@ -3346,17 +3384,19 @@ const AdvancedPlayModal = ({ onClose, visible }: { onClose: () => void; visible:
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal animationType="slide" onRequestClose={onClose} presentationStyle="pageSheet" visible={visible}>
|
||||
<SafeAreaView style={styles.modalSafeArea}>
|
||||
<View style={styles.modalHeader}>
|
||||
<IconButton onPress={onClose}>
|
||||
<X color="#0f172a" size={20} />
|
||||
</IconButton>
|
||||
<Text style={styles.modalTitle}>进阶玩法</Text>
|
||||
<View style={styles.iconButtonPlaceholder} />
|
||||
<View style={styles.settingsGroup}>
|
||||
<Pressable accessibilityState={{ expanded }} onPress={() => setExpanded((value) => !value)} style={styles.settingsAccordionHeader}>
|
||||
<View style={styles.settingsLinkCopy}>
|
||||
<View style={styles.settingsGroupHeader}>
|
||||
<Sparkles color="#047857" size={16} />
|
||||
<Text style={styles.settingsGroupTitle}>进阶玩法</Text>
|
||||
</View>
|
||||
<Text style={styles.settingsLinkDescription}>搭配 AI Agent 的进阶玩法。</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.editorForm}>
|
||||
{expanded ? <ChevronDown color="#94a3b8" size={17} /> : <ChevronRight color="#94a3b8" size={17} />}
|
||||
</Pressable>
|
||||
{expanded ? (
|
||||
<View style={styles.settingsAccordionContent}>
|
||||
<View style={styles.guideHero}>
|
||||
<Sparkles color="#047857" size={24} />
|
||||
<Text style={styles.panelValue}>搭配 AI Agent 的进阶工作流</Text>
|
||||
@@ -3376,9 +3416,9 @@ const AdvancedPlayModal = ({ onClose, visible }: { onClose: () => void; visible:
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3497,46 +3537,20 @@ const SyncQueueModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
const SystemInfoModal = ({
|
||||
baseUrl,
|
||||
memoCount,
|
||||
notebookCount,
|
||||
onClose,
|
||||
visible,
|
||||
}: {
|
||||
baseUrl: string;
|
||||
memoCount: number;
|
||||
notebookCount: number;
|
||||
onClose: () => void;
|
||||
visible: boolean;
|
||||
}) => {
|
||||
const SystemInfoCard = () => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const localePreference = useMobileLocalePreference();
|
||||
const resolvedLocale = getResolvedMobileLocale(localePreference);
|
||||
const copy = getMobileSystemInfoText(localePreference);
|
||||
const expoConfig = Constants.expoConfig;
|
||||
const expoExtra = expoConfig?.extra as { eas?: { projectId?: string } } | undefined;
|
||||
const nativeIdentifier = Platform.select({
|
||||
android: expoConfig?.android?.package,
|
||||
ios: expoConfig?.ios?.bundleIdentifier,
|
||||
default: expoConfig?.slug,
|
||||
});
|
||||
const infoItems = [
|
||||
{ label: copy.version, value: `v${MOBILE_APP_VERSION}` },
|
||||
{ label: copy.build, value: __DEV__ ? "development" : "production" },
|
||||
{ label: copy.platform, value: Platform.OS },
|
||||
{ label: copy.platformVersion, value: String(Platform.Version) },
|
||||
{ label: copy.installMode, value: formatExecutionEnvironment(Constants.executionEnvironment, localePreference) },
|
||||
{ label: copy.appIdentifier, value: nativeIdentifier || copy.unknown },
|
||||
{ label: "Expo Owner", value: expoConfig?.owner || copy.notSet },
|
||||
{ label: "Expo Slug", value: expoConfig?.slug || copy.unknown },
|
||||
{ label: "EAS Project ID", value: Constants.easConfig?.projectId || expoExtra?.eas?.projectId || copy.disconnected },
|
||||
{ label: copy.instanceUrl, value: baseUrl || copy.disconnected },
|
||||
{ label: copy.notebookCount, value: String(notebookCount) },
|
||||
{ label: copy.memoCount, value: String(memoCount) },
|
||||
{ label: copy.timeZone, value: Intl.DateTimeFormat().resolvedOptions().timeZone || copy.unknown },
|
||||
{ label: copy.language, value: localePreference === "system" ? `${resolvedLocale} (${copy.followSystem})` : resolvedLocale },
|
||||
...getStartupPerformanceItems(),
|
||||
{ label: copy.timeZone, value: Intl.DateTimeFormat().resolvedOptions().timeZone || copy.unknown },
|
||||
{ label: copy.installMode, value: formatExecutionEnvironment(Constants.executionEnvironment, localePreference) },
|
||||
];
|
||||
|
||||
const copySystemInfo = async () => {
|
||||
@@ -3546,26 +3560,28 @@ const SystemInfoModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal animationType="slide" onRequestClose={onClose} presentationStyle="pageSheet" visible={visible}>
|
||||
<SafeAreaView style={styles.modalSafeArea}>
|
||||
<View style={styles.modalHeader}>
|
||||
<IconButton onPress={onClose}>
|
||||
<X color="#0f172a" size={20} />
|
||||
</IconButton>
|
||||
<Text style={styles.modalTitle}>{copy.title}</Text>
|
||||
<IconButton onPress={copySystemInfo}>
|
||||
{copied ? <ShieldCheck color="#047857" size={18} /> : <Copy color="#0f172a" size={18} />}
|
||||
</IconButton>
|
||||
<View style={styles.settingsGroup}>
|
||||
<Pressable accessibilityState={{ expanded }} onPress={() => setExpanded((value) => !value)} style={styles.settingsAccordionHeader}>
|
||||
<View style={styles.settingsLinkCopy}>
|
||||
<View style={styles.settingsGroupHeader}>
|
||||
<HardDrive color="#047857" size={16} />
|
||||
<Text style={styles.settingsGroupTitle}>{copy.title}</Text>
|
||||
</View>
|
||||
<Text style={styles.settingsLinkDescription}>{copy.description}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.editorForm}>
|
||||
<Text style={styles.sectionSubtitle}>{copy.description}</Text>
|
||||
{expanded ? <ChevronDown color="#94a3b8" size={17} /> : <ChevronRight color="#94a3b8" size={17} />}
|
||||
</Pressable>
|
||||
{expanded ? (
|
||||
<View style={styles.settingsAccordionContent}>
|
||||
<ActionButton label={copied ? "已复制" : "复制信息"} onPress={copySystemInfo}>
|
||||
{copied ? <ShieldCheck color="#047857" size={16} /> : <Copy color="#0f172a" size={16} />}
|
||||
</ActionButton>
|
||||
{infoItems.map((item) => (
|
||||
<PanelRow key={item.label} label={item.label} value={item.value} />
|
||||
))}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6365,13 +6381,13 @@ const toCompressedImageFilename = (filename: string) => {
|
||||
const getTokenScopeLabel = (scope: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
"read:notebooks": "读取笔记本",
|
||||
"write:notebooks": "写入笔记本",
|
||||
"write:notebooks": "创建与修改笔记本",
|
||||
"read:memos": "读取笔记",
|
||||
"write:memos": "写入笔记",
|
||||
"read:resources": "读取资源",
|
||||
"write:resources": "写入资源",
|
||||
"write:memos": "创建与修改笔记",
|
||||
"read:resources": "读取附件资源",
|
||||
"write:resources": "上传与修改附件",
|
||||
"read:tags": "读取标签",
|
||||
"write:tags": "写入标签",
|
||||
"write:tags": "创建与修改标签",
|
||||
};
|
||||
|
||||
return labels[scope] ?? scope;
|
||||
@@ -6728,12 +6744,39 @@ const baseWorkspaceStyles = StyleSheet.create({
|
||||
lineHeight: 17,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
settingsLinkHeader: {
|
||||
settingsAccordionHeader: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingRight: 16,
|
||||
},
|
||||
settingsInlineCardHeader: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingRight: 16,
|
||||
},
|
||||
settingsAccordionContent: {
|
||||
borderTopColor: "#f1f5f9",
|
||||
borderTopWidth: 1,
|
||||
gap: 10,
|
||||
padding: 16,
|
||||
},
|
||||
settingsDialogBackdrop: {
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(15, 23, 42, 0.46)",
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
padding: 20,
|
||||
},
|
||||
settingsExampleDialog: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: 14,
|
||||
gap: 14,
|
||||
maxWidth: 620,
|
||||
padding: 16,
|
||||
width: "100%",
|
||||
},
|
||||
settingsLinkCopy: {
|
||||
flex: 1,
|
||||
paddingBottom: 16,
|
||||
@@ -7944,6 +7987,17 @@ const baseWorkspaceStyles = StyleSheet.create({
|
||||
flexWrap: "wrap",
|
||||
gap: 8,
|
||||
},
|
||||
tokenScopeHeader: {
|
||||
alignItems: "center",
|
||||
borderBottomColor: "#f1f5f9",
|
||||
borderBottomWidth: 1,
|
||||
borderTopColor: "#f1f5f9",
|
||||
borderTopWidth: 1,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
minHeight: 52,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
scopePill: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#e2e8f0",
|
||||
|
||||
Reference in new issue
Block a user