v0.16.0: 后台体验升级——i18n(默认英文)/浅深主题/字号放大/关站维护页/个人设置(头像·语言·主题·密码·MCP Token)/权限管理(菜单+MCP·API)/询盘抽屉翻倍+PHP序列化数据清洗(22.7万行)+去裸JSON
This commit is contained in:
+31
-16
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* # 路由 /admin/audit 审计日志
|
||||
* 后台全部写操作留痕(audit_logs):操作人 / 动作 / 实体 / 时间。
|
||||
* 支持按动作名过滤与分页;meta 为 JSON 摘要。
|
||||
* 支持按动作名过滤与分页;摘要以可读键值对展示(k=v 徽标),不再出现裸 JSON。
|
||||
*/
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Card, Input, Table, Typography } from "antd";
|
||||
import { Card, Input, Table, Tag, Tooltip } from "antd";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { adminListAuditLogs } from "~/server/admin";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/audit")({
|
||||
@@ -25,6 +26,7 @@ export const Route = createFileRoute("/admin/audit")({
|
||||
|
||||
/** # 函数 审计日志页面组件 */
|
||||
function AuditPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
@@ -35,11 +37,11 @@ function AuditPage() {
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="审计日志"
|
||||
title={t("审计日志")}
|
||||
extra={
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="按动作过滤(如 content.save)"
|
||||
placeholder={t("按动作过滤(如 content.save)")}
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
@@ -58,21 +60,34 @@ function AuditPage() {
|
||||
)}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{ title: "操作人", dataIndex: "actorEmail", width: 220, ellipsis: true, render: (v: string | null) => v ?? `#${""}` },
|
||||
{ title: "动作", dataIndex: "action", width: 200, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
|
||||
{ title: "实体", dataIndex: "entity", width: 110 },
|
||||
{ title: "实体 ID", dataIndex: "entityId", width: 90 },
|
||||
{ title: t("操作人"), dataIndex: "actorEmail", width: 220, ellipsis: true, render: (v: string | null) => v || "-" },
|
||||
{ title: t("动作"), dataIndex: "action", width: 200, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
|
||||
{ title: t("实体"), dataIndex: "entity", width: 110 },
|
||||
{ title: t("实体 ID"), dataIndex: "entityId", width: 90 },
|
||||
{
|
||||
title: "摘要",
|
||||
title: t("摘要"),
|
||||
dataIndex: "meta",
|
||||
ellipsis: true,
|
||||
render: (v: unknown) => (
|
||||
<Typography.Text code style={{ fontSize: 12 }}>
|
||||
{Object.keys(v ?? {}).length ? JSON.stringify(v) : "-"}
|
||||
</Typography.Text>
|
||||
),
|
||||
render: (v: unknown) => {
|
||||
// 摘要以 k=v 徽标展示(嵌套值取其字符串形式),无摘要显示 -
|
||||
const entries = v && typeof v === "object" && !Array.isArray(v) ? Object.entries(v) : [];
|
||||
if (!entries.length) return "-";
|
||||
return (
|
||||
<span style={{ display: "inline-flex", flexWrap: "wrap", gap: 4 }}>
|
||||
{entries.map(([k, val]) => {
|
||||
const text = val === null || val === undefined ? "-" : String(val);
|
||||
return (
|
||||
<Tooltip key={k} title={text}>
|
||||
<Tag style={{ marginInlineEnd: 0, maxWidth: 260, overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{k}={text}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{ title: t("时间"), dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { App, Alert, Button, Card, Form, Input, InputNumber, Space, Switch, Tabl
|
||||
import { CloudUpload, DatabaseBackup, Delete, RefreshCw, Zap } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { adminDeleteBackup, adminListBackups, adminRunBackup, adminUpdateSetting } from "~/server/admin";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
import { Forbidden, PageHeader, guardAdmin, fmtDateTime, type Json } from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/backups")({
|
||||
@@ -39,6 +40,7 @@ function fmtSize(n: number): string {
|
||||
|
||||
/** # 函数 备份管理页面组件 */
|
||||
function BackupsPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData() as BackupsData | null;
|
||||
const router = useRouter();
|
||||
const { message, modal } = App.useApp();
|
||||
@@ -72,10 +74,10 @@ function BackupsPage() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const entry = await adminRunBackup({ data: { kind } });
|
||||
message.success(`备份完成:${entry.name}(${fmtSize(entry.sizeBytes)}${entry.s3Key ? ",已上传 S3" : ""})`);
|
||||
message.success(t("备份完成:{name}({size}{s3})", { name: entry.name, size: fmtSize(entry.sizeBytes), s3: entry.s3Key ? t(",已上传 S3") : "" }));
|
||||
await router.invalidate();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "备份失败");
|
||||
message.error(e instanceof Error ? e.message : t("备份失败"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -84,17 +86,17 @@ function BackupsPage() {
|
||||
/** # 函数 删除备份(全量删除会破坏其后的增量链,弹窗确认) */
|
||||
function confirmDelete(name: string, type: string) {
|
||||
modal.confirm({
|
||||
title: `删除备份 ${name}?`,
|
||||
title: t("删除备份 {name}?", { name }),
|
||||
content:
|
||||
type === "full"
|
||||
? "该全量之后的所有增量将无法合并恢复,请确认不再需要这条备份链。"
|
||||
: "仅删除该增量备份,不影响全量与其他增量。",
|
||||
okText: "删除",
|
||||
? t("该全量之后的所有增量将无法合并恢复,请确认不再需要这条备份链。")
|
||||
: t("仅删除该增量备份,不影响全量与其他增量。"),
|
||||
okText: t("删除"),
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: "取消",
|
||||
cancelText: t("取消"),
|
||||
onOk: async () => {
|
||||
await adminDeleteBackup({ data: { name } });
|
||||
message.success("已删除");
|
||||
message.success(t("已删除"));
|
||||
await router.invalidate();
|
||||
},
|
||||
});
|
||||
@@ -119,10 +121,10 @@ function BackupsPage() {
|
||||
if (!values.endpoint) delete merged.s3;
|
||||
try {
|
||||
await adminUpdateSetting({ data: { key: "backup", value: merged as Json } });
|
||||
message.success("备份配置已保存");
|
||||
message.success(t("备份配置已保存"));
|
||||
await router.invalidate();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "保存失败");
|
||||
message.error(e instanceof Error ? e.message : t("保存失败"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,17 +132,17 @@ function BackupsPage() {
|
||||
<div>
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="备份管理(PostgreSQL 18 物理备份)"
|
||||
title={t("备份管理(PostgreSQL 18 物理备份)")}
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<RefreshCw size={14} aria-hidden />} onClick={() => void router.invalidate()}>
|
||||
刷新
|
||||
{t("刷新")}
|
||||
</Button>
|
||||
<Button loading={busy} icon={<Zap size={14} aria-hidden />} onClick={() => void run("incr")} disabled={!d.local.length}>
|
||||
增量备份
|
||||
{t("增量备份")}
|
||||
</Button>
|
||||
<Button loading={busy} type="primary" icon={<DatabaseBackup size={14} aria-hidden />} onClick={() => void run("full")}>
|
||||
全量备份
|
||||
{t("全量备份")}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
@@ -148,7 +150,7 @@ function BackupsPage() {
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="增量备份基于 PG 17+ summarize_wal(当前 18.6 已开启):只落全量以来的变更块;恢复用 pg_combinebackup 合并「全量 + 其后全部增量」"
|
||||
message={t("增量备份基于 PG 17+ summarize_wal(当前 18.6 已开启):只落全量以来的变更块;恢复用 pg_combinebackup 合并「全量 + 其后全部增量」")}
|
||||
/>
|
||||
<Table
|
||||
style={{ marginTop: 12 }}
|
||||
@@ -156,36 +158,35 @@ function BackupsPage() {
|
||||
size="small"
|
||||
dataSource={data.local}
|
||||
pagination={false}
|
||||
locale={{ emptyText: "暂无备份(建议先做一次全量)" }}
|
||||
locale={{ emptyText: t("暂无备份(建议先做一次全量)") }}
|
||||
columns={[
|
||||
{ title: "备份名", dataIndex: "name", width: 220, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
|
||||
{ title: "类型", dataIndex: "type", width: 90, render: (v: string) => <Tag color={v === "full" ? "blue" : "cyan"}>{v === "full" ? "全量" : "增量"}</Tag> },
|
||||
{ title: "数据目录", dataIndex: "sizeBytes", width: 110, render: (v: number) => fmtSize(v) },
|
||||
{ title: "归档", dataIndex: "artifact", width: 90, render: (v?: string) => (v ? <Tag color="green">已打包</Tag> : <Tag>未打包</Tag>) },
|
||||
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{ title: t("备份名"), dataIndex: "name", width: 220, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
|
||||
{ title: t("类型"), dataIndex: "type", width: 90, render: (v: string) => <Tag color={v === "full" ? "blue" : "cyan"}>{v === "full" ? t("全量") : t("增量")}</Tag> },
|
||||
{ title: t("数据目录"), dataIndex: "sizeBytes", width: 110, render: (v: number) => fmtSize(v) },
|
||||
{ title: t("归档"), dataIndex: "artifact", width: 90, render: (v?: string) => (v ? <Tag color="green">{t("已打包")}</Tag> : <Tag>{t("未打包")}</Tag>) },
|
||||
{ title: t("时间"), dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{
|
||||
title: "操作",
|
||||
title: t("操作"),
|
||||
key: "act",
|
||||
width: 90,
|
||||
render: (_, r) => (
|
||||
<Button size="small" danger type="text" icon={<Delete size={14} aria-label="删除" />} onClick={() => confirmDelete(r.name, r.type)} />
|
||||
<Button size="small" danger type="text" icon={<Delete size={14} aria-label={t("删除")} />} onClick={() => confirmDelete(r.name, r.type)} />
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 12, fontSize: 12, marginBottom: 0 }}>
|
||||
恢复步骤(示例):`docker exec -u postgres dusuniot-pg pg_combinebackup -o /tmp/restore /backups/<全量> /backups/<增量1> /backups/<增量2>`
|
||||
→ 停库后把合并目录作为 PGDATA 启动。S3 归档为 tar.gz,解包后同法操作。
|
||||
{t("恢复步骤(示例):`docker exec -u postgres dusuniot-pg pg_combinebackup -o /tmp/restore /backups/<全量> /backups/<增量1> /backups/<增量2>` → 停库后把合并目录作为 PGDATA 启动。S3 归档为 tar.gz,解包后同法操作。")}
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
|
||||
<Card size="small" style={{ marginTop: 16 }} title={
|
||||
<Space><CloudUpload size={16} aria-hidden /> S3 归档与保留策略 {data.s3Configured ? <Tag color="green">S3 已配置</Tag> : <Tag>未配置(仅本地)</Tag>}</Space>
|
||||
<Space><CloudUpload size={16} aria-hidden /> {t("S3 归档与保留策略")} {data.s3Configured ? <Tag color="green">{t("S3 已配置")}</Tag> : <Tag>{t("未配置(仅本地)")}</Tag>}</Space>
|
||||
}>
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 860 }} onFinish={(v) => void saveConfig(v as Parameters<typeof saveConfig>[0])}>
|
||||
<Space size={16} style={{ display: "flex" }} align="start">
|
||||
<Form.Item name="endpoint" label="S3 Endpoint(S3 兼容,如 https://s3.endpoint)" style={{ minWidth: 300, flex: 1 }}>
|
||||
<Input placeholder="https://s3.us-east-1.amazonaws.com 或自建端点" />
|
||||
<Form.Item name="endpoint" label={t("S3 Endpoint(S3 兼容,如 https://s3.endpoint)")} style={{ minWidth: 300, flex: 1 }}>
|
||||
<Input placeholder={t("https://s3.us-east-1.amazonaws.com 或自建端点")} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bucket" label="Bucket" style={{ width: 200 }}>
|
||||
<Input placeholder="dusuniot-backup" />
|
||||
@@ -201,23 +202,23 @@ function BackupsPage() {
|
||||
<Form.Item name="secretAccessKey" label="SecretAccessKey" style={{ width: 260 }}>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item name="prefix" label="对象前缀" style={{ width: 160 }}>
|
||||
<Form.Item name="prefix" label={t("对象前缀")} style={{ width: 160 }}>
|
||||
<Input placeholder="dusuniot" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={24} style={{ display: "flex" }} align="start">
|
||||
<Form.Item name="keepFull" label="保留全量链数" initialValue={3}>
|
||||
<Form.Item name="keepFull" label={t("保留全量链数")} initialValue={3}>
|
||||
<InputNumber min={1} max={30} />
|
||||
</Form.Item>
|
||||
<Form.Item name="scheduleEnabled" label="定时备份" valuePropName="checked" initialValue={false}>
|
||||
<Form.Item name="scheduleEnabled" label={t("定时备份")} valuePropName="checked" initialValue={false}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="intervalHours" label="间隔(小时)" initialValue={24}>
|
||||
<Form.Item name="intervalHours" label={t("间隔(小时)")} initialValue={24}>
|
||||
<InputNumber min={1} max={168} />
|
||||
</Form.Item>
|
||||
<Form.Item label=" ">
|
||||
<Button type="primary" htmlType="submit" icon={<CloudUpload size={14} aria-hidden />}>
|
||||
保存配置
|
||||
{t("保存配置")}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
@@ -229,16 +230,16 @@ function BackupsPage() {
|
||||
style={{ marginTop: 8 }}
|
||||
dataSource={data.s3}
|
||||
pagination={false}
|
||||
locale={{ emptyText: "S3 暂无归档对象" }}
|
||||
locale={{ emptyText: t("S3 暂无归档对象") }}
|
||||
columns={[
|
||||
{ title: "S3 对象", dataIndex: "key", ellipsis: true, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
|
||||
{ title: "大小", dataIndex: "size", width: 110, render: (v: number) => fmtSize(v) },
|
||||
{ title: "上传时间", dataIndex: "lastModified", width: 170, render: (v: string) => fmtDateTime(v) },
|
||||
{ title: t("S3 对象"), dataIndex: "key", ellipsis: true, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
|
||||
{ title: t("大小"), dataIndex: "size", width: 110, render: (v: number) => fmtSize(v) },
|
||||
{ title: t("上传时间"), dataIndex: "lastModified", width: 170, render: (v: string) => fmtDateTime(v) },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8 }}>
|
||||
定时备份由 jobs worker(`bun scripts/jobs-worker.ts`)消费 `backup.run` 任务执行:自动选择增量(无备份时全量),完成后按间隔自排下一轮。
|
||||
{t("定时备份由 jobs worker(`bun scripts/jobs-worker.ts`)消费 `backup.run` 任务执行:自动选择增量(无备份时全量),完成后按间隔自排下一轮。")}
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ArrowLeft, ExternalLink, History, Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { adminGetContent, adminGetRevision, adminListRevisions, adminRestoreRevision, adminSaveContent } from "~/server/admin";
|
||||
import { CONTENT_STATUS_OPTIONS, Forbidden, fmtDateTime, guardAdmin } from "~/components/admin/common";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
import { RichEditor } from "~/components/admin/RichEditor";
|
||||
|
||||
/** # 类型 编辑表单数据形状(新建时给默认值) **/
|
||||
@@ -84,6 +85,7 @@ export const Route = createFileRoute("/admin/contents/$id")({
|
||||
|
||||
/** # 函数 内容编辑页面组件 */
|
||||
function ContentEditPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
@@ -127,11 +129,11 @@ function ContentEditPage() {
|
||||
categoryIds: selectedTermIds,
|
||||
},
|
||||
});
|
||||
message.success("保存成功(已留修订快照)");
|
||||
message.success(t("保存成功(已留修订快照)"));
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/admin/contents/" });
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "保存失败");
|
||||
message.error(e instanceof Error ? e.message : t("保存失败"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,22 +149,22 @@ function ContentEditPage() {
|
||||
const rev = await adminGetRevision({ data: { id: row.id } });
|
||||
if (!rev) return;
|
||||
modal.confirm({
|
||||
title: `恢复修订 #${row.id}(${fmtDateTime(row.createdAt)})`,
|
||||
title: t("恢复修订 #{id}({time})", { id: row.id, time: fmtDateTime(row.createdAt) }),
|
||||
width: 760,
|
||||
content: (
|
||||
<div style={{ maxHeight: 420, overflow: "auto" }}>
|
||||
<p style={{ fontWeight: 600 }}>{rev.title}</p>
|
||||
<pre style={{ whiteSpace: "pre-wrap", fontSize: 12, maxHeight: 340, overflow: "auto", background: "#fafafa", padding: 8 }}>
|
||||
{rev.html.slice(0, 5000) || "(空正文)"}
|
||||
{rev.html.length > 5000 ? "\n…(截断预览)" : ""}
|
||||
<pre style={{ whiteSpace: "pre-wrap", fontSize: 12, maxHeight: 340, overflow: "auto", background: "var(--ant-color-bg-container)", padding: 8 }}>
|
||||
{rev.html.slice(0, 5000) || t("(空正文)")}
|
||||
{rev.html.length > 5000 ? "\n" + t("…(截断预览)") : ""}
|
||||
</pre>
|
||||
</div>
|
||||
),
|
||||
okText: "恢复此版本",
|
||||
cancelText: "取消",
|
||||
okText: t("恢复此版本"),
|
||||
cancelText: t("取消"),
|
||||
onOk: async () => {
|
||||
await adminRestoreRevision({ data: { id: row.id } });
|
||||
message.success("已恢复(恢复前版本已再留档)");
|
||||
message.success(t("已恢复(恢复前版本已再留档)"));
|
||||
setRevOpen(false);
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/admin/contents/$id/", params: { id: String(content.id) } }).then(() => window.location.reload());
|
||||
@@ -173,13 +175,13 @@ function ContentEditPage() {
|
||||
/** # 成员变量 分类/标签树数据源(taxonomy 分组) **/
|
||||
const termTreeData = [
|
||||
{
|
||||
title: "分类目录",
|
||||
title: t("分类目录"),
|
||||
value: "__cat__",
|
||||
selectable: false,
|
||||
children: content.termOptions.filter((t) => t.taxonomy === "category").map((t) => ({ title: t.name, value: t.id })),
|
||||
},
|
||||
{
|
||||
title: "标签",
|
||||
title: t("标签"),
|
||||
value: "__tag__",
|
||||
selectable: false,
|
||||
children: content.termOptions.filter((t) => t.taxonomy === "post_tag").map((t) => ({ title: t.name, value: t.id })),
|
||||
@@ -190,17 +192,17 @@ function ContentEditPage() {
|
||||
<Card size="small">
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button icon={<ArrowLeft size={14} aria-hidden />} onClick={() => void navigate({ to: "/admin/contents/" })}>
|
||||
返回列表
|
||||
{t("返回列表")}
|
||||
</Button>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>{content.id ? `编辑内容 #${content.id}` : "新建内容"}</h2>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>{content.id ? t("编辑内容 #{id}", { id: content.id }) : t("新建内容")}</h2>
|
||||
{content.id ? (
|
||||
<>
|
||||
<Button icon={<History size={14} aria-hidden />} onClick={() => void openRevisions()}>
|
||||
修订历史
|
||||
{t("修订历史")}
|
||||
</Button>
|
||||
{content.status === "published" && content.slug ? (
|
||||
<Button icon={<ExternalLink size={14} aria-hidden />} onClick={() => window.open(`/${content.slug}/`, "_blank")}>
|
||||
前台预览
|
||||
{t("前台预览")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
@@ -208,32 +210,32 @@ function ContentEditPage() {
|
||||
</Space>
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 960 }} onFinish={(v) => void handleSave(v as Omit<ContentForm, "id">)}>
|
||||
<Space size={16} style={{ display: "flex" }} align="start">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]} style={{ minWidth: 380, flex: 1 }}>
|
||||
<Input placeholder="内容标题" />
|
||||
<Form.Item name="title" label={t("标题")} rules={[{ required: true, message: t("请输入标题") }]} style={{ minWidth: 380, flex: 1 }}>
|
||||
<Input placeholder={t("内容标题")} />
|
||||
</Form.Item>
|
||||
<Form.Item name="slug" label="别名(留空自动生成;即前台 URL /slug/)">
|
||||
<Form.Item name="slug" label={t("别名(留空自动生成;即前台 URL /slug/)")}>
|
||||
<Input placeholder="url-slug" style={{ width: 280 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={16} style={{ display: "flex" }}>
|
||||
<Form.Item name="type" label="类型">
|
||||
<Form.Item name="type" label={t("类型")}>
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
options={[
|
||||
{ value: "page", label: "页面 page" },
|
||||
{ value: "post", label: "文章 post" },
|
||||
{ value: "landing", label: "落地页 landing" },
|
||||
{ value: "page", label: t("页面 page") },
|
||||
{ value: "post", label: t("文章 post") },
|
||||
{ value: "landing", label: t("落地页 landing") },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Form.Item name="status" label={t("状态")}>
|
||||
<Select style={{ width: 140 }} options={CONTENT_STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="template" label="模板">
|
||||
<Form.Item name="template" label={t("模板")}>
|
||||
<Input style={{ width: 200 }} placeholder="default / home / blank …" />
|
||||
</Form.Item>
|
||||
{content.id ? (
|
||||
<Form.Item label="分类 / 标签">
|
||||
<Form.Item label={t("分类 / 标签")}>
|
||||
<TreeSelect
|
||||
treeData={termTreeData}
|
||||
value={selectedTermIds}
|
||||
@@ -241,44 +243,44 @@ function ContentEditPage() {
|
||||
treeCheckable
|
||||
showCheckedStrategy={TreeSelect.SHOW_CHILD}
|
||||
maxTagCount={4}
|
||||
placeholder="选择分类与标签"
|
||||
placeholder={t("选择分类与标签")}
|
||||
style={{ width: 320 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</Space>
|
||||
<Form.Item name="excerpt" label="摘要">
|
||||
<Form.Item name="excerpt" label={t("摘要")}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="html"
|
||||
label="正文(富文本编辑,Markdown 快捷语法自动转换)"
|
||||
tooltip="可视化编辑;需要精细控制时可切 HTML 源码。无 Elementor JSON 时前台按此渲染"
|
||||
label={t("正文(富文本编辑,Markdown 快捷语法自动转换)")}
|
||||
tooltip={t("可视化编辑;需要精细控制时可切 HTML 源码。无 Elementor JSON 时前台按此渲染")}
|
||||
>
|
||||
<RichEditor minHeight={380} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="elementorJson"
|
||||
label="Elementor JSON(meta.elementor,优先于 HTML 渲染)"
|
||||
tooltip='结构:{ "elements": [ ... ] } 或元素数组;可从原站 _elementor_data 复制'
|
||||
label={t("Elementor JSON(meta.elementor,优先于 HTML 渲染)")}
|
||||
tooltip={t('结构:{ "elements": [ ... ] } 或元素数组;可从原站 _elementor_data 复制')}
|
||||
>
|
||||
<Input.TextArea rows={10} style={{ fontFamily: "monospace", fontSize: 12 }} placeholder='{"elements": [...]}' />
|
||||
</Form.Item>
|
||||
<Space size={16} style={{ display: "flex" }}>
|
||||
<Form.Item name="seoTitle" label="SEO 标题" style={{ minWidth: 420, flex: 1 }}>
|
||||
<Form.Item name="seoTitle" label={t("SEO 标题")} style={{ minWidth: 420, flex: 1 }}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="seoDescription" label="SEO 描述">
|
||||
<Form.Item name="seoDescription" label={t("SEO 描述")}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<Save size={14} aria-hidden />}>
|
||||
保存
|
||||
{t("保存")}
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Modal
|
||||
title="修订历史(每次保存自动留档)"
|
||||
title={t("修订历史(每次保存自动留档)")}
|
||||
open={revOpen}
|
||||
onCancel={() => setRevOpen(false)}
|
||||
footer={null}
|
||||
@@ -289,19 +291,19 @@ function ContentEditPage() {
|
||||
size="small"
|
||||
dataSource={revisions}
|
||||
pagination={{ pageSize: 8 }}
|
||||
locale={{ emptyText: "暂无修订" }}
|
||||
locale={{ emptyText: t("暂无修订") }}
|
||||
columns={[
|
||||
{ title: "修订 ID", dataIndex: "id", width: 90 },
|
||||
{ title: "标题快照", dataIndex: "title", ellipsis: true },
|
||||
{ title: "修订人", dataIndex: "authorId", width: 90 },
|
||||
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{ title: t("修订 ID"), dataIndex: "id", width: 90 },
|
||||
{ title: t("标题快照"), dataIndex: "title", ellipsis: true },
|
||||
{ title: t("修订人"), dataIndex: "authorId", width: 90 },
|
||||
{ title: t("时间"), dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{
|
||||
title: "操作",
|
||||
title: t("操作"),
|
||||
key: "act",
|
||||
width: 100,
|
||||
render: (_, r) => (
|
||||
<Button size="small" onClick={() => void viewRevision(r)}>
|
||||
查看 / 恢复
|
||||
{t("查看 / 恢复")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { App, Button, Card, Input, Popconfirm, Space, Table, Tabs, Tag } from "a
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { adminDeleteContent, adminListContents } from "~/server/admin";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
import {
|
||||
AdminEmpty,
|
||||
CONTENT_STATUS_COLOR,
|
||||
@@ -48,6 +49,7 @@ export const Route = createFileRoute("/admin/contents/")({
|
||||
|
||||
/** # 函数 内容列表页面组件 */
|
||||
function ContentsPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
@@ -65,19 +67,19 @@ function ContentsPage() {
|
||||
/** # 函数 删除内容并刷新 */
|
||||
async function handleDelete(id: number) {
|
||||
await adminDeleteContent({ data: { id } });
|
||||
message.success("已删除");
|
||||
message.success(t("已删除"));
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="内容管理"
|
||||
title={t("内容管理")}
|
||||
extra={
|
||||
<>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索标题 / 别名"
|
||||
placeholder={t("搜索标题 / 别名")}
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
@@ -86,7 +88,7 @@ function ContentsPage() {
|
||||
/>
|
||||
<Link to="/admin/contents/$id/" params={{ id: "new" }}>
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden />}>
|
||||
新建内容
|
||||
{t("新建内容")}
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
@@ -96,10 +98,10 @@ function ContentsPage() {
|
||||
activeKey={search.type ?? ""}
|
||||
onChange={(key) => updateSearch({ type: key })}
|
||||
items={[
|
||||
{ key: "", label: "全部" },
|
||||
{ key: "page", label: "页面" },
|
||||
{ key: "post", label: "文章" },
|
||||
{ key: "landing", label: "落地页" },
|
||||
{ key: "", label: t("全部") },
|
||||
{ key: "page", label: t("页面") },
|
||||
{ key: "post", label: t("文章") },
|
||||
{ key: "landing", label: t("落地页") },
|
||||
]}
|
||||
/>
|
||||
<Table
|
||||
@@ -111,7 +113,7 @@ function ContentsPage() {
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{
|
||||
title: "标题",
|
||||
title: t("标题"),
|
||||
dataIndex: "title",
|
||||
render: (v: string, r) => (
|
||||
<Link to="/admin/contents/$id/" params={{ id: String(r.id) }}>
|
||||
@@ -119,32 +121,32 @@ function ContentsPage() {
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ title: "别名", dataIndex: "slug", width: 220, ellipsis: true },
|
||||
{ title: t("别名"), dataIndex: "slug", width: 220, ellipsis: true },
|
||||
{
|
||||
title: "类型",
|
||||
title: t("类型"),
|
||||
dataIndex: "type",
|
||||
width: 90,
|
||||
render: (v: string) => <Tag>{CONTENT_TYPE_LABEL[v] ?? v}</Tag>,
|
||||
render: (v: string) => <Tag>{t(CONTENT_TYPE_LABEL[v] ?? v)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
title: t("状态"),
|
||||
dataIndex: "status",
|
||||
width: 100,
|
||||
render: (v: string) => <StatusTag value={v} colorMap={CONTENT_STATUS_COLOR} />,
|
||||
},
|
||||
{ title: "浏览", dataIndex: "views", width: 80 },
|
||||
{ title: "更新时间", dataIndex: "updatedAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{ title: t("浏览"), dataIndex: "views", width: 80 },
|
||||
{ title: t("更新时间"), dataIndex: "updatedAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{
|
||||
title: "操作",
|
||||
title: t("操作"),
|
||||
key: "actions",
|
||||
width: 110,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
<Link to="/admin/contents/$id/" params={{ id: String(r.id) }}>
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} />
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label={t("编辑")} />} />
|
||||
</Link>
|
||||
<Popconfirm title="确认删除该内容?" onConfirm={() => void handleDelete(r.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label="删除" />} />
|
||||
<Popconfirm title={t("确认删除该内容?")} onConfirm={() => void handleDelete(r.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label={t("删除")} />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { App, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Select,
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { adminDeleteFilter, adminListFilters, adminSaveFilter } from "~/server/admin";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
import { AdminEmpty, Forbidden, PageHeader, guardAdmin } from "~/components/admin/common";
|
||||
import type { filters } from "~/db/schema";
|
||||
|
||||
@@ -20,6 +21,7 @@ const FILTER_TYPE_LABEL: Record<string, string> = { attribute: "属性", categor
|
||||
|
||||
/** # 函数 筛选配置页面组件 */
|
||||
function FiltersPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
@@ -43,7 +45,7 @@ function FiltersPage() {
|
||||
/** # 函数 保存筛选配置 */
|
||||
async function handleSave(values: { name: string; slug: string; type: string; attributeId: number | null; position: number; enabled: boolean }) {
|
||||
await adminSaveFilter({ data: { ...values, id: editing?.id } });
|
||||
message.success("已保存");
|
||||
message.success(t("已保存"));
|
||||
setEditing(null);
|
||||
await router.invalidate();
|
||||
}
|
||||
@@ -51,17 +53,17 @@ function FiltersPage() {
|
||||
/** # 函数 删除筛选配置 */
|
||||
async function handleDelete(id: number) {
|
||||
await adminDeleteFilter({ data: { id } });
|
||||
message.success("已删除");
|
||||
message.success(t("已删除"));
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="筛选配置"
|
||||
title={t("筛选配置")}
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={() => openEdit(null)}>
|
||||
新增筛选
|
||||
{t("新增筛选")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -73,36 +75,36 @@ function FiltersPage() {
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{ title: "名称", dataIndex: "name", width: 180 },
|
||||
{ title: "URL 参数", dataIndex: "slug", width: 180 },
|
||||
{ title: t("名称"), dataIndex: "name", width: 180 },
|
||||
{ title: t("URL 参数"), dataIndex: "slug", width: 180 },
|
||||
{
|
||||
title: "类型",
|
||||
title: t("类型"),
|
||||
dataIndex: "type",
|
||||
width: 100,
|
||||
render: (v: string) => <Tag>{FILTER_TYPE_LABEL[v] ?? v}</Tag>,
|
||||
render: (v: string) => <Tag>{t(FILTER_TYPE_LABEL[v] ?? v)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "关联属性",
|
||||
title: t("关联属性"),
|
||||
dataIndex: "attributeId",
|
||||
width: 160,
|
||||
render: (v: number | null) => (v ? (attrName.get(v) ?? `#${v}`) : "-"),
|
||||
},
|
||||
{ title: "位置", dataIndex: "position", width: 70 },
|
||||
{ title: t("位置"), dataIndex: "position", width: 70 },
|
||||
{
|
||||
title: "启用",
|
||||
title: t("启用"),
|
||||
dataIndex: "enabled",
|
||||
width: 80,
|
||||
render: (_, r) => <Tag color={r.enabled ? "green" : "default"}>{r.enabled ? "启用" : "停用"}</Tag>,
|
||||
render: (_, r) => <Tag color={r.enabled ? "green" : "default"}>{r.enabled ? t("启用") : t("停用")}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
title: t("操作"),
|
||||
key: "actions",
|
||||
width: 110,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} />
|
||||
<Popconfirm title="确认删除该筛选?" onConfirm={() => void handleDelete(r.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label="删除" />} />
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label={t("编辑")} />} onClick={() => openEdit(r)} />
|
||||
<Popconfirm title={t("确认删除该筛选?")} onConfirm={() => void handleDelete(r.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label={t("删除")} />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
@@ -111,42 +113,42 @@ function FiltersPage() {
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? `编辑筛选 #${editing.id}` : "新增筛选"}
|
||||
title={editing ? t("编辑筛选 #{id}", { id: editing.id }) : t("新增筛选")}
|
||||
open={editing !== null}
|
||||
onCancel={() => setEditing(null)}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v)}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入名称" }]}>
|
||||
<Input placeholder="如 Chip Platform" />
|
||||
<Form.Item name="name" label={t("名称")} rules={[{ required: true, message: t("请输入名称") }]}>
|
||||
<Input placeholder={t("如 Chip Platform")} />
|
||||
</Form.Item>
|
||||
<Form.Item name="slug" label="URL 参数名" rules={[{ required: true, message: "请输入 URL 参数名" }]}>
|
||||
<Form.Item name="slug" label={t("URL 参数名")} rules={[{ required: true, message: t("请输入 URL 参数名") }]}>
|
||||
<Input placeholder="chip-platform" />
|
||||
</Form.Item>
|
||||
<Space size={16} style={{ display: "flex" }}>
|
||||
<Form.Item name="type" label="类型">
|
||||
<Form.Item name="type" label={t("类型")}>
|
||||
<Select
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: "attribute", label: "属性" },
|
||||
{ value: "category", label: "分类" },
|
||||
{ value: "price", label: "价格" },
|
||||
{ value: "attribute", label: t("属性") },
|
||||
{ value: "category", label: t("分类") },
|
||||
{ value: "price", label: t("价格") },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="attributeId" label="关联属性">
|
||||
<Form.Item name="attributeId" label={t("关联属性")}>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
placeholder="仅属性类型需要"
|
||||
placeholder={t("仅属性类型需要")}
|
||||
options={data.attributes.map((a) => ({ value: a.id, label: `${a.name} (${a.slug})` }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="position" label="位置">
|
||||
<Form.Item name="position" label={t("位置")}>
|
||||
<InputNumber min={0} style={{ width: 100 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Form.Item name="enabled" label={t("启用")} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
+44
-42
@@ -19,6 +19,7 @@ import {
|
||||
TimerReset,
|
||||
} from "lucide-react";
|
||||
import { adminStats } from "~/server/admin";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
import {
|
||||
AdminEmpty,
|
||||
Forbidden,
|
||||
@@ -39,6 +40,7 @@ export const Route = createFileRoute("/admin/")({
|
||||
* 数据全部来自 adminStats(30 天趋势、待办、状态分布、最近订单/询盘)。
|
||||
*/
|
||||
function DashboardPage() {
|
||||
const t = useAdminT();
|
||||
const stats = Route.useLoaderData();
|
||||
if (!stats) return <Forbidden />;
|
||||
const { counts, trend, trend30, inquiryStatus, revenue, recentInquiries, recentOrders } = stats;
|
||||
@@ -61,12 +63,12 @@ function DashboardPage() {
|
||||
|
||||
/** # 成员变量 KPI 卡配置(图标 + 标题 + 数值 + 环比) **/
|
||||
const cards = [
|
||||
{ title: "订单(30 天环比)", value: ordNow, delta: delta(ordNow, ordPrev), icon: <ShoppingCart size={18} aria-hidden color="#faad14" />, href: "/admin/orders/" },
|
||||
{ title: "营收(30 天环比)", value: revNow, delta: delta(revNow, revPrev), icon: <DollarSign size={18} aria-hidden color="#52c41a" />, href: "/admin/orders/", money: true },
|
||||
{ title: "新询盘(待跟进)", value: newInq, delta: delta(inqNow, inqPrev), icon: <Inbox size={18} aria-hidden color="#722ed1" />, href: "/admin/inquiries?status=new" },
|
||||
{ title: "待处理订单", value: counts.pendingOrders, icon: <TimerReset size={18} aria-hidden color="#fa541c" />, href: "/admin/orders/" },
|
||||
{ title: "产品", value: counts.products, icon: <Package size={18} aria-hidden color="#13c2c2" />, href: "/admin/products/" },
|
||||
{ title: "页面 / 文章", value: counts.contents, icon: <FileText size={18} aria-hidden color="#1677ff" />, href: "/admin/contents/" },
|
||||
{ title: t("订单(30 天环比)"), value: ordNow, delta: delta(ordNow, ordPrev), icon: <ShoppingCart size={18} aria-hidden color="#faad14" />, href: "/admin/orders/" },
|
||||
{ title: t("营收(30 天环比)"), value: revNow, delta: delta(revNow, revPrev), icon: <DollarSign size={18} aria-hidden color="#52c41a" />, href: "/admin/orders/", money: true },
|
||||
{ title: t("新询盘(待跟进)"), value: newInq, delta: delta(inqNow, inqPrev), icon: <Inbox size={18} aria-hidden color="#722ed1" />, href: "/admin/inquiries?status=new" },
|
||||
{ title: t("待处理订单"), value: counts.pendingOrders, icon: <TimerReset size={18} aria-hidden color="#fa541c" />, href: "/admin/orders/" },
|
||||
{ title: t("产品"), value: counts.products, icon: <Package size={18} aria-hidden color="#13c2c2" />, href: "/admin/products/" },
|
||||
{ title: t("页面 / 文章"), value: counts.contents, icon: <FileText size={18} aria-hidden color="#1677ff" />, href: "/admin/contents/" },
|
||||
];
|
||||
|
||||
/** # 函数 环比箭头与配色 */
|
||||
@@ -89,11 +91,11 @@ function DashboardPage() {
|
||||
<a href={c.href} style={{ display: "block" }}>
|
||||
<Card size="small" hoverable style={{ height: "100%" }}>
|
||||
<Space size={10} align="start">
|
||||
<div style={{ width: 34, height: 34, borderRadius: 8, background: "#f5f7fa", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: 8, background: "var(--ant-color-fill-quaternary)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
{c.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: "#8c8c8c" }}>{c.title}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ant-color-text-tertiary)" }}>{c.title}</div>
|
||||
<Space size={8}>
|
||||
<span style={{ fontSize: 22, fontWeight: 600, lineHeight: 1.2 }}>
|
||||
{c.money ? `$${Math.round(c.value).toLocaleString()}` : c.value.toLocaleString()}
|
||||
@@ -112,31 +114,31 @@ function DashboardPage() {
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col xs={24} xl={16}>
|
||||
<Card
|
||||
title="近 30 天订单 / 询盘趋势"
|
||||
title={t("近 30 天订单 / 询盘趋势")}
|
||||
size="small"
|
||||
extra={
|
||||
<Space size={16} style={{ fontSize: 12, color: "#666" }}>
|
||||
<span><span style={{ display: "inline-block", width: 10, height: 10, background: "#1677ff", borderRadius: 2, marginRight: 4 }} />订单</span>
|
||||
<span><span style={{ display: "inline-block", width: 10, height: 10, background: "#722ed1", borderRadius: 2, marginRight: 4 }} />询盘</span>
|
||||
<Space size={16} style={{ fontSize: 12, color: "var(--ant-color-text-secondary)" }}>
|
||||
<span><span style={{ display: "inline-block", width: 10, height: 10, background: "#1677ff", borderRadius: 2, marginRight: 4 }} />{t("订单")}</span>
|
||||
<span><span style={{ display: "inline-block", width: 10, height: 10, background: "#722ed1", borderRadius: 2, marginRight: 4 }} />{t("询盘")}</span>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{/* 双列柱图(纯 div,无图表库依赖) */}
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 3, height: 180 }}>
|
||||
{trend30.map((t) => (
|
||||
<div key={t.day} title={`${t.day}\n订单 ${t.orders}($${Math.round(t.revenue)})\n询盘 ${t.inquiries}`} style={{ flex: 1, height: "100%", display: "flex", alignItems: "flex-end", justifyContent: "center", gap: 1 }}>
|
||||
<div style={{ width: "46%", height: `${(t.orders / max30) * 100}%`, minHeight: t.orders ? 3 : 0, background: "#1677ff", borderRadius: "2px 2px 0 0" }} />
|
||||
<div style={{ width: "46%", height: `${(t.inquiries / max30) * 100}%`, minHeight: t.inquiries ? 3 : 0, background: "#722ed1", borderRadius: "2px 2px 0 0", opacity: 0.75 }} />
|
||||
{trend30.map((d) => (
|
||||
<div key={d.day} title={`${d.day}\n${t("订单")} ${d.orders}($${Math.round(d.revenue)})\n${t("询盘")} ${d.inquiries}`} style={{ flex: 1, height: "100%", display: "flex", alignItems: "flex-end", justifyContent: "center", gap: 1 }}>
|
||||
<div style={{ width: "46%", height: `${(d.orders / max30) * 100}%`, minHeight: d.orders ? 3 : 0, background: "#1677ff", borderRadius: "2px 2px 0 0" }} />
|
||||
<div style={{ width: "46%", height: `${(d.inquiries / max30) * 100}%`, minHeight: d.inquiries ? 3 : 0, background: "#722ed1", borderRadius: "2px 2px 0 0", opacity: 0.75 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "#999", marginTop: 6 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "var(--ant-color-text-tertiary)", marginTop: 6 }}>
|
||||
<span>{trend30[0]?.day}</span>
|
||||
<span>{trend30[Math.floor(trend30.length / 2)]?.day}</span>
|
||||
<span>{trend30[trend30.length - 1]?.day}</span>
|
||||
</div>
|
||||
{/* 营收迷你面积条 */}
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 3, height: 44, marginTop: 10 }} title="每日营收($)">
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 3, height: 44, marginTop: 10 }} title={t("每日营收($)")}>
|
||||
{trend30.map((t) => (
|
||||
<div key={t.day} style={{ flex: 1, height: `${Math.max(t.revenue ? 8 : 0, (t.revenue / maxRevenue) * 100)}%`, background: "#b7eb8f", borderRadius: "2px 2px 0 0" }} />
|
||||
))}
|
||||
@@ -144,22 +146,22 @@ function DashboardPage() {
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} xl={8}>
|
||||
<Card title="询盘漏斗" size="small" style={{ height: "100%" }}>
|
||||
<Card title={t("询盘漏斗")} size="small" style={{ height: "100%" }}>
|
||||
<Space direction="vertical" size={12} style={{ display: "flex" }}>
|
||||
{(["new", "read", "replied", "spam", "archived"] as const).map((st) => {
|
||||
const n = inquiryStatus.find((s) => s.status === st)?.count ?? 0;
|
||||
return (
|
||||
<div key={st}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 13, marginBottom: 4 }}>
|
||||
<span>{INQUIRY_STATUS_LABEL[st] ?? st}</span>
|
||||
<span>{t(INQUIRY_STATUS_LABEL[st] ?? st)}</span>
|
||||
<span style={{ fontWeight: 600 }}>{n.toLocaleString()}</span>
|
||||
</div>
|
||||
<Progress percent={Math.round((n / totalInq) * 100)} showInfo={false} strokeColor={INQUIRY_STATUS_COLOR[st] === "red" ? "#ff4d4f" : st === "new" ? "#1677ff" : "#95de64"} size="small" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div style={{ fontSize: 12, color: "#999" }}>
|
||||
共 {totalInq.toLocaleString()} 条,其中垃圾 {spamInq.toLocaleString()} 条({(Math.round((spamInq / totalInq) * 100))}%)
|
||||
<div style={{ fontSize: 12, color: "var(--ant-color-text-tertiary)" }}>
|
||||
{t("共 {n} 条,其中垃圾 {s} 条({p}%)", { n: totalInq.toLocaleString(), s: spamInq.toLocaleString(), p: Math.round((spamInq / totalInq) * 100) })}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
@@ -170,9 +172,9 @@ function DashboardPage() {
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col xs={24} xl={12}>
|
||||
<Card
|
||||
title="最近订单"
|
||||
title={t("最近订单")}
|
||||
size="small"
|
||||
extra={<Link to="/admin/orders/">全部订单 →</Link>}
|
||||
extra={<Link to="/admin/orders/">{t("全部订单 →")}</Link>}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
@@ -181,20 +183,20 @@ function DashboardPage() {
|
||||
pagination={false}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
columns={[
|
||||
{ title: "单号", dataIndex: "orderNumber", width: 130, render: (v: string, r) => <Link to="/admin/orders/$id/" params={{ id: String(r.id) }}>{v}</Link> },
|
||||
{ title: "客户", dataIndex: "customerName", ellipsis: true },
|
||||
{ title: "金额", dataIndex: "total", width: 100, render: (v: string, r) => `${r.currency} ${v}` },
|
||||
{ title: "状态", dataIndex: "status", width: 90, render: (v: string) => <Tag color={ORDER_STATUS_COLOR[v] ?? "default"}>{ORDER_STATUS_LABEL[v] ?? v}</Tag> },
|
||||
{ title: "时间", dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
|
||||
{ title: t("单号"), dataIndex: "orderNumber", width: 130, render: (v: string, r) => <Link to="/admin/orders/$id/" params={{ id: String(r.id) }}>{v}</Link> },
|
||||
{ title: t("客户"), dataIndex: "customerName", ellipsis: true },
|
||||
{ title: t("金额"), dataIndex: "total", width: 100, render: (v: string, r) => `${r.currency} ${v}` },
|
||||
{ title: t("状态"), dataIndex: "status", width: 90, render: (v: string) => <Tag color={ORDER_STATUS_COLOR[v] ?? "default"}>{t(ORDER_STATUS_LABEL[v] ?? v)}</Tag> },
|
||||
{ title: t("时间"), dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} xl={12}>
|
||||
<Card
|
||||
title="最新询盘"
|
||||
title={t("最新询盘")}
|
||||
size="small"
|
||||
extra={<Link to="/admin/inquiries/">全部询盘 →</Link>}
|
||||
extra={<Link to="/admin/inquiries/">{t("全部询盘 →")}</Link>}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
@@ -203,11 +205,11 @@ function DashboardPage() {
|
||||
pagination={false}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
columns={[
|
||||
{ title: "姓名", dataIndex: "name", width: 120, ellipsis: true },
|
||||
{ title: "邮箱", dataIndex: "email", width: 200, ellipsis: true },
|
||||
{ title: "国家", dataIndex: "country", width: 100 },
|
||||
{ title: "状态", dataIndex: "status", width: 90, render: (v: string) => <Tag color={INQUIRY_STATUS_COLOR[v] ?? "default"}>{INQUIRY_STATUS_LABEL[v] ?? v}</Tag> },
|
||||
{ title: "时间", dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
|
||||
{ title: t("姓名"), dataIndex: "name", width: 120, ellipsis: true },
|
||||
{ title: t("邮箱"), dataIndex: "email", width: 200, ellipsis: true },
|
||||
{ title: t("国家"), dataIndex: "country", width: 100 },
|
||||
{ title: t("状态"), dataIndex: "status", width: 90, render: (v: string) => <Tag color={INQUIRY_STATUS_COLOR[v] ?? "default"}>{t(INQUIRY_STATUS_LABEL[v] ?? v)}</Tag> },
|
||||
{ title: t("时间"), dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
@@ -215,13 +217,13 @@ function DashboardPage() {
|
||||
</Row>
|
||||
|
||||
{/* 快捷入口 */}
|
||||
<Card title="快捷操作" size="small" style={{ marginTop: 16 }}>
|
||||
<Card title={t("快捷操作")} size="small" style={{ marginTop: 16 }}>
|
||||
<Space size={12} wrap>
|
||||
<Link to="/admin/contents/$id/" params={{ id: "new" }}><Tag style={{ padding: "6px 12px", fontSize: 13 }}><FileText size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />新建页面</Tag></Link>
|
||||
<Link to="/admin/products/"><Tag style={{ padding: "6px 12px", fontSize: 13 }}><PackagePlus size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />产品管理</Tag></Link>
|
||||
<Link to="/admin/media/"><Tag style={{ padding: "6px 12px", fontSize: 13 }}><ClipboardList size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />媒体库</Tag></Link>
|
||||
<Link to="/admin/inquiries/" search={{ status: "new" }}><Tag style={{ padding: "6px 12px", fontSize: 13 }}><MessageSquarePlus size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />跟进新询盘</Tag></Link>
|
||||
<Link to="/admin/menus/"><Tag style={{ padding: "6px 12px", fontSize: 13 }}><ClipboardList size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />菜单管理</Tag></Link>
|
||||
<Link to="/admin/contents/$id/" params={{ id: "new" }}><Tag style={{ padding: "6px 12px", fontSize: 13 }}><FileText size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />{t("新建页面")}</Tag></Link>
|
||||
<Link to="/admin/products/"><Tag style={{ padding: "6px 12px", fontSize: 13 }}><PackagePlus size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />{t("产品管理")}</Tag></Link>
|
||||
<Link to="/admin/media/"><Tag style={{ padding: "6px 12px", fontSize: 13 }}><ClipboardList size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />{t("媒体库")}</Tag></Link>
|
||||
<Link to="/admin/inquiries/" search={{ status: "new" }}><Tag style={{ padding: "6px 12px", fontSize: 13 }}><MessageSquarePlus size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />{t("跟进新询盘")}</Tag></Link>
|
||||
<Link to="/admin/menus/"><Tag style={{ padding: "6px 12px", fontSize: 13 }}><ClipboardList size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />{t("菜单管理")}</Tag></Link>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* # 路由 /admin/inquiries 询盘管理
|
||||
* 状态 Tab 过滤 + 表格;行点击抽屉查看完整 fields JSON;操作按钮标记已读/已回复/垃圾。
|
||||
* 状态 Tab 过滤 + 表格;行点击抽屉查看详情(宽度为普通抽屉两倍,便于阅读);
|
||||
* fields 以可读键值渲染(过滤下划线内部键,PHP 序列化遗留值兜底还原),不再出现裸 JSON。
|
||||
* 数据接口复用 src/server/inquiries.ts 的 listInquiries / updateInquiryStatus。
|
||||
*/
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
@@ -9,6 +10,8 @@ import { CheckOutlined, SearchOutlined, SendOutlined, StopOutlined } from "@ant-
|
||||
import { useState } from "react";
|
||||
import { listInquiries, updateInquiryStatus } from "~/server/inquiries";
|
||||
import type { inquiries } from "~/db/schema";
|
||||
import { deepConvertPhp } from "~/lib/php-serialize";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
import {
|
||||
AdminEmpty,
|
||||
Forbidden,
|
||||
@@ -16,7 +19,6 @@ import {
|
||||
INQUIRY_STATUS_LABEL,
|
||||
Json,
|
||||
PageHeader,
|
||||
StatusTag,
|
||||
fmtDateTime,
|
||||
guardAdmin,
|
||||
tablePagination,
|
||||
@@ -44,8 +46,42 @@ export const Route = createFileRoute("/admin/inquiries")({
|
||||
/** # 成员变量 询盘行类型(fields/utm 收窄为 Json 后的形状) **/
|
||||
type InquiryRow = Omit<typeof inquiries.$inferSelect, "fields" | "utm"> & { fields: Json; utm: Json };
|
||||
|
||||
/** # 成员变量 fields 常见键的中文标签(其余键原样展示;下划线内部键不展示) **/
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
"your-name": "姓名",
|
||||
"your-email": "邮箱",
|
||||
"your-message": "询盘内容",
|
||||
"your-subject": "主题",
|
||||
tel: "电话",
|
||||
telephone: "电话",
|
||||
phone: "电话",
|
||||
company: "公司",
|
||||
website: "网站",
|
||||
country: "国家",
|
||||
subject: "主题",
|
||||
message: "询盘内容",
|
||||
annualdemand: "年需求量",
|
||||
"annual-demand": "年需求量",
|
||||
accept: "接受条款",
|
||||
};
|
||||
|
||||
/** # 函数 把任意字段值转为可读文本(数组逗号连接;对象键值拼接;PHP 序列化串已由 deepConvertPhp 还原) */
|
||||
function fieldText(v: unknown): string {
|
||||
if (v === null || v === undefined) return "";
|
||||
if (typeof v === "boolean") return v ? "yes" : "no";
|
||||
if (Array.isArray(v)) return v.map((x) => fieldText(x)).filter(Boolean).join(", ");
|
||||
if (typeof v === "object") {
|
||||
return Object.entries(v as Record<string, unknown>)
|
||||
.map(([k, x]) => `${k}: ${fieldText(x)}`)
|
||||
.filter((s) => !s.endsWith(": "))
|
||||
.join(";");
|
||||
}
|
||||
return String(v).trim();
|
||||
}
|
||||
|
||||
/** # 函数 询盘管理页面组件 */
|
||||
function InquiriesPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
@@ -65,18 +101,25 @@ function InquiriesPage() {
|
||||
/** # 函数 更新询盘状态并刷新 */
|
||||
async function handleStatus(id: number, status: string) {
|
||||
await updateInquiryStatus({ data: { id, status } });
|
||||
message.success("状态已更新");
|
||||
message.success(t("状态已更新"));
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
/** # 成员变量 抽屉内可读展示的字段行(过滤下划线内部键与空值;PHP 遗留串兜底还原) **/
|
||||
const extraFieldRows = viewing
|
||||
? Object.entries((deepConvertPhp(viewing.fields) ?? {}) as Record<string, unknown>)
|
||||
.filter(([k, v]) => !k.startsWith("_") && fieldText(v) !== "")
|
||||
.map(([k, v]) => ({ key: k, label: FIELD_LABELS[k] ? t(FIELD_LABELS[k]) : k, text: fieldText(v) }))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="询盘管理"
|
||||
title={t("询盘管理")}
|
||||
extra={
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索姓名 / 邮箱 / 公司"
|
||||
placeholder={t("搜索姓名 / 邮箱 / 公司")}
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
@@ -89,8 +132,8 @@ function InquiriesPage() {
|
||||
activeKey={search.status ?? ""}
|
||||
onChange={(key) => updateSearch({ status: key })}
|
||||
items={[
|
||||
{ key: "", label: "全部" },
|
||||
...(["new", "read", "replied", "spam", "archived"] as const).map((s) => ({ key: s, label: INQUIRY_STATUS_LABEL[s] })),
|
||||
{ key: "", label: t("全部") },
|
||||
...(["new", "read", "replied", "spam", "archived"] as const).map((s) => ({ key: s, label: t(INQUIRY_STATUS_LABEL[s]) })),
|
||||
]}
|
||||
/>
|
||||
<Table
|
||||
@@ -103,34 +146,34 @@ function InquiriesPage() {
|
||||
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||
)}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{ title: "时间", dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
|
||||
{ title: "姓名", dataIndex: "name", width: 130, ellipsis: true },
|
||||
{ title: "邮箱", dataIndex: "email", width: 200, ellipsis: true },
|
||||
{ title: "公司", dataIndex: "company", width: 160, ellipsis: true },
|
||||
{ title: "国家", dataIndex: "country", width: 110, ellipsis: true },
|
||||
{ title: "感兴趣产品", dataIndex: "productInterest", ellipsis: true },
|
||||
{ title: "来源", dataIndex: "formName", width: 150, ellipsis: true },
|
||||
{ title: t("ID"), dataIndex: "id", width: 70 },
|
||||
{ title: t("时间"), dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
|
||||
{ title: t("姓名"), dataIndex: "name", width: 130, ellipsis: true },
|
||||
{ title: t("邮箱"), dataIndex: "email", width: 200, ellipsis: true },
|
||||
{ title: t("公司"), dataIndex: "company", width: 160, ellipsis: true },
|
||||
{ title: t("国家"), dataIndex: "country", width: 110, ellipsis: true },
|
||||
{ title: t("感兴趣产品"), dataIndex: "productInterest", ellipsis: true },
|
||||
{ title: t("来源"), dataIndex: "formName", width: 150, ellipsis: true },
|
||||
{
|
||||
title: "状态",
|
||||
title: t("状态"),
|
||||
dataIndex: "status",
|
||||
width: 100,
|
||||
render: (v: string) => <StatusTag value={v} colorMap={INQUIRY_STATUS_COLOR} labelMap={INQUIRY_STATUS_LABEL} />,
|
||||
render: (v: string) => <Tag color={INQUIRY_STATUS_COLOR[v] ?? "default"}>{t(INQUIRY_STATUS_LABEL[v] ?? v)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
title: t("操作"),
|
||||
key: "actions",
|
||||
width: 130,
|
||||
render: (_, r) => (
|
||||
<Space size={0} onClick={(e) => e.stopPropagation()}>
|
||||
{r.status !== "read" && r.status !== "replied" && (
|
||||
<Button size="small" type="text" icon={<CheckOutlined aria-label="标记已读" />} title="标记已读" onClick={() => void handleStatus(r.id, "read")} />
|
||||
<Button size="small" type="text" icon={<CheckOutlined aria-label={t("标记已读")} />} title={t("标记已读")} onClick={() => void handleStatus(r.id, "read")} />
|
||||
)}
|
||||
{r.status !== "replied" && (
|
||||
<Button size="small" type="text" icon={<SendOutlined aria-label="标记已回复" />} title="标记已回复" onClick={() => void handleStatus(r.id, "replied")} />
|
||||
<Button size="small" type="text" icon={<SendOutlined aria-label={t("标记已回复")} />} title={t("标记已回复")} onClick={() => void handleStatus(r.id, "replied")} />
|
||||
)}
|
||||
{r.status !== "spam" && (
|
||||
<Button size="small" type="text" danger icon={<StopOutlined aria-label="标记垃圾" />} title="标记垃圾" onClick={() => void handleStatus(r.id, "spam")} />
|
||||
<Button size="small" type="text" danger icon={<StopOutlined aria-label={t("标记垃圾")} />} title={t("标记垃圾")} onClick={() => void handleStatus(r.id, "spam")} />
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
@@ -139,34 +182,40 @@ function InquiriesPage() {
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title={viewing ? `询盘 #${viewing.id} · ${viewing.name || viewing.email}` : ""}
|
||||
width={560}
|
||||
title={viewing ? `${t("询盘")} #${viewing.id} · ${viewing.name || viewing.email}` : ""}
|
||||
width="min(1120px, 92vw)"
|
||||
open={viewing !== null}
|
||||
onClose={() => setViewing(null)}
|
||||
destroyOnHidden
|
||||
>
|
||||
{viewing && (
|
||||
<>
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={INQUIRY_STATUS_COLOR[viewing.status] ?? "default"}>{INQUIRY_STATUS_LABEL[viewing.status] ?? viewing.status}</Tag>
|
||||
<Descriptions size="small" column={2} bordered styles={{ label: { width: 130 } }}>
|
||||
<Descriptions.Item label={t("状态")}>
|
||||
<Tag color={INQUIRY_STATUS_COLOR[viewing.status] ?? "default"}>{t(INQUIRY_STATUS_LABEL[viewing.status] ?? viewing.status)}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtDateTime(viewing.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="姓名">{viewing.name || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="邮箱">{viewing.email || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="电话">{viewing.phone || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="公司">{viewing.company || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="国家">{viewing.country || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="感兴趣产品">{viewing.productInterest || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="来源">{viewing.formName || viewing.channel || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="来源页面">{viewing.sourcePage || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label={t("时间")}>{fmtDateTime(viewing.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t("姓名")}>{viewing.name || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label={t("邮箱")}>{viewing.email || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label={t("电话")}>{viewing.phone || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label={t("公司")}>{viewing.company || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label={t("国家")}>{viewing.country || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label={t("感兴趣产品")}>{viewing.productInterest || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label={t("来源")}>{viewing.formName || viewing.channel || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label={t("来源页面")}>{viewing.sourcePage || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="IP">{viewing.ip || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="询盘内容">{viewing.message || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label={t("询盘内容")}>{viewing.message || "-"}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<h4 style={{ margin: "16px 0 8px" }}>完整字段(fields JSON)</h4>
|
||||
<pre style={{ background: "#f5f5f5", padding: 12, borderRadius: 6, fontSize: 12, maxHeight: 320, overflow: "auto" }}>
|
||||
{JSON.stringify(viewing.fields, null, 2)}
|
||||
</pre>
|
||||
{extraFieldRows.length ? (
|
||||
<>
|
||||
<h4 style={{ margin: "20px 0 8px" }}>{t("其他提交字段")}</h4>
|
||||
<Descriptions size="small" column={1} bordered styles={{ label: { width: 180 } }}>
|
||||
{extraFieldRows.map((r) => (
|
||||
<Descriptions.Item key={r.key} label={r.label}>{r.text}</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { App, Card, Switch, Table, Tag } from "antd";
|
||||
import { adminListLanguages, adminUpdateLanguage } from "~/server/admin";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
import { AdminEmpty, Forbidden, PageHeader, guardAdmin } from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/languages")({
|
||||
@@ -14,6 +15,7 @@ export const Route = createFileRoute("/admin/languages")({
|
||||
|
||||
/** # 函数 语言配置页面组件 */
|
||||
function LanguagesPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
@@ -23,40 +25,52 @@ function LanguagesPage() {
|
||||
/** # 函数 切换语言启用状态并刷新 */
|
||||
async function toggleEnabled(code: string, enabled: boolean) {
|
||||
await adminUpdateLanguage({ data: { code, enabled } });
|
||||
message.success(enabled ? `已启用 ${code}` : `已禁用 ${code}`);
|
||||
message.success(enabled ? t("已启用 {code}", { code }) : t("已禁用 {code}", { code }));
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader title="语言配置" extra={<span style={{ color: "#999" }}>启用后该语言会生成真实页面;默认语言不可禁用逻辑由前台渲染决定</span>} />
|
||||
<PageHeader
|
||||
title={t("语言配置")}
|
||||
extra={
|
||||
<span style={{ color: "var(--ant-color-text-tertiary)" }}>{t("启用后该语言会生成真实页面;默认语言不可禁用逻辑由前台渲染决定")}</span>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
rowKey="code"
|
||||
size="small"
|
||||
dataSource={data.items}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
pagination={{ pageSize: 20, showTotal: (t: number) => `共 ${t} 种语言` }}
|
||||
pagination={{ pageSize: 20, showTotal: (total: number) => t("共 {n} 种语言", { n: total }) }}
|
||||
columns={[
|
||||
{ title: "代码", dataIndex: "code", width: 110 },
|
||||
{ title: "名称", dataIndex: "name", width: 200 },
|
||||
{ title: t("代码"), dataIndex: "code", width: 110 },
|
||||
{ title: t("名称"), dataIndex: "name", width: 200 },
|
||||
{
|
||||
title: "默认语言",
|
||||
title: t("默认语言"),
|
||||
dataIndex: "isDefault",
|
||||
width: 100,
|
||||
render: (v: boolean) => (v ? <Tag color="blue">默认</Tag> : <Tag>否</Tag>),
|
||||
render: (v: boolean) => (v ? <Tag color="blue">{t("默认")}</Tag> : <Tag>{t("否")}</Tag>),
|
||||
},
|
||||
{
|
||||
title: "机翻",
|
||||
title: t("机翻"),
|
||||
dataIndex: "machine",
|
||||
width: 90,
|
||||
render: (v: boolean) => (v ? <Tag color="purple">机翻</Tag> : "-"),
|
||||
render: (v: boolean) => (v ? <Tag color="purple">{t("机翻")}</Tag> : "-"),
|
||||
},
|
||||
{ title: "排序", dataIndex: "sort", width: 80 },
|
||||
{ title: t("排序"), dataIndex: "sort", width: 80 },
|
||||
{
|
||||
title: "启用",
|
||||
title: t("启用"),
|
||||
dataIndex: "enabled",
|
||||
width: 90,
|
||||
render: (v: boolean, r) => <Switch size="small" checked={v} onChange={(on) => void toggleEnabled(r.code, on)} aria-label={`切换语言 ${r.code} 启用状态`} />,
|
||||
render: (v: boolean, r) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={v}
|
||||
onChange={(on) => void toggleEnabled(r.code, on)}
|
||||
aria-label={t("切换语言 {code} 启用状态", { code: r.code })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
+22
-20
@@ -8,6 +8,7 @@ import { EditOutlined, SearchOutlined, UploadOutlined } from "@ant-design/icons"
|
||||
import { File as FileIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { adminListMedia, adminUpdateMedia, adminUploadMedia } from "~/server/admin";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||
import type { media } from "~/db/schema";
|
||||
|
||||
@@ -30,6 +31,7 @@ type MediaRow = typeof media.$inferSelect;
|
||||
|
||||
/** # 函数 媒体库页面组件 */
|
||||
function MediaPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
@@ -57,7 +59,7 @@ function MediaPage() {
|
||||
async function handleSave(values: { altText?: string; title?: string }) {
|
||||
if (!editing) return;
|
||||
await adminUpdateMedia({ data: { id: editing.id, altText: values.altText, title: values.title } });
|
||||
message.success("已保存");
|
||||
message.success(t("已保存"));
|
||||
setEditing(null);
|
||||
await router.invalidate();
|
||||
}
|
||||
@@ -72,12 +74,12 @@ function MediaPage() {
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="媒体库"
|
||||
title={t("媒体库")}
|
||||
extra={
|
||||
<Space>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索文件路径"
|
||||
placeholder={t("搜索文件路径")}
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
@@ -97,17 +99,17 @@ function MediaPage() {
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < buf.length; i += CHUNK) bin += String.fromCharCode(...buf.subarray(i, i + CHUNK));
|
||||
const res = await adminUploadMedia({ data: { filename: f.name, mime: f.type || "", dataBase64: btoa(bin) } });
|
||||
message.success(`已上传 ${f.name}(#${res.id})`);
|
||||
message.success(t("已上传 {name}(#{id})", { name: f.name, id: res.id }));
|
||||
onSuccess?.(res);
|
||||
await router.invalidate();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "上传失败");
|
||||
message.error(e instanceof Error ? e.message : t("上传失败"));
|
||||
onError?.(e as Error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="primary" icon={<UploadOutlined aria-hidden />}>
|
||||
上传媒体
|
||||
{t("上传媒体")}
|
||||
</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
@@ -124,18 +126,18 @@ function MediaPage() {
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{
|
||||
title: "预览",
|
||||
title: t("预览"),
|
||||
dataIndex: "url",
|
||||
width: 88,
|
||||
render: (v: string, r) =>
|
||||
r.mimeType.startsWith("image") ? (
|
||||
<Image src={v} alt={r.altText || r.path} width={56} height={56} style={{ objectFit: "cover", borderRadius: 4 }} />
|
||||
) : (
|
||||
<FileIcon size={36} aria-hidden color="#bfbfbf" />
|
||||
<FileIcon size={36} aria-hidden color="var(--ant-color-text-quaternary)" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "路径",
|
||||
title: t("路径"),
|
||||
dataIndex: "path",
|
||||
render: (v: string, r) => (
|
||||
<Typography.Link href={r.url} target="_blank" style={{ wordBreak: "break-all" }}>
|
||||
@@ -143,29 +145,29 @@ function MediaPage() {
|
||||
</Typography.Link>
|
||||
),
|
||||
},
|
||||
{ title: "类型", dataIndex: "mimeType", width: 140 },
|
||||
{ title: "尺寸", key: "dim", width: 110, render: (_, r) => (r.width ? `${r.width} x ${r.height}` : "-") },
|
||||
{ title: "大小", dataIndex: "size", width: 90, render: (v: number | null) => fmtSize(v) },
|
||||
{ title: "引用", dataIndex: "refCount", width: 70 },
|
||||
{ title: "替代文本", dataIndex: "altText", ellipsis: true },
|
||||
{ title: "上传时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{ title: t("类型"), dataIndex: "mimeType", width: 140 },
|
||||
{ title: t("尺寸"), key: "dim", width: 110, render: (_, r) => (r.width ? `${r.width} x ${r.height}` : "-") },
|
||||
{ title: t("大小"), dataIndex: "size", width: 90, render: (v: number | null) => fmtSize(v) },
|
||||
{ title: t("引用数"), dataIndex: "refCount", width: 70 },
|
||||
{ title: t("替代文本"), dataIndex: "altText", ellipsis: true },
|
||||
{ title: t("上传时间"), dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{
|
||||
title: "操作",
|
||||
title: t("操作"),
|
||||
key: "actions",
|
||||
width: 70,
|
||||
render: (_, r) => (
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} />
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label={t("编辑")} />} onClick={() => openEdit(r)} />
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal title={`编辑媒体 #${editing?.id ?? ""}`} open={editing !== null} onCancel={() => setEditing(null)} onOk={() => form.submit()} destroyOnHidden>
|
||||
<Modal title={t("编辑媒体 #{id}", { id: editing?.id ?? "" })} open={editing !== null} onCancel={() => setEditing(null)} onOk={() => form.submit()} destroyOnHidden>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v)}>
|
||||
<Form.Item name="altText" label="替代文本(alt)">
|
||||
<Form.Item name="altText" label={t("替代文本(alt)")}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="标题">
|
||||
<Form.Item name="title" label={t("标题")}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
+26
-24
@@ -8,6 +8,7 @@ import { App, Button, Card, Form, Input, InputNumber, Modal, Select, Space, Tabl
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { adminDeleteMenuItem, adminListMenus, adminSaveMenuItem } from "~/server/admin";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
import { AdminEmpty, Forbidden, PageHeader, guardAdmin } from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/menus")({
|
||||
@@ -22,6 +23,7 @@ type MenuItemRow = { id: number; menuId: number; parentId: number | null; title:
|
||||
|
||||
/** # 函数 菜单管理页面组件 */
|
||||
function MenusPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
@@ -68,34 +70,34 @@ function MenusPage() {
|
||||
async function handleSave(values: { menuId: number; parentId?: number | null; title: string; url: string; target: string; sort: number }) {
|
||||
try {
|
||||
await adminSaveMenuItem({ data: { ...values, parentId: values.parentId ?? null, id: editing?.id || undefined } });
|
||||
message.success("已保存");
|
||||
message.success(t("已保存"));
|
||||
setEditing(null);
|
||||
await router.invalidate();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "保存失败");
|
||||
message.error(e instanceof Error ? e.message : t("保存失败"));
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数 删除菜单项(子项自动提升顶层) */
|
||||
async function handleDelete(id: number) {
|
||||
await adminDeleteMenuItem({ data: { id } });
|
||||
message.success("已删除(子项已提升为顶层)");
|
||||
message.success(t("已删除(子项已提升为顶层)"));
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
/** # 成员变量 父级选择树(排除自身防环) **/
|
||||
const parentOptions = [
|
||||
{ title: "(顶层)", value: 0 },
|
||||
{ title: t("(顶层)"), value: 0 },
|
||||
...rows.filter((r) => r.id !== editing?.id).map((r) => ({ title: `${"— ".repeat(r.depth)}${r.title}`, value: r.id })),
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="菜单管理"
|
||||
title={t("菜单管理")}
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={() => openEdit()} disabled={menuId === null}>
|
||||
新增菜单项
|
||||
{t("新增菜单项")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -104,9 +106,9 @@ function MenusPage() {
|
||||
style={{ width: 300 }}
|
||||
value={menuId ?? undefined}
|
||||
onChange={(v) => setMenuId(v)}
|
||||
options={data.menus.map((m: MenuDef) => ({ value: m.id, label: `${m.name}(${m.location})` }))}
|
||||
options={data.menus.map((m: MenuDef) => ({ value: m.id, label: t("{name}({location})", { name: m.name, location: m.location }) }))}
|
||||
/>
|
||||
<span style={{ color: "#999", fontSize: 12 }}>前台 Header/Footer 即时读取所选位置菜单</span>
|
||||
<span style={{ color: "var(--ant-color-text-tertiary)", fontSize: 12 }}>{t("前台 Header/Footer 即时读取所选位置菜单")}</span>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
@@ -116,26 +118,26 @@ function MenusPage() {
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: "标题",
|
||||
title: t("标题"),
|
||||
dataIndex: "title",
|
||||
render: (v: string, r) => (
|
||||
<span style={{ paddingLeft: r.depth * 24 }}>
|
||||
{r.depth > 0 ? <span style={{ color: "#bbb", marginRight: 4 }}>└</span> : null}
|
||||
{r.depth > 0 ? <span style={{ color: "var(--ant-color-text-quaternary)", marginRight: 4 }}>└</span> : null}
|
||||
{v}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ title: "链接", dataIndex: "url", ellipsis: true, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
|
||||
{ title: "排序", dataIndex: "sort", width: 70 },
|
||||
{ title: "打开方式", dataIndex: "target", width: 100 },
|
||||
{ title: t("链接"), dataIndex: "url", ellipsis: true, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
|
||||
{ title: t("排序"), dataIndex: "sort", width: 70 },
|
||||
{ title: t("打开方式"), dataIndex: "target", width: 100 },
|
||||
{
|
||||
title: "操作",
|
||||
title: t("操作"),
|
||||
key: "act",
|
||||
width: 110,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} />
|
||||
<Button size="small" danger type="text" icon={<DeleteOutlined aria-label="删除" />} onClick={() => void handleDelete(r.id)} />
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label={t("编辑")} />} onClick={() => openEdit(r)} />
|
||||
<Button size="small" danger type="text" icon={<DeleteOutlined aria-label={t("删除")} />} onClick={() => void handleDelete(r.id)} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -143,28 +145,28 @@ function MenusPage() {
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing?.id ? `编辑菜单项 #${editing.id}` : "新增菜单项"}
|
||||
title={editing?.id ? t("编辑菜单项 #{id}", { id: editing.id }) : t("新增菜单项")}
|
||||
open={editing !== null}
|
||||
onCancel={() => setEditing(null)}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v as { menuId: number; parentId?: number | null; title: string; url: string; target: string; sort: number })}>
|
||||
<Form.Item name="title" label="显示文字" rules={[{ required: true, message: "请输入显示文字" }]}>
|
||||
<Form.Item name="title" label={t("显示文字")} rules={[{ required: true, message: t("请输入显示文字") }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="url" label="链接地址" rules={[{ required: true, message: "请输入链接" }]}>
|
||||
<Input placeholder="/products/ 或 https://…" />
|
||||
<Form.Item name="url" label={t("链接地址")} rules={[{ required: true, message: t("请输入链接") }]}>
|
||||
<Input placeholder={t("/products/ 或 https://…")} />
|
||||
</Form.Item>
|
||||
<Form.Item name="parentId" label="父级菜单项" initialValue={0}>
|
||||
<Form.Item name="parentId" label={t("父级菜单项")} initialValue={0}>
|
||||
<TreeSelect treeData={parentOptions} treeDefaultExpandAll />
|
||||
</Form.Item>
|
||||
<Space size={16} style={{ display: "flex" }}>
|
||||
<Form.Item name="sort" label="排序" initialValue={0}>
|
||||
<Form.Item name="sort" label={t("排序")} initialValue={0}>
|
||||
<InputNumber min={0} max={9999} />
|
||||
</Form.Item>
|
||||
<Form.Item name="target" label="打开方式" initialValue="_self">
|
||||
<Select style={{ width: 140 }} options={[{ value: "_self", label: "当前窗口" }, { value: "_blank", label: "新窗口" }]} />
|
||||
<Form.Item name="target" label={t("打开方式")} initialValue="_self">
|
||||
<Select style={{ width: 140 }} options={[{ value: "_self", label: t("当前窗口") }, { value: "_blank", label: t("新窗口") }]} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="menuId" hidden initialValue={menuId ?? 0}>
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
/**
|
||||
* # 路由 /admin/orders/$id 订单详情
|
||||
* 订单信息描述列表 + 明细表格 + 状态流转 Select。
|
||||
* 账单 / 收货信息以可读键值渲染(地址字段中文化),不再出现裸 JSON。
|
||||
*/
|
||||
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { App, Button, Card, Descriptions, Select, Space, Table, Tag } from "antd";
|
||||
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||
import type { Json } from "~/components/admin/common";
|
||||
import { adminGetOrder, adminUpdateOrderStatus } from "~/server/admin";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
import {
|
||||
AdminEmpty,
|
||||
ORDER_STATUS_COLOR,
|
||||
ORDER_STATUS_LABEL,
|
||||
ORDER_STATUS_OPTIONS,
|
||||
StatusTag,
|
||||
fmtDateTime,
|
||||
Forbidden,
|
||||
guardAdmin,
|
||||
@@ -22,8 +24,32 @@ export const Route = createFileRoute("/admin/orders/$id")({
|
||||
component: OrderDetailPage,
|
||||
});
|
||||
|
||||
/** # 成员变量 地址对象常见字段的中文标签 **/
|
||||
const ADDRESS_LABELS: Record<string, string> = {
|
||||
first_name: "名",
|
||||
last_name: "姓",
|
||||
company: "公司",
|
||||
address_1: "地址 1",
|
||||
address_2: "地址 2",
|
||||
city: "城市",
|
||||
state: "州 / 省",
|
||||
postcode: "邮编",
|
||||
country: "国家",
|
||||
email: "邮箱",
|
||||
phone: "电话",
|
||||
};
|
||||
|
||||
/** # 函数 地址对象 → 可读键值行(过滤空值;未知键原样展示) */
|
||||
function addressRows(v: Json | undefined): { key: string; label: string; text: string }[] {
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return [];
|
||||
return Object.entries(v)
|
||||
.filter(([, x]) => x !== null && x !== undefined && String(x).trim() !== "")
|
||||
.map(([k, x]) => ({ key: k, label: ADDRESS_LABELS[k] ?? k, text: String(x) }));
|
||||
}
|
||||
|
||||
/** # 函数 订单详情页面组件 */
|
||||
function OrderDetailPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
@@ -31,56 +57,79 @@ function OrderDetailPage() {
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
const { order, userEmail, items } = data;
|
||||
const billingRows = addressRows(order.billing as Json);
|
||||
const shippingRows = addressRows(order.shipping as Json);
|
||||
|
||||
/** # 函数 状态流转:更新订单状态并刷新 */
|
||||
async function handleStatus(status: string) {
|
||||
await adminUpdateOrderStatus({ data: { id: order.id, status } });
|
||||
message.success("状态已更新");
|
||||
message.success(t("状态已更新"));
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
/** # 函数 地址键值行渲染(一行一组,紧凑排版) */
|
||||
function renderAddressRows(rows: { key: string; label: string; text: string }[]) {
|
||||
if (!rows.length) return "-";
|
||||
return (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "2px 20px" }}>
|
||||
{rows.map((r) => (
|
||||
<span key={r.key} style={{ fontSize: 14 }}>
|
||||
<span style={{ color: "var(--ant-color-text-tertiary)" }}>{t(r.label)}:</span>
|
||||
{r.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined aria-hidden />} onClick={() => void navigate({ to: "/admin/orders/" })}>
|
||||
返回列表
|
||||
{t("返回列表")}
|
||||
</Button>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>订单 {order.orderNumber}</h2>
|
||||
<StatusTag value={order.status} colorMap={ORDER_STATUS_COLOR} labelMap={ORDER_STATUS_LABEL} />
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>{t("订单")} {order.orderNumber}</h2>
|
||||
<Tag color={ORDER_STATUS_COLOR[order.status] ?? "default"}>{t(ORDER_STATUS_LABEL[order.status] ?? order.status)}</Tag>
|
||||
</Space>
|
||||
|
||||
<Card type="inner" title="订单信息" size="small" style={{ marginBottom: 16 }}>
|
||||
<Card type="inner" title={t("订单信息")} size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions size="small" column={3}>
|
||||
<Descriptions.Item label="状态流转">
|
||||
<Select value={order.status} options={ORDER_STATUS_OPTIONS} style={{ width: 140 }} onChange={(v) => void handleStatus(v)} aria-label="订单状态" />
|
||||
<Descriptions.Item label={t("状态流转")}>
|
||||
<Select
|
||||
value={order.status}
|
||||
options={ORDER_STATUS_OPTIONS.map((o) => ({ value: o.value, label: t(o.label) }))}
|
||||
style={{ width: 140 }}
|
||||
onChange={(v) => void handleStatus(v)}
|
||||
aria-label={t("订单状态")}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="支付状态">
|
||||
<Descriptions.Item label={t("支付状态")}>
|
||||
<Tag>{order.paymentStatus}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="支付方式">{order.paymentMethod || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="总额">
|
||||
<Descriptions.Item label={t("支付方式")}>{order.paymentMethod || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label={t("总额")}>
|
||||
{order.currency} {order.total}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="小计">{order.subtotal}</Descriptions.Item>
|
||||
<Descriptions.Item label="运费 / 税费">
|
||||
<Descriptions.Item label={t("小计")}>{order.subtotal}</Descriptions.Item>
|
||||
<Descriptions.Item label={t("运费 / 税费")}>
|
||||
{order.shippingTotal} / {order.taxTotal}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="客户邮箱">{userEmail || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="客户姓名">{order.customerName || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="下单时间">{fmtDateTime(order.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="客户备注" span={3}>
|
||||
<Descriptions.Item label={t("客户邮箱")}>{userEmail || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label={t("客户姓名")}>{order.customerName || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label={t("下单时间")}>{fmtDateTime(order.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t("客户备注")} span={3}>
|
||||
{order.customerNote || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账单信息" span={3}>
|
||||
<pre style={{ margin: 0, fontSize: 12 }}>{JSON.stringify(order.billing, null, 2)}</pre>
|
||||
<Descriptions.Item label={t("账单信息")} span={3}>
|
||||
{renderAddressRows(billingRows)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="收货信息" span={3}>
|
||||
<pre style={{ margin: 0, fontSize: 12 }}>{JSON.stringify(order.shipping, null, 2)}</pre>
|
||||
<Descriptions.Item label={t("收货信息")} span={3}>
|
||||
{renderAddressRows(shippingRows)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card type="inner" title="订单明细" size="small">
|
||||
<Card type="inner" title={t("订单明细")} size="small">
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
@@ -88,11 +137,11 @@ function OrderDetailPage() {
|
||||
pagination={false}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
columns={[
|
||||
{ title: "商品名", dataIndex: "name" },
|
||||
{ title: t("商品名"), dataIndex: "name" },
|
||||
{ title: "SKU", dataIndex: "sku", width: 140 },
|
||||
{ title: "数量", dataIndex: "qty", width: 80 },
|
||||
{ title: "单价", dataIndex: "price", width: 110 },
|
||||
{ title: "小计", dataIndex: "total", width: 110 },
|
||||
{ title: t("数量"), dataIndex: "qty", width: 80 },
|
||||
{ title: t("单价"), dataIndex: "price", width: 110 },
|
||||
{ title: t("小计"), dataIndex: "total", width: 110 },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||
import { Button, Card, Space, Table, Tabs, Tag } from "antd";
|
||||
import { EyeOutlined } from "@ant-design/icons";
|
||||
import { adminListOrders } from "~/server/admin";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
import {
|
||||
AdminEmpty,
|
||||
Forbidden,
|
||||
@@ -35,6 +36,7 @@ export const Route = createFileRoute("/admin/orders/")({
|
||||
|
||||
/** # 函数 订单列表页面组件 */
|
||||
function OrdersPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
@@ -43,15 +45,15 @@ function OrdersPage() {
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader title="订单管理" />
|
||||
<PageHeader title={t("订单管理")} />
|
||||
<Tabs
|
||||
activeKey={search.status ?? ""}
|
||||
onChange={(key) => void navigate({ search: (prev) => ({ ...prev, status: key, page: 1 }) })}
|
||||
items={[
|
||||
{ key: "", label: "全部" },
|
||||
{ key: "", label: t("全部") },
|
||||
...(["pending", "on-hold", "processing", "completed", "cancelled", "refunded"] as const).map((s) => ({
|
||||
key: s,
|
||||
label: ORDER_STATUS_LABEL[s],
|
||||
label: t(ORDER_STATUS_LABEL[s]),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
@@ -66,7 +68,7 @@ function OrdersPage() {
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{
|
||||
title: "订单号",
|
||||
title: t("订单号"),
|
||||
dataIndex: "orderNumber",
|
||||
width: 160,
|
||||
render: (v: string, r) => (
|
||||
@@ -75,29 +77,36 @@ function OrdersPage() {
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ title: "客户", dataIndex: "customerName", width: 180, ellipsis: true },
|
||||
{ title: t("客户"), dataIndex: "customerName", width: 180, ellipsis: true },
|
||||
{
|
||||
title: "金额",
|
||||
title: t("金额"),
|
||||
dataIndex: "total",
|
||||
width: 120,
|
||||
render: (v: string, r) => `${r.currency} ${v}`,
|
||||
},
|
||||
{ title: "支付状态", dataIndex: "paymentStatus", width: 110, render: (v: string) => <Tag>{v}</Tag> },
|
||||
{ title: t("支付状态"), dataIndex: "paymentStatus", width: 110, render: (v: string) => <Tag>{v}</Tag> },
|
||||
{
|
||||
title: "订单状态",
|
||||
title: t("订单状态"),
|
||||
dataIndex: "status",
|
||||
width: 110,
|
||||
render: (v: string) => <StatusTag value={v} colorMap={ORDER_STATUS_COLOR} labelMap={ORDER_STATUS_LABEL} />,
|
||||
// 渲染处翻译标签映射(不改动 common 中的常量定义)
|
||||
render: (v: string) => (
|
||||
<StatusTag
|
||||
value={v}
|
||||
colorMap={ORDER_STATUS_COLOR}
|
||||
labelMap={Object.fromEntries(Object.entries(ORDER_STATUS_LABEL).map(([k, label]) => [k, t(label)]))}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ title: "下单时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{ title: t("下单时间"), dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{
|
||||
title: "操作",
|
||||
title: t("操作"),
|
||||
key: "actions",
|
||||
width: 80,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
<Link to="/admin/orders/$id/" params={{ id: String(r.id) }}>
|
||||
<Button size="small" type="text" icon={<EyeOutlined aria-label="查看详情" />} />
|
||||
<Button size="small" type="text" icon={<EyeOutlined aria-label={t("查看详情")} />} />
|
||||
</Link>
|
||||
</Space>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* # 路由 /admin/permissions 权限管理(仅管理员可编辑)
|
||||
* 按角色配置:菜单功能权限(树形勾选)+ MCP / API 访问开关。
|
||||
* admin 角色恒为全量且不可编辑(防锁死);customer 未勾选任何菜单即无后台权限。
|
||||
* 配置存 settings.permissions;服务端 requireCapability 叠加校验(域内至少一个菜单)。
|
||||
*/
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { App, Alert, Button, Card, Space, Spin, Switch, Tag, Tree, Typography } from "antd";
|
||||
import { Save } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { adminGetPermissions, adminSavePermissions } from "~/server/permissions";
|
||||
import { Forbidden, PageHeader, guardAdmin } from "~/components/admin/common";
|
||||
import { ADMIN_MENU_GROUPS } from "~/admin-menus";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
|
||||
export const Route = createFileRoute("/admin/permissions")({
|
||||
loader: () => guardAdmin(() => adminGetPermissions()),
|
||||
component: PermissionsPage,
|
||||
});
|
||||
|
||||
/** # 类型 权限页数据(loader 返回:各角色有效授权 + 是否已显式配置) **/
|
||||
type PermData = Record<string, { menus: string[]; mcp: boolean; api: boolean; configured: boolean }>;
|
||||
|
||||
/** # 成员变量 可编辑角色(admin 恒全量,不在此列) **/
|
||||
const EDITABLE_ROLES = ["manager", "editor", "customer"] as const;
|
||||
|
||||
/** # 成员变量 角色中文标签与说明 **/
|
||||
const ROLE_META: Record<string, { label: string; desc: string }> = {
|
||||
manager: { label: "经理", desc: "默认:内容 + 商务全部菜单,MCP / API 开启" },
|
||||
editor: { label: "编辑", desc: "默认:内容全部菜单,MCP / API 关闭" },
|
||||
customer: { label: "客户", desc: "商城注册用户,默认无任何后台权限" },
|
||||
};
|
||||
|
||||
/** # 成员变量 分组 key → 该组全部菜单 key(父子联动勾选换算用) **/
|
||||
const GROUP_CHILDREN = new Map(ADMIN_MENU_GROUPS.map((g) => [`group:${g.label}`, g.children.map((m) => m.key)]));
|
||||
|
||||
/** # 函数 菜单树数据(分组 → 菜单;标题经 t() 翻译,key 即菜单路由) */
|
||||
function buildMenuTree(t: (s: string) => string) {
|
||||
return ADMIN_MENU_GROUPS.map((g) => ({
|
||||
title: t(g.label),
|
||||
key: `group:${g.label}`,
|
||||
children: g.children.map((m) => ({ title: t(m.label), key: m.key })),
|
||||
}));
|
||||
}
|
||||
|
||||
/** # 函数 权限管理页面组件 */
|
||||
function PermissionsPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData() as PermData | null;
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
/** # 成员变量 各角色草稿(勾选菜单 + MCP/API 开关) **/
|
||||
const [draft, setDraft] = useState<Record<string, { menus: Set<string>; mcp: boolean; api: boolean }>>({});
|
||||
/** # 成员变量 保存中 **/
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setDraft(
|
||||
Object.fromEntries(
|
||||
EDITABLE_ROLES.map((role) => [
|
||||
role,
|
||||
{ menus: new Set(data[role]?.menus ?? []), mcp: data[role]?.mcp ?? false, api: data[role]?.api ?? false },
|
||||
]),
|
||||
),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
/** # 成员变量 未登录/无权限(非 admin)占位 **/
|
||||
const forbidden = useMemo(() => !data, [data]);
|
||||
if (forbidden) return <Forbidden />;
|
||||
|
||||
/** # 函数 Tree 勾选变化:父子联动后把「整组全选」折叠回分组 key 存储(等价语义) */
|
||||
function handleCheck(role: string, checked: { checked: string[] }) {
|
||||
setDraft((d) => {
|
||||
const menus = new Set<string>();
|
||||
for (const k of checked.checked) {
|
||||
if (k.startsWith("group:")) {
|
||||
const children = GROUP_CHILDREN.get(k) ?? [];
|
||||
children.forEach((c) => menus.add(c));
|
||||
} else menus.add(k);
|
||||
}
|
||||
return { ...d, [role]: { ...d[role]!, menus } };
|
||||
});
|
||||
}
|
||||
|
||||
/** # 函数 Tree 默认勾选项(整组全选渲染为分组勾选) */
|
||||
function checkedKeys(role: string): string[] {
|
||||
const menus = draft[role]?.menus ?? new Set<string>();
|
||||
const keys: string[] = [];
|
||||
for (const [gk, children] of GROUP_CHILDREN) {
|
||||
if (children.every((c) => menus.has(c))) keys.push(gk);
|
||||
else children.forEach((c) => menus.has(c) && keys.push(c));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** # 函数 保存:提交全部角色草稿 */
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminSavePermissions({
|
||||
data: {
|
||||
roles: Object.fromEntries(
|
||||
EDITABLE_ROLES.map((role) => [
|
||||
role,
|
||||
{ menus: [...(draft[role]?.menus ?? [])], mcp: draft[role]?.mcp ?? false, api: draft[role]?.api ?? false },
|
||||
]),
|
||||
),
|
||||
},
|
||||
});
|
||||
message.success(t("权限配置已保存"));
|
||||
await router.invalidate();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : t("保存失败"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title={t("权限管理")}
|
||||
extra={
|
||||
<Button type="primary" icon={<Save size={14} aria-hidden />} loading={saving} onClick={() => void handleSave()}>
|
||||
{t("保存")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={t("按角色配置菜单功能权限与 MCP / API 访问。admin 角色恒为全量,不可编辑;未勾选任何菜单的角色无法访问对应后台页面。")}
|
||||
/>
|
||||
<Space direction="vertical" size={16} style={{ display: "flex" }}>
|
||||
{EDITABLE_ROLES.map((role) =>
|
||||
draft[role] ? (
|
||||
<Card
|
||||
key={role}
|
||||
type="inner"
|
||||
size="small"
|
||||
title={
|
||||
<Space size={8}>
|
||||
<Tag color={role === "manager" ? "geekblue" : role === "editor" ? "cyan" : "default"}>{t(ROLE_META[role]!.label)}</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13, fontWeight: 400 }}>{t(ROLE_META[role]!.desc)}</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space size={16}>
|
||||
<Space size={6}>
|
||||
<Typography.Text style={{ fontSize: 13 }}>MCP</Typography.Text>
|
||||
<Switch size="small" checked={draft[role]!.mcp} onChange={(v) => setDraft((d) => ({ ...d, [role]: { ...d[role]!, mcp: v } }))} />
|
||||
</Space>
|
||||
<Space size={6}>
|
||||
<Typography.Text style={{ fontSize: 13 }}>API</Typography.Text>
|
||||
<Switch size="small" checked={draft[role]!.api} onChange={(v) => setDraft((d) => ({ ...d, [role]: { ...d[role]!, api: v } }))} />
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Tree
|
||||
checkable
|
||||
selectable={false}
|
||||
defaultExpandAll
|
||||
checkedKeys={checkedKeys(role)}
|
||||
onCheck={(_, info) => handleCheck(role, { checked: (info as unknown as { checked: string[] }).checked })}
|
||||
treeData={buildMenuTree(t)}
|
||||
style={{ maxWidth: 560 }}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<Spin key={role} />
|
||||
),
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useState } from "react";
|
||||
import { App, Button, Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
|
||||
import { ArrowLeftOutlined, SaveOutlined } from "@ant-design/icons";
|
||||
import { adminGetProduct, adminSaveProduct, adminSearchMedia } from "~/server/admin";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
import { Forbidden, guardAdmin } from "~/components/admin/common";
|
||||
import { RichEditor } from "~/components/admin/RichEditor";
|
||||
|
||||
@@ -34,6 +35,7 @@ function mediaLabel(path: string): string {
|
||||
* 参数 options:初始选项;multiple:是否多选;value/onChange 受控由 Form.Item 接管
|
||||
*/
|
||||
function MediaSelect(props: { options: MediaOption[]; multiple?: boolean; placeholder?: string }) {
|
||||
const t = useAdminT();
|
||||
const [opts, setOpts] = useState<MediaOption[]>(props.options);
|
||||
|
||||
/** # 函数 远程搜索媒体(防抖 300ms) */
|
||||
@@ -51,7 +53,7 @@ function MediaSelect(props: { options: MediaOption[]; multiple?: boolean; placeh
|
||||
mode={props.multiple ? "multiple" : undefined}
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder={props.placeholder || "搜索媒体文件名"}
|
||||
placeholder={props.placeholder || t("搜索媒体文件名")}
|
||||
onSearch={handleSearch}
|
||||
optionFilterProp="label"
|
||||
style={{ maxWidth: 640, width: "100%" }}
|
||||
@@ -62,6 +64,7 @@ function MediaSelect(props: { options: MediaOption[]; multiple?: boolean; placeh
|
||||
|
||||
/** # 函数 产品编辑页面组件 */
|
||||
function ProductEditPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
@@ -122,11 +125,11 @@ function ProductEditPage() {
|
||||
categoryIds: (values.categoryIds as number[]) ?? [],
|
||||
},
|
||||
});
|
||||
message.success("保存成功");
|
||||
message.success(t("保存成功"));
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/admin/products/" });
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "保存失败");
|
||||
message.error(e instanceof Error ? e.message : t("保存失败"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,16 +137,16 @@ function ProductEditPage() {
|
||||
<Card size="small">
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined aria-hidden />} onClick={() => void navigate({ to: "/admin/products/" })}>
|
||||
返回列表
|
||||
{t("返回列表")}
|
||||
</Button>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>{product ? `编辑产品 #${product.id}` : "新建产品"}</h2>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>{product ? t("编辑产品 #{id}", { id: product.id }) : t("新建产品")}</h2>
|
||||
</Space>
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 900 }} initialValues={initialValues} onFinish={(v) => void handleSave(v)}>
|
||||
<Space size={16} style={{ display: "flex" }} align="start">
|
||||
<Form.Item name="title" label="名称" rules={[{ required: true, message: "请输入产品名称" }]} style={{ minWidth: 420, flex: 1 }}>
|
||||
<Form.Item name="title" label={t("名称")} rules={[{ required: true, message: t("请输入产品名称") }]} style={{ minWidth: 420, flex: 1 }}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="slug" label="别名(留空自动生成)">
|
||||
<Form.Item name="slug" label={t("别名(留空自动生成)")}>
|
||||
<Input style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sku" label="SKU">
|
||||
@@ -151,57 +154,57 @@ function ProductEditPage() {
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={16} style={{ display: "flex" }} wrap>
|
||||
<Form.Item name="price" label="现价(USD)">
|
||||
<Form.Item name="price" label={t("现价(USD)")}>
|
||||
<Input style={{ width: 120 }} placeholder="0.00" />
|
||||
</Form.Item>
|
||||
<Form.Item name="regularPrice" label="原价(USD)">
|
||||
<Form.Item name="regularPrice" label={t("原价(USD)")}>
|
||||
<Input style={{ width: 120 }} placeholder="0.00" />
|
||||
</Form.Item>
|
||||
<Form.Item name="stock" label="库存数量">
|
||||
<InputNumber min={0} style={{ width: 120 }} placeholder="留空不限" />
|
||||
<Form.Item name="stock" label={t("库存数量")}>
|
||||
<InputNumber min={0} style={{ width: 120 }} placeholder={t("留空不限")} />
|
||||
</Form.Item>
|
||||
<Form.Item name="stockStatus" label="库存状态">
|
||||
<Form.Item name="stockStatus" label={t("库存状态")}>
|
||||
<Select
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: "instock", label: "有货" },
|
||||
{ value: "outofstock", label: "缺货" },
|
||||
{ value: "onbackorder", label: "预订" },
|
||||
{ value: "instock", label: t("有货") },
|
||||
{ value: "outofstock", label: t("缺货") },
|
||||
{ value: "onbackorder", label: t("预订") },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Form.Item name="status" label={t("状态")}>
|
||||
<Select
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "published", label: "已发布" },
|
||||
{ value: "draft", label: t("草稿") },
|
||||
{ value: "published", label: t("已发布") },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="featured" label="主推产品" valuePropName="checked">
|
||||
<Form.Item name="featured" label={t("主推产品")} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="specUrl" label="规格书地址">
|
||||
<Form.Item name="specUrl" label={t("规格书地址")}>
|
||||
<Input style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={16} style={{ display: "flex" }} wrap>
|
||||
<Form.Item name="featuredMediaId" label="特色图" style={{ minWidth: 300 }}>
|
||||
<MediaSelect options={mediaOptions} placeholder="搜索媒体文件名选择特色图" />
|
||||
<Form.Item name="featuredMediaId" label={t("特色图")} style={{ minWidth: 300 }}>
|
||||
<MediaSelect options={mediaOptions} placeholder={t("搜索媒体文件名选择特色图")} />
|
||||
</Form.Item>
|
||||
<Form.Item name="galleryIds" label="图集(可多选)" style={{ minWidth: 300 }}>
|
||||
<Form.Item name="galleryIds" label={t("图集(可多选)")} style={{ minWidth: 300 }}>
|
||||
<MediaSelect options={[...initialGalleryOptions, ...mediaOptions.filter((m) => !initialGalleryOptions.some((g) => g.id === m.id))]} multiple />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="shortDescription" label="短描述">
|
||||
<Form.Item name="shortDescription" label={t("短描述")}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="详细描述(富文本编辑)">
|
||||
<Form.Item name="description" label={t("详细描述(富文本编辑)")}>
|
||||
<RichEditor minHeight={380} />
|
||||
</Form.Item>
|
||||
{attributes.length > 0 && (
|
||||
<Card type="inner" title="产品属性" size="small" style={{ marginBottom: 16 }}>
|
||||
<Card type="inner" title={t("产品属性")} size="small" style={{ marginBottom: 16 }}>
|
||||
{attributes.map((a) => (
|
||||
<Form.Item
|
||||
key={a.id}
|
||||
@@ -212,7 +215,7 @@ function ProductEditPage() {
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
placeholder={`选择 ${a.name} 属性值`}
|
||||
placeholder={t("选择 {name} 属性值", { name: a.name })}
|
||||
style={{ maxWidth: 640 }}
|
||||
options={a.values.map((v) => ({ value: v.id, label: v.value }))}
|
||||
/>
|
||||
@@ -220,17 +223,17 @@ function ProductEditPage() {
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
<Form.Item name="categoryIds" label="产品分类">
|
||||
<Select mode="multiple" allowClear placeholder="选择分类" style={{ maxWidth: 640 }} options={categories.map((c) => ({ value: c.id, label: c.name }))} />
|
||||
<Form.Item name="categoryIds" label={t("产品分类")}>
|
||||
<Select mode="multiple" allowClear placeholder={t("选择分类")} style={{ maxWidth: 640 }} options={categories.map((c) => ({ value: c.id, label: c.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="seoTitle" label="SEO 标题">
|
||||
<Form.Item name="seoTitle" label={t("SEO 标题")}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="seoDescription" label="SEO 描述">
|
||||
<Form.Item name="seoDescription" label={t("SEO 描述")}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined aria-hidden />}>
|
||||
保存
|
||||
{t("保存")}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant
|
||||
import { Package } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { adminDeleteProduct, adminListProducts } from "~/server/admin";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/products/")({
|
||||
@@ -30,6 +31,7 @@ const STOCK_COLOR: Record<string, string> = { instock: "green", outofstock: "red
|
||||
|
||||
/** # 函数 产品列表页面组件 */
|
||||
function ProductsPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
@@ -47,19 +49,19 @@ function ProductsPage() {
|
||||
/** # 函数 删除产品并刷新 */
|
||||
async function handleDelete(id: number) {
|
||||
await adminDeleteProduct({ data: { id } });
|
||||
message.success("已删除");
|
||||
message.success(t("已删除"));
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="产品管理"
|
||||
title={t("产品管理")}
|
||||
extra={
|
||||
<>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索标题 / SKU / 别名"
|
||||
placeholder={t("搜索标题 / SKU / 别名")}
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
@@ -68,7 +70,7 @@ function ProductsPage() {
|
||||
/>
|
||||
<Link to="/admin/products/$id/" params={{ id: "new" }}>
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden />}>
|
||||
新建产品
|
||||
{t("新建产品")}
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
@@ -83,19 +85,19 @@ function ProductsPage() {
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{
|
||||
title: "图",
|
||||
title: t("图"),
|
||||
dataIndex: "thumb",
|
||||
width: 72,
|
||||
render: (v: string | null) =>
|
||||
v ? (
|
||||
<Image src={v} alt="产品缩略图" width={44} height={44} style={{ objectFit: "cover", borderRadius: 4 }} />
|
||||
<Image src={v} alt={t("产品缩略图")} width={44} height={44} style={{ objectFit: "cover", borderRadius: 4 }} />
|
||||
) : (
|
||||
<Package size={36} aria-hidden color="#d9d9d9" />
|
||||
<Package size={36} aria-hidden color="var(--ant-color-text-quaternary)" />
|
||||
),
|
||||
},
|
||||
{ title: "SKU", dataIndex: "sku", width: 140 },
|
||||
{
|
||||
title: "标题",
|
||||
title: t("标题"),
|
||||
dataIndex: "title",
|
||||
render: (v: string, r) => (
|
||||
<Link to="/admin/products/$id/" params={{ id: String(r.id) }}>
|
||||
@@ -104,40 +106,40 @@ function ProductsPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "价格",
|
||||
title: t("价格"),
|
||||
dataIndex: "price",
|
||||
width: 110,
|
||||
render: (v: string | null, r) => (v != null ? `$${v}` : r.regularPrice != null ? `$${r.regularPrice}` : "-"),
|
||||
},
|
||||
{
|
||||
title: "库存",
|
||||
title: t("库存"),
|
||||
dataIndex: "stockStatus",
|
||||
width: 100,
|
||||
render: (v: string, r) => (
|
||||
<Tag color={STOCK_COLOR[v] ?? "default"}>
|
||||
{STOCK_LABEL[v] ?? v}
|
||||
{t(STOCK_LABEL[v] ?? v)}
|
||||
{typeof r.stock === "number" ? ` (${r.stock})` : ""}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
title: t("状态"),
|
||||
dataIndex: "status",
|
||||
width: 90,
|
||||
render: (v: string) => <Tag color={v === "published" ? "green" : "default"}>{v}</Tag>,
|
||||
},
|
||||
{ title: "更新时间", dataIndex: "updatedAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{ title: t("更新时间"), dataIndex: "updatedAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{
|
||||
title: "操作",
|
||||
title: t("操作"),
|
||||
key: "actions",
|
||||
width: 110,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
<Link to="/admin/products/$id/" params={{ id: String(r.id) }}>
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} />
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label={t("编辑")} />} />
|
||||
</Link>
|
||||
<Popconfirm title="确认删除该产品?" onConfirm={() => void handleDelete(r.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label="删除" />} />
|
||||
<Popconfirm title={t("确认删除该产品?")} onConfirm={() => void handleDelete(r.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label={t("删除")} />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+32
-25
@@ -7,6 +7,7 @@ import { App, Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Switc
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { adminDeleteRedirect, adminListRedirects, adminSaveRedirect } from "~/server/admin";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
import { AdminEmpty, Forbidden, PageHeader, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||
import type { redirects } from "~/db/schema";
|
||||
|
||||
@@ -29,6 +30,7 @@ type RedirectRow = typeof redirects.$inferSelect;
|
||||
|
||||
/** # 函数 SEO / 重定向页面组件 */
|
||||
function SeoPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
@@ -57,7 +59,7 @@ function SeoPage() {
|
||||
/** # 函数 保存重定向 */
|
||||
async function handleSave(values: { source: string; target: string; code: number; isRegex: boolean; enabled: boolean }) {
|
||||
await adminSaveRedirect({ data: { ...values, id: editing?.id } });
|
||||
message.success("已保存");
|
||||
message.success(t("已保存"));
|
||||
setEditing(null);
|
||||
await router.invalidate();
|
||||
}
|
||||
@@ -65,7 +67,7 @@ function SeoPage() {
|
||||
/** # 函数 删除重定向 */
|
||||
async function handleDelete(id: number) {
|
||||
await adminDeleteRedirect({ data: { id } });
|
||||
message.success("已删除");
|
||||
message.success(t("已删除"));
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
@@ -78,12 +80,12 @@ function SeoPage() {
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="重定向管理"
|
||||
title={t("重定向管理")}
|
||||
extra={
|
||||
<>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索源路径 / 目标"
|
||||
placeholder={t("搜索源路径 / 目标")}
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
@@ -91,7 +93,7 @@ function SeoPage() {
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={() => openEdit(null)}>
|
||||
新增重定向
|
||||
{t("新增重定向")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
@@ -106,38 +108,43 @@ function SeoPage() {
|
||||
)}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{ title: "源路径", dataIndex: "source", ellipsis: true },
|
||||
{ title: "目标", dataIndex: "target", ellipsis: true },
|
||||
{ title: t("源路径"), dataIndex: "source", ellipsis: true },
|
||||
{ title: t("目标"), dataIndex: "target", ellipsis: true },
|
||||
{
|
||||
title: "状态码",
|
||||
title: t("状态码"),
|
||||
dataIndex: "code",
|
||||
width: 90,
|
||||
render: (v: number) => <Tag color={v === 301 ? "green" : "blue"}>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "正则",
|
||||
title: t("正则"),
|
||||
dataIndex: "isRegex",
|
||||
width: 70,
|
||||
render: (v: boolean) => (v ? <Tag color="purple">正则</Tag> : "-"),
|
||||
render: (v: boolean) => (v ? <Tag color="purple">{t("正则")}</Tag> : "-"),
|
||||
},
|
||||
{ title: "命中", dataIndex: "hits", width: 70 },
|
||||
{ title: t("命中"), dataIndex: "hits", width: 70 },
|
||||
{
|
||||
title: "启用",
|
||||
title: t("启用"),
|
||||
dataIndex: "enabled",
|
||||
width: 80,
|
||||
render: (_, r) => (
|
||||
<Switch size="small" checked={r.enabled} onChange={(on) => void toggleEnabled(r, on)} aria-label={`切换重定向 ${r.source}`} />
|
||||
<Switch
|
||||
size="small"
|
||||
checked={r.enabled}
|
||||
onChange={(on) => void toggleEnabled(r, on)}
|
||||
aria-label={t("切换重定向 {source}", { source: r.source })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
title: t("操作"),
|
||||
key: "actions",
|
||||
width: 110,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} />
|
||||
<Popconfirm title="确认删除该重定向?" onConfirm={() => void handleDelete(r.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label="删除" />} />
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label={t("编辑")} />} onClick={() => openEdit(r)} />
|
||||
<Popconfirm title={t("确认删除该重定向?")} onConfirm={() => void handleDelete(r.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label={t("删除")} />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
@@ -146,27 +153,27 @@ function SeoPage() {
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? `编辑重定向 #${editing.id}` : "新增重定向"}
|
||||
title={editing ? t("编辑重定向 #{id}", { id: editing.id }) : t("新增重定向")}
|
||||
open={editing !== null}
|
||||
onCancel={() => setEditing(null)}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v)}>
|
||||
<Form.Item name="source" label="源路径(支持正则)" rules={[{ required: true, message: "请输入源路径" }]}>
|
||||
<Input placeholder="/old-path 或 /blog/(.*)" />
|
||||
<Form.Item name="source" label={t("源路径(支持正则)")} rules={[{ required: true, message: t("请输入源路径") }]}>
|
||||
<Input placeholder={t("/old-path 或 /blog/(.*)")} />
|
||||
</Form.Item>
|
||||
<Form.Item name="target" label="目标地址" rules={[{ required: true, message: "请输入目标地址" }]}>
|
||||
<Input placeholder="/new-path 或 https://…" />
|
||||
<Form.Item name="target" label={t("目标地址")} rules={[{ required: true, message: t("请输入目标地址") }]}>
|
||||
<Input placeholder={t("/new-path 或 https://…")} />
|
||||
</Form.Item>
|
||||
<Space size={16} style={{ display: "flex" }}>
|
||||
<Form.Item name="code" label="状态码">
|
||||
<Form.Item name="code" label={t("状态码")}>
|
||||
<Select style={{ width: 110 }} options={[{ value: 301, label: "301" }, { value: 302, label: "302" }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="isRegex" label="正则匹配" valuePropName="checked">
|
||||
<Form.Item name="isRegex" label={t("正则匹配")} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Form.Item name="enabled" label={t("启用")} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
+169
-179
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* # 路由 /admin/settings 系统设置
|
||||
* Tabs 分组表单(v0.14.0 起弃用整键 JSON 编辑器):站点信息 / 商城配置 / 邮件配置 /
|
||||
* SEO / 登录安全,每组一个 antd Form,保存时与现有值合并(未知字段保留)。
|
||||
* 其余键(backup 等由专属页面管理的除外)落到「其他」Tab,保留 JSON 编辑兜底。
|
||||
* SEO / 登录安全 / 关闭网站(维护模式 + 富文本维护页内容),每组一个 antd Form,
|
||||
* 保存时与现有值合并(未知字段保留)。
|
||||
* 其余键(backup 等由专属页面管理的除外)落到「其他」Tab,按可读键值展示(不再出现裸 JSON)。
|
||||
* 仅 admin 角色可保存(server 端强制)。
|
||||
*/
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
@@ -11,6 +12,8 @@ import { Save } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { adminListSettings, adminUpdateSetting } from "~/server/admin";
|
||||
import { Forbidden, PageHeader, fmtDateTime, guardAdmin, type Json } from "~/components/admin/common";
|
||||
import { RichEditor } from "~/components/admin/RichEditor";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
|
||||
export const Route = createFileRoute("/admin/settings")({
|
||||
loader: () => guardAdmin(() => adminListSettings()),
|
||||
@@ -21,10 +24,10 @@ export const Route = createFileRoute("/admin/settings")({
|
||||
type FieldDef = {
|
||||
/** # 成员变量 字段名(写入 settings 值对象的键) **/
|
||||
name: string;
|
||||
/** # 成员变量 中文标签 **/
|
||||
/** # 成员变量 中文标签(i18n 源串) **/
|
||||
label: string;
|
||||
/** # 成员变量 控件类型 **/
|
||||
type: "text" | "password" | "number" | "switch" | "select" | "textarea";
|
||||
type: "text" | "password" | "number" | "switch" | "select" | "textarea" | "rich";
|
||||
/** # 成员变量 说明/占位 **/
|
||||
placeholder?: string;
|
||||
/** # 成员变量 select 选项 **/
|
||||
@@ -39,180 +42,137 @@ type FieldDef = {
|
||||
span?: 6 | 8 | 12 | 24;
|
||||
};
|
||||
|
||||
/** # 成员变量 各 Tab 的分组表单配置(key 与 settings 表键一致;中文名 + 字段清单) **/
|
||||
const GROUPS: { key: string; label: string; desc?: ReactNode; fields: FieldDef[] }[] = [
|
||||
{
|
||||
key: "site",
|
||||
label: "站点信息",
|
||||
fields: [
|
||||
{ name: "name", label: "站点名称", type: "text", placeholder: "Dusun IoT" },
|
||||
{ name: "url", label: "站点 URL", type: "text", placeholder: "https://dusuniot.com" },
|
||||
{
|
||||
name: "currency",
|
||||
label: "货币",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "USD", label: "USD 美元" },
|
||||
{ value: "CNY", label: "CNY 人民币" },
|
||||
{ value: "EUR", label: "EUR 欧元" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "timezone",
|
||||
label: "时区",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "Asia/Shanghai", label: "Asia/Shanghai(东八区)" },
|
||||
/** # 函数 各 Tab 的分组表单配置(key 与 settings 表键一致;中文源串 + 字段清单) */
|
||||
function buildGroups(t: (s: string) => string) {
|
||||
const currencyOptions = [
|
||||
{ value: "USD", label: "USD" },
|
||||
{ value: "CNY", label: "CNY" },
|
||||
{ value: "EUR", label: "EUR" },
|
||||
];
|
||||
return [
|
||||
{
|
||||
key: "site",
|
||||
label: t("站点信息"),
|
||||
fields: [
|
||||
{ name: "name", label: t("站点名称"), type: "text", placeholder: "Dusun IoT" },
|
||||
{ name: "url", label: t("站点 URL"), type: "text", placeholder: "https://dusuniot.com" },
|
||||
{ name: "currency", label: t("货币"), type: "select", options: currencyOptions },
|
||||
{ name: "timezone", label: t("时区"), type: "select", options: [
|
||||
{ value: "Asia/Shanghai", label: "Asia/Shanghai (UTC+8)" },
|
||||
{ value: "UTC", label: "UTC" },
|
||||
{ value: "America/New_York", label: "America/New_York" },
|
||||
{ value: "Europe/London", label: "Europe/London" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "woocommerce",
|
||||
label: "商城配置",
|
||||
fields: [
|
||||
{ name: "productBase", label: "产品路由前缀", type: "text", placeholder: "product" },
|
||||
{ name: "categoryBase", label: "分类路由前缀", type: "text", placeholder: "collection" },
|
||||
{ name: "tagBase", label: "标签路由前缀", type: "text", placeholder: "product-tag" },
|
||||
{
|
||||
name: "currency",
|
||||
label: "货币",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "USD", label: "USD 美元" },
|
||||
{ value: "CNY", label: "CNY 人民币" },
|
||||
{ value: "EUR", label: "EUR 欧元" },
|
||||
],
|
||||
},
|
||||
{ name: "guestCheckout", label: "游客结账", type: "switch", extra: "允许未注册用户下单" },
|
||||
{ name: "pricesIncludeTax", label: "价格含税", type: "switch" },
|
||||
{ name: "signupFromCheckout", label: "结账时引导注册", type: "switch" },
|
||||
{
|
||||
name: "allowedCountries",
|
||||
label: "销售地区",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "all", label: "全部国家" },
|
||||
{ value: "all_except", label: "全部(除特定地区)" },
|
||||
{ value: "specified", label: "仅指定地区" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "mail",
|
||||
label: "邮件配置",
|
||||
desc: "询盘通知 / 订单邮件的 SMTP 发信配置;修改后建议发一封测试邮件验证。",
|
||||
fields: [
|
||||
{ name: "fromName", label: "发件人名称", type: "text", placeholder: "Dusun IoT" },
|
||||
{ name: "fromEmail", label: "发件邮箱", type: "text", placeholder: "no-reply@dusuniot.com" },
|
||||
{ name: "smtpHost", label: "SMTP 主机", type: "text", placeholder: "smtp.exmail.qq.com" },
|
||||
{ name: "smtpPort", label: "SMTP 端口", type: "number", min: 1, max: 65535, placeholder: "465" },
|
||||
{
|
||||
name: "smtpSecure",
|
||||
label: "加密方式",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "none", label: "不加密" },
|
||||
{ value: "ssl", label: "SSL(465)" },
|
||||
{ value: "tls", label: "STARTTLS(587)" },
|
||||
],
|
||||
},
|
||||
{ name: "smtpUser", label: "SMTP 账号", type: "text" },
|
||||
{ name: "smtpPass", label: "SMTP 密码/授权码", type: "password" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "seo",
|
||||
label: "SEO",
|
||||
fields: [
|
||||
{ name: "titleTemplate", label: "标题模板", type: "text", placeholder: "%title% | Dusun IoT", extra: "%title% 为页面标题占位符" },
|
||||
{
|
||||
name: "robots",
|
||||
label: "全站 robots",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "all", label: "all(允许索引)" },
|
||||
{ value: "noindex", label: "noindex(禁止索引)" },
|
||||
],
|
||||
},
|
||||
{ name: "sitemap", label: "启用 sitemap.xml", type: "switch" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "security",
|
||||
label: "登录安全",
|
||||
desc: (
|
||||
<>
|
||||
reCAPTCHA 在 <a href="https://www.google.com/recaptcha/admin" target="_blank" rel="noreferrer">Google reCAPTCHA 控制台</a>
|
||||
申请;v2 为「我是人类」复选框,v3 为隐形评分。开启后 /admin 与 /my-account 登录均需通过验证;
|
||||
超过失败次数上限的账号将锁定指定分钟。
|
||||
</>
|
||||
),
|
||||
fields: [
|
||||
{ name: "recaptchaEnabled", label: "启用 reCAPTCHA", type: "switch", extra: "关闭时登录流程保持现状,不加载 Google 脚本" },
|
||||
{
|
||||
name: "recaptchaVersion",
|
||||
label: "版本",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "v2", label: "v2 复选框" },
|
||||
{ value: "v3", label: "v3 隐形评分" },
|
||||
],
|
||||
},
|
||||
{ name: "recaptchaSiteKey", label: "Site Key(站点密钥)", type: "text", placeholder: "6Le…", span: 12 },
|
||||
{ name: "recaptchaSecretKey", label: "Secret Key(服务端密钥)", type: "password", placeholder: "6Le…", span: 12 },
|
||||
{ name: "recaptchaScoreThreshold", label: "v3 评分阈值", type: "number", min: 0, max: 1, step: 0.1, extra: "0~1,低于该分数判定为机器人,默认 0.5", span: 8 },
|
||||
{ name: "maxLoginAttempts", label: "失败次数上限", type: "number", min: 3, max: 100, extra: "同一账号连续失败达上限即锁定", span: 8 },
|
||||
{ name: "lockMinutes", label: "锁定时长(分钟)", type: "number", min: 1, max: 1440, span: 8 },
|
||||
],
|
||||
},
|
||||
];
|
||||
] },
|
||||
] as FieldDef[],
|
||||
},
|
||||
{
|
||||
key: "woocommerce",
|
||||
label: t("商城配置"),
|
||||
fields: [
|
||||
{ name: "productBase", label: t("产品路由前缀"), type: "text", placeholder: "product" },
|
||||
{ name: "categoryBase", label: t("分类路由前缀"), type: "text", placeholder: "collection" },
|
||||
{ name: "tagBase", label: t("标签路由前缀"), type: "text", placeholder: "product-tag" },
|
||||
{ name: "currency", label: t("货币"), type: "select", options: currencyOptions },
|
||||
{ name: "guestCheckout", label: t("游客结账"), type: "switch", extra: t("允许未注册用户下单") },
|
||||
{ name: "pricesIncludeTax", label: t("价格含税"), type: "switch" },
|
||||
{ name: "signupFromCheckout", label: t("结账时引导注册"), type: "switch" },
|
||||
{ name: "allowedCountries", label: t("销售地区"), type: "select", options: [
|
||||
{ value: "all", label: t("全部国家") },
|
||||
{ value: "all_except", label: t("全部(除特定地区)") },
|
||||
{ value: "specified", label: t("仅指定地区") },
|
||||
] },
|
||||
] as FieldDef[],
|
||||
},
|
||||
{
|
||||
key: "mail",
|
||||
label: t("邮件配置"),
|
||||
desc: t("询盘通知 / 订单邮件的 SMTP 发信配置;修改后建议发一封测试邮件验证。"),
|
||||
fields: [
|
||||
{ name: "fromName", label: t("发件人名称"), type: "text", placeholder: "Dusun IoT" },
|
||||
{ name: "fromEmail", label: t("发件邮箱"), type: "text", placeholder: "no-reply@dusuniot.com" },
|
||||
{ name: "smtpHost", label: t("SMTP 主机"), type: "text", placeholder: "smtp.exmail.qq.com" },
|
||||
{ name: "smtpPort", label: t("SMTP 端口"), type: "number", min: 1, max: 65535, placeholder: "465" },
|
||||
{ name: "smtpSecure", label: t("加密方式"), type: "select", options: [
|
||||
{ value: "none", label: t("不加密") },
|
||||
{ value: "ssl", label: t("SSL(465)") },
|
||||
{ value: "tls", label: t("STARTTLS(587)") },
|
||||
] },
|
||||
{ name: "smtpUser", label: t("SMTP 账号"), type: "text" },
|
||||
{ name: "smtpPass", label: t("SMTP 密码/授权码"), type: "password" },
|
||||
] as FieldDef[],
|
||||
},
|
||||
{
|
||||
key: "seo",
|
||||
label: t("SEO"),
|
||||
fields: [
|
||||
{ name: "titleTemplate", label: t("标题模板"), type: "text", placeholder: "%title% | Dusun IoT", extra: t("%title% 为页面标题占位符") },
|
||||
{ name: "robots", label: t("全站 robots"), type: "select", options: [
|
||||
{ value: "all", label: t("all(允许索引)") },
|
||||
{ value: "noindex", label: t("noindex(禁止索引)") },
|
||||
] },
|
||||
{ name: "sitemap", label: t("启用 sitemap.xml"), type: "switch" },
|
||||
] as FieldDef[],
|
||||
},
|
||||
{
|
||||
key: "security",
|
||||
label: t("登录安全"),
|
||||
desc: (
|
||||
<>
|
||||
reCAPTCHA 在 <a href="https://www.google.com/recaptcha/admin" target="_blank" rel="noreferrer">Google reCAPTCHA {t("控制台")}</a>
|
||||
{t("申请;v2 为「我是人类」复选框,v3 为隐形评分。开启后 /admin 与 /my-account 登录均需通过验证;超过失败次数上限的账号将锁定指定分钟。")}
|
||||
</>
|
||||
),
|
||||
fields: [
|
||||
{ name: "recaptchaEnabled", label: t("启用 reCAPTCHA"), type: "switch", extra: t("关闭时登录流程保持现状,不加载 Google 脚本") },
|
||||
{ name: "recaptchaVersion", label: t("版本"), type: "select", options: [
|
||||
{ value: "v2", label: t("v2 复选框") },
|
||||
{ value: "v3", label: t("v3 隐形评分") },
|
||||
] },
|
||||
{ name: "recaptchaSiteKey", label: t("Site Key(站点密钥)"), type: "text", placeholder: "6Le…", span: 12 },
|
||||
{ name: "recaptchaSecretKey", label: t("Secret Key(服务端密钥)"), type: "password", placeholder: "6Le…", span: 12 },
|
||||
{ name: "recaptchaScoreThreshold", label: t("v3 评分阈值"), type: "number", min: 0, max: 1, step: 0.1, extra: t("0~1,低于该分数判定为机器人,默认 0.5"), span: 8 },
|
||||
{ name: "maxLoginAttempts", label: t("失败次数上限"), type: "number", min: 3, max: 100, extra: t("同一账号连续失败达上限即锁定"), span: 8 },
|
||||
{ name: "lockMinutes", label: t("锁定时长(分钟)"), type: "number", min: 1, max: 1440, span: 8 },
|
||||
] as FieldDef[],
|
||||
},
|
||||
{
|
||||
key: "maintenance",
|
||||
label: t("关闭网站"),
|
||||
desc: t("开启后前台所有页面立即显示下方维护内容(返回 503),后台 /admin 不受影响。可用于临时停机维护或站点下线公告。"),
|
||||
fields: [
|
||||
{ name: "enabled", label: t("关闭网站(维护模式)"), type: "switch", extra: t("开启后前台立即只显示维护页") },
|
||||
{ name: "html", label: t("维护页内容"), type: "rich", extra: t("支持富文本 / HTML(图片可从媒体库选择),将显示在维护页中央") },
|
||||
] as FieldDef[],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** # 成员变量 由专属页面管理的键(不在本页出现,避免双头编辑冲突) **/
|
||||
const MANAGED_ELSEWHERE = new Set(["backup"]);
|
||||
const MANAGED_ELSEWHERE = new Set(["backup", "permissions"]);
|
||||
|
||||
/** # 类型 设置行(updatedAt 为 Date,loader 序列化前的服务端类型) **/
|
||||
type SettingRow = { key: string; value: Json; updatedAt: Date | string };
|
||||
|
||||
/** # 函数 系统设置页面组件 */
|
||||
function SettingsPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
/** # 成员变量 其他键的 JSON 草稿(键 → 文本) **/
|
||||
const [otherDrafts, setOtherDrafts] = useState<Record<string, string>>({});
|
||||
const GROUPS = buildGroups(t);
|
||||
/** # 成员变量 其他键(无表单定义)清单 **/
|
||||
const [otherRows, setOtherRows] = useState<{ key: string; value: Json; updatedAt: Date | string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of data as SettingRow[]) {
|
||||
if (!MANAGED_ELSEWHERE.has(r.key) && !GROUPS.some((g) => g.key === r.key)) {
|
||||
map[r.key] = JSON.stringify(r.value, null, 2);
|
||||
}
|
||||
}
|
||||
setOtherDrafts(map);
|
||||
setOtherRows((data as SettingRow[]).filter((r) => !MANAGED_ELSEWHERE.has(r.key) && !GROUPS.some((g) => g.key === r.key)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
|
||||
const rows = data as SettingRow[];
|
||||
|
||||
/** # 函数 保存「其他」键的 JSON */
|
||||
async function handleSaveOther(key: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(otherDrafts[key] ?? "{}") as Json;
|
||||
await adminUpdateSetting({ data: { key, value: parsed } });
|
||||
message.success(`已保存 ${key}`);
|
||||
await router.invalidate();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "保存失败(JSON 格式需合法)");
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数 渲染单个字段控件 */
|
||||
function renderField(f: FieldDef) {
|
||||
switch (f.type) {
|
||||
@@ -224,6 +184,8 @@ function SettingsPage() {
|
||||
return <Select options={f.options} style={{ width: 260 }} placeholder={f.placeholder} allowClear={false} />;
|
||||
case "textarea":
|
||||
return <Input.TextArea rows={3} placeholder={f.placeholder} />;
|
||||
case "rich":
|
||||
return <RichEditor minHeight={220} />;
|
||||
case "password":
|
||||
return <Input.Password placeholder={f.placeholder} autoComplete="new-password" />;
|
||||
default:
|
||||
@@ -233,7 +195,7 @@ function SettingsPage() {
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader title="系统设置" extra={<Tag color="orange">仅管理员可保存</Tag>} />
|
||||
<PageHeader title={t("系统设置")} extra={<Tag color="orange">{t("仅管理员可保存")}</Tag>} />
|
||||
<Tabs
|
||||
defaultActiveKey={GROUPS[0]!.key}
|
||||
items={GROUPS.map((g) => ({
|
||||
@@ -241,26 +203,17 @@ function SettingsPage() {
|
||||
label: g.label,
|
||||
children: <SettingsGroupForm group={g} current={(rows.find((r) => r.key === g.key)?.value ?? {}) as Record<string, unknown>} updatedAt={rows.find((r) => r.key === g.key)?.updatedAt} renderField={renderField} />,
|
||||
})).concat(
|
||||
Object.keys(otherDrafts).length
|
||||
otherRows.length
|
||||
? [
|
||||
{
|
||||
key: "__other__",
|
||||
label: "其他",
|
||||
label: t("其他"),
|
||||
children: (
|
||||
<Space direction="vertical" size={16} style={{ display: "flex" }}>
|
||||
<Typography.Text type="secondary">以下键无表单定义(如由脚本写入的配置),按 JSON 编辑保存。</Typography.Text>
|
||||
{Object.keys(otherDrafts).map((k) => (
|
||||
<Card key={k} type="inner" size="small" title={k} extra={
|
||||
<Button size="small" type="primary" icon={<Save size={13} aria-hidden />} onClick={() => void handleSaveOther(k)}>
|
||||
保存
|
||||
</Button>
|
||||
}>
|
||||
<Input.TextArea
|
||||
rows={Math.min(14, Math.max(4, (otherDrafts[k] ?? "").split("\n").length))}
|
||||
value={otherDrafts[k] ?? ""}
|
||||
onChange={(e) => setOtherDrafts((d) => ({ ...d, [k]: e.target.value }))}
|
||||
style={{ fontFamily: "monospace", fontSize: 12 }}
|
||||
/>
|
||||
<Typography.Text type="secondary">{t("以下键无表单定义(由脚本或系统内部写入),此处只读展示。")}</Typography.Text>
|
||||
{otherRows.map((r) => (
|
||||
<Card key={r.key} type="inner" size="small" title={r.key} extra={<Typography.Text type="secondary" style={{ fontSize: 12 }}>{t("最后更新")}:{fmtDateTime(r.updatedAt)}</Typography.Text>}>
|
||||
<ReadableValue value={r.value} />
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
@@ -274,15 +227,52 @@ function SettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 可读键值展示(替代裸 JSON:对象/数组逐行键值,标量直接显示) */
|
||||
function ReadableValue(props: { value: Json; level?: number }) {
|
||||
const { value, level = 0 } = props;
|
||||
if (value === null || value === undefined) return <Typography.Text type="secondary">-</Typography.Text>;
|
||||
if (typeof value === "boolean") return <Tag>{value ? "true" : "false"}</Tag>;
|
||||
if (typeof value !== "object") return <Typography.Text>{String(value)}</Typography.Text>;
|
||||
const entries = Array.isArray(value) ? value.map((v, i) => [String(i), v] as const) : Object.entries(value);
|
||||
if (entries.length === 0) return <Typography.Text type="secondary">-</Typography.Text>;
|
||||
const plain = entries.every(([, v]) => v === null || typeof v !== "object");
|
||||
if (plain) {
|
||||
// 扁平结构:一行一组 key=value,紧凑易读
|
||||
return (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px 24px" }}>
|
||||
{entries.map(([k, v]) => (
|
||||
<Typography.Text key={k} style={{ fontSize: 14 }}>
|
||||
<Typography.Text type="secondary">{k}</Typography.Text>
|
||||
{": "}
|
||||
{v === null || v === "" ? "-" : String(v)}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 嵌套结构:逐行缩进展开
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4, paddingLeft: level ? 16 : 0 }}>
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k} style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
|
||||
<Typography.Text type="secondary" style={{ minWidth: level ? 120 : 180, fontSize: 13 }}>{k}</Typography.Text>
|
||||
<ReadableValue value={v} level={level + 1} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 单个设置分组的表单(真实组件承载 Form.useForm,符合 hooks 规则)
|
||||
* 提交时表单值与现有 JSON 合并(保留未在表单中的字段)后整键覆写。
|
||||
*/
|
||||
function SettingsGroupForm(props: {
|
||||
group: (typeof GROUPS)[number];
|
||||
group: { key: string; label: string; desc?: ReactNode; fields: FieldDef[] };
|
||||
current: Record<string, unknown>;
|
||||
updatedAt?: string | Date;
|
||||
renderField: (f: FieldDef) => ReactNode;
|
||||
}) {
|
||||
const t = useAdminT();
|
||||
const { message } = App.useApp();
|
||||
const router = useRouter();
|
||||
const [form] = Form.useForm();
|
||||
@@ -293,15 +283,15 @@ function SettingsGroupForm(props: {
|
||||
const values = await form.validateFields();
|
||||
const merged = { ...props.current, ...values };
|
||||
await adminUpdateSetting({ data: { key: props.group.key, value: merged as Json } });
|
||||
message.success(`已保存「${props.group.label}」`);
|
||||
message.success(t("已保存「{name}」", { name: props.group.label }));
|
||||
await router.invalidate();
|
||||
} catch (e) {
|
||||
if (e && typeof e === "object" && "errorFields" in e) return; // 表单校验错误已就地提示
|
||||
message.error(e instanceof Error ? e.message : "保存失败");
|
||||
message.error(e instanceof Error ? e.message : t("保存失败"));
|
||||
}
|
||||
}
|
||||
|
||||
const g = props.group;
|
||||
const g = { ...props.group, label: t(props.group.label) };
|
||||
return (
|
||||
<div>
|
||||
{g.desc ? <Alert type="info" showIcon message={g.desc} style={{ marginBottom: 16 }} /> : null}
|
||||
@@ -317,21 +307,21 @@ function SettingsGroupForm(props: {
|
||||
<Form.Item
|
||||
key={f.name}
|
||||
name={f.name}
|
||||
label={f.label}
|
||||
label={t(f.label)}
|
||||
extra={f.extra}
|
||||
valuePropName={f.type === "switch" ? "checked" : "value"}
|
||||
style={{ width: `calc(${(f.span ?? 8) / 24 * 100}% - 16px)`, minWidth: 220 }}
|
||||
style={{ width: `calc(${(f.span ?? 8) / 24 * 100}% - 16px)`, minWidth: f.type === "rich" ? "100%" : 220 }}
|
||||
>
|
||||
{props.renderField(f)}
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
<Button type="primary" htmlType="submit" icon={<Save size={14} aria-hidden />}>
|
||||
保存
|
||||
{t("保存")}
|
||||
</Button>
|
||||
</Form>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: "block", marginTop: 10 }}>
|
||||
最后更新:{fmtDateTime(props.updatedAt ?? "")}
|
||||
{t("最后更新")}:{fmtDateTime(props.updatedAt ?? "")}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
+211
-143
@@ -1,81 +1,71 @@
|
||||
/**
|
||||
* # 路由 /admin 后台布局
|
||||
* 左侧 Sider 菜单 + 顶部 Header(当前用户 / 退出)。
|
||||
* 未登录或无后台权限:整页渲染登录页(不再展示后台骨架)。
|
||||
* 左侧 Sider 菜单(按角色权限过滤)+ 顶部 Header(语言选择 / 主题切换 / 用户头像下拉)。
|
||||
* 未登录或无后台权限:整页渲染登录页。
|
||||
* v0.16.0:后台 i18n(默认英文)、浅/深主题、个人资料入口、菜单权限。
|
||||
*/
|
||||
import { createFileRoute, Outlet, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
|
||||
import { App, Avatar, Button, Card, ConfigProvider, Form, Input, Layout, Menu, Space, Tag } from "antd";
|
||||
import { LockKeyhole } from "lucide-react";
|
||||
import { App, Avatar, Button, Card, Dropdown, Form, Input, Layout, Menu, Select, Space, Switch, Tag } from "antd";
|
||||
import { LockKeyhole, LogOut, Moon, Settings, Sun, UserRound, Languages } from "lucide-react";
|
||||
import {
|
||||
DatabaseBackup,
|
||||
Files,
|
||||
Inbox,
|
||||
Image as ImageIcon,
|
||||
Languages,
|
||||
Languages as LanguagesIcon,
|
||||
LayoutDashboard,
|
||||
List,
|
||||
LogOut,
|
||||
Package,
|
||||
Route as RouteIcon,
|
||||
ScrollText,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
UserRound,
|
||||
UserRound as UserRoundIcon,
|
||||
} from "lucide-react";
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import { StyleProvider } from "@ant-design/cssinjs";
|
||||
import { getCurrentUser, loginFn, logoutFn } from "~/server/auth";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { loginFn, logoutFn } from "~/server/auth";
|
||||
import { getAdminLayoutData } from "~/server/permissions";
|
||||
import { getLoginSecurityPublic } from "~/server/security";
|
||||
import { RecaptchaField, useRecaptcha, type LoginSecurityPublic } from "~/components/admin/Recaptcha";
|
||||
import { AdminLocaleProvider, useAdminI18n, useAdminT, type AdminLocaleDef } from "~/admin-i18n";
|
||||
import { ADMIN_LOCALES } from "~/admin-i18n/shared";
|
||||
import { AdminThemeProvider, useAdminTheme } from "~/admin-theme";
|
||||
import { ADMIN_MENU_GROUPS } from "~/admin-menus";
|
||||
|
||||
/** # 类型 后台菜单分组(分组标签不可点;key 即目标路由路径) **/
|
||||
const MENU_GROUPS: { label: string; children: { key: string; icon: ReactNode; label: string }[] }[] = [
|
||||
{
|
||||
label: "概览",
|
||||
children: [{ key: "/admin/", icon: <LayoutDashboard size={16} aria-hidden />, label: "Dashboard" }],
|
||||
},
|
||||
{
|
||||
label: "内容",
|
||||
children: [
|
||||
{ key: "/admin/contents/", icon: <Files size={16} aria-hidden />, label: "页面 / 文章" },
|
||||
{ key: "/admin/menus/", icon: <List size={16} aria-hidden />, label: "菜单" },
|
||||
{ key: "/admin/media/", icon: <ImageIcon size={16} aria-hidden />, label: "媒体库" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "商务",
|
||||
children: [
|
||||
{ key: "/admin/products/", icon: <Package size={16} aria-hidden />, label: "产品" },
|
||||
{ key: "/admin/orders/", icon: <ShoppingCart size={16} aria-hidden />, label: "订单" },
|
||||
{ key: "/admin/inquiries/", icon: <Inbox size={16} aria-hidden />, label: "询盘" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "系统",
|
||||
children: [
|
||||
{ key: "/admin/filters/", icon: <SlidersHorizontal size={16} aria-hidden />, label: "筛选" },
|
||||
{ key: "/admin/languages/", icon: <Languages size={16} aria-hidden />, label: "语言" },
|
||||
{ key: "/admin/seo/", icon: <RouteIcon size={16} aria-hidden />, label: "SEO / 重定向" },
|
||||
{ key: "/admin/users/", icon: <UserRound size={16} aria-hidden />, label: "用户" },
|
||||
{ key: "/admin/settings/", icon: <Settings size={16} aria-hidden />, label: "系统设置" },
|
||||
{ key: "/admin/backups/", icon: <DatabaseBackup size={16} aria-hidden />, label: "备份" },
|
||||
{ key: "/admin/audit/", icon: <ScrollText size={16} aria-hidden />, label: "审计日志" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** # 类型 后台菜单项(扁平视图,用于路径高亮匹配) **/
|
||||
const MENU_ITEMS = MENU_GROUPS.flatMap((g) => g.children);
|
||||
|
||||
/** # 类型 合法菜单路径(用于类型安全的导航) **/
|
||||
type MenuKey = string;
|
||||
/** # 成员变量 菜单 key → 图标(菜单定义在 admin-menus.ts,图标在此补充) **/
|
||||
const MENU_ICONS: Record<string, ReactNode> = {
|
||||
"/admin/": <LayoutDashboard size={16} aria-hidden />,
|
||||
"/admin/contents/": <Files size={16} aria-hidden />,
|
||||
"/admin/menus/": <List size={16} aria-hidden />,
|
||||
"/admin/media/": <ImageIcon size={16} aria-hidden />,
|
||||
"/admin/products/": <Package size={16} aria-hidden />,
|
||||
"/admin/orders/": <ShoppingCart size={16} aria-hidden />,
|
||||
"/admin/inquiries/": <Inbox size={16} aria-hidden />,
|
||||
"/admin/filters/": <SlidersHorizontal size={16} aria-hidden />,
|
||||
"/admin/languages/": <LanguagesIcon size={16} aria-hidden />,
|
||||
"/admin/seo/": <RouteIcon size={16} aria-hidden />,
|
||||
"/admin/users/": <UserRoundIcon size={16} aria-hidden />,
|
||||
"/admin/permissions/": <ShieldCheck size={16} aria-hidden />,
|
||||
"/admin/settings/": <Settings size={16} aria-hidden />,
|
||||
"/admin/backups/": <DatabaseBackup size={16} aria-hidden />,
|
||||
"/admin/audit/": <ScrollText size={16} aria-hidden />,
|
||||
};
|
||||
|
||||
/** # 类型 拥有后台权限的角色 **/
|
||||
const ADMIN_ROLES = ["admin", "editor", "manager"];
|
||||
|
||||
/** # 类型 loader 返回(用户 + 该角色授权菜单) **/
|
||||
type AdminLoaderData = {
|
||||
user: { displayName: string | null; email: string; role: string; avatar: string; locale: string; theme: string } | null;
|
||||
/** # 成员变量 当前角色可见的菜单 key(未登录为空) **/
|
||||
menus: string[];
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/admin")({
|
||||
loader: () => getCurrentUser(),
|
||||
loader: async (): Promise<AdminLoaderData> => {
|
||||
return await getAdminLayoutData();
|
||||
},
|
||||
component: AdminLayout,
|
||||
// AntD 烘焙静态样式(scripts/gen-antd-css.tsx 生成到 public/):
|
||||
// 用 <link> 而非 JS import —— dev 下 vite 会把 import 的 CSS 注入成 <style> 标签,
|
||||
@@ -88,9 +78,10 @@ export const Route = createFileRoute("/admin")({
|
||||
|
||||
/** # 函数 后台布局组件
|
||||
* 未登录 / 角色无后台权限 → 渲染全屏登录页;已授权 → 正常布局。
|
||||
* 外层 AdminLocaleProvider / AdminThemeProvider 为纯客户端偏好(localStorage)。
|
||||
*/
|
||||
function AdminLayout() {
|
||||
const user = Route.useLoaderData();
|
||||
const data = Route.useLoaderData();
|
||||
const router = useRouter();
|
||||
|
||||
/** # 函数 登录成功回调:刷新路由数据(loader 重新取用户,进入布局) */
|
||||
@@ -98,15 +89,24 @@ function AdminLayout() {
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
if (!user || !ADMIN_ROLES.includes(user.role)) {
|
||||
return <AdminLoginPage noPermission={!!user} onOk={handleLoginSuccess} />;
|
||||
}
|
||||
|
||||
return <AdminShell user={user} />;
|
||||
return (
|
||||
<AdminLocaleProvider>
|
||||
<AdminThemeProvider>
|
||||
{!data.user || !ADMIN_ROLES.includes(data.user.role) ? (
|
||||
<AdminLoginPage noPermission={!!data.user} onOk={handleLoginSuccess} />
|
||||
) : (
|
||||
<AdminShell user={data.user} allowedMenus={data.menus} />
|
||||
)}
|
||||
</AdminThemeProvider>
|
||||
</AdminLocaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 后台登录页(全屏居中卡片,邮箱 + 密码;reCAPTCHA 按系统设置「登录安全」开关渲染) */
|
||||
/** # 组件 后台登录页(全屏居中卡片,邮箱 + 密码;reCAPTCHA 按系统设置「登录安全」开关渲染;i18n + 暗色自适应) */
|
||||
function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void> }) {
|
||||
const t = useAdminT();
|
||||
const { locale, setLocale } = useAdminI18n();
|
||||
const { theme, setTheme } = useAdminTheme();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
/** # 成员变量 登录页公开配置(reCAPTCHA 启用状态/版本/站点密钥) **/
|
||||
@@ -127,7 +127,7 @@ function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void
|
||||
try {
|
||||
const token = await recaptcha.getToken();
|
||||
if (secCfg?.enabled && token === null) {
|
||||
setError("人机验证未完成,请完成勾选/等待验证脚本加载后重试");
|
||||
setError(t("人机验证未完成,请完成勾选/等待验证脚本加载后重试"));
|
||||
recaptcha.reset();
|
||||
return;
|
||||
}
|
||||
@@ -137,7 +137,7 @@ function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void
|
||||
recaptcha.reset();
|
||||
} else await props.onOk();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "登录失败,请稍后重试");
|
||||
setError(e instanceof Error ? e.message : t("登录失败,请稍后重试"));
|
||||
recaptcha.reset();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -145,97 +145,116 @@ function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={{ token: { colorPrimary: "#1677ff" }, cssVar: { key: "dusun" } }}>
|
||||
{/* hashPriority=high:运行时生成的选择器不带哈希前缀,与烘焙 CSS(antd-baked.css)保持一致 */}
|
||||
<StyleProvider hashPriority="high">
|
||||
<App>
|
||||
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "linear-gradient(135deg, #f5f8fb 0%, #e8eef5 100%)" }}>
|
||||
<Card style={{ width: 380, boxShadow: "0 8px 32px rgba(16, 42, 76, 0.12)" }}>
|
||||
<div style={{ textAlign: "center", marginBottom: 24 }}>
|
||||
<LockKeyhole size={34} aria-hidden color="#1677ff" style={{ marginBottom: 10 }} />
|
||||
<h2 style={{ margin: 0, fontSize: 20 }}>DusunIoT 后台</h2>
|
||||
<p style={{ margin: "6px 0 0", color: "#8c8c8c", fontSize: 13 }}>
|
||||
{props.noPermission ? "当前账号无后台权限,请更换管理员账号" : "请使用管理员账号登录"}
|
||||
</p>
|
||||
</div>
|
||||
<Form layout="vertical" onFinish={(v) => void handleFinish(v as { email: string; password: string })} requiredMark={false}>
|
||||
<Form.Item name="email" label="邮箱" rules={[{ required: true, message: "请输入邮箱" }]}>
|
||||
<Input type="email" placeholder="admin@example.com" autoComplete="email" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true, message: "请输入密码" }]}>
|
||||
<Input.Password placeholder="密码" autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
{/* reCAPTCHA(系统设置 → 登录安全 开启后出现;v2 复选框 / v3 隐形) */}
|
||||
<RecaptchaField cfg={secCfg} rec={recaptcha} />
|
||||
{error ? (
|
||||
<div role="alert" style={{ color: "#cf1322", background: "#fff1f0", border: "1px solid #ffa39e", borderRadius: 6, padding: "8px 12px", marginBottom: 16, fontSize: 13 }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--ant-color-bg-layout)", position: "relative" }}>
|
||||
{/* 语言 / 主题偏好(登录页同样可用) */}
|
||||
<Space size={8} style={{ position: "absolute", top: 16, right: 24 }}>
|
||||
<Select<AdminLocaleDef["code"]>
|
||||
size="small"
|
||||
value={locale}
|
||||
onChange={setLocale}
|
||||
options={ADMIN_LOCALES.map((l) => ({ value: l.code, label: l.label }))}
|
||||
style={{ width: 130 }}
|
||||
aria-label={t("语言")}
|
||||
/>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={theme === "dark"}
|
||||
checkedChildren={<Moon size={12} aria-hidden />}
|
||||
unCheckedChildren={<Sun size={12} aria-hidden />}
|
||||
onChange={(v) => setTheme(v ? "dark" : "light")}
|
||||
aria-label={t("主题切换")}
|
||||
/>
|
||||
</Space>
|
||||
<Card style={{ width: 420, boxShadow: "0 8px 32px rgba(16, 42, 76, 0.12)" }}>
|
||||
<div style={{ textAlign: "center", marginBottom: 28 }}>
|
||||
<LockKeyhole size={38} aria-hidden color="#1677ff" style={{ marginBottom: 12 }} />
|
||||
<h2 style={{ margin: 0, fontSize: 24 }}>{t("DusunIoT 后台")}</h2>
|
||||
<p style={{ margin: "8px 0 0", color: "var(--ant-color-text-tertiary)", fontSize: 14 }}>
|
||||
{props.noPermission ? t("当前账号无后台权限,请更换管理员账号") : t("请使用管理员账号登录")}
|
||||
</p>
|
||||
</div>
|
||||
</App>
|
||||
</StyleProvider>
|
||||
</ConfigProvider>
|
||||
<Form layout="vertical" onFinish={(v) => void handleFinish(v as { email: string; password: string })} requiredMark={false}>
|
||||
<Form.Item name="email" label={t("邮箱")} rules={[{ required: true, message: t("请输入邮箱") }]}>
|
||||
<Input type="email" placeholder="admin@example.com" autoComplete="email" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label={t("密码")} rules={[{ required: true, message: t("请输入密码") }]}>
|
||||
<Input.Password placeholder={t("密码")} autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
{/* reCAPTCHA(系统设置 → 登录安全 开启后出现;v2 复选框 / v3 隐形) */}
|
||||
<RecaptchaField cfg={secCfg} rec={recaptcha} />
|
||||
{error ? (
|
||||
<div role="alert" style={{ color: "var(--ant-color-error)", background: "var(--ant-color-error-bg)", border: "1px solid var(--ant-color-error-border)", borderRadius: 6, padding: "8px 12px", marginBottom: 16, fontSize: 14 }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
{t("登录")}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数 后台已授权布局外壳
|
||||
* 顶部 Header 展示当前用户与退出按钮;左侧菜单按当前路径高亮;Outlet 渲染子页面。
|
||||
* 顶部 Header 展示语言 / 主题 / 用户区;左侧菜单按当前路径高亮并按权限过滤;Outlet 渲染子页面。
|
||||
* 注意:App.useApp() 必须在 <App> 内部的子组件中调用,故用户区拆为 UserBadge。
|
||||
*/
|
||||
function AdminShell({ user }: { user: { displayName: string | null; email: string; role: string } }) {
|
||||
function AdminShell(props: { user: NonNullable<AdminLoaderData["user"]>; allowedMenus: string[] }) {
|
||||
const t = useAdminT();
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
|
||||
/** # 成员变量 按权限过滤后的菜单分组(个人设置对所有后台角色可见) **/
|
||||
const allowed = new Set(props.allowedMenus);
|
||||
allowed.add("/admin/profile");
|
||||
const groups = ADMIN_MENU_GROUPS.map((g) => ({
|
||||
type: "group" as const,
|
||||
label: <span style={{ fontSize: 12, opacity: 0.6 }}>{t(g.label)}</span>,
|
||||
children: g.children
|
||||
.filter((m) => allowed.has(m.key))
|
||||
.map((m) => ({ key: m.key, icon: MENU_ICONS[m.key], label: t(m.label) })),
|
||||
})).filter((g) => g.children.length > 0);
|
||||
|
||||
/** # 成员变量 当前应高亮的菜单 key:取最长匹配的菜单路径 **/
|
||||
const selectedKey = MENU_ITEMS.map((m) => m.key)
|
||||
.filter((k) => pathname === k || pathname.startsWith(k + "/"))
|
||||
const selectedKey = [...allowed]
|
||||
.filter((k) => pathname === k || pathname.startsWith(k))
|
||||
.sort((a, b) => b.length - a.length)[0] ?? "/admin/";
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={{ token: { colorPrimary: "#1677ff" }, cssVar: { key: "dusun" } }}>
|
||||
<StyleProvider hashPriority="high">
|
||||
<App>
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Layout.Sider collapsible collapsed={collapsed} onCollapse={setCollapsed} width={200}>
|
||||
<div style={{ height: 48, display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontWeight: 600, fontSize: 16, letterSpacing: 1 }}>
|
||||
{collapsed ? "D" : "DusunIoT 后台"}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
items={MENU_GROUPS.map((g) => ({
|
||||
type: "group" as const,
|
||||
label: <span style={{ fontSize: 12, opacity: 0.6 }}>{g.label}</span>,
|
||||
children: g.children.map((m) => ({ key: m.key, icon: m.icon, label: m.label })),
|
||||
}))}
|
||||
selectedKeys={[selectedKey]}
|
||||
onClick={({ key }) => void navigate({ to: key as MenuKey })}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
<Layout>
|
||||
<Layout.Header style={{ background: "#fff", display: "flex", justifyContent: "flex-end", alignItems: "center", paddingInline: 24, borderBottom: "1px solid #f0f0f0" }}>
|
||||
<UserBadge user={user} />
|
||||
</Layout.Header>
|
||||
<Layout.Content style={{ margin: 16 }}>
|
||||
<Outlet />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</App>
|
||||
</StyleProvider>
|
||||
</ConfigProvider>
|
||||
<Layout style={{ minHeight: "100vh" }} className="dusun">
|
||||
<Layout.Sider collapsible collapsed={collapsed} onCollapse={setCollapsed} width={200} theme="dark">
|
||||
<div style={{ height: 48, display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontWeight: 600, fontSize: 17, letterSpacing: 1 }}>
|
||||
{collapsed ? "D" : t("DusunIoT 后台")}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
items={groups}
|
||||
selectedKeys={[selectedKey]}
|
||||
onClick={({ key }) => void navigate({ to: key })}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
<Layout>
|
||||
<Layout.Header style={{ background: "var(--ant-color-bg-container)", display: "flex", justifyContent: "flex-end", alignItems: "center", paddingInline: 24, borderBottom: "1px solid var(--ant-color-split)", height: 56 }}>
|
||||
<UserBadge user={props.user} />
|
||||
</Layout.Header>
|
||||
<Layout.Content style={{ margin: 16 }}>
|
||||
<Outlet />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 顶部用户区(在 <App> 内渲染,可安全使用 App.useApp 的 message) */
|
||||
function UserBadge({ user }: { user: { displayName: string | null; email: string; role: string } }) {
|
||||
/** # 组件 顶部用户区(在 <App> 内渲染,可安全使用 App.useApp 的 message)
|
||||
* 从左到右:语言选择 → 主题切换 → 用户头像下拉(个人设置 / 退出登录)。
|
||||
*/
|
||||
function UserBadge(props: { user: NonNullable<AdminLoaderData["user"]> }) {
|
||||
const t = useAdminT();
|
||||
const { locale, setLocale } = useAdminI18n();
|
||||
const { theme, setTheme } = useAdminTheme();
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
@@ -243,19 +262,68 @@ function UserBadge({ user }: { user: { displayName: string | null; email: string
|
||||
/** # 函数 退出登录:注销会话 → 刷新路由数据 → 回到 /admin */
|
||||
async function handleLogout() {
|
||||
await logoutFn();
|
||||
message.success("已退出登录");
|
||||
message.success(t("已退出登录"));
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/admin/" });
|
||||
}
|
||||
|
||||
return (
|
||||
<Space size={12}>
|
||||
<Avatar size="small" icon={<UserRound size={14} aria-hidden />} />
|
||||
<span>{user.displayName || user.email}</span>
|
||||
<Tag color={user.role === "admin" ? "blue" : "default"}>{user.role}</Tag>
|
||||
<Button size="small" icon={<LogOut size={14} aria-label="退出登录" />} onClick={() => void handleLogout()}>
|
||||
退出
|
||||
</Button>
|
||||
<Space size={14}>
|
||||
<Space size={6}>
|
||||
<Languages size={15} aria-hidden style={{ color: "var(--ant-color-text-tertiary)" }} />
|
||||
<Select
|
||||
size="small"
|
||||
variant="borderless"
|
||||
value={locale}
|
||||
onChange={setLocale}
|
||||
options={ADMIN_LOCALES.map((l) => ({ value: l.code, label: l.label }))}
|
||||
style={{ minWidth: 120 }}
|
||||
aria-label={t("语言")}
|
||||
/>
|
||||
</Space>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={theme === "dark"}
|
||||
checkedChildren={<Moon size={11} aria-hidden />}
|
||||
unCheckedChildren={<Sun size={11} aria-hidden />}
|
||||
onChange={(v) => setTheme(v ? "dark" : "light")}
|
||||
aria-label={t("主题切换")}
|
||||
/>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: "profile",
|
||||
icon: <UserRound size={14} aria-hidden />,
|
||||
label: (
|
||||
<div style={{ minWidth: 160 }}>
|
||||
<div style={{ fontWeight: 600 }}>{props.user.displayName || props.user.email}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ant-color-text-tertiary)" }}>{props.user.email}</div>
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
},
|
||||
{ type: "divider" as const },
|
||||
{ key: "settings", icon: <Settings size={14} aria-hidden />, label: t("个人设置") },
|
||||
{ key: "logout", icon: <LogOut size={14} aria-hidden />, label: t("退出登录"), danger: true },
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === "settings") void navigate({ to: "/admin/profile/" });
|
||||
else if (key === "logout") void handleLogout();
|
||||
},
|
||||
}}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Space size={8} style={{ cursor: "pointer" }} data-testid="user-menu">
|
||||
{props.user.avatar ? (
|
||||
<Avatar size={30} src={props.user.avatar} />
|
||||
) : (
|
||||
<Avatar size={30} icon={<UserRound size={15} aria-hidden />} />
|
||||
)}
|
||||
<span>{props.user.displayName || props.user.email}</span>
|
||||
<Tag color={props.user.role === "admin" ? "blue" : "default"} style={{ marginInlineEnd: 0 }}>{props.user.role}</Tag>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
+39
-33
@@ -9,6 +9,7 @@ import { KeyRound, Search, UserPlus } from "lucide-react";
|
||||
import { getCurrentUser } from "~/server/auth";
|
||||
import { useState } from "react";
|
||||
import { adminCreateUser, adminListUsers, adminResetUserPassword, adminUpdateUserRole } from "~/server/admin";
|
||||
import { useAdminT } from "~/admin-i18n";
|
||||
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/users")({
|
||||
@@ -40,6 +41,7 @@ const ROLE_OPTIONS = (["admin", "editor", "manager", "customer"] as const).map((
|
||||
|
||||
/** # 函数 用户列表页面组件 */
|
||||
function UsersPage() {
|
||||
const t = useAdminT();
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
@@ -57,10 +59,10 @@ function UsersPage() {
|
||||
async function handleRoleChange(id: number, role: string) {
|
||||
try {
|
||||
await adminUpdateUserRole({ data: { id, role } });
|
||||
message.success("角色已更新");
|
||||
message.success(t("角色已更新"));
|
||||
await router.invalidate();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "更新失败");
|
||||
message.error(e instanceof Error ? e.message : t("更新失败"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,16 +70,16 @@ function UsersPage() {
|
||||
function openResetPassword(id: number, email: string) {
|
||||
let pwd = "";
|
||||
modal.confirm({
|
||||
title: `重置密码:${email}`,
|
||||
content: <Input.Password placeholder="新密码(至少 8 位)" onChange={(e) => (pwd = e.target.value)} autoFocus />,
|
||||
okText: "重置",
|
||||
cancelText: "取消",
|
||||
title: t("重置密码:{email}", { email }),
|
||||
content: <Input.Password placeholder={t("新密码(至少 8 位)")} onChange={(e) => (pwd = e.target.value)} autoFocus />,
|
||||
okText: t("重置"),
|
||||
cancelText: t("取消"),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await adminResetUserPassword({ data: { id, password: pwd } });
|
||||
message.success("密码已重置");
|
||||
message.success(t("密码已重置"));
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "重置失败");
|
||||
message.error(e instanceof Error ? e.message : t("重置失败"));
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -87,24 +89,24 @@ function UsersPage() {
|
||||
async function handleCreate(values: { email: string; username?: string; displayName?: string; password: string; role: string }) {
|
||||
try {
|
||||
await adminCreateUser({ data: values });
|
||||
message.success("用户已创建");
|
||||
message.success(t("用户已创建"));
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
await router.invalidate();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "创建失败");
|
||||
message.error(e instanceof Error ? e.message : t("创建失败"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="用户管理"
|
||||
title={t("用户管理")}
|
||||
extra={
|
||||
<Space>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索邮箱 / 显示名"
|
||||
placeholder={t("搜索邮箱 / 显示名")}
|
||||
prefix={<Search size={13} aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
@@ -113,7 +115,7 @@ function UsersPage() {
|
||||
/>
|
||||
{isAdmin ? (
|
||||
<Button type="primary" icon={<UserPlus size={14} aria-hidden />} onClick={() => setCreateOpen(true)}>
|
||||
新建用户
|
||||
{t("新建用户")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
@@ -124,9 +126,9 @@ function UsersPage() {
|
||||
activeKey={search.kind ?? ""}
|
||||
onChange={(key) => void navigate({ search: (prev) => ({ ...prev, kind: key || undefined, page: 1 }) })}
|
||||
items={[
|
||||
{ key: "", label: "全部" },
|
||||
{ key: "backend", label: "后台用户" },
|
||||
{ key: "customer", label: "普通用户" },
|
||||
{ key: "", label: t("全部") },
|
||||
{ key: "backend", label: t("后台用户") },
|
||||
{ key: "customer", label: t("普通用户") },
|
||||
]}
|
||||
/>
|
||||
<Table
|
||||
@@ -134,15 +136,19 @@ function UsersPage() {
|
||||
size="small"
|
||||
dataSource={data.users.items}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
pagination={tablePagination(data.users.total, data.users.page, data.users.pageSize, (p) =>
|
||||
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||
pagination={tablePagination(
|
||||
data.users.total,
|
||||
data.users.page,
|
||||
data.users.pageSize,
|
||||
(p) => void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||
(n) => t("共 {n} 条", { n }),
|
||||
)}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 80 },
|
||||
{ title: "邮箱", dataIndex: "email", width: 240, ellipsis: true },
|
||||
{ title: "显示名", dataIndex: "displayName", width: 170, ellipsis: true },
|
||||
{ title: t("邮箱"), dataIndex: "email", width: 240, ellipsis: true },
|
||||
{ title: t("显示名"), dataIndex: "displayName", width: 170, ellipsis: true },
|
||||
{
|
||||
title: "角色",
|
||||
title: t("角色"),
|
||||
dataIndex: "role",
|
||||
width: 170,
|
||||
render: (v: string, r) =>
|
||||
@@ -151,22 +157,22 @@ function UsersPage() {
|
||||
size="small"
|
||||
value={v}
|
||||
style={{ width: 120 }}
|
||||
options={ROLE_OPTIONS}
|
||||
options={ROLE_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
|
||||
onChange={(role) => void handleRoleChange(r.id, role)}
|
||||
/>
|
||||
) : (
|
||||
<Tag color={ROLE_COLOR[v] ?? "default"}>{ROLE_LABEL[v] ?? v}</Tag>
|
||||
<Tag color={ROLE_COLOR[v] ?? "default"}>{ROLE_LABEL[v] ? t(ROLE_LABEL[v]) : v}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: "注册时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{ title: t("注册时间"), dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{
|
||||
title: "操作",
|
||||
title: t("操作"),
|
||||
key: "act",
|
||||
width: 120,
|
||||
render: (_, r) =>
|
||||
isAdmin ? (
|
||||
<Button size="small" icon={<KeyRound size={13} aria-hidden />} onClick={() => openResetPassword(r.id, r.email)}>
|
||||
重置密码
|
||||
{t("重置密码")}
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
@@ -174,25 +180,25 @@ function UsersPage() {
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="新建用户"
|
||||
title={t("新建用户")}
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => createForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={createForm} layout="vertical" onFinish={(v) => void handleCreate(v as { email: string; password: string; role: string })}>
|
||||
<Form.Item name="email" label="邮箱" rules={[{ required: true, type: "email", message: "请输入有效邮箱" }]}>
|
||||
<Form.Item name="email" label={t("邮箱")} rules={[{ required: true, type: "email", message: t("请输入有效邮箱") }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space size={16} style={{ display: "flex" }}>
|
||||
<Form.Item name="displayName" label="显示名" style={{ flex: 1 }}>
|
||||
<Input placeholder="留空取邮箱前缀" />
|
||||
<Form.Item name="displayName" label={t("显示名")} style={{ flex: 1 }}>
|
||||
<Input placeholder={t("留空取邮箱前缀")} />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label="角色" initialValue="editor" rules={[{ required: true }]}>
|
||||
<Select style={{ width: 140 }} options={ROLE_OPTIONS} />
|
||||
<Form.Item name="role" label={t("角色")} initialValue="editor" rules={[{ required: true }]}>
|
||||
<Select style={{ width: 140 }} options={ROLE_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="password" label="初始密码" rules={[{ required: true, min: 8, message: "至少 8 位" }]}>
|
||||
<Form.Item name="password" label={t("初始密码")} rules={[{ required: true, min: 8, message: t("至少 8 位") }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
Reference in New Issue
Block a user