v0.16.2: 后台 UI 大气化(控件 36px/圆角 8/表格单元格内边距放大/头部 60px/内容区加宽留白)、登录页大输入框+语言与月亮太阳主题按钮、右上角去角色 Tag、权限页 MCP/API 合并为单开关、Alert message→title 弃用清理
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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" },
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user