Files
dusuniot/src/routes/admin.profile.tsx
T

279 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # 路由 /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 orientation="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 orientation="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>
);
}