diff --git a/frontend/src/components/Primitives.tsx b/frontend/src/components/Primitives.tsx
new file mode 100644
index 0000000..32de319
--- /dev/null
+++ b/frontend/src/components/Primitives.tsx
@@ -0,0 +1,325 @@
+// Bespoke primitives that mirror prototype/src/primitives.jsx exactly so
+// screens like UserDetail keep pixel-level fidelity to the prototype rather
+// than picking up AntD's default chrome.
+
+import React from "react";
+
+const fieldStyles = {
+ label: {
+ width: 88,
+ flex: "none" as const,
+ textAlign: "right" as const,
+ paddingRight: 8,
+ color: "var(--text)",
+ fontSize: 12,
+ lineHeight: "26px",
+ whiteSpace: "nowrap" as const,
+ overflow: "hidden" as const,
+ textOverflow: "ellipsis" as const,
+ },
+ control: {
+ flex: 1,
+ minWidth: 0,
+ height: "var(--input-h)",
+ border: "1px solid var(--border-input)",
+ background: "var(--bg-input)",
+ padding: "0 6px",
+ fontSize: 12,
+ color: "var(--text)",
+ borderRadius: 0,
+ outline: "none",
+ fontFamily: "inherit",
+ },
+ controlDisabled: { background: "var(--bg-disabled)", color: "var(--text-muted)" },
+ controlReq: { background: "#d4e8f7" }, // light cyan tint matching prototype screenshots
+};
+
+interface FieldProps {
+ label: string;
+ required?: boolean;
+ children: React.ReactNode;
+ labelWidth?: number;
+}
+
+export function Field({ label, required, children, labelWidth }: FieldProps) {
+ return (
+
+
+ {required ? * : null}
+ {label}:
+
+ {children}
+
+ );
+}
+
+interface InputProps {
+ value: string;
+ onChange?: (v: string) => void;
+ disabled?: boolean;
+ required?: boolean;
+ placeholder?: string;
+ mono?: boolean;
+}
+
+export function PrimInput({ value, onChange, disabled, required, placeholder, mono }: InputProps) {
+ return (
+ onChange(e.target.value) : undefined}
+ disabled={disabled}
+ placeholder={placeholder}
+ style={{
+ ...fieldStyles.control,
+ ...(disabled ? fieldStyles.controlDisabled : {}),
+ ...(required && !disabled ? fieldStyles.controlReq : {}),
+ ...(mono
+ ? { fontFamily: '"JetBrains Mono", Menlo, Consolas, monospace', fontSize: 11 }
+ : {}),
+ }}
+ />
+ );
+}
+
+interface SelectOption {
+ value: string;
+ label: string;
+}
+
+interface SelectProps {
+ value: string;
+ onChange?: (v: string) => void;
+ disabled?: boolean;
+ required?: boolean;
+ options: (string | SelectOption)[];
+ allowEmpty?: boolean;
+}
+
+export function PrimSelect({
+ value,
+ onChange,
+ disabled,
+ required,
+ options,
+ allowEmpty = true,
+}: SelectProps) {
+ return (
+
+
+
+ ▼
+
+
+ );
+}
+
+interface CheckboxProps {
+ checked: boolean;
+ onChange?: (v: boolean) => void;
+ disabled?: boolean;
+ label?: string;
+ size?: number;
+}
+
+export function PrimCheckbox({ checked, onChange, disabled, label, size = 13 }: CheckboxProps) {
+ return (
+
+ );
+}
+
+interface ToolbarBtnDarkProps {
+ children: React.ReactNode;
+ icon?: React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+ danger?: boolean;
+}
+
+export function ToolbarBtnDark({
+ children,
+ icon,
+ onClick,
+ disabled,
+ danger,
+}: ToolbarBtnDarkProps) {
+ const [hover, setHover] = React.useState(false);
+ return (
+
+ );
+}
+
+interface ToolbarBtnLightProps {
+ children: React.ReactNode;
+ icon?: React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+ primary?: boolean;
+ danger?: boolean;
+ success?: boolean;
+}
+
+export function ToolbarBtnLight({
+ children,
+ icon,
+ onClick,
+ disabled,
+ primary,
+ danger,
+ success,
+}: ToolbarBtnLightProps) {
+ const [hover, setHover] = React.useState(false);
+ let bg = "#fff",
+ border = "var(--border-input)",
+ color = "var(--text)";
+ if (primary) {
+ bg = hover ? "#3a9ae8" : "#5cabe8";
+ border = "#3a8ad6";
+ color = "#fff";
+ } else if (danger) {
+ bg = hover ? "#e85a5a" : "#f07070";
+ border = "#d04141";
+ color = "#fff";
+ } else if (success) {
+ bg = hover ? "#38b777" : "#4cc488";
+ border = "#27a567";
+ color = "#fff";
+ } else if (hover && !disabled) {
+ bg = "var(--accent-soft)";
+ border = "var(--accent)";
+ }
+ return (
+
+ );
+}
diff --git a/frontend/src/pages/usr/UserDetail.tsx b/frontend/src/pages/usr/UserDetail.tsx
index 3e92242..da0b465 100644
--- a/frontend/src/pages/usr/UserDetail.tsx
+++ b/frontend/src/pages/usr/UserDetail.tsx
@@ -1,15 +1,32 @@
import { useEffect, useState } from "react";
-import { Form, Input, Select, Button, App as AntApp } from "antd";
+import { App as AntApp } from "antd";
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SaveOutlined,
CloseOutlined,
+ AppstoreOutlined,
+ FileExcelOutlined,
KeyOutlined,
+ RollbackOutlined,
+ SettingOutlined,
} from "@ant-design/icons";
+import {
+ Field,
+ PrimInput,
+ PrimSelect,
+ PrimCheckbox,
+ ToolbarBtnDark,
+} from "@/components/Primitives";
import { createUser, updateUser, type UserDTO } from "@/api/user";
-import { USER_TYPES, LANGUAGE_OPTIONS } from "@/utils/data";
+import {
+ USER_TYPES,
+ LANGUAGE_OPTIONS,
+ PERMISSION_GROUPS,
+ DEPARTMENTS,
+ SCOPE_ITEMS,
+} from "@/utils/data";
import { useAppDispatch, useAppSelector } from "@/store";
import { closeTab, setActiveTab } from "@/store/tabsSlice";
import { fmtDateTime } from "@/utils/format";
@@ -21,18 +38,26 @@ interface Props {
mode: Mode;
}
-interface FormShape {
+interface FormState {
sUserNo: string;
sUserName: string;
- staffName?: string;
- department?: string;
+ staffName: string;
+ department: string;
sUserType: string;
sLanguage: string;
- bCanModifyDocs: "全部允许" | "仅本人单据" | "仅查看";
+ bCanModifyDocs: boolean;
}
+const PERM_TABS = [
+ { id: "groups", label: "权限组" },
+ { id: "customer", label: "客户查看权限" },
+ { id: "supplier", label: "供应商查看权限" },
+ { id: "staff", label: "人员查看权限" },
+ { id: "process", label: "工序查看权限" },
+ { id: "driver", label: "司机查看权限" },
+];
+
export default function UserDetail({ userId, mode: initialMode }: Props) {
- const [form] = Form.useForm();
const [mode, setMode] = useState(initialMode);
const [submitting, setSubmitting] = useState(false);
const dispatch = useAppDispatch();
@@ -54,56 +79,74 @@ export default function UserDetail({ userId, mode: initialMode }: Props) {
}
| undefined;
+ const initialForm: FormState =
+ mode === "new" || !snapshot
+ ? {
+ sUserNo: "",
+ sUserName: "",
+ staffName: "",
+ department: "",
+ sUserType: "普通用户",
+ sLanguage: "zh",
+ bCanModifyDocs: false,
+ }
+ : {
+ sUserNo: snapshot.sUserNo,
+ sUserName: snapshot.sUserName,
+ staffName: snapshot.staffName ?? "",
+ department: snapshot.department ?? "",
+ sUserType: snapshot.sUserType,
+ sLanguage: snapshot.sLanguage,
+ bCanModifyDocs: !!snapshot.bCanModifyDocs,
+ };
+
+ const [form, setForm] = useState(initialForm);
+ const [permTab, setPermTab] = useState("groups");
+ // Visual-only — not persisted to backend.
+ const [permissions, setPermissions] = useState>(() =>
+ Object.fromEntries(PERMISSION_GROUPS.map((g) => [g, false]))
+ );
+ const [tabPerms, setTabPerms] = useState>({});
+
useEffect(() => {
- if (mode === "new") {
- form.setFieldsValue({
- sUserNo: "",
- sUserName: "",
- staffName: "",
- department: "",
- sUserType: "普通用户",
- sLanguage: "zh",
- bCanModifyDocs: "仅查看",
- });
- } else if (snapshot) {
- form.setFieldsValue({
- sUserNo: snapshot.sUserNo,
- sUserName: snapshot.sUserName,
- staffName: snapshot.staffName ?? "",
- department: snapshot.department ?? "",
- sUserType: snapshot.sUserType,
- sLanguage: snapshot.sLanguage,
- bCanModifyDocs: snapshot.bCanModifyDocs ? "全部允许" : "仅查看",
- });
- }
- }, [snapshot, mode, form]);
+ setForm(initialForm);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [snapshot, mode]);
+
+ const set = (k: K, v: FormState[K]) =>
+ setForm((s) => ({ ...s, [k]: v }));
const disabled = mode === "view";
+ const checkedCount = Object.values(permissions).filter(Boolean).length;
const startEdit = () => setMode("edit");
const startNew = () => setMode("new");
-
const cancel = () => {
if (mode === "new") {
dispatch(closeTab(activeTabId));
dispatch(setActiveTab("userlist"));
} else {
+ setForm(initialForm);
setMode("view");
}
};
const save = async () => {
+ if (!form.sUserNo.trim() || !form.sUserName.trim() || !form.sUserType || !form.sLanguage) {
+ message.error("请填写必填项");
+ return;
+ }
+ const dto: UserDTO = {
+ sUserNo: form.sUserNo,
+ sUserName: form.sUserName,
+ sUserType: form.sUserType,
+ sLanguage: form.sLanguage,
+ bCanModifyDocs: form.bCanModifyDocs,
+ // iStaffId omitted: prototype has no staff picker.
+ // permissionCategoryIds omitted: prototype permissions are by name; backend wants IDs.
+ };
+ setSubmitting(true);
try {
- const values = await form.validateFields();
- const dto: UserDTO = {
- sUserNo: values.sUserNo,
- sUserName: values.sUserName,
- sUserType: values.sUserType,
- sLanguage: values.sLanguage,
- bCanModifyDocs: values.bCanModifyDocs === "全部允许",
- // iStaffId omitted: prototype has no staff picker; backend treats as null.
- };
- setSubmitting(true);
if (mode === "new") {
await createUser(dto);
message.success("新增成功");
@@ -115,7 +158,7 @@ export default function UserDetail({ userId, mode: initialMode }: Props) {
setMode("view");
}
} catch {
- // validation or interceptor
+ // interceptor
} finally {
setSubmitting(false);
}
@@ -128,46 +171,59 @@ export default function UserDetail({ userId, mode: initialMode }: Props) {
flexDirection: "column",
height: "100%",
background: "var(--bg-app)",
+ position: "relative",
}}
>
-
-
}
- onClick={startNew}
- disabled={mode !== "view"}
- size="small"
- >
+
+
} onClick={startNew} disabled={mode !== "view"}>
新增
-
-
-
} disabled={mode !== "view"} danger size="small">
+
+
} disabled={mode !== "view"} danger>
删除
-
-
}
- onClick={save}
- disabled={mode === "view"}
- loading={submitting}
- type="primary"
- size="small"
- >
+
+
} onClick={save} disabled={mode === "view" || submitting}>
保存
-
-
} onClick={cancel} disabled={mode === "view"} size="small">
+
+
} onClick={cancel} disabled={mode === "view"}>
取消
-
-
-
} disabled={mode !== "view"} size="small">
- 重置密码
-
+
+
+
}>功能
+
}>作废
+
}>重置密码
+
}>取消作废
+
+ 已选权限:
+ {checkedCount} /{" "}
+ {PERMISSION_GROUPS.length - 1}
+
{mode === "view" ? "只读" : mode === "new" ? "新增中" : "编辑中"}
+
+
+
+ {PERM_TABS.map((t) => {
+ const active = t.id === permTab;
+ return (
+
setPermTab(t.id)}
+ style={{
+ padding: "6px 14px",
+ fontSize: 12,
+ cursor: "pointer",
+ color: active ? "var(--accent-strong)" : "var(--text)",
+ background: active ? "var(--accent-soft)" : "transparent",
+ borderTop: active ? "2px solid var(--accent)" : "2px solid transparent",
+ borderRight: "1px solid var(--border)",
+ fontWeight: active ? 500 : 400,
+ }}
+ onMouseEnter={(e) => {
+ if (!active) e.currentTarget.style.background = "var(--bg-row-hover)";
+ }}
+ onMouseLeave={(e) => {
+ if (!active) e.currentTarget.style.background = "transparent";
+ }}
+ >
+ {t.label}
+
+ );
+ })}
+
+
+
+ {permTab === "groups" ? (
+
+ setPermissions((s) => ({ ...s, [g]: v }))
+ }
+ disabled={disabled}
+ />
+ ) : (
+ setTabPerms((s) => ({ ...s, [permTab]: v }))}
+ disabled={disabled}
+ />
+ )}
+
+
+
+
+
+
+ );
+}
+
+interface PermissionGridProps {
+ permissions: Record;
+ setPermission: (g: string, v: boolean) => void;
+ disabled?: boolean;
+}
-
-
-
-
-
-
-
-
+function PermissionGrid({ permissions, setPermission, disabled }: PermissionGridProps) {
+ const [filter, setFilter] = useState("");
+ const [hovered, setHovered] = useState(null);
+ const allChecked = PERMISSION_GROUPS.every((g) => permissions[g]);
-
-
-
-
-
-
-
-