feat: v2.1.0 界面与凭据改进 —— 单一 API/MCP Key、邮件 Markdown 正文、/inboxs 域名筛选、暗色主题
- 单一 Key:API 与 MCP 共用一个 wmh_ Key,凭据迁移至 settings 表(0004 删除 api_credentials) - Markdown:入库时 HTML→Markdown(mailhook 同思路),webhook payload 新增 markdown 字段 - /emails 路由改为 /inboxs 并支持域名筛选;设置按钮移入头像下拉 - 主题切换按钮(头像左侧,亮/暗持久化);设置模态仅 全局 Webhook/账号密码 两 tab 且高度限屏 - 列表操作列按钮加 SVG 图标;mailboxes 去 Webhook 列;最近邮件加收件人列
This commit is contained in:
@@ -1,18 +1,19 @@
|
||||
/** 应用框架:100vh 布局(侧栏 + 顶栏页面标题 + 内容区滚动),会话守卫 + 设置入口 */
|
||||
/** 应用框架:100vh 布局(侧栏 + 顶栏页面标题 + 内容区滚动),会话守卫 + 主题切换 + 头像下拉(设置/退出) */
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Avatar, Button, Dropdown, Layout, Menu, Tooltip } from "antd";
|
||||
import { Avatar, Button, Dropdown, Layout, Menu } from "antd";
|
||||
import {
|
||||
DashboardOutlined,
|
||||
MailOutlined,
|
||||
CloudServerOutlined,
|
||||
GlobalOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { health, logout, me } from "./api.ts";
|
||||
import { SettingsModal } from "./components/SettingsModal.tsx";
|
||||
import { useTheme } from "./theme-context.ts";
|
||||
|
||||
const { Sider, Header, Content } = Layout;
|
||||
|
||||
@@ -22,7 +23,7 @@ const MENU_ITEMS = [
|
||||
{ key: "/accounts", icon: <CloudServerOutlined />, label: "账号 Accounts" },
|
||||
{ key: "/domains", icon: <GlobalOutlined />, label: "域名 Domains" },
|
||||
{ key: "/mailboxes", icon: <MailOutlined />, label: "邮箱 Mailboxes" },
|
||||
{ key: "/emails", icon: <MailOutlined />, label: "邮件 Emails" },
|
||||
{ key: "/inboxs", icon: <MailOutlined />, label: "收件箱 Inboxs" },
|
||||
];
|
||||
|
||||
/** # 成员变量: 路由 -> 页面标题/副标题 **/
|
||||
@@ -31,15 +32,29 @@ const PAGE_META: Record<string, { title: string; subtitle: string }> = {
|
||||
"/accounts": { title: "账号", subtitle: "通过 Cloudflare OAuth 接入的 Cloudflare 账号(Email Worker 直连)" },
|
||||
"/domains": { title: "域名", subtitle: "OAuth 自动同步的收件域名与 Email Workers" },
|
||||
"/mailboxes": { title: "邮箱", subtitle: "收件地址、保留策略与邮箱级 Webhook" },
|
||||
"/emails": { title: "邮件", subtitle: "已接收邮件(默认保留 1 天)" },
|
||||
"/inboxs": { title: "收件箱", subtitle: "已接收邮件(默认保留 1 天),支持按域名筛选" },
|
||||
};
|
||||
|
||||
/** # 成员变量: 主题切换按钮的太阳/月亮 SVG 图标 **/
|
||||
const SunIcon = (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</svg>
|
||||
);
|
||||
const MoonIcon = (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** # 函数
|
||||
* 应用框架组件:未登录跳 /login;顶栏展示页面标题 + 设置入口;侧栏品牌带版本号
|
||||
* 应用框架组件:未登录跳 /login;顶栏展示页面标题 + 主题切换 + 头像下拉;侧栏品牌带版本号
|
||||
*/
|
||||
export function App() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { dark, toggle } = useTheme();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [version, setVersion] = useState("");
|
||||
@@ -62,67 +77,78 @@ export function App() {
|
||||
const meta = PAGE_META[location.pathname] ?? { title: "WmailHub", subtitle: "" };
|
||||
|
||||
return (
|
||||
<Layout className="app-layout">
|
||||
<Sider width={228} className="app-sider" theme="light">
|
||||
<div className="brand">
|
||||
<img src="/logo.svg" alt="WmailHub" />
|
||||
<span className="name">WmailHub</span>
|
||||
{/* 版本号小一号、居上对齐(来源 package.json) */}
|
||||
{version && <span className="brand-version">v{version}</span>}
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={MENU_ITEMS}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
style={{ borderInlineEnd: "none" }}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="app-header">
|
||||
<div className="header-meta">
|
||||
<div className="title">{meta.title}</div>
|
||||
<div className="subtitle">{meta.subtitle}</div>
|
||||
<>
|
||||
<Layout className="app-layout">
|
||||
<Sider width={228} className="app-sider" theme={dark ? "dark" : "light"}>
|
||||
<div className="brand">
|
||||
<img src="/logo.svg" alt="WmailHub" />
|
||||
<span className="name">WmailHub</span>
|
||||
{/* 版本号小一号、居上对齐(来源 package.json) */}
|
||||
{version && <span className="brand-version">v{version}</span>}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<Tooltip title="设置(全局 Webhook / 密码 / API & MCP Keys)">
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={MENU_ITEMS}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
style={{ borderInlineEnd: "none" }}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="app-header">
|
||||
<div className="header-meta">
|
||||
<div className="title">{meta.title}</div>
|
||||
<div className="subtitle">{meta.subtitle}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
{/* 主题切换:亮↔暗,位于头像左侧 */}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
aria-label="设置"
|
||||
aria-label="切换主题"
|
||||
title={dark ? "切换到亮色模式" : "切换到暗色模式"}
|
||||
icon={dark ? SunIcon : MoonIcon}
|
||||
onClick={toggle}
|
||||
/>
|
||||
</Tooltip>
|
||||
{/* Dropdown 子元素必须是 DOM 元素(antd v6 坑),所以外包 div */}
|
||||
<div>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: "logout",
|
||||
icon: <LogoutOutlined />,
|
||||
label: "退出登录",
|
||||
onClick: async () => {
|
||||
await logout().catch(() => undefined);
|
||||
window.location.href = "/login";
|
||||
{/* 设置入口收进头像下拉 */}
|
||||
<div>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: "settings",
|
||||
icon: <SettingOutlined />,
|
||||
label: "设置",
|
||||
onClick: () => setSettingsOpen(true),
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
|
||||
<Avatar size={30} icon={<UserOutlined />} />
|
||||
<span>{username}</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
{
|
||||
type: "divider" as const,
|
||||
},
|
||||
{
|
||||
key: "logout",
|
||||
icon: <LogoutOutlined />,
|
||||
label: "退出登录",
|
||||
onClick: async () => {
|
||||
await logout().catch(() => undefined);
|
||||
window.location.href = "/login";
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
|
||||
<Avatar size={30} icon={<UserOutlined />} />
|
||||
<span>{username}</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
<Content className="app-content">
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Header>
|
||||
<Content className="app-content">
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+22
-21
@@ -99,7 +99,14 @@ export const stats = () =>
|
||||
deliveries_pending: number;
|
||||
oauth_connections: number;
|
||||
worker_credentials: number;
|
||||
recent_emails: { id: string; subject: string; from_address: string; received_at: string; size: number }[];
|
||||
recent_emails: {
|
||||
id: string;
|
||||
subject: string;
|
||||
from_address: string;
|
||||
to_addresses: string;
|
||||
received_at: string;
|
||||
size: number;
|
||||
}[];
|
||||
}>("/api/admin/stats");
|
||||
|
||||
/** 手动触发 Cron */
|
||||
@@ -243,8 +250,13 @@ export interface EmailRow {
|
||||
attachment_count: number;
|
||||
}
|
||||
|
||||
export const listEmails = (params: { mailbox_id?: string; q?: string; limit?: number; offset?: number }) =>
|
||||
api<{ items: EmailRow[]; total: number }>(`/api/emails${qs(params)}`);
|
||||
export const listEmails = (params: {
|
||||
domain_id?: string;
|
||||
mailbox_id?: string;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) => api<{ items: EmailRow[]; total: number }>(`/api/emails${qs(params)}`);
|
||||
|
||||
export interface EmailDetail {
|
||||
email: EmailRow & { text_body: string | null; html_body: string | null; raw_storage_key: string; cc_addresses: string };
|
||||
@@ -260,6 +272,7 @@ export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE");
|
||||
export interface SettingsInfo {
|
||||
username: string;
|
||||
webhook: { url: string; headers: Record<string, string>; has_key: boolean; key_last4: string };
|
||||
api_key: { has_key: boolean; key_last4: string; created_at: string | null };
|
||||
}
|
||||
|
||||
export const getSettings = () => api<SettingsInfo>("/api/settings");
|
||||
@@ -272,23 +285,11 @@ export const putSettings = (body: {
|
||||
export const changePassword = (old_password: string, new_password: string) =>
|
||||
api("/api/settings/password", "PUT", { old_password, new_password });
|
||||
|
||||
/* ---- API/MCP Keys(设置模态窗口内使用) ---- */
|
||||
/* ---- API/MCP Key(单一 Key,设置模态窗口内使用;API 与 MCP 共用) ---- */
|
||||
|
||||
export interface KeyRow {
|
||||
id: string;
|
||||
kind: "api" | "mcp";
|
||||
name: string;
|
||||
key_last4: string;
|
||||
created_at: string;
|
||||
revoked_at: string | null;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
/** 生成(轮换)Key:明文仅本次返回 */
|
||||
export const createApiKey = () =>
|
||||
api<{ ok: boolean; key: string; created_at: string }>("/api/settings/api-key", "POST");
|
||||
|
||||
export const listKeys = () => api<{ items: KeyRow[] }>("/api/keys");
|
||||
|
||||
export const createKey = (kind: "api" | "mcp", name?: string) =>
|
||||
api<{ ok: boolean; id: string; kind: string; key: string }>("/api/keys", "POST", { kind, name });
|
||||
|
||||
export const revokeKey = (id: string) => api(`/api/keys/${id}/revoke`, "POST");
|
||||
|
||||
export const deleteKey = (id: string) => api(`/api/keys/${id}`, "DELETE");
|
||||
/** 撤销并删除 Key */
|
||||
export const deleteApiKey = () => api("/api/settings/api-key", "DELETE");
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
/**
|
||||
* 设置模态窗口(v2.0.0):
|
||||
* - 基本设置:全局 Webhook(url/自定义头/key)+ 修改密码
|
||||
* - API Keys / MCP Keys:wmh_ 凭据的生成、撤销、删除(原 /keys 页面收拢于此)
|
||||
* 设置模态窗口(v2.1.0):
|
||||
* - 两个 tab:全局 Webhook(含 API/MCP Key 卡片)、账号密码
|
||||
* - 单一 API/MCP Key(API 与 MCP 共用),凭据存 settings 表,明文仅生成时返回一次
|
||||
* - 内容高度不超过屏幕(超出滚动)
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
App,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
changePassword,
|
||||
createKey,
|
||||
deleteKey,
|
||||
getSettings,
|
||||
listKeys,
|
||||
putSettings,
|
||||
revokeKey,
|
||||
type KeyRow,
|
||||
type SettingsInfo,
|
||||
} from "../api.ts";
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
KeyOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { changePassword, createApiKey, deleteApiKey, getSettings, putSettings, type SettingsInfo } from "../api.ts";
|
||||
|
||||
/** # 函数
|
||||
* 设置模态窗口组件
|
||||
@@ -51,12 +47,21 @@ export function SettingsModal({ open, onClose }: { open: boolean; onClose: () =>
|
||||
}, [open, load]);
|
||||
|
||||
return (
|
||||
<Modal title="设置" open={open} onCancel={onClose} footer={null} width={760} destroyOnHidden>
|
||||
<Modal
|
||||
title="设置"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={720}
|
||||
centered
|
||||
destroyOnHidden
|
||||
// 高度不超过屏幕:内容区受限滚动
|
||||
styles={{ body: { maxHeight: "calc(100vh - 180px)", overflowY: "auto", paddingRight: 8 } }}
|
||||
>
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: "general", label: "基本设置", children: info ? <GeneralTab info={info} onSaved={load} /> : null },
|
||||
{ key: "api", label: "API Keys", children: <KeysTab kind="api" /> },
|
||||
{ key: "mcp", label: "MCP Keys", children: <KeysTab kind="mcp" /> },
|
||||
{ key: "webhook", label: "全局 Webhook", children: info ? <WebhookTab info={info} onSaved={load} /> : null },
|
||||
{ key: "password", label: "账号密码", children: <PasswordTab /> },
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
@@ -71,16 +76,11 @@ interface WebhookFormValues {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 基本设置:全局 Webhook + 修改密码
|
||||
* 全局 Webhook tab:Webhook 表单 + 单一 API/MCP Key 卡片
|
||||
*/
|
||||
function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) {
|
||||
function WebhookTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [webhookForm] = Form.useForm<WebhookFormValues>();
|
||||
const [pwdForm] = Form.useForm<{
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
confirm: string;
|
||||
}>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -117,22 +117,9 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
||||
}
|
||||
};
|
||||
|
||||
/** 修改密码 */
|
||||
const savePassword = async () => {
|
||||
const values = await pwdForm.validateFields();
|
||||
try {
|
||||
await changePassword(values.old_password, values.new_password);
|
||||
message.success("密码已更新");
|
||||
pwdForm.resetFields();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "修改失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
<Form form={webhookForm} layout="vertical">
|
||||
<Typography.Title level={5}>全局 Webhook</Typography.Title>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
@@ -165,153 +152,155 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Form form={pwdForm} layout="vertical" onFinish={() => void savePassword()}>
|
||||
<Typography.Title level={5}>修改密码</Typography.Title>
|
||||
<Form.Item name="old_password" label="原密码" rules={[{ required: true, message: "请输入原密码" }]}>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item name="new_password" label="新密码(至少 6 位)" rules={[{ required: true, min: 6, message: "至少 6 位" }]}>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirm"
|
||||
label="确认新密码"
|
||||
dependencies={["new_password"]}
|
||||
rules={[
|
||||
{ required: true, message: "请再次输入" },
|
||||
({ getFieldValue }) => ({
|
||||
validator: (_, v) =>
|
||||
!v || v === getFieldValue("new_password")
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error("两次输入不一致")),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
更新密码
|
||||
</Button>
|
||||
</Form>
|
||||
<ApiKeyCard apiKey={info.api_key} onChanged={onSaved} />
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* API/MCP Key 管理表格
|
||||
* API/MCP Key 卡片:单一 Key,API 与 MCP 接口共用;生成/轮换与撤销
|
||||
*/
|
||||
function KeysTab({ kind }: { kind: "api" | "mcp" }) {
|
||||
function ApiKeyCard({
|
||||
apiKey,
|
||||
onChanged,
|
||||
}: {
|
||||
apiKey: SettingsInfo["api_key"];
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { message, modal } = App.useApp();
|
||||
const [items, setItems] = useState<KeyRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setItems((await listKeys()).items.filter((k) => k.kind === kind));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [kind]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const filtered = items.filter((k) =>
|
||||
`${k.name} ${k.key_last4}`.toLowerCase().includes(q.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
/** 生成新 key:明文仅展示一次 */
|
||||
/** 生成(轮换)key:明文仅展示一次 */
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await createKey(kind);
|
||||
const res = await createApiKey();
|
||||
modal.info({
|
||||
title: `${kind === "api" ? "API" : "MCP"} Key 已生成(仅显示一次)`,
|
||||
title: "API/MCP Key 已生成(仅显示一次)",
|
||||
content: (
|
||||
<div>
|
||||
<Typography.Paragraph copyable className="mono" style={{ marginBottom: 4 }}>
|
||||
{res.key}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Text type="secondary">请立即保存;关闭后无法再次查看。</Typography.Text>
|
||||
<Typography.Text type="secondary">API 与 MCP 接口共用此 Key;请立即保存,关闭后无法再次查看。</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
void load();
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "生成失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 撤销删除 key */
|
||||
const remove = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await deleteApiKey();
|
||||
message.success("已撤销并删除 Key");
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "操作失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-toolbar">
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索名称 / 尾号"
|
||||
style={{ width: 220 }}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => void create()}>
|
||||
生成 {kind === "api" ? "API" : "MCP"} Key
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
dataSource={filtered}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: "名称", dataIndex: "name", ellipsis: true },
|
||||
{ title: "尾号", dataIndex: "key_last4", width: 80, render: (v: string) => <span className="mono">****{v}</span> },
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "revoked_at",
|
||||
width: 90,
|
||||
render: (v: string | null) => (v ? <Tag color="error">已撤销</Tag> : <Tag color="success">有效</Tag>),
|
||||
},
|
||||
{ title: "创建时间", dataIndex: "created_at", width: 165, render: (v: string) => new Date(v).toLocaleString() },
|
||||
{
|
||||
title: "最近使用",
|
||||
dataIndex: "last_used_at",
|
||||
width: 165,
|
||||
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 130,
|
||||
render: (_: unknown, row: KeyRow) => (
|
||||
<Space>
|
||||
{!row.revoked_at && (
|
||||
<Popconfirm
|
||||
title="撤销后使用该 Key 的请求将被拒绝,确认?"
|
||||
onConfirm={async () => {
|
||||
await revokeKey(row.id);
|
||||
message.success("已撤销");
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
<Button size="small">撤销</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={async () => {
|
||||
await deleteKey(row.id);
|
||||
message.success("已删除");
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
<Card size="small" title={<span><KeyOutlined style={{ marginRight: 8 }} />API / MCP Key</span>}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="Console API(/api/v1)与 MCP(/api/v1/mcp)共用同一个 Key;生成新 Key 后旧 Key 立即失效。"
|
||||
/>
|
||||
</div>
|
||||
<Descriptions size="small" column={1} style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="状态">
|
||||
{apiKey.has_key ? (
|
||||
<span>
|
||||
<span className="mono">****{apiKey.key_last4}</span>{" "}
|
||||
<Typography.Text type="secondary">
|
||||
{apiKey.created_at ? `创建于 ${new Date(apiKey.created_at).toLocaleString()}` : ""}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
) : (
|
||||
<Typography.Text type="secondary">未生成</Typography.Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={apiKey.has_key ? <CopyOutlined /> : <PlusOutlined />}
|
||||
loading={busy}
|
||||
onClick={() => void create()}
|
||||
>
|
||||
{apiKey.has_key ? "重新生成(轮换)" : "生成 Key"}
|
||||
</Button>
|
||||
{apiKey.has_key && (
|
||||
<Popconfirm title="撤销后使用该 Key 的请求将被拒绝,确认?" onConfirm={() => void remove()}>
|
||||
<Button danger icon={<DeleteOutlined />} disabled={busy}>
|
||||
撤销
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 账号密码 tab:修改登录密码
|
||||
*/
|
||||
function PasswordTab() {
|
||||
const { message } = App.useApp();
|
||||
const [pwdForm] = Form.useForm<{
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
confirm: string;
|
||||
}>();
|
||||
|
||||
/** 修改密码 */
|
||||
const savePassword = async () => {
|
||||
const values = await pwdForm.validateFields();
|
||||
try {
|
||||
await changePassword(values.old_password, values.new_password);
|
||||
message.success("密码已更新");
|
||||
pwdForm.resetFields();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "修改失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form form={pwdForm} layout="vertical" onFinish={() => void savePassword()}>
|
||||
<Form.Item name="old_password" label="原密码" rules={[{ required: true, message: "请输入原密码" }]}>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item name="new_password" label="新密码(至少 6 位)" rules={[{ required: true, min: 6, message: "至少 6 位" }]}>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirm"
|
||||
label="确认新密码"
|
||||
dependencies={["new_password"]}
|
||||
rules={[
|
||||
{ required: true, message: "请再次输入" },
|
||||
({ getFieldValue }) => ({
|
||||
validator: (_, v) =>
|
||||
!v || v === getFieldValue("new_password")
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error("两次输入不一致")),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
更新密码
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/** 前端入口:antd v6 + react-router(Vite 构建) */
|
||||
import React from "react";
|
||||
/** 前端入口:antd v6 + react-router(Vite 构建);亮/暗主题状态在顶层持有 */
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ConfigProvider, App as AntdApp } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import { antdTheme } from "./theme.ts";
|
||||
import { applyDarkMode, buildAntdTheme, loadDarkMode } from "./theme.ts";
|
||||
import { ThemeContext } from "./theme-context.ts";
|
||||
import "./styles.css";
|
||||
import { App } from "./App.tsx";
|
||||
import { LoginPage } from "./pages/Login.tsx";
|
||||
@@ -13,10 +14,10 @@ import { DashboardPage } from "./pages/Dashboard.tsx";
|
||||
import { AccountsPage } from "./pages/Accounts.tsx";
|
||||
import { DomainsPage } from "./pages/Domains.tsx";
|
||||
import { MailboxesPage } from "./pages/Mailboxes.tsx";
|
||||
import { EmailsPage } from "./pages/Emails.tsx";
|
||||
import { InboxPage } from "./pages/Inbox.tsx";
|
||||
import { setUnauthorizedHandler } from "./api.ts";
|
||||
|
||||
/** # 成员变量: 路由表 **/
|
||||
/** # 成员变量: 路由表(邮件页路由 /inboxs,v2.1.0) **/
|
||||
const router = createBrowserRouter([
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/oauth/sim/authorize", element: <OAuthSimPage /> },
|
||||
@@ -28,11 +29,32 @@ const router = createBrowserRouter([
|
||||
{ path: "accounts", element: <AccountsPage /> },
|
||||
{ path: "domains", element: <DomainsPage /> },
|
||||
{ path: "mailboxes", element: <MailboxesPage /> },
|
||||
{ path: "emails", element: <EmailsPage /> },
|
||||
{ path: "inboxs", element: <InboxPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
/** # 函数
|
||||
* 顶层壳:持有主题状态,注入 Context 与 ConfigProvider
|
||||
*/
|
||||
function Shell() {
|
||||
const [dark, setDark] = useState(loadDarkMode);
|
||||
|
||||
useEffect(() => {
|
||||
applyDarkMode(dark);
|
||||
}, [dark]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ dark, toggle: () => setDark((v) => !v) }}>
|
||||
<ConfigProvider locale={zhCN} theme={buildAntdTheme(dark)}>
|
||||
<AntdApp>
|
||||
<RouterProvider router={router} />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** 未登录统一跳登录页 */
|
||||
setUnauthorizedHandler(() => {
|
||||
if (!location.pathname.startsWith("/login")) {
|
||||
@@ -42,10 +64,6 @@ setUnauthorizedHandler(() => {
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider locale={zhCN} theme={antdTheme}>
|
||||
<AntdApp>
|
||||
<RouterProvider router={router} />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
<Shell />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Typography,
|
||||
App,
|
||||
} from "antd";
|
||||
import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
deleteAccount,
|
||||
getAccount,
|
||||
@@ -167,6 +167,7 @@ export function AccountsPage() {
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
modal.confirm({
|
||||
|
||||
@@ -4,14 +4,33 @@ import { Button, Card, Col, Row, Statistic, Table, message } from "antd";
|
||||
import { ReloadOutlined, ClearOutlined } from "@ant-design/icons";
|
||||
import { health, runCron, stats } from "../api.ts";
|
||||
|
||||
/** # 成员变量: 最近邮件表格列 **/
|
||||
/** # 成员变量: 最近邮件表格列(含收件人) **/
|
||||
const recentColumns = [
|
||||
{ title: "主题", dataIndex: "subject", ellipsis: true },
|
||||
{ title: "发件人", dataIndex: "from_address", width: 240, ellipsis: true },
|
||||
{ title: "大小", dataIndex: "size", width: 100, render: (v: number) => fmtSize(v) },
|
||||
{ title: "接收时间", dataIndex: "received_at", width: 190, render: (v: string) => new Date(v).toLocaleString() },
|
||||
{ title: "发件人", dataIndex: "from_address", width: 200, ellipsis: true },
|
||||
{
|
||||
title: "收件人",
|
||||
dataIndex: "to_addresses",
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (v: string | null) => firstRecipient(v) ?? "-",
|
||||
},
|
||||
{ title: "大小", dataIndex: "size", width: 90, render: (v: number) => fmtSize(v) },
|
||||
{ title: "接收时间", dataIndex: "received_at", width: 180, render: (v: string) => new Date(v).toLocaleString() },
|
||||
];
|
||||
|
||||
/** # 函数
|
||||
* 取收件人列表第一个地址(JSON 数组字段)
|
||||
*/
|
||||
function firstRecipient(s: string | null): string | null {
|
||||
try {
|
||||
const v = JSON.parse(s ?? "[]");
|
||||
return Array.isArray(v) && typeof v[0] === "string" && v[0] ? v[0] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 字节数格式化
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Card, Drawer, Input, Select, Space, Table, Tag, Tooltip, Typography, App } from "antd";
|
||||
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { CloudUploadOutlined, DeleteOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
deleteDomain,
|
||||
listDomainAccounts,
|
||||
@@ -132,17 +132,18 @@ export function DomainsPage() {
|
||||
render: (_: unknown, row: DomainRow) => (
|
||||
<Space onClick={(e) => e.stopPropagation()}>
|
||||
{row.status !== "active" ? (
|
||||
<Button size="small" type="primary" onClick={() => void runSetup(row)}>
|
||||
<Button size="small" type="primary" icon={<CloudUploadOutlined />} onClick={() => void runSetup(row)}>
|
||||
设置 Email Workers
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="small" onClick={() => void runSetup(row)}>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={() => void runSetup(row)}>
|
||||
重新配置
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() =>
|
||||
modal.confirm({
|
||||
title: `删除域名 ${row.name}?`,
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
/** 邮件页:搜索、分页、详情(正文/HTML 预览/附件/raw 下载) */
|
||||
/** 收件箱页(/inboxs):域名筛选、搜索、分页、详情(正文/HTML 预览/附件/raw 下载) */
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Card, Descriptions, Drawer, Input, Space, Table, Tag, Typography, App, Popconfirm, Modal } from "antd";
|
||||
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { deleteEmail, getEmail, listEmails, type EmailDetail, type EmailRow } from "../api.ts";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
App,
|
||||
Popconfirm,
|
||||
Modal,
|
||||
} from "antd";
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { deleteEmail, getEmail, listDomains, listEmails, type EmailDetail, type EmailRow } from "../api.ts";
|
||||
|
||||
/** # 函数
|
||||
* 字节数格式化
|
||||
@@ -21,8 +41,8 @@ const columns = [
|
||||
ellipsis: true,
|
||||
render: (v: string | null) => v ?? <span className="muted">(无主题)</span>,
|
||||
},
|
||||
{ title: "发件人", dataIndex: "from_address", width: 220, ellipsis: true },
|
||||
{ title: "邮箱", dataIndex: "mailbox_address", width: 200, ellipsis: true, render: (v: string | null) => v ?? "-" },
|
||||
{ title: "发件人", dataIndex: "from_address", width: 200, ellipsis: true },
|
||||
{ title: "邮箱", dataIndex: "mailbox_address", width: 190, ellipsis: true, render: (v: string | null) => v ?? "-" },
|
||||
{
|
||||
title: "附件",
|
||||
dataIndex: "attachment_count",
|
||||
@@ -45,15 +65,17 @@ const columns = [
|
||||
];
|
||||
|
||||
/** # 函数
|
||||
* 邮件页组件
|
||||
* 收件箱页组件
|
||||
*/
|
||||
export function EmailsPage() {
|
||||
export function InboxPage() {
|
||||
const { message } = App.useApp();
|
||||
const [items, setItems] = useState<EmailRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [q, setQ] = useState("");
|
||||
const [domainId, setDomainId] = useState<string | undefined>();
|
||||
const [domains, setDomains] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<EmailDetail | null>(null);
|
||||
const [htmlPreview, setHtmlPreview] = useState(false);
|
||||
@@ -61,18 +83,28 @@ export function EmailsPage() {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listEmails({ q, limit: pageSize, offset: (page - 1) * pageSize });
|
||||
const res = await listEmails({ q, domain_id: domainId, limit: pageSize, offset: (page - 1) * pageSize });
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [q, page, pageSize]);
|
||||
}, [q, domainId, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setDomains((await listDomains()).items.map((d) => ({ id: d.id, name: d.name })));
|
||||
} catch {
|
||||
// 域名选项加载失败不阻塞页面
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-toolbar">
|
||||
@@ -81,12 +113,25 @@ export function EmailsPage() {
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索主题 / 发件人"
|
||||
style={{ width: 280 }}
|
||||
style={{ width: 260 }}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="按域名筛选"
|
||||
style={{ width: 200 }}
|
||||
options={domains.map((d) => ({ value: d.id, label: d.name }))}
|
||||
value={domainId}
|
||||
onChange={(v) => {
|
||||
setDomainId(v);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||
</div>
|
||||
@@ -126,10 +171,10 @@ export function EmailsPage() {
|
||||
extra={
|
||||
detail && (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setHtmlPreview((v) => !v)} disabled={!detail.email.html_body}>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => setHtmlPreview((v) => !v)} disabled={!detail.email.html_body}>
|
||||
{htmlPreview ? "显示文本" : "HTML 预览"}
|
||||
</Button>
|
||||
<Button size="small" href={`/api/emails/${detail.email.id}/raw`} target="_blank">
|
||||
<Button size="small" icon={<DownloadOutlined />} href={`/api/emails/${detail.email.id}/raw`} target="_blank">
|
||||
下载 .eml
|
||||
</Button>
|
||||
<Popconfirm
|
||||
@@ -141,7 +186,7 @@ export function EmailsPage() {
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
<Button size="small" danger>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
@@ -174,6 +219,7 @@ export function EmailsPage() {
|
||||
<Button
|
||||
key={att.id}
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
href={`/api/emails/${detail.email.id}/attachments/${att.id}`}
|
||||
target="_blank"
|
||||
>
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 邮箱页:创建/停用/删除/保留策略/邮箱级 Webhook;支持搜索 */
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, App } from "antd";
|
||||
import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { ApiOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
createMailbox,
|
||||
deleteMailbox,
|
||||
@@ -97,12 +97,6 @@ export function MailboxesPage() {
|
||||
width: 95,
|
||||
render: (v: string) => <Tag color={v === "active" ? "success" : "default"}>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "Webhook",
|
||||
width: 90,
|
||||
render: (_: unknown, row: MailboxRow) =>
|
||||
row.webhook_url ? <Tag color="blue">已配置</Tag> : <span className="muted">-</span>,
|
||||
},
|
||||
{
|
||||
title: "保留(秒)",
|
||||
dataIndex: "retention_seconds",
|
||||
@@ -122,12 +116,13 @@ export function MailboxesPage() {
|
||||
width: 150,
|
||||
render: (_: unknown, row: MailboxRow) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setWebhookTarget(row)}>
|
||||
<Button size="small" icon={<ApiOutlined />} onClick={() => setWebhookTarget(row)}>
|
||||
Webhook
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() =>
|
||||
modal.confirm({
|
||||
title: `删除邮箱 ${row.address}?`,
|
||||
|
||||
@@ -20,6 +20,33 @@ body,
|
||||
border-right: 1px solid rgba(5, 5, 5, 0.06);
|
||||
}
|
||||
|
||||
/* 暗色主题下的边框/文字覆盖(data-theme 由主题切换维护) */
|
||||
[data-theme="dark"] .app-sider,
|
||||
[data-theme="dark"] .app-header {
|
||||
border-color: rgba(253, 253, 253, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .app-sider .brand .name,
|
||||
[data-theme="dark"] .app-header .title {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .app-header .subtitle,
|
||||
[data-theme="dark"] .muted {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .brand-version {
|
||||
color: #85a5ff;
|
||||
background: rgba(133, 165, 255, 0.12);
|
||||
border-color: rgba(133, 165, 255, 0.35);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .email-html-frame {
|
||||
background: #fff; /* HTML 邮件按原样渲染,保持白色底 */
|
||||
border-color: rgba(253, 253, 253, 0.15);
|
||||
}
|
||||
|
||||
.app-sider .brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** 主题上下文:App 框架读取当前模式与切换函数(状态保存在 main.tsx 顶层) */
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
/** # 成员变量: 主题上下文 **/
|
||||
export const ThemeContext = createContext<{ dark: boolean; toggle: () => void }>({
|
||||
dark: false,
|
||||
toggle: () => undefined,
|
||||
});
|
||||
|
||||
/** # 函数
|
||||
* 组件内读取主题状态
|
||||
*/
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
@@ -1,25 +1,64 @@
|
||||
/** antd v6 主题配置:默认主题 + 少量 token 覆写(主色与品牌一致) */
|
||||
/** antd v6 主题配置:亮/暗双主题(暗色用 darkAlgorithm),品牌色一致 */
|
||||
import type { ThemeConfig } from "antd";
|
||||
import { theme } from "antd";
|
||||
|
||||
/** # 成员变量: 品牌主色 **/
|
||||
export const BRAND_COLOR = "#2f54eb";
|
||||
|
||||
/** # 成员变量: antd 主题 */
|
||||
export const antdTheme: ThemeConfig = {
|
||||
token: {
|
||||
colorPrimary: BRAND_COLOR,
|
||||
borderRadius: 8,
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
siderBg: "#ffffff",
|
||||
headerBg: "#ffffff",
|
||||
headerHeight: 64,
|
||||
},
|
||||
Card: {
|
||||
headerHeight: 56,
|
||||
},
|
||||
},
|
||||
/** # 成员变量: 主题存储键(localStorage) **/
|
||||
export const THEME_STORAGE_KEY = "wmh_theme";
|
||||
|
||||
/** # 成员变量: 亮色主题基础 token(两种主题共用) **/
|
||||
const baseToken = {
|
||||
colorPrimary: BRAND_COLOR,
|
||||
borderRadius: 8,
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
|
||||
};
|
||||
|
||||
/** # 函数
|
||||
* 构建主题配置
|
||||
* 参数: dark - 是否暗色模式
|
||||
* 返回值: antd ThemeConfig
|
||||
*/
|
||||
export function buildAntdTheme(dark: boolean): ThemeConfig {
|
||||
return {
|
||||
algorithm: dark ? [theme.darkAlgorithm] : undefined,
|
||||
token: {
|
||||
...baseToken,
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
siderBg: dark ? "#141414" : "#ffffff",
|
||||
headerBg: dark ? "#141414" : "#ffffff",
|
||||
headerHeight: 64,
|
||||
},
|
||||
Card: {
|
||||
headerHeight: 56,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 读取本地存储的主题偏好(默认亮色)
|
||||
*/
|
||||
export function loadDarkMode(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(THEME_STORAGE_KEY) === "dark";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 持久化主题偏好并同步 <html data-theme>(供 styles.css 暗色覆盖)
|
||||
*/
|
||||
export function applyDarkMode(dark: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, dark ? "dark" : "light");
|
||||
} catch {
|
||||
// 隐私模式等场景忽略
|
||||
}
|
||||
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user