Commit 3491ef6c2fca2596ea06e9a132c5910e04a41ad5

Authored by zichun
1 parent 748348f0

feat(frontend): restore prototype UserDetail look (perm grid + scope tabs)

Reverses the earlier 'hide unsupported fields' decision per user request
to strictly follow the prototype look. Brings back:

- Full dark sub-toolbar: 新增 / 修改 / 删除 / 保存 / 取消 (separator)
  功能 / 作废 / 重置密码 / 取消作废, with status chip + settings gear
  on the right and 已选权限 counter
- 9-field 3x3 form with prototype's required-field cyan tint, 制单人
  showing 保存后自动生成 placeholder in new mode
- 6 perm tabs (权限组 / 客户 / 供应商 / 人员 / 工序 / 司机)
- 30-row permission grid with hover, accent-soft selected row, filter
- Scope tabs with master enable + 4-col grid of items
- Floating vertical 帮助 button on right edge

Permissions and the no-op buttons are visual-only — they don't round-trip
to backend. Save still sends only sUserNo/sUserName/sUserType/sLanguage/
bCanModifyDocs (iStaffId and permissionCategoryIds intentionally omitted).

Adds bespoke Primitives.tsx (Field/PrimInput/PrimSelect/PrimCheckbox/
ToolbarBtnDark/ToolbarBtnLight) so this screen renders pixel-faithful
to prototype rather than picking up AntD chrome.
frontend/src/components/Primitives.tsx 0 → 100644
  1 +// Bespoke primitives that mirror prototype/src/primitives.jsx exactly so
  2 +// screens like UserDetail keep pixel-level fidelity to the prototype rather
  3 +// than picking up AntD's default chrome.
  4 +
  5 +import React from "react";
  6 +
  7 +const fieldStyles = {
  8 + label: {
  9 + width: 88,
  10 + flex: "none" as const,
  11 + textAlign: "right" as const,
  12 + paddingRight: 8,
  13 + color: "var(--text)",
  14 + fontSize: 12,
  15 + lineHeight: "26px",
  16 + whiteSpace: "nowrap" as const,
  17 + overflow: "hidden" as const,
  18 + textOverflow: "ellipsis" as const,
  19 + },
  20 + control: {
  21 + flex: 1,
  22 + minWidth: 0,
  23 + height: "var(--input-h)",
  24 + border: "1px solid var(--border-input)",
  25 + background: "var(--bg-input)",
  26 + padding: "0 6px",
  27 + fontSize: 12,
  28 + color: "var(--text)",
  29 + borderRadius: 0,
  30 + outline: "none",
  31 + fontFamily: "inherit",
  32 + },
  33 + controlDisabled: { background: "var(--bg-disabled)", color: "var(--text-muted)" },
  34 + controlReq: { background: "#d4e8f7" }, // light cyan tint matching prototype screenshots
  35 +};
  36 +
  37 +interface FieldProps {
  38 + label: string;
  39 + required?: boolean;
  40 + children: React.ReactNode;
  41 + labelWidth?: number;
  42 +}
  43 +
  44 +export function Field({ label, required, children, labelWidth }: FieldProps) {
  45 + return (
  46 + <div style={{ display: "flex", alignItems: "center", minWidth: 0 }}>
  47 + <div
  48 + style={{
  49 + ...fieldStyles.label,
  50 + width: labelWidth ?? fieldStyles.label.width,
  51 + ...(required ? { color: "var(--required)" } : {}),
  52 + }}
  53 + title={label}
  54 + >
  55 + {required ? <span style={{ marginRight: 2 }}>*</span> : null}
  56 + {label}:
  57 + </div>
  58 + {children}
  59 + </div>
  60 + );
  61 +}
  62 +
  63 +interface InputProps {
  64 + value: string;
  65 + onChange?: (v: string) => void;
  66 + disabled?: boolean;
  67 + required?: boolean;
  68 + placeholder?: string;
  69 + mono?: boolean;
  70 +}
  71 +
  72 +export function PrimInput({ value, onChange, disabled, required, placeholder, mono }: InputProps) {
  73 + return (
  74 + <input
  75 + type="text"
  76 + value={value ?? ""}
  77 + onChange={onChange ? (e) => onChange(e.target.value) : undefined}
  78 + disabled={disabled}
  79 + placeholder={placeholder}
  80 + style={{
  81 + ...fieldStyles.control,
  82 + ...(disabled ? fieldStyles.controlDisabled : {}),
  83 + ...(required && !disabled ? fieldStyles.controlReq : {}),
  84 + ...(mono
  85 + ? { fontFamily: '"JetBrains Mono", Menlo, Consolas, monospace', fontSize: 11 }
  86 + : {}),
  87 + }}
  88 + />
  89 + );
  90 +}
  91 +
  92 +interface SelectOption {
  93 + value: string;
  94 + label: string;
  95 +}
  96 +
  97 +interface SelectProps {
  98 + value: string;
  99 + onChange?: (v: string) => void;
  100 + disabled?: boolean;
  101 + required?: boolean;
  102 + options: (string | SelectOption)[];
  103 + allowEmpty?: boolean;
  104 +}
  105 +
  106 +export function PrimSelect({
  107 + value,
  108 + onChange,
  109 + disabled,
  110 + required,
  111 + options,
  112 + allowEmpty = true,
  113 +}: SelectProps) {
  114 + return (
  115 + <div style={{ position: "relative", flex: 1, minWidth: 0 }}>
  116 + <select
  117 + value={value ?? ""}
  118 + onChange={onChange ? (e) => onChange(e.target.value) : undefined}
  119 + disabled={disabled}
  120 + style={{
  121 + ...fieldStyles.control,
  122 + ...(disabled ? fieldStyles.controlDisabled : {}),
  123 + ...(required && !disabled ? fieldStyles.controlReq : {}),
  124 + appearance: "none",
  125 + paddingRight: 22,
  126 + width: "100%",
  127 + }}
  128 + >
  129 + {allowEmpty ? <option value="" /> : null}
  130 + {options.map((o) => {
  131 + const v = typeof o === "string" ? o : o.value;
  132 + const l = typeof o === "string" ? o : o.label;
  133 + return (
  134 + <option key={v} value={v}>
  135 + {l}
  136 + </option>
  137 + );
  138 + })}
  139 + </select>
  140 + <div
  141 + style={{
  142 + position: "absolute",
  143 + right: 6,
  144 + top: "50%",
  145 + transform: "translateY(-50%)",
  146 + color: "var(--text-faint)",
  147 + pointerEvents: "none",
  148 + fontSize: 9,
  149 + }}
  150 + >
  151 + ▼
  152 + </div>
  153 + </div>
  154 + );
  155 +}
  156 +
  157 +interface CheckboxProps {
  158 + checked: boolean;
  159 + onChange?: (v: boolean) => void;
  160 + disabled?: boolean;
  161 + label?: string;
  162 + size?: number;
  163 +}
  164 +
  165 +export function PrimCheckbox({ checked, onChange, disabled, label, size = 13 }: CheckboxProps) {
  166 + return (
  167 + <label
  168 + style={{
  169 + display: "inline-flex",
  170 + alignItems: "center",
  171 + gap: 4,
  172 + cursor: disabled ? "not-allowed" : "pointer",
  173 + userSelect: "none",
  174 + color: "var(--text)",
  175 + fontSize: 12,
  176 + }}
  177 + >
  178 + <span
  179 + onClick={disabled ? undefined : () => onChange?.(!checked)}
  180 + style={{
  181 + width: size,
  182 + height: size,
  183 + border: "1px solid var(--border-strong)",
  184 + background: checked ? "var(--accent)" : "#fff",
  185 + borderColor: checked ? "var(--accent)" : "var(--border-strong)",
  186 + display: "inline-flex",
  187 + alignItems: "center",
  188 + justifyContent: "center",
  189 + cursor: disabled ? "not-allowed" : "pointer",
  190 + flex: "none",
  191 + opacity: disabled ? 0.6 : 1,
  192 + }}
  193 + >
  194 + {checked ? (
  195 + <svg width={size - 4} height={size - 4} viewBox="0 0 10 10" fill="none">
  196 + <path
  197 + d="M2 5l2 2 4-4"
  198 + stroke="#fff"
  199 + strokeWidth="1.5"
  200 + strokeLinecap="round"
  201 + strokeLinejoin="round"
  202 + />
  203 + </svg>
  204 + ) : null}
  205 + </span>
  206 + {label != null ? <span>{label}</span> : null}
  207 + </label>
  208 + );
  209 +}
  210 +
  211 +interface ToolbarBtnDarkProps {
  212 + children: React.ReactNode;
  213 + icon?: React.ReactNode;
  214 + onClick?: () => void;
  215 + disabled?: boolean;
  216 + danger?: boolean;
  217 +}
  218 +
  219 +export function ToolbarBtnDark({
  220 + children,
  221 + icon,
  222 + onClick,
  223 + disabled,
  224 + danger,
  225 +}: ToolbarBtnDarkProps) {
  226 + const [hover, setHover] = React.useState(false);
  227 + return (
  228 + <button
  229 + onClick={disabled ? undefined : onClick}
  230 + onMouseEnter={() => setHover(true)}
  231 + onMouseLeave={() => setHover(false)}
  232 + disabled={disabled}
  233 + style={{
  234 + height: 26,
  235 + padding: "0 10px",
  236 + display: "inline-flex",
  237 + alignItems: "center",
  238 + gap: 4,
  239 + background: hover && !disabled ? "rgba(255,255,255,0.08)" : "transparent",
  240 + border: "1px solid transparent",
  241 + color: disabled
  242 + ? "var(--text-on-dark-muted)"
  243 + : danger && hover
  244 + ? "#ff8b8b"
  245 + : "var(--text-on-dark)",
  246 + cursor: disabled ? "not-allowed" : "pointer",
  247 + fontSize: 12,
  248 + borderRadius: 2,
  249 + whiteSpace: "nowrap",
  250 + fontFamily: "inherit",
  251 + }}
  252 + >
  253 + {icon ? <span style={{ display: "inline-flex" }}>{icon}</span> : null}
  254 + {children}
  255 + </button>
  256 + );
  257 +}
  258 +
  259 +interface ToolbarBtnLightProps {
  260 + children: React.ReactNode;
  261 + icon?: React.ReactNode;
  262 + onClick?: () => void;
  263 + disabled?: boolean;
  264 + primary?: boolean;
  265 + danger?: boolean;
  266 + success?: boolean;
  267 +}
  268 +
  269 +export function ToolbarBtnLight({
  270 + children,
  271 + icon,
  272 + onClick,
  273 + disabled,
  274 + primary,
  275 + danger,
  276 + success,
  277 +}: ToolbarBtnLightProps) {
  278 + const [hover, setHover] = React.useState(false);
  279 + let bg = "#fff",
  280 + border = "var(--border-input)",
  281 + color = "var(--text)";
  282 + if (primary) {
  283 + bg = hover ? "#3a9ae8" : "#5cabe8";
  284 + border = "#3a8ad6";
  285 + color = "#fff";
  286 + } else if (danger) {
  287 + bg = hover ? "#e85a5a" : "#f07070";
  288 + border = "#d04141";
  289 + color = "#fff";
  290 + } else if (success) {
  291 + bg = hover ? "#38b777" : "#4cc488";
  292 + border = "#27a567";
  293 + color = "#fff";
  294 + } else if (hover && !disabled) {
  295 + bg = "var(--accent-soft)";
  296 + border = "var(--accent)";
  297 + }
  298 + return (
  299 + <button
  300 + onClick={disabled ? undefined : onClick}
  301 + onMouseEnter={() => setHover(true)}
  302 + onMouseLeave={() => setHover(false)}
  303 + disabled={disabled}
  304 + style={{
  305 + height: "var(--tb-h)",
  306 + padding: "0 10px",
  307 + display: "inline-flex",
  308 + alignItems: "center",
  309 + gap: 4,
  310 + background: disabled ? "var(--bg-disabled)" : bg,
  311 + border: `1px solid ${disabled ? "var(--border)" : border}`,
  312 + color: disabled ? "var(--text-faint)" : color,
  313 + cursor: disabled ? "not-allowed" : "pointer",
  314 + fontSize: 12,
  315 + fontWeight: primary || danger || success ? 500 : 400,
  316 + borderRadius: 2,
  317 + whiteSpace: "nowrap",
  318 + fontFamily: "inherit",
  319 + }}
  320 + >
  321 + {icon ? <span style={{ display: "inline-flex" }}>{icon}</span> : null}
  322 + {children}
  323 + </button>
  324 + );
  325 +}
... ...
frontend/src/pages/usr/UserDetail.tsx
1 1 import { useEffect, useState } from "react";
2   -import { Form, Input, Select, Button, App as AntApp } from "antd";
  2 +import { App as AntApp } from "antd";
3 3 import {
4 4 PlusOutlined,
5 5 EditOutlined,
6 6 DeleteOutlined,
7 7 SaveOutlined,
8 8 CloseOutlined,
  9 + AppstoreOutlined,
  10 + FileExcelOutlined,
9 11 KeyOutlined,
  12 + RollbackOutlined,
  13 + SettingOutlined,
10 14 } from "@ant-design/icons";
  15 +import {
  16 + Field,
  17 + PrimInput,
  18 + PrimSelect,
  19 + PrimCheckbox,
  20 + ToolbarBtnDark,
  21 +} from "@/components/Primitives";
11 22 import { createUser, updateUser, type UserDTO } from "@/api/user";
12   -import { USER_TYPES, LANGUAGE_OPTIONS } from "@/utils/data";
  23 +import {
  24 + USER_TYPES,
  25 + LANGUAGE_OPTIONS,
  26 + PERMISSION_GROUPS,
  27 + DEPARTMENTS,
  28 + SCOPE_ITEMS,
  29 +} from "@/utils/data";
13 30 import { useAppDispatch, useAppSelector } from "@/store";
14 31 import { closeTab, setActiveTab } from "@/store/tabsSlice";
15 32 import { fmtDateTime } from "@/utils/format";
... ... @@ -21,18 +38,26 @@ interface Props {
21 38 mode: Mode;
22 39 }
23 40  
24   -interface FormShape {
  41 +interface FormState {
25 42 sUserNo: string;
26 43 sUserName: string;
27   - staffName?: string;
28   - department?: string;
  44 + staffName: string;
  45 + department: string;
29 46 sUserType: string;
30 47 sLanguage: string;
31   - bCanModifyDocs: "全部允许" | "仅本人单据" | "仅查看";
  48 + bCanModifyDocs: boolean;
32 49 }
33 50  
  51 +const PERM_TABS = [
  52 + { id: "groups", label: "权限组" },
  53 + { id: "customer", label: "客户查看权限" },
  54 + { id: "supplier", label: "供应商查看权限" },
  55 + { id: "staff", label: "人员查看权限" },
  56 + { id: "process", label: "工序查看权限" },
  57 + { id: "driver", label: "司机查看权限" },
  58 +];
  59 +
34 60 export default function UserDetail({ userId, mode: initialMode }: Props) {
35   - const [form] = Form.useForm<FormShape>();
36 61 const [mode, setMode] = useState<Mode>(initialMode);
37 62 const [submitting, setSubmitting] = useState(false);
38 63 const dispatch = useAppDispatch();
... ... @@ -54,56 +79,74 @@ export default function UserDetail({ userId, mode: initialMode }: Props) {
54 79 }
55 80 | undefined;
56 81  
  82 + const initialForm: FormState =
  83 + mode === "new" || !snapshot
  84 + ? {
  85 + sUserNo: "",
  86 + sUserName: "",
  87 + staffName: "",
  88 + department: "",
  89 + sUserType: "普通用户",
  90 + sLanguage: "zh",
  91 + bCanModifyDocs: false,
  92 + }
  93 + : {
  94 + sUserNo: snapshot.sUserNo,
  95 + sUserName: snapshot.sUserName,
  96 + staffName: snapshot.staffName ?? "",
  97 + department: snapshot.department ?? "",
  98 + sUserType: snapshot.sUserType,
  99 + sLanguage: snapshot.sLanguage,
  100 + bCanModifyDocs: !!snapshot.bCanModifyDocs,
  101 + };
  102 +
  103 + const [form, setForm] = useState<FormState>(initialForm);
  104 + const [permTab, setPermTab] = useState("groups");
  105 + // Visual-only — not persisted to backend.
  106 + const [permissions, setPermissions] = useState<Record<string, boolean>>(() =>
  107 + Object.fromEntries(PERMISSION_GROUPS.map((g) => [g, false]))
  108 + );
  109 + const [tabPerms, setTabPerms] = useState<Record<string, boolean>>({});
  110 +
57 111 useEffect(() => {
58   - if (mode === "new") {
59   - form.setFieldsValue({
60   - sUserNo: "",
61   - sUserName: "",
62   - staffName: "",
63   - department: "",
64   - sUserType: "普通用户",
65   - sLanguage: "zh",
66   - bCanModifyDocs: "仅查看",
67   - });
68   - } else if (snapshot) {
69   - form.setFieldsValue({
70   - sUserNo: snapshot.sUserNo,
71   - sUserName: snapshot.sUserName,
72   - staffName: snapshot.staffName ?? "",
73   - department: snapshot.department ?? "",
74   - sUserType: snapshot.sUserType,
75   - sLanguage: snapshot.sLanguage,
76   - bCanModifyDocs: snapshot.bCanModifyDocs ? "全部允许" : "仅查看",
77   - });
78   - }
79   - }, [snapshot, mode, form]);
  112 + setForm(initialForm);
  113 + // eslint-disable-next-line react-hooks/exhaustive-deps
  114 + }, [snapshot, mode]);
  115 +
  116 + const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
  117 + setForm((s) => ({ ...s, [k]: v }));
80 118  
81 119 const disabled = mode === "view";
  120 + const checkedCount = Object.values(permissions).filter(Boolean).length;
82 121  
83 122 const startEdit = () => setMode("edit");
84 123 const startNew = () => setMode("new");
85   -
86 124 const cancel = () => {
87 125 if (mode === "new") {
88 126 dispatch(closeTab(activeTabId));
89 127 dispatch(setActiveTab("userlist"));
90 128 } else {
  129 + setForm(initialForm);
91 130 setMode("view");
92 131 }
93 132 };
94 133  
95 134 const save = async () => {
  135 + if (!form.sUserNo.trim() || !form.sUserName.trim() || !form.sUserType || !form.sLanguage) {
  136 + message.error("请填写必填项");
  137 + return;
  138 + }
  139 + const dto: UserDTO = {
  140 + sUserNo: form.sUserNo,
  141 + sUserName: form.sUserName,
  142 + sUserType: form.sUserType,
  143 + sLanguage: form.sLanguage,
  144 + bCanModifyDocs: form.bCanModifyDocs,
  145 + // iStaffId omitted: prototype has no staff picker.
  146 + // permissionCategoryIds omitted: prototype permissions are by name; backend wants IDs.
  147 + };
  148 + setSubmitting(true);
96 149 try {
97   - const values = await form.validateFields();
98   - const dto: UserDTO = {
99   - sUserNo: values.sUserNo,
100   - sUserName: values.sUserName,
101   - sUserType: values.sUserType,
102   - sLanguage: values.sLanguage,
103   - bCanModifyDocs: values.bCanModifyDocs === "全部允许",
104   - // iStaffId omitted: prototype has no staff picker; backend treats as null.
105   - };
106   - setSubmitting(true);
107 150 if (mode === "new") {
108 151 await createUser(dto);
109 152 message.success("新增成功");
... ... @@ -115,7 +158,7 @@ export default function UserDetail({ userId, mode: initialMode }: Props) {
115 158 setMode("view");
116 159 }
117 160 } catch {
118   - // validation or interceptor
  161 + // interceptor
119 162 } finally {
120 163 setSubmitting(false);
121 164 }
... ... @@ -128,46 +171,59 @@ export default function UserDetail({ userId, mode: initialMode }: Props) {
128 171 flexDirection: "column",
129 172 height: "100%",
130 173 background: "var(--bg-app)",
  174 + position: "relative",
131 175 }}
132 176 >
133   - <div className="dark-toolbar">
134   - <Button
135   - icon={<PlusOutlined />}
136   - onClick={startNew}
137   - disabled={mode !== "view"}
138   - size="small"
139   - >
  177 + <div
  178 + style={{
  179 + display: "flex",
  180 + alignItems: "center",
  181 + gap: 2,
  182 + flexWrap: "wrap",
  183 + rowGap: 4,
  184 + padding: "4px 8px",
  185 + background: "var(--bg-toolbar-dark)",
  186 + flex: "none",
  187 + borderBottom: "1px solid #1a1f2a",
  188 + }}
  189 + >
  190 + <ToolbarBtnDark icon={<PlusOutlined />} onClick={startNew} disabled={mode !== "view"}>
140 191 新增
141   - </Button>
142   - <Button
  192 + </ToolbarBtnDark>
  193 + <ToolbarBtnDark
143 194 icon={<EditOutlined />}
144 195 onClick={startEdit}
145 196 disabled={mode !== "view" || !userId}
146   - size="small"
147 197 >
148 198 修改
149   - </Button>
150   - <Button icon={<DeleteOutlined />} disabled={mode !== "view"} danger size="small">
  199 + </ToolbarBtnDark>
  200 + <ToolbarBtnDark icon={<DeleteOutlined />} disabled={mode !== "view"} danger>
151 201 删除
152   - </Button>
153   - <Button
154   - icon={<SaveOutlined />}
155   - onClick={save}
156   - disabled={mode === "view"}
157   - loading={submitting}
158   - type="primary"
159   - size="small"
160   - >
  202 + </ToolbarBtnDark>
  203 + <ToolbarBtnDark icon={<SaveOutlined />} onClick={save} disabled={mode === "view" || submitting}>
161 204 保存
162   - </Button>
163   - <Button icon={<CloseOutlined />} onClick={cancel} disabled={mode === "view"} size="small">
  205 + </ToolbarBtnDark>
  206 + <ToolbarBtnDark icon={<CloseOutlined />} onClick={cancel} disabled={mode === "view"}>
164 207 取消
165   - </Button>
166   - <span style={{ width: 1, height: 16, background: "rgba(255,255,255,0.12)", margin: "0 4px" }} />
167   - <Button icon={<KeyOutlined />} disabled={mode !== "view"} size="small">
168   - 重置密码
169   - </Button>
  208 + </ToolbarBtnDark>
  209 + <span
  210 + style={{
  211 + width: 1,
  212 + height: 16,
  213 + background: "rgba(255,255,255,0.12)",
  214 + margin: "0 4px",
  215 + }}
  216 + />
  217 + <ToolbarBtnDark icon={<AppstoreOutlined />}>功能</ToolbarBtnDark>
  218 + <ToolbarBtnDark icon={<FileExcelOutlined />}>作废</ToolbarBtnDark>
  219 + <ToolbarBtnDark icon={<KeyOutlined />}>重置密码</ToolbarBtnDark>
  220 + <ToolbarBtnDark icon={<RollbackOutlined />}>取消作废</ToolbarBtnDark>
170 221 <div style={{ flex: 1 }} />
  222 + <span style={{ fontSize: 11, color: "var(--text-on-dark-muted)", marginRight: 8 }}>
  223 + 已选权限:
  224 + <span style={{ color: "#fff", fontWeight: 500 }}>{checkedCount}</span> /{" "}
  225 + {PERMISSION_GROUPS.length - 1}
  226 + </span>
171 227 <span
172 228 style={{
173 229 padding: "2px 8px",
... ... @@ -195,67 +251,396 @@ export default function UserDetail({ userId, mode: initialMode }: Props) {
195 251 >
196 252 {mode === "view" ? "只读" : mode === "new" ? "新增中" : "编辑中"}
197 253 </span>
  254 + <button
  255 + style={{
  256 + marginLeft: 8,
  257 + width: 22,
  258 + height: 22,
  259 + border: "none",
  260 + background: "transparent",
  261 + color: "var(--text-on-dark-muted)",
  262 + cursor: "pointer",
  263 + }}
  264 + >
  265 + <SettingOutlined />
  266 + </button>
198 267 </div>
199 268  
200 269 <div
201 270 style={{
202   - flex: 1,
203   - overflow: "auto",
204 271 background: "#fff",
205   - padding: "12px 16px",
  272 + padding: "10px 12px",
  273 + borderBottom: "1px solid var(--border)",
  274 + flex: "none",
  275 + display: "grid",
  276 + gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
  277 + rowGap: 6,
  278 + columnGap: 16,
206 279 }}
207 280 >
208   - <Form
209   - form={form}
210   - layout="vertical"
211   - disabled={disabled}
  281 + <Field label="创建时间">
  282 + <PrimInput
  283 + value={
  284 + mode === "new"
  285 + ? "保存后自动生成"
  286 + : fmtDateTime(snapshot?.tCreateDate)
  287 + }
  288 + disabled
  289 + mono
  290 + />
  291 + </Field>
  292 + <Field label="制单人" required>
  293 + <PrimInput
  294 + value={mode === "new" ? "保存后自动生成" : snapshot?.sCreatedBy ?? ""}
  295 + disabled
  296 + required
  297 + />
  298 + </Field>
  299 + <Field label="员工名" required>
  300 + <PrimInput
  301 + value={form.staffName}
  302 + onChange={(v) => set("staffName", v)}
  303 + disabled={disabled}
  304 + required
  305 + placeholder="(关联职员)"
  306 + />
  307 + </Field>
  308 +
  309 + <Field label="用户名" required>
  310 + <PrimInput
  311 + value={form.sUserName}
  312 + onChange={(v) => set("sUserName", v)}
  313 + disabled={disabled}
  314 + required
  315 + />
  316 + </Field>
  317 + <Field label="类型" required>
  318 + <PrimSelect
  319 + value={form.sUserType}
  320 + onChange={(v) => set("sUserType", v)}
  321 + disabled={disabled}
  322 + required
  323 + allowEmpty={false}
  324 + options={USER_TYPES}
  325 + />
  326 + </Field>
  327 + <Field label="用户号" required>
  328 + <PrimInput
  329 + value={form.sUserNo}
  330 + onChange={(v) => set("sUserNo", v)}
  331 + disabled={disabled}
  332 + required
  333 + mono
  334 + />
  335 + </Field>
  336 +
  337 + <Field label="部门">
  338 + <PrimSelect
  339 + value={form.department}
  340 + onChange={(v) => set("department", v)}
  341 + disabled={disabled}
  342 + options={DEPARTMENTS}
  343 + />
  344 + </Field>
  345 + <Field label="语言" required>
  346 + <PrimSelect
  347 + value={form.sLanguage}
  348 + onChange={(v) => set("sLanguage", v)}
  349 + disabled={disabled}
  350 + required
  351 + allowEmpty={false}
  352 + options={LANGUAGE_OPTIONS}
  353 + />
  354 + </Field>
  355 + <Field label="单据修改权限">
  356 + <div
  357 + style={{
  358 + flex: 1,
  359 + height: "var(--input-h)",
  360 + display: "flex",
  361 + alignItems: "center",
  362 + paddingLeft: 4,
  363 + border: "1px solid var(--border-input)",
  364 + background: disabled ? "var(--bg-disabled)" : "var(--bg-input)",
  365 + }}
  366 + >
  367 + <PrimCheckbox
  368 + checked={form.bCanModifyDocs}
  369 + onChange={(v) => set("bCanModifyDocs", v)}
  370 + disabled={disabled}
  371 + />
  372 + </div>
  373 + </Field>
  374 + </div>
  375 +
  376 + <div
  377 + style={{
  378 + display: "flex",
  379 + alignItems: "stretch",
  380 + background: "#fff",
  381 + borderBottom: "1px solid var(--border)",
  382 + flex: "none",
  383 + }}
  384 + >
  385 + {PERM_TABS.map((t) => {
  386 + const active = t.id === permTab;
  387 + return (
  388 + <div
  389 + key={t.id}
  390 + onClick={() => setPermTab(t.id)}
  391 + style={{
  392 + padding: "6px 14px",
  393 + fontSize: 12,
  394 + cursor: "pointer",
  395 + color: active ? "var(--accent-strong)" : "var(--text)",
  396 + background: active ? "var(--accent-soft)" : "transparent",
  397 + borderTop: active ? "2px solid var(--accent)" : "2px solid transparent",
  398 + borderRight: "1px solid var(--border)",
  399 + fontWeight: active ? 500 : 400,
  400 + }}
  401 + onMouseEnter={(e) => {
  402 + if (!active) e.currentTarget.style.background = "var(--bg-row-hover)";
  403 + }}
  404 + onMouseLeave={(e) => {
  405 + if (!active) e.currentTarget.style.background = "transparent";
  406 + }}
  407 + >
  408 + {t.label}
  409 + </div>
  410 + );
  411 + })}
  412 + </div>
  413 +
  414 + <div style={{ flex: 1, overflow: "auto", background: "#fff" }}>
  415 + {permTab === "groups" ? (
  416 + <PermissionGrid
  417 + permissions={permissions}
  418 + setPermission={(g, v) =>
  419 + setPermissions((s) => ({ ...s, [g]: v }))
  420 + }
  421 + disabled={disabled}
  422 + />
  423 + ) : (
  424 + <ScopeTab
  425 + tabKey={permTab}
  426 + enabled={!!tabPerms[permTab]}
  427 + onToggle={(v) => setTabPerms((s) => ({ ...s, [permTab]: v }))}
  428 + disabled={disabled}
  429 + />
  430 + )}
  431 + </div>
  432 +
  433 + <div style={{ position: "absolute", right: 0, top: "50%", transform: "translateY(-50%)" }}>
  434 + <button
212 435 style={{
213   - display: "grid",
214   - gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
215   - rowGap: 6,
216   - columnGap: 16,
  436 + width: 18,
  437 + height: 40,
  438 + background: "var(--accent)",
  439 + color: "#fff",
  440 + border: "none",
  441 + borderTopLeftRadius: 4,
  442 + borderBottomLeftRadius: 4,
  443 + cursor: "pointer",
  444 + fontSize: 11,
  445 + writingMode: "vertical-rl",
217 446 }}
218 447 >
219   - <Form.Item label="创建时间">
220   - <Input value={fmtDateTime(snapshot?.tCreateDate)} disabled className="mono" />
221   - </Form.Item>
222   - <Form.Item label="制单人">
223   - <Input value={snapshot?.sCreatedBy ?? ""} disabled />
224   - </Form.Item>
225   - <Form.Item
226   - label="员工名"
227   - name="staffName"
228   - help="新增/编辑暂不关联职员;如需关联请使用职员管理"
229   - >
230   - <Input disabled />
231   - </Form.Item>
  448 + 帮助
  449 + </button>
  450 + </div>
  451 + </div>
  452 + );
  453 +}
  454 +
  455 +interface PermissionGridProps {
  456 + permissions: Record<string, boolean>;
  457 + setPermission: (g: string, v: boolean) => void;
  458 + disabled?: boolean;
  459 +}
232 460  
233   - <Form.Item label="用户名" name="sUserName" rules={[{ required: true }, { max: 50 }]}>
234   - <Input />
235   - </Form.Item>
236   - <Form.Item label="类型" name="sUserType" rules={[{ required: true }]}>
237   - <Select options={USER_TYPES.map((t) => ({ value: t, label: t }))} />
238   - </Form.Item>
239   - <Form.Item label="用户号" name="sUserNo" rules={[{ required: true }, { max: 50 }]}>
240   - <Input className="mono" />
241   - </Form.Item>
  461 +function PermissionGrid({ permissions, setPermission, disabled }: PermissionGridProps) {
  462 + const [filter, setFilter] = useState("");
  463 + const [hovered, setHovered] = useState<string | null>(null);
  464 + const allChecked = PERMISSION_GROUPS.every((g) => permissions[g]);
242 465  
243   - <Form.Item label="部门" name="department">
244   - <Input disabled />
245   - </Form.Item>
246   - <Form.Item label="语言" name="sLanguage" rules={[{ required: true }]}>
247   - <Select options={LANGUAGE_OPTIONS} />
248   - </Form.Item>
249   - <Form.Item label="单据修改权限" name="bCanModifyDocs">
250   - <Select
251   - options={[
252   - { value: "全部允许", label: "全部允许" },
253   - { value: "仅本人单据", label: "仅本人单据" },
254   - { value: "仅查看", label: "仅查看" },
255   - ]}
  466 + return (
  467 + <table
  468 + style={{
  469 + borderCollapse: "collapse",
  470 + width: "100%",
  471 + fontSize: 12,
  472 + tableLayout: "fixed",
  473 + }}
  474 + >
  475 + <colgroup>
  476 + <col style={{ width: 40 }} />
  477 + <col />
  478 + </colgroup>
  479 + <thead>
  480 + <tr style={{ background: "var(--bg-row-zebra)", borderBottom: "1px solid var(--border)" }}>
  481 + <th
  482 + style={{
  483 + padding: "5px 8px",
  484 + borderRight: "1px solid var(--border)",
  485 + textAlign: "center",
  486 + }}
  487 + >
  488 + <PrimCheckbox
  489 + checked={allChecked}
  490 + onChange={(v) => {
  491 + PERMISSION_GROUPS.forEach((g) => setPermission(g, v));
  492 + }}
  493 + disabled={disabled}
256 494 />
257   - </Form.Item>
258   - </Form>
  495 + </th>
  496 + <th style={{ padding: "5px 8px", textAlign: "left", fontWeight: 500 }}>
  497 + <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
  498 + 权限分类
  499 + <input
  500 + value={filter}
  501 + onChange={(e) => setFilter(e.target.value)}
  502 + placeholder="过滤..."
  503 + style={{
  504 + height: 20,
  505 + padding: "0 6px",
  506 + border: "1px solid var(--border-input)",
  507 + background: "#fff",
  508 + fontSize: 11,
  509 + width: 120,
  510 + fontFamily: "inherit",
  511 + outline: "none",
  512 + }}
  513 + />
  514 + </span>
  515 + </th>
  516 + </tr>
  517 + </thead>
  518 + <tbody>
  519 + {PERMISSION_GROUPS.filter((g) => !filter || g.includes(filter)).map((g, i) => {
  520 + const isHover = hovered === g;
  521 + const checked = !!permissions[g];
  522 + return (
  523 + <tr
  524 + key={g}
  525 + onMouseEnter={() => setHovered(g)}
  526 + onMouseLeave={() => setHovered(null)}
  527 + onClick={disabled ? undefined : () => setPermission(g, !checked)}
  528 + style={{
  529 + background:
  530 + isHover && !disabled
  531 + ? "var(--bg-row-hover)"
  532 + : checked
  533 + ? "var(--accent-soft)"
  534 + : i % 2 === 0
  535 + ? "#fff"
  536 + : "var(--bg-row-zebra)",
  537 + cursor: disabled ? "default" : "pointer",
  538 + height: 24,
  539 + }}
  540 + >
  541 + <td
  542 + style={{
  543 + padding: "3px 0",
  544 + textAlign: "center",
  545 + borderRight: "1px solid var(--border)",
  546 + borderBottom: "1px solid #f0f2f5",
  547 + }}
  548 + >
  549 + <PrimCheckbox
  550 + checked={checked}
  551 + onChange={(v) => setPermission(g, v)}
  552 + disabled={disabled}
  553 + />
  554 + </td>
  555 + <td
  556 + style={{
  557 + padding: "3px 8px",
  558 + borderBottom: "1px solid #f0f2f5",
  559 + color: checked ? "var(--accent-strong)" : "var(--text)",
  560 + fontWeight: checked ? 500 : 400,
  561 + }}
  562 + >
  563 + {g}
  564 + </td>
  565 + </tr>
  566 + );
  567 + })}
  568 + </tbody>
  569 + </table>
  570 + );
  571 +}
  572 +
  573 +interface ScopeTabProps {
  574 + tabKey: string;
  575 + enabled: boolean;
  576 + onToggle: (v: boolean) => void;
  577 + disabled?: boolean;
  578 +}
  579 +
  580 +function ScopeTab({ tabKey, enabled, onToggle, disabled }: ScopeTabProps) {
  581 + const cfg = SCOPE_ITEMS[tabKey];
  582 + const [filter, setFilter] = useState("");
  583 + if (!cfg) return null;
  584 +
  585 + const filtered = cfg.items.filter((it) => !filter || it.includes(filter));
  586 +
  587 + return (
  588 + <div style={{ padding: 12 }}>
  589 + <div
  590 + style={{
  591 + display: "flex",
  592 + alignItems: "center",
  593 + gap: 12,
  594 + marginBottom: 10,
  595 + paddingBottom: 8,
  596 + borderBottom: "1px solid var(--border)",
  597 + }}
  598 + >
  599 + <PrimCheckbox
  600 + checked={enabled}
  601 + onChange={onToggle}
  602 + disabled={disabled}
  603 + label={`启用${cfg.label}查看权限`}
  604 + />
  605 + <input
  606 + value={filter}
  607 + onChange={(e) => setFilter(e.target.value)}
  608 + placeholder={`筛选${cfg.label}...`}
  609 + style={{
  610 + height: 24,
  611 + padding: "0 8px",
  612 + border: "1px solid var(--border-input)",
  613 + background: "var(--bg-input)",
  614 + fontSize: 12,
  615 + width: 200,
  616 + fontFamily: "inherit",
  617 + outline: "none",
  618 + }}
  619 + />
  620 + <span style={{ flex: 1 }} />
  621 + <span style={{ fontSize: 11, color: "var(--text-muted)" }}>
  622 + 共 {cfg.items.length} 个{cfg.label}
  623 + </span>
  624 + </div>
  625 + <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 6 }}>
  626 + {filtered.map((it) => (
  627 + <label
  628 + key={it}
  629 + style={{
  630 + display: "flex",
  631 + alignItems: "center",
  632 + gap: 6,
  633 + padding: "5px 8px",
  634 + border: "1px solid var(--border)",
  635 + background: enabled ? "#fff" : "var(--bg-disabled)",
  636 + cursor: disabled || !enabled ? "default" : "pointer",
  637 + fontSize: 12,
  638 + }}
  639 + >
  640 + <PrimCheckbox disabled={disabled || !enabled} checked={false} />
  641 + <span style={{ color: enabled ? "var(--text)" : "var(--text-faint)" }}>{it}</span>
  642 + </label>
  643 + ))}
259 644 </div>
260 645 </div>
261 646 );
... ...
frontend/src/utils/data.ts
... ... @@ -21,6 +21,92 @@ export const COMPANIES = [
21 21 // Backend USER_TYPES enum: 普通用户 | 超级管理员
22 22 export const USER_TYPES = ["超级管理员", "普通用户"];
23 23  
  24 +// Visual-only — permission grid in UserDetail. Backend persistence (by IDs)
  25 +// not yet wired; toggling these does not round-trip.
  26 +export const PERMISSION_GROUPS = [
  27 + "默认显示(必选)",
  28 + "禁止查看价格",
  29 + "客服跟单",
  30 + "报价组员工",
  31 + "物控部员工",
  32 + "供应链 PMC",
  33 + "允许查看订单价格",
  34 + "储运部员工",
  35 + "外部供应商",
  36 + "品质部员工",
  37 + "技术中心员工",
  38 + "机修组员工",
  39 + "生产部计划员工",
  40 + "外发组员工",
  41 + "模烫车间",
  42 + "装订车间",
  43 + "粘接工车间",
  44 + "品质部管理",
  45 + "精品车间",
  46 + "人事组",
  47 + "统计组",
  48 + "机修主管",
  49 + "样品开发部员工",
  50 + "设计开发",
  51 + "总经办",
  52 + "财务部",
  53 + "销售员",
  54 + "采购员",
  55 + "仓库管理员",
  56 +];
  57 +
  58 +// Visual-only — 部门 dropdown options on UserDetail.
  59 +export const DEPARTMENTS = [
  60 + "工艺技术",
  61 + "印刷车间",
  62 + "机修",
  63 + "机务部",
  64 + "财务部",
  65 + "装订车间",
  66 + "总经办公室",
  67 + "总务部",
  68 + "供应链",
  69 + "质量管理部",
  70 + "模切车间",
  71 + "计划组",
  72 + "样品开发",
  73 + "设计部",
  74 + "仓库",
  75 +];
  76 +
  77 +// Visual-only — scope tabs in UserDetail (客户/供应商/人员/工序/司机).
  78 +export const SCOPE_ITEMS: Record<string, { label: string; items: string[] }> = {
  79 + customer: {
  80 + label: "客户",
  81 + items: [
  82 + "上海印行包装",
  83 + "锐尚文创",
  84 + "京华彩印",
  85 + "广印纸品",
  86 + "万象图文",
  87 + "联合包装",
  88 + "鼎盛印刷",
  89 + "九洲胶印",
  90 + ],
  91 + },
  92 + supplier: {
  93 + label: "供应商",
  94 + items: ["华东油墨", "正信纸业", "宝洁化工", "日新油墨", "三鼎纸业", "鸿丰胶辊", "永利印材"],
  95 + },
  96 + staff: {
  97 + label: "人员",
  98 + items: ["管广飞", "李斌", "孟威", "王宽明", "潘强", "杨柳"],
  99 + },
  100 + process: {
  101 + label: "工序",
  102 + items: ["印前", "印刷", "覆膜", "模切", "装订", "胶装", "丝网", "烫金", "包装"],
  103 + },
  104 + driver: {
  105 + label: "司机",
  106 + items: ["陈师傅", "李师傅", "王师傅", "钱师傅", "赵师傅"],
  107 + },
  108 +};
  109 +
24 110 // Backend LANGUAGES enum: zh | en | zh-TW. Display strings for the form.
25 111 export const LANGUAGE_OPTIONS: { value: string; label: string }[] = [
26 112 { value: "zh", label: "中文" },
... ...