/** * # 路由 /admin/profile 个人设置 * 当前登录用户自助管理:头像(媒体库选择)/ 显示名 / 默认语言 / 默认主题 / 密码 / MCP Token。 * 语言与主题保存即生效(localStorage + 上下文即时切换),同时写入账号作为下次登录默认。 */ import { createFileRoute, useRouter } from "@tanstack/react-router"; import { App, Avatar, Button, Card, Form, Input, Select, Space, Typography } from "antd"; import { Copy, KeyRound, LibraryBig, RefreshCw, Save, Trash2, UserRound } from "lucide-react"; import { useState } from "react"; import { getMyProfile, updateMyProfile, changeMyPassword, regenerateMyMcpToken, clearMyMcpToken, type ProfileData } from "~/server/profile"; import { Forbidden, PageHeader, guardAdmin } from "~/components/admin/common"; import { MediaPickerModal } from "~/components/admin/MediaPickerModal"; import { useAdminI18n, useAdminT } from "~/admin-i18n"; import { ADMIN_LOCALES } from "~/admin-i18n/shared"; import { useAdminTheme } from "~/admin-theme"; export const Route = createFileRoute("/admin/profile")({ loader: () => guardAdmin(() => getMyProfile()), component: ProfilePage, }); /** # 函数 个人设置页面组件 */ function ProfilePage() { const t = useAdminT(); const data = Route.useLoaderData() as ProfileData | null; if (!data) return ; return ( ); } /** # 组件 基本资料表单(头像 / 显示名 / 默认语言 / 默认主题) */ function ProfileForm(props: { profile: ProfileData }) { const t = useAdminT(); const { message } = App.useApp(); const router = useRouter(); const { setLocale } = useAdminI18n(); const { setTheme } = useAdminTheme(); const [form] = Form.useForm(); /** # 成员变量 头像草稿(预览用;保存时提交) **/ const [avatar, setAvatar] = useState(props.profile.avatar || ""); /** # 成员变量 媒体库选择弹窗 **/ const [mediaOpen, setMediaOpen] = useState(false); /** # 成员变量 保存中 **/ const [saving, setSaving] = useState(false); /** # 函数 保存资料:提交服务端 + 本地偏好即时生效 */ async function handleSave(values: { displayName?: string; locale?: string; theme?: string }) { setSaving(true); try { await updateMyProfile({ data: { displayName: values.displayName, avatar, locale: values.locale, theme: values.theme } }); if (values.locale) setLocale(values.locale); if (values.theme) setTheme(values.theme as "light" | "dark"); message.success(t("个人资料已保存")); await router.invalidate(); } catch (e) { message.error(e instanceof Error ? e.message : t("保存失败")); } finally { setSaving(false); } } return ( void handleSave(v as { displayName?: string; locale?: string; theme?: string })} initialValues={{ displayName: props.profile.displayName, locale: props.profile.locale || "en", theme: props.profile.theme || "light" }} style={{ maxWidth: 560 }} > {avatar ? ( ) : ( } /> )} } onClick={() => setMediaOpen(true)}> {t("从媒体库选择")} {avatar ? ( } onClick={() => setAvatar("")} /> ) : null} ({ value: l.code, label: l.label }))} style={{ width: 200 }} /> }> {t("保存")} setMediaOpen(false)} onSelect={(url) => setAvatar(url)} /> ); } /** # 组件 修改密码表单(当前密码 + 新密码 + 确认;成功后本人其他会话被吊销) */ function PasswordForm() { const t = useAdminT(); const { message, modal } = App.useApp(); const [form] = Form.useForm(); const [saving, setSaving] = useState(false); /** # 函数 提交改密(二次确认) */ async function handleFinish(values: { currentPassword: string; newPassword: string; confirm: string }) { modal.confirm({ title: t("确认修改密码?"), content: t("修改成功后,其他已登录设备将被退出。"), okText: t("确认修改"), cancelText: t("取消"), onOk: async () => { setSaving(true); try { await changeMyPassword({ data: { currentPassword: values.currentPassword, newPassword: values.newPassword } }); message.success(t("密码已修改")); form.resetFields(); } catch (e) { message.error(e instanceof Error ? e.message : t("修改失败")); } finally { setSaving(false); } }, }); } return ( {t("修改密码")}}> void handleFinish(v as { currentPassword: string; newPassword: string; confirm: string })} style={{ maxWidth: 420 }}> ({ validator: (_, v) => (v === getFieldValue("newPassword") ? Promise.resolve() : Promise.reject(new Error(t("两次输入不一致")))), }), ]} > {t("修改密码")} ); } /** # 组件 MCP Token 卡片(生成 / 复制 / 重置 / 清除) */ function McpTokenCard(props: { profile: ProfileData }) { const t = useAdminT(); const { message, modal } = App.useApp(); const router = useRouter(); const [token, setToken] = useState(props.profile.mcpToken || ""); const [busy, setBusy] = useState(false); /** # 函数 复制令牌到剪贴板 */ async function handleCopy() { try { await navigator.clipboard.writeText(token); message.success(t("已复制到剪贴板")); } catch { message.error(t("复制失败,请手动选择复制")); } } /** # 函数 重新生成(确认后覆盖旧令牌) */ function handleRegenerate() { modal.confirm({ title: t("重新生成 MCP Token?"), content: t("旧令牌将立即失效,使用旧令牌的客户端需要更新配置。"), okText: t("重新生成"), cancelText: t("取消"), onOk: async () => { setBusy(true); try { const res = await regenerateMyMcpToken(); setToken(res.token); message.success(t("已生成新令牌")); await router.invalidate(); } catch (e) { message.error(e instanceof Error ? e.message : t("操作失败")); } finally { setBusy(false); } }, }); } /** # 函数 清除令牌(确认后停用 MCP 访问) */ function handleClear() { modal.confirm({ title: t("清除 MCP Token?"), content: t("清除后使用该令牌的客户端将无法访问。"), okText: t("清除"), okButtonProps: { danger: true }, cancelText: t("取消"), onOk: async () => { setBusy(true); try { await clearMyMcpToken(); setToken(""); message.success(t("已清除令牌")); await router.invalidate(); } catch (e) { message.error(e instanceof Error ? e.message : t("操作失败")); } finally { setBusy(false); } }, }); } return ( } onClick={() => void handleCopy()}>{t("复制")} } loading={busy} onClick={handleRegenerate}>{t("重新生成")} } loading={busy} onClick={handleClear}>{t("清除")} ) : ( } loading={busy} onClick={handleRegenerate}>{t("生成令牌")} ) }> {t("用于 Agent / MCP 客户端访问本站 MCP 服务的个人访问令牌。令牌等同于你的账号权限,请妥善保管;泄露时请立即重新生成。")} {token ? ( {token.slice(0, 6)}••••••••••••••••{token.slice(-4)} ) : ( {t("尚未生成令牌")} )} ); }