feat(mobile): align settings with PWA mobile flow

This commit is contained in:
tianma-if committed 2026-07-19 03:51:42 +08:00
1 parent 354eaed1e4
commit 39b2017f9c
3 files changed
+352 -301

No files matched your search

+1
View File
@@ -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"],
+138 -142
View File
@@ -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" },
+213 -159
View File
@@ -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",