v0.16.0: 后台体验升级——i18n(默认英文)/浅深主题/字号放大/关站维护页/个人设置(头像·语言·主题·密码·MCP Token)/权限管理(菜单+MCP·API)/询盘抽屉翻倍+PHP序列化数据清洗(22.7万行)+去裸JSON

This commit is contained in:
bigcode
2026-11-05 01:46:00 +08:00
parent c3df5d9db1
commit a018019504
47 changed files with 3008 additions and 830 deletions
+278
View File
@@ -0,0 +1,278 @@
/**
* # 路由 /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 <Forbidden />;
return (
<Card size="small">
<PageHeader title={t("个人设置")} />
<Space direction="vertical" size={16} style={{ display: "flex", maxWidth: 820 }}>
<ProfileForm profile={data} />
<PasswordForm />
<McpTokenCard profile={data} />
</Space>
</Card>
);
}
/** # 组件 基本资料表单(头像 / 显示名 / 默认语言 / 默认主题) */
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 (
<Card type="inner" size="small" title={t("基本资料")}>
<Form
form={form}
layout="vertical"
onFinish={(v) => 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 }}
>
<Space size={20} align="start" style={{ display: "flex" }}>
<Form.Item label={t("头像")} style={{ textAlign: "center" }}>
<Space direction="vertical" size={8}>
{avatar ? (
<Avatar size={72} src={avatar} />
) : (
<Avatar size={72} icon={<UserRound size={30} aria-hidden />} />
)}
<Space size={4}>
<Button size="small" icon={<LibraryBig size={13} aria-hidden />} onClick={() => setMediaOpen(true)}>
{t("从媒体库选择")}
</Button>
{avatar ? (
<Button size="small" type="text" danger icon={<Trash2 size={13} aria-hidden />} onClick={() => setAvatar("")} />
) : null}
</Space>
</Space>
</Form.Item>
<div style={{ flex: 1, minWidth: 320 }}>
<Form.Item name="displayName" label={t("显示名")} extra={props.profile.email}>
<Input maxLength={64} />
</Form.Item>
<Space size={16} style={{ display: "flex" }}>
<Form.Item name="locale" label={t("默认语言")} extra={t("保存后立即生效,并作为下次登录的默认语言")} style={{ flex: 1, minWidth: 220 }}>
<Select options={ADMIN_LOCALES.map((l) => ({ value: l.code, label: l.label }))} style={{ width: 200 }} />
</Form.Item>
<Form.Item name="theme" label={t("默认主题")} extra={t("保存后立即生效,并作为下次登录的默认主题")} style={{ flex: 1, minWidth: 220 }}>
<Select
options={[
{ value: "light", label: t("浅色") },
{ value: "dark", label: t("深暗") },
]}
style={{ width: 200 }}
/>
</Form.Item>
</Space>
</div>
</Space>
<Button type="primary" htmlType="submit" loading={saving} icon={<Save size={14} aria-hidden />}>
{t("保存")}
</Button>
</Form>
<MediaPickerModal
open={mediaOpen}
onClose={() => setMediaOpen(false)}
onSelect={(url) => setAvatar(url)}
/>
</Card>
);
}
/** # 组件 修改密码表单(当前密码 + 新密码 + 确认;成功后本人其他会话被吊销) */
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 (
<Card type="inner" size="small" title={<Space size={6}><KeyRound size={14} aria-hidden />{t("修改密码")}</Space>}>
<Form form={form} layout="vertical" onFinish={(v) => void handleFinish(v as { currentPassword: string; newPassword: string; confirm: string })} style={{ maxWidth: 420 }}>
<Form.Item name="currentPassword" label={t("当前密码")} rules={[{ required: true, message: t("请输入当前密码") }]}>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item name="newPassword" label={t("新密码")} rules={[{ required: true, min: 8, message: t("至少 8 位") }]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item
name="confirm"
label={t("确认新密码")}
dependencies={["newPassword"]}
rules={[
{ required: true, message: t("请再次输入新密码") },
({ getFieldValue }) => ({
validator: (_, v) => (v === getFieldValue("newPassword") ? Promise.resolve() : Promise.reject(new Error(t("两次输入不一致")))),
}),
]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={saving}>
{t("修改密码")}
</Button>
</Form>
</Card>
);
}
/** # 组件 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 (
<Card type="inner" size="small" title="MCP Token" extra={
token ? (
<Space size={8}>
<Button size="small" icon={<Copy size={13} aria-hidden />} onClick={() => void handleCopy()}>{t("复制")}</Button>
<Button size="small" icon={<RefreshCw size={13} aria-hidden />} loading={busy} onClick={handleRegenerate}>{t("重新生成")}</Button>
<Button size="small" danger icon={<Trash2 size={13} aria-hidden />} loading={busy} onClick={handleClear}>{t("清除")}</Button>
</Space>
) : (
<Button size="small" type="primary" icon={<RefreshCw size={13} aria-hidden />} loading={busy} onClick={handleRegenerate}>{t("生成令牌")}</Button>
)
}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
{t("用于 Agent / MCP 客户端访问本站 MCP 服务的个人访问令牌。令牌等同于你的账号权限,请妥善保管;泄露时请立即重新生成。")}
</Typography.Paragraph>
{token ? (
<Typography.Text code copyable={false} style={{ fontSize: 14 }}>
{token.slice(0, 6)}••••••••••••••••{token.slice(-4)}
</Typography.Text>
) : (
<Typography.Text type="secondary">{t("尚未生成令牌")}</Typography.Text>
)}
</Card>
);
}