v0.16.0: 后台体验升级——i18n(默认英文)/浅深主题/字号放大/关站维护页/个人设置(头像·语言·主题·密码·MCP Token)/权限管理(菜单+MCP·API)/询盘抽屉翻倍+PHP序列化数据清洗(22.7万行)+去裸JSON
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user