v0.16.2: 后台 UI 大气化(控件 36px/圆角 8/表格单元格内边距放大/头部 60px/内容区加宽留白)、登录页大输入框+语言与月亮太阳主题按钮、右上角去角色 Tag、权限页 MCP/API 合并为单开关、Alert message→title 弃用清理

This commit is contained in:
bigcode
2026-11-05 02:39:42 +08:00
parent 1cefdecfe0
commit 50394a51be
7 changed files with 90 additions and 62 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dusuniot-rebuild",
"version": "0.16.1",
"version": "0.16.2",
"private": true,
"type": "module",
"scripts": {
File diff suppressed because one or more lines are too long
+19 -2
View File
@@ -51,7 +51,24 @@ import { createCache, StyleProvider, extractStyle } from "@ant-design/cssinjs";
/** # 成员变量 与 admin.tsx 完全一致的主题配置(改动后必须重新烘焙)
* cssVar.key 固定为 "dusun":变量作用域类名确定,烘焙 CSS 与运行时 DOM 一致 **/
const THEME = { token: { colorPrimary: "#1677ff", fontSize: 16 }, cssVar: { key: "dusun" } };
const THEME = {
token: {
colorPrimary: "#1677ff",
fontSize: 16,
controlHeight: 36,
borderRadius: 8,
},
components: {
Table: {
cellPaddingBlock: 15,
cellPaddingInline: 18,
cellPaddingBlockSM: 12,
cellPaddingInlineSM: 14,
headerSplitColor: "transparent",
},
},
cssVar: { key: "dusun" },
};
/** # 组件 覆盖后台全部 AntD 组件的样例树(每个组件至少渲染一次以注册其样式) **/
function Showcase() {
@@ -175,7 +192,7 @@ function Showcase() {
defaultExpandAll
treeData={[{ title: "分组", key: "g", children: [{ title: "子项", key: "c" }] }]}
/>
<Alert type="info" showIcon message="提示" style={{ marginTop: 8 }} />
<Alert type="info" showIcon title="提示" style={{ marginTop: 8 }} />
<Pagination size="small" total={50} style={{ marginTop: 8 }} />
</Layout.Content>
<Layout.Footer>F</Layout.Footer>
+17 -3
View File
@@ -21,8 +21,22 @@ type ThemeCtx = {
/** # 成员变量 主题上下文(默认浅色) **/
const Ctx = createContext<ThemeCtx>({ theme: "light", setTheme: () => {} });
/** # 成员变量 后台统一主题 token(字号放大两号:14 → 16;主题变更后需重跑 scripts/gen-antd-css.tsx) **/
export const ADMIN_THEME_TOKEN = { colorPrimary: "#1677ff", fontSize: 16 };
/** # 成员变量 后台统一主题配置(字号 16 + 更舒展的控件与表格密度;主题变更后需重跑 scripts/gen-antd-css.tsx)
* - controlHeight 36 / borderRadius 8:控件更饱满大气
* - components.Table:单元格内边距整体放大(含 small 表格),消除「密密」观感;表头无分隔竖线
**/
export const ADMIN_THEME_CONFIG = {
token: { colorPrimary: "#1677ff", fontSize: 16, controlHeight: 36, borderRadius: 8 },
components: {
Table: {
cellPaddingBlock: 15,
cellPaddingInline: 18,
cellPaddingBlockSM: 12,
cellPaddingInlineSM: 14,
headerSplitColor: "transparent",
},
},
};
/** # 组件 后台主题 + AntD 全局包装(语言上下文之外的所有页面都应在其中) */
export function AdminThemeProvider(props: { children: ReactNode }) {
@@ -41,7 +55,7 @@ export function AdminThemeProvider(props: { children: ReactNode }) {
<Ctx.Provider value={value}>
<ConfigProvider
theme={{
token: ADMIN_THEME_TOKEN,
...ADMIN_THEME_CONFIG,
algorithm: theme === "dark" ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
cssVar: { key: "dusun" },
}}
+7 -9
View File
@@ -148,15 +148,13 @@ function PermissionsPage() {
</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 size={8}>
<Typography.Text style={{ fontSize: 14 }}>MCP / API</Typography.Text>
{/* MCP 与 API 同源权限:一个开关同时控制两者(保存时写入同值) */}
<Switch
checked={draft[role]!.mcp || draft[role]!.api}
onChange={(v) => setDraft((d) => ({ ...d, [role]: { ...d[role]!, mcp: v, api: v } }))}
/>
</Space>
}
>
+1 -1
View File
@@ -294,7 +294,7 @@ function SettingsGroupForm(props: {
const g = { ...props.group, label: t(props.group.label) };
return (
<div>
{g.desc ? <Alert type="info" showIcon message={g.desc} style={{ marginBottom: 16 }} /> : null}
{g.desc ? <Alert type="info" showIcon title={g.desc} style={{ marginBottom: 16 }} /> : null}
<Form
form={form}
layout="vertical"
+42 -43
View File
@@ -5,7 +5,7 @@
* v0.16.0:后台 i18n(默认英文)、浅/深主题、个人资料入口、菜单权限。
*/
import { createFileRoute, Outlet, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
import { App, Avatar, Button, Card, Dropdown, Form, Input, Layout, Menu, Select, Space, Switch, Tag } from "antd";
import { App, Avatar, Button, Card, Dropdown, Form, Input, Layout, Menu, Select, Space, Tooltip } from "antd";
import { LockKeyhole, LogOut, Moon, Settings, Sun, UserRound, Languages } from "lucide-react";
import {
DatabaseBackup,
@@ -76,6 +76,24 @@ export const Route = createFileRoute("/admin")({
}),
});
/** # 组件 主题切换按钮(月亮/太阳图标,非开关;浅色显示月亮=点切深色,深色显示太阳=点切浅色) */
function ThemeToggleButton(props: { size?: "small" | "middle" }) {
const t = useAdminT();
const { theme, setTheme } = useAdminTheme();
const dark = theme === "dark";
return (
<Tooltip title={t("主题切换")}>
<Button
type="text"
size={props.size}
onClick={() => setTheme(dark ? "light" : "dark")}
icon={dark ? <Sun size={17} aria-hidden /> : <Moon size={17} aria-hidden />}
aria-label={t("主题切换")}
/>
</Tooltip>
);
}
/** # 函数 后台布局组件
* 未登录 / 角色无后台权限 → 渲染全屏登录页;已授权 → 正常布局。
* 外层 AdminLocaleProvider / AdminThemeProvider 为纯客户端偏好(localStorage)。
@@ -106,7 +124,6 @@ function AdminLayout() {
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 启用状态/版本/站点密钥) **/
@@ -147,47 +164,39 @@ function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void
return (
<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 }}>
<Space size={10} style={{ position: "absolute", top: 20, right: 28 }}>
<Select<AdminLocaleDef["code"]>
size="small"
value={locale}
onChange={setLocale}
options={ADMIN_LOCALES.map((l) => ({ value: l.code, label: l.label }))}
style={{ width: 130 }}
style={{ width: 150 }}
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("主题切换")}
/>
<ThemeToggleButton />
</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 }}>
<Card style={{ width: 440, boxShadow: "0 12px 40px rgba(16, 42, 76, 0.14)", borderRadius: 12 }}>
<div style={{ textAlign: "center", marginBottom: 32 }}>
<LockKeyhole size={44} aria-hidden color="#1677ff" style={{ marginBottom: 14 }} />
<h2 style={{ margin: 0, fontSize: 26 }}>{t("DusunIoT 后台")}</h2>
<p style={{ margin: "10px 0 0", color: "var(--ant-color-text-tertiary)", fontSize: 15 }}>
{props.noPermission ? t("当前账号无后台权限,请更换管理员账号") : t("请使用管理员账号登录")}
</p>
</div>
<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" />
<Input type="email" size="large" 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" />
<Input.Password size="large" 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 }}>
<div role="alert" style={{ color: "var(--ant-color-error)", background: "var(--ant-color-error-bg)", border: "1px solid var(--ant-color-error-border)", borderRadius: 8, padding: "10px 14px", marginBottom: 16, fontSize: 14 }}>
{error}
</div>
) : null}
<Button type="primary" htmlType="submit" block loading={loading}>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
{t("登录")}
</Button>
</Form>
@@ -225,7 +234,7 @@ function AdminShell(props: { user: NonNullable<AdminLoaderData["user"]>; allowed
return (
<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 }}>
<div style={{ height: 60, display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontWeight: 600, fontSize: 18, letterSpacing: 1 }}>
{collapsed ? "D" : t("DusunIoT 后台")}
</div>
<Menu
@@ -237,10 +246,10 @@ function AdminShell(props: { user: NonNullable<AdminLoaderData["user"]>; allowed
/>
</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 }}>
<Layout.Header style={{ background: "var(--ant-color-bg-container)", display: "flex", justifyContent: "flex-end", alignItems: "center", paddingInline: 28, borderBottom: "1px solid var(--ant-color-split)", height: 60 }}>
<UserBadge user={props.user} />
</Layout.Header>
<Layout.Content style={{ margin: 16 }}>
<Layout.Content style={{ margin: "20px 24px 28px" }}>
<Outlet />
</Layout.Content>
</Layout>
@@ -254,7 +263,6 @@ function AdminShell(props: { user: NonNullable<AdminLoaderData["user"]>; allowed
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();
@@ -268,27 +276,19 @@ function UserBadge(props: { user: NonNullable<AdminLoaderData["user"]> }) {
}
return (
<Space size={14}>
<Space size={16}>
<Space size={6}>
<Languages size={15} aria-hidden style={{ color: "var(--ant-color-text-tertiary)" }} />
<Languages size={16} 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 }}
style={{ minWidth: 130 }}
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("主题切换")}
/>
<ThemeToggleButton />
<Dropdown
menu={{
items: [
@@ -314,14 +314,13 @@ function UserBadge(props: { user: NonNullable<AdminLoaderData["user"]> }) {
}}
placement="bottomRight"
>
<Space size={8} style={{ cursor: "pointer" }} data-testid="user-menu">
<Space size={10} style={{ cursor: "pointer" }} data-testid="user-menu">
{props.user.avatar ? (
<Avatar size={30} src={props.user.avatar} />
<Avatar size={34} src={props.user.avatar} />
) : (
<Avatar size={30} icon={<UserRound size={15} aria-hidden />} />
<Avatar size={34} icon={<UserRound size={17} 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>
<span style={{ fontSize: 15 }}>{props.user.displayName || props.user.email}</span>
</Space>
</Dropdown>
</Space>