From 3491ef6c2fca2596ea06e9a132c5910e04a41ad5 Mon Sep 17 00:00:00 2001 From: zichun Date: Thu, 30 Apr 2026 18:17:06 +0800 Subject: [PATCH] feat(frontend): restore prototype UserDetail look (perm grid + scope tabs) --- frontend/src/components/Primitives.tsx | 325 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/src/pages/usr/UserDetail.tsx | 619 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------------------------------------------------------------------- frontend/src/utils/data.ts | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 913 insertions(+), 117 deletions(-) create mode 100644 frontend/src/components/Primitives.tsx 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", }} > -
- - - - - - - + + + }>功能 + }>作废 + }>重置密码 + }>取消作废
+ + 已选权限: + {checkedCount} /{" "} + {PERMISSION_GROUPS.length - 1} + {mode === "view" ? "只读" : mode === "new" ? "新增中" : "编辑中"} +
-
+ + + + + + + set("staffName", v)} + disabled={disabled} + required + placeholder="(关联职员)" + /> + + + + set("sUserName", v)} + disabled={disabled} + required + /> + + + set("sUserType", v)} + disabled={disabled} + required + allowEmpty={false} + options={USER_TYPES} + /> + + + set("sUserNo", v)} + disabled={disabled} + required + mono + /> + + + + set("department", v)} + disabled={disabled} + options={DEPARTMENTS} + /> + + + set("sLanguage", v)} + disabled={disabled} + required + allowEmpty={false} + options={LANGUAGE_OPTIONS} + /> + + +
+ set("bCanModifyDocs", v)} + disabled={disabled} + /> +
+
+
+ +
+ {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]); - - - - - + + + + + + + + { + PERMISSION_GROUPS.forEach((g) => setPermission(g, v)); + }} + disabled={disabled} /> - - + + + + 权限分类 + setFilter(e.target.value)} + placeholder="过滤..." + style={{ + height: 20, + padding: "0 6px", + border: "1px solid var(--border-input)", + background: "#fff", + fontSize: 11, + width: 120, + fontFamily: "inherit", + outline: "none", + }} + /> + + + + + + {PERMISSION_GROUPS.filter((g) => !filter || g.includes(filter)).map((g, i) => { + const isHover = hovered === g; + const checked = !!permissions[g]; + return ( + setHovered(g)} + onMouseLeave={() => setHovered(null)} + onClick={disabled ? undefined : () => setPermission(g, !checked)} + style={{ + background: + isHover && !disabled + ? "var(--bg-row-hover)" + : checked + ? "var(--accent-soft)" + : i % 2 === 0 + ? "#fff" + : "var(--bg-row-zebra)", + cursor: disabled ? "default" : "pointer", + height: 24, + }} + > + + setPermission(g, v)} + disabled={disabled} + /> + + + {g} + + + ); + })} + + + ); +} + +interface ScopeTabProps { + tabKey: string; + enabled: boolean; + onToggle: (v: boolean) => void; + disabled?: boolean; +} + +function ScopeTab({ tabKey, enabled, onToggle, disabled }: ScopeTabProps) { + const cfg = SCOPE_ITEMS[tabKey]; + const [filter, setFilter] = useState(""); + if (!cfg) return null; + + const filtered = cfg.items.filter((it) => !filter || it.includes(filter)); + + return ( +
+
+ + setFilter(e.target.value)} + placeholder={`筛选${cfg.label}...`} + style={{ + height: 24, + padding: "0 8px", + border: "1px solid var(--border-input)", + background: "var(--bg-input)", + fontSize: 12, + width: 200, + fontFamily: "inherit", + outline: "none", + }} + /> + + + 共 {cfg.items.length} 个{cfg.label} + +
+
+ {filtered.map((it) => ( + + ))}
); diff --git a/frontend/src/utils/data.ts b/frontend/src/utils/data.ts index 04ac3aa..5a00bcc 100644 --- a/frontend/src/utils/data.ts +++ b/frontend/src/utils/data.ts @@ -21,6 +21,92 @@ export const COMPANIES = [ // Backend USER_TYPES enum: 普通用户 | 超级管理员 export const USER_TYPES = ["超级管理员", "普通用户"]; +// Visual-only — permission grid in UserDetail. Backend persistence (by IDs) +// not yet wired; toggling these does not round-trip. +export const PERMISSION_GROUPS = [ + "默认显示(必选)", + "禁止查看价格", + "客服跟单", + "报价组员工", + "物控部员工", + "供应链 PMC", + "允许查看订单价格", + "储运部员工", + "外部供应商", + "品质部员工", + "技术中心员工", + "机修组员工", + "生产部计划员工", + "外发组员工", + "模烫车间", + "装订车间", + "粘接工车间", + "品质部管理", + "精品车间", + "人事组", + "统计组", + "机修主管", + "样品开发部员工", + "设计开发", + "总经办", + "财务部", + "销售员", + "采购员", + "仓库管理员", +]; + +// Visual-only — 部门 dropdown options on UserDetail. +export const DEPARTMENTS = [ + "工艺技术", + "印刷车间", + "机修", + "机务部", + "财务部", + "装订车间", + "总经办公室", + "总务部", + "供应链", + "质量管理部", + "模切车间", + "计划组", + "样品开发", + "设计部", + "仓库", +]; + +// Visual-only — scope tabs in UserDetail (客户/供应商/人员/工序/司机). +export const SCOPE_ITEMS: Record = { + customer: { + label: "客户", + items: [ + "上海印行包装", + "锐尚文创", + "京华彩印", + "广印纸品", + "万象图文", + "联合包装", + "鼎盛印刷", + "九洲胶印", + ], + }, + supplier: { + label: "供应商", + items: ["华东油墨", "正信纸业", "宝洁化工", "日新油墨", "三鼎纸业", "鸿丰胶辊", "永利印材"], + }, + staff: { + label: "人员", + items: ["管广飞", "李斌", "孟威", "王宽明", "潘强", "杨柳"], + }, + process: { + label: "工序", + items: ["印前", "印刷", "覆膜", "模切", "装订", "胶装", "丝网", "烫金", "包装"], + }, + driver: { + label: "司机", + items: ["陈师傅", "李师傅", "王师傅", "钱师傅", "赵师傅"], + }, +}; + // Backend LANGUAGES enum: zh | en | zh-TW. Display strings for the form. export const LANGUAGE_OPTIONS: { value: string; label: string }[] = [ { value: "zh", label: "中文" }, -- libgit2 0.22.2