Commit 3491ef6c2fca2596ea06e9a132c5910e04a41ad5
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.
Showing
3 changed files
with
913 additions
and
117 deletions
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 | import { useEffect, useState } from "react"; | 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 | import { | 3 | import { |
| 4 | PlusOutlined, | 4 | PlusOutlined, |
| 5 | EditOutlined, | 5 | EditOutlined, |
| 6 | DeleteOutlined, | 6 | DeleteOutlined, |
| 7 | SaveOutlined, | 7 | SaveOutlined, |
| 8 | CloseOutlined, | 8 | CloseOutlined, |
| 9 | + AppstoreOutlined, | ||
| 10 | + FileExcelOutlined, | ||
| 9 | KeyOutlined, | 11 | KeyOutlined, |
| 12 | + RollbackOutlined, | ||
| 13 | + SettingOutlined, | ||
| 10 | } from "@ant-design/icons"; | 14 | } from "@ant-design/icons"; |
| 15 | +import { | ||
| 16 | + Field, | ||
| 17 | + PrimInput, | ||
| 18 | + PrimSelect, | ||
| 19 | + PrimCheckbox, | ||
| 20 | + ToolbarBtnDark, | ||
| 21 | +} from "@/components/Primitives"; | ||
| 11 | import { createUser, updateUser, type UserDTO } from "@/api/user"; | 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 | import { useAppDispatch, useAppSelector } from "@/store"; | 30 | import { useAppDispatch, useAppSelector } from "@/store"; |
| 14 | import { closeTab, setActiveTab } from "@/store/tabsSlice"; | 31 | import { closeTab, setActiveTab } from "@/store/tabsSlice"; |
| 15 | import { fmtDateTime } from "@/utils/format"; | 32 | import { fmtDateTime } from "@/utils/format"; |
| @@ -21,18 +38,26 @@ interface Props { | @@ -21,18 +38,26 @@ interface Props { | ||
| 21 | mode: Mode; | 38 | mode: Mode; |
| 22 | } | 39 | } |
| 23 | 40 | ||
| 24 | -interface FormShape { | 41 | +interface FormState { |
| 25 | sUserNo: string; | 42 | sUserNo: string; |
| 26 | sUserName: string; | 43 | sUserName: string; |
| 27 | - staffName?: string; | ||
| 28 | - department?: string; | 44 | + staffName: string; |
| 45 | + department: string; | ||
| 29 | sUserType: string; | 46 | sUserType: string; |
| 30 | sLanguage: string; | 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 | export default function UserDetail({ userId, mode: initialMode }: Props) { | 60 | export default function UserDetail({ userId, mode: initialMode }: Props) { |
| 35 | - const [form] = Form.useForm<FormShape>(); | ||
| 36 | const [mode, setMode] = useState<Mode>(initialMode); | 61 | const [mode, setMode] = useState<Mode>(initialMode); |
| 37 | const [submitting, setSubmitting] = useState(false); | 62 | const [submitting, setSubmitting] = useState(false); |
| 38 | const dispatch = useAppDispatch(); | 63 | const dispatch = useAppDispatch(); |
| @@ -54,56 +79,74 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { | @@ -54,56 +79,74 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { | ||
| 54 | } | 79 | } |
| 55 | | undefined; | 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 | useEffect(() => { | 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 | const disabled = mode === "view"; | 119 | const disabled = mode === "view"; |
| 120 | + const checkedCount = Object.values(permissions).filter(Boolean).length; | ||
| 82 | 121 | ||
| 83 | const startEdit = () => setMode("edit"); | 122 | const startEdit = () => setMode("edit"); |
| 84 | const startNew = () => setMode("new"); | 123 | const startNew = () => setMode("new"); |
| 85 | - | ||
| 86 | const cancel = () => { | 124 | const cancel = () => { |
| 87 | if (mode === "new") { | 125 | if (mode === "new") { |
| 88 | dispatch(closeTab(activeTabId)); | 126 | dispatch(closeTab(activeTabId)); |
| 89 | dispatch(setActiveTab("userlist")); | 127 | dispatch(setActiveTab("userlist")); |
| 90 | } else { | 128 | } else { |
| 129 | + setForm(initialForm); | ||
| 91 | setMode("view"); | 130 | setMode("view"); |
| 92 | } | 131 | } |
| 93 | }; | 132 | }; |
| 94 | 133 | ||
| 95 | const save = async () => { | 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 | try { | 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 | if (mode === "new") { | 150 | if (mode === "new") { |
| 108 | await createUser(dto); | 151 | await createUser(dto); |
| 109 | message.success("新增成功"); | 152 | message.success("新增成功"); |
| @@ -115,7 +158,7 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { | @@ -115,7 +158,7 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { | ||
| 115 | setMode("view"); | 158 | setMode("view"); |
| 116 | } | 159 | } |
| 117 | } catch { | 160 | } catch { |
| 118 | - // validation or interceptor | 161 | + // interceptor |
| 119 | } finally { | 162 | } finally { |
| 120 | setSubmitting(false); | 163 | setSubmitting(false); |
| 121 | } | 164 | } |
| @@ -128,46 +171,59 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { | @@ -128,46 +171,59 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { | ||
| 128 | flexDirection: "column", | 171 | flexDirection: "column", |
| 129 | height: "100%", | 172 | height: "100%", |
| 130 | background: "var(--bg-app)", | 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 | icon={<EditOutlined />} | 194 | icon={<EditOutlined />} |
| 144 | onClick={startEdit} | 195 | onClick={startEdit} |
| 145 | disabled={mode !== "view" || !userId} | 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 | <div style={{ flex: 1 }} /> | 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 | <span | 227 | <span |
| 172 | style={{ | 228 | style={{ |
| 173 | padding: "2px 8px", | 229 | padding: "2px 8px", |
| @@ -195,67 +251,396 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { | @@ -195,67 +251,396 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { | ||
| 195 | > | 251 | > |
| 196 | {mode === "view" ? "只读" : mode === "new" ? "新增中" : "编辑中"} | 252 | {mode === "view" ? "只读" : mode === "new" ? "新增中" : "编辑中"} |
| 197 | </span> | 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 | </div> | 267 | </div> |
| 199 | 268 | ||
| 200 | <div | 269 | <div |
| 201 | style={{ | 270 | style={{ |
| 202 | - flex: 1, | ||
| 203 | - overflow: "auto", | ||
| 204 | background: "#fff", | 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 | style={{ | 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 | </div> | 644 | </div> |
| 260 | </div> | 645 | </div> |
| 261 | ); | 646 | ); |
frontend/src/utils/data.ts
| @@ -21,6 +21,92 @@ export const COMPANIES = [ | @@ -21,6 +21,92 @@ export const COMPANIES = [ | ||
| 21 | // Backend USER_TYPES enum: 普通用户 | 超级管理员 | 21 | // Backend USER_TYPES enum: 普通用户 | 超级管理员 |
| 22 | export const USER_TYPES = ["超级管理员", "普通用户"]; | 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 | // Backend LANGUAGES enum: zh | en | zh-TW. Display strings for the form. | 110 | // Backend LANGUAGES enum: zh | en | zh-TW. Display strings for the form. |
| 25 | export const LANGUAGE_OPTIONS: { value: string; label: string }[] = [ | 111 | export const LANGUAGE_OPTIONS: { value: string; label: string }[] = [ |
| 26 | { value: "zh", label: "中文" }, | 112 | { value: "zh", label: "中文" }, |