Commit abb6bea57ac70e50351cdd779d17df4e5dbcf209

Authored by 杨鑫
1 parent 1bdbd1fc

用户管理

美国版/Food Labeling Management Platform/.env.local
1   -VITE_API_BASE_URL=http://192.168.31.88:19001
  1 +VITE_API_BASE_URL=http://192.168.31.87:19001
2 2  
... ...
美国版/Food Labeling Management Platform/build/assets/index-2Xatwc8-.js deleted
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/index-TU5tblcP.js 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/index.html
... ... @@ -5,7 +5,7 @@
5 5 <meta charset="UTF-8" />
6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 7 <title>Food Labeling Management Platform</title>
8   - <script type="module" crossorigin src="/assets/index-2Xatwc8-.js"></script>
  8 + <script type="module" crossorigin src="/assets/index-TU5tblcP.js"></script>
9 9 <link rel="stylesheet" crossorigin href="/assets/index-DKXCW1Pt.css">
10 10 </head>
11 11  
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/LabelCanvas.tsx
... ... @@ -708,7 +708,6 @@ export function LabelCanvas({
708 708 "flex-1 overflow-auto bg-gray-100 relative",
709 709 isSpacePressed ? "cursor-grab active:cursor-grabbing" : ""
710 710 )}
711   - onClick={canvasClick}
712 711 onPointerDown={handleContainerPointerDown}
713 712 onPointerMove={handleContainerPointerMove}
714 713 onPointerUp={handleContainerPointerUp}
... ... @@ -745,6 +744,15 @@ export function LabelCanvas({
745 744 // 如果按住空格,禁用 canvas 内部的 pointer-events 以便拖动容器
746 745 pointerEvents: isSpacePressed ? 'none' : 'auto'
747 746 }}
  747 + onClick={(e) => {
  748 + // 点击画布空白处取消选中
  749 + const target = e.target as HTMLElement;
  750 + const isOnElement = target.closest('[id^="element-"]');
  751 + const isOnPaperResize = target.closest('[title*="拖拽拉高"]') || target.closest('[title*="拖拽拉宽"]');
  752 + if (!isOnElement && !isOnPaperResize) {
  753 + onSelect(null);
  754 + }
  755 + }}
748 756 onPointerDown={(e) => {
749 757 // 空白处或标尺等非控件区域按下即开始平移(放宽判定:在画布内且未点到元素/纸张拖拽条)
750 758 const target = e.target as HTMLElement;
... ... @@ -752,22 +760,25 @@ export function LabelCanvas({
752 760 const isOnPaperResize = target.closest('[title*="拖拽拉高"]') || target.closest('[title*="拖拽拉宽"]');
753 761 const isOnCanvasArea = canvasRef.current?.contains(target);
754 762 if (isOnCanvasArea && !isOnElement && !isOnPaperResize && !dragRef.current && !resizeRef.current) {
755   - e.preventDefault();
756   - e.stopPropagation();
757   - setIsPanning(true);
758   - panOffsetStartRef.current = {
759   - x: panOffset.x,
760   - y: panOffset.y,
761   - startX: e.clientX,
762   - startY: e.clientY,
763   - };
764   - panStartRef.current = {
765   - x: e.clientX,
766   - y: e.clientY,
767   - scrollLeft: scrollContainerRef.current?.scrollLeft ?? 0,
768   - scrollTop: scrollContainerRef.current?.scrollTop ?? 0,
769   - };
770   - (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
  763 + // 如果按住空格或中键,开始平移
  764 + if (isSpacePressed || e.button === 1) {
  765 + e.preventDefault();
  766 + e.stopPropagation();
  767 + setIsPanning(true);
  768 + panOffsetStartRef.current = {
  769 + x: panOffset.x,
  770 + y: panOffset.y,
  771 + startX: e.clientX,
  772 + startY: e.clientY,
  773 + };
  774 + panStartRef.current = {
  775 + x: e.clientX,
  776 + y: e.clientY,
  777 + scrollLeft: scrollContainerRef.current?.scrollLeft ?? 0,
  778 + scrollTop: scrollContainerRef.current?.scrollTop ?? 0,
  779 + };
  780 + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
  781 + }
771 782 }
772 783 }}
773 784 onPointerMove={handlePointerMove}
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/PropertiesPanel.tsx
... ... @@ -276,12 +276,20 @@ function ElementConfigFields({
276 276 <div>
277 277 <Label className="text-xs">Text</Label>
278 278 <Input
279   - value={(cfg.text as string) ?? ''}
  279 + value={(cfg.text as string) ?? '0.00'}
280 280 onChange={(e) => update('text', e.target.value)}
281 281 className="h-8 text-sm mt-1"
282 282 />
283 283 </div>
284 284 <div>
  285 + <Label className="text-xs">Prefix</Label>
  286 + <Input
  287 + value={(cfg.prefix as string) ?? '¥'}
  288 + onChange={(e) => update('prefix', e.target.value)}
  289 + className="h-8 text-sm mt-1"
  290 + />
  291 + </div>
  292 + <div>
285 293 <Label className="text-xs">Font Size</Label>
286 294 <Input
287 295 type="number"
... ... @@ -293,7 +301,7 @@ function ElementConfigFields({
293 301 <div>
294 302 <Label className="text-xs">Text Align</Label>
295 303 <Select
296   - value={(cfg.textAlign as string) ?? 'left'}
  304 + value={(cfg.textAlign as string) ?? 'right'}
297 305 onValueChange={(v) => update('textAlign', v)}
298 306 >
299 307 <SelectTrigger className="h-8 text-sm mt-1">
... ... @@ -314,7 +322,7 @@ function ElementConfigFields({
314 322 <div>
315 323 <Label className="text-xs">Data</Label>
316 324 <Input
317   - value={(cfg.data as string) ?? ''}
  325 + value={(cfg.data as string) ?? '123456789'}
318 326 onChange={(e) => update('data', e.target.value)}
319 327 className="h-8 text-sm mt-1"
320 328 />
... ... @@ -334,6 +342,13 @@ function ElementConfigFields({
334 342 </SelectContent>
335 343 </Select>
336 344 </div>
  345 + <div className="flex items-center gap-2">
  346 + <Switch
  347 + checked={(cfg.showText as boolean) !== false}
  348 + onCheckedChange={(v) => update('showText', v)}
  349 + />
  350 + <Label className="text-xs">Show Text</Label>
  351 + </div>
337 352 </>
338 353 );
339 354 case 'QRCODE':
... ... @@ -341,24 +356,207 @@ function ElementConfigFields({
341 356 <div>
342 357 <Label className="text-xs">Data (URL)</Label>
343 358 <Input
344   - value={(cfg.data as string) ?? ''}
  359 + value={(cfg.data as string) ?? 'https://example.com'}
345 360 onChange={(e) => update('data', e.target.value)}
346 361 className="h-8 text-sm mt-1"
347 362 />
348 363 </div>
349 364 );
350   - case 'WEIGHT':
  365 + case 'IMAGE':
  366 + return (
  367 + <>
  368 + <div>
  369 + <Label className="text-xs">Image URL</Label>
  370 + <Input
  371 + value={(cfg.src as string) ?? ''}
  372 + onChange={(e) => update('src', e.target.value)}
  373 + className="h-8 text-sm mt-1"
  374 + placeholder="输入图片URL或路径"
  375 + />
  376 + </div>
  377 + <div>
  378 + <Label className="text-xs">Scale Mode</Label>
  379 + <Select
  380 + value={(cfg.scaleMode as string) ?? 'contain'}
  381 + onValueChange={(v) => update('scaleMode', v)}
  382 + >
  383 + <SelectTrigger className="h-8 text-sm mt-1">
  384 + <SelectValue />
  385 + </SelectTrigger>
  386 + <SelectContent>
  387 + <SelectItem value="contain">Contain</SelectItem>
  388 + <SelectItem value="cover">Cover</SelectItem>
  389 + <SelectItem value="fill">Fill</SelectItem>
  390 + </SelectContent>
  391 + </Select>
  392 + </div>
  393 + </>
  394 + );
  395 + case 'DATE':
  396 + return (
  397 + <>
  398 + <div>
  399 + <Label className="text-xs">Format</Label>
  400 + <Input
  401 + value={(cfg.format as string) ?? 'YYYY-MM-DD'}
  402 + onChange={(e) => update('format', e.target.value)}
  403 + className="h-8 text-sm mt-1"
  404 + placeholder="YYYY-MM-DD"
  405 + />
  406 + </div>
  407 + <div>
  408 + <Label className="text-xs">Offset Days</Label>
  409 + <Input
  410 + type="number"
  411 + value={(cfg.offsetDays as number) ?? 0}
  412 + onChange={(e) => update('offsetDays', Number(e.target.value) || 0)}
  413 + className="h-8 text-sm mt-1"
  414 + />
  415 + </div>
  416 + </>
  417 + );
  418 + case 'TIME':
351 419 return (
352 420 <div>
353   - <Label className="text-xs">Value</Label>
  421 + <Label className="text-xs">Format</Label>
354 422 <Input
355   - type="number"
356   - value={(cfg.value as number) ?? 0}
357   - onChange={(e) => update('value', Number(e.target.value) || 0)}
  423 + value={(cfg.format as string) ?? 'HH:mm'}
  424 + onChange={(e) => update('format', e.target.value)}
358 425 className="h-8 text-sm mt-1"
  426 + placeholder="HH:mm"
359 427 />
360 428 </div>
361 429 );
  430 + case 'DURATION':
  431 + return (
  432 + <>
  433 + <div>
  434 + <Label className="text-xs">Format</Label>
  435 + <Input
  436 + value={(cfg.format as string) ?? 'YYYY-MM-DD'}
  437 + onChange={(e) => update('format', e.target.value)}
  438 + className="h-8 text-sm mt-1"
  439 + placeholder="YYYY-MM-DD"
  440 + />
  441 + </div>
  442 + <div>
  443 + <Label className="text-xs">Offset Days</Label>
  444 + <Input
  445 + type="number"
  446 + value={(cfg.offsetDays as number) ?? 3}
  447 + onChange={(e) => update('offsetDays', Number(e.target.value) || 3)}
  448 + className="h-8 text-sm mt-1"
  449 + />
  450 + </div>
  451 + </>
  452 + );
  453 + case 'WEIGHT':
  454 + return (
  455 + <>
  456 + <div>
  457 + <Label className="text-xs">Value</Label>
  458 + <Input
  459 + type="number"
  460 + value={(cfg.value as number) ?? 500}
  461 + onChange={(e) => update('value', Number(e.target.value) || 0)}
  462 + className="h-8 text-sm mt-1"
  463 + />
  464 + </div>
  465 + <div>
  466 + <Label className="text-xs">Unit</Label>
  467 + <Select
  468 + value={(cfg.unit as string) ?? 'g'}
  469 + onValueChange={(v) => update('unit', v)}
  470 + >
  471 + <SelectTrigger className="h-8 text-sm mt-1">
  472 + <SelectValue />
  473 + </SelectTrigger>
  474 + <SelectContent>
  475 + <SelectItem value="g">g</SelectItem>
  476 + <SelectItem value="kg">kg</SelectItem>
  477 + <SelectItem value="oz">oz</SelectItem>
  478 + <SelectItem value="lb">lb</SelectItem>
  479 + </SelectContent>
  480 + </Select>
  481 + </div>
  482 + </>
  483 + );
  484 + case 'WEIGHT_PRICE':
  485 + return (
  486 + <>
  487 + <div>
  488 + <Label className="text-xs">Unit Price</Label>
  489 + <Input
  490 + type="number"
  491 + value={(cfg.unitPrice as number) ?? 10}
  492 + onChange={(e) => update('unitPrice', Number(e.target.value) || 0)}
  493 + className="h-8 text-sm mt-1"
  494 + />
  495 + </div>
  496 + <div>
  497 + <Label className="text-xs">Weight</Label>
  498 + <Input
  499 + type="number"
  500 + step="0.1"
  501 + value={(cfg.weight as number) ?? 0.5}
  502 + onChange={(e) => update('weight', Number(e.target.value) || 0)}
  503 + className="h-8 text-sm mt-1"
  504 + />
  505 + </div>
  506 + <div>
  507 + <Label className="text-xs">Currency</Label>
  508 + <Input
  509 + value={(cfg.currency as string) ?? '¥'}
  510 + onChange={(e) => update('currency', e.target.value)}
  511 + className="h-8 text-sm mt-1"
  512 + />
  513 + </div>
  514 + </>
  515 + );
  516 + case 'NUTRITION':
  517 + return (
  518 + <>
  519 + <div>
  520 + <Label className="text-xs">Calories</Label>
  521 + <Input
  522 + type="number"
  523 + value={(cfg.calories as number) ?? 120}
  524 + onChange={(e) => update('calories', Number(e.target.value) || 0)}
  525 + className="h-8 text-sm mt-1"
  526 + />
  527 + </div>
  528 + <div>
  529 + <Label className="text-xs">Fat</Label>
  530 + <Input
  531 + value={(cfg.fat as string) ?? '5g'}
  532 + onChange={(e) => update('fat', e.target.value)}
  533 + className="h-8 text-sm mt-1"
  534 + />
  535 + </div>
  536 + <div>
  537 + <Label className="text-xs">Protein</Label>
  538 + <Input
  539 + value={(cfg.protein as string) ?? '3g'}
  540 + onChange={(e) => update('protein', e.target.value)}
  541 + className="h-8 text-sm mt-1"
  542 + />
  543 + </div>
  544 + <div>
  545 + <Label className="text-xs">Carbs</Label>
  546 + <Input
  547 + value={(cfg.carbs as string) ?? '10g'}
  548 + onChange={(e) => update('carbs', e.target.value)}
  549 + className="h-8 text-sm mt-1"
  550 + />
  551 + </div>
  552 + </>
  553 + );
  554 + case 'BLANK':
  555 + return (
  556 + <div className="text-xs text-gray-500">
  557 + 空白占位元素,无需配置
  558 + </div>
  559 + );
362 560 default:
363 561 return (
364 562 <div className="text-xs text-gray-500">
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx
... ... @@ -47,7 +47,69 @@ export function LabelTemplateEditor({
47 47 type: Parameters<typeof createDefaultElement>[0],
48 48 configOverride?: Partial<Record<string, unknown>>
49 49 ) => {
50   - const el = createDefaultElement(type, 30, 30);
  50 + // 计算画布中心位置(像素)
  51 + const unitToPx = (value: number, unit: 'cm' | 'inch'): number => {
  52 + return unit === 'cm' ? value * 37.8 : value * 96;
  53 + };
  54 +
  55 + const canvasWidthPx = unitToPx(template.width, template.unit);
  56 + const canvasHeightPx = unitToPx(template.height, template.unit);
  57 +
  58 + // 创建默认元素以获取其尺寸
  59 + const tempEl = createDefaultElement(type, 0, 0);
  60 +
  61 + // 对齐到网格
  62 + const GRID_SIZE = 8;
  63 + const snapToGrid = (value: number): number => {
  64 + return Math.round(value / GRID_SIZE) * GRID_SIZE;
  65 + };
  66 +
  67 + // 计算元素中心对齐到画布中心的位置
  68 + let centerX = (canvasWidthPx - tempEl.width) / 2;
  69 + let centerY = (canvasHeightPx - tempEl.height) / 2;
  70 +
  71 + // 检查是否与现有元素重叠,如果重叠则尝试偏移
  72 + const checkOverlap = (x: number, y: number, width: number, height: number): boolean => {
  73 + return template.elements.some((el) => {
  74 + const elRight = el.x + el.width;
  75 + const elBottom = el.y + el.height;
  76 + const newRight = x + width;
  77 + const newBottom = y + height;
  78 + return !(x >= elRight || newRight <= el.x || y >= elBottom || newBottom <= el.y);
  79 + });
  80 + };
  81 +
  82 + // 如果中心位置重叠,尝试在周围寻找空位
  83 + if (checkOverlap(centerX, centerY, tempEl.width, tempEl.height)) {
  84 + const offset = GRID_SIZE * 2;
  85 + let found = false;
  86 + // 尝试右下方
  87 + for (let tryY = centerY; tryY < canvasHeightPx - tempEl.height && !found; tryY += offset) {
  88 + for (let tryX = centerX; tryX < canvasWidthPx - tempEl.width && !found; tryX += offset) {
  89 + if (!checkOverlap(tryX, tryY, tempEl.width, tempEl.height)) {
  90 + centerX = tryX;
  91 + centerY = tryY;
  92 + found = true;
  93 + break;
  94 + }
  95 + }
  96 + }
  97 + // 如果还没找到,尝试左上方
  98 + if (!found) {
  99 + for (let tryY = centerY; tryY >= 0 && !found; tryY -= offset) {
  100 + for (let tryX = centerX; tryX >= 0 && !found; tryX -= offset) {
  101 + if (!checkOverlap(tryX, tryY, tempEl.width, tempEl.height)) {
  102 + centerX = tryX;
  103 + centerY = tryY;
  104 + found = true;
  105 + break;
  106 + }
  107 + }
  108 + }
  109 + }
  110 + }
  111 +
  112 + const el = createDefaultElement(type, Math.max(0, snapToGrid(centerX)), Math.max(0, snapToGrid(centerY)));
51 113 if (configOverride && Object.keys(configOverride).length > 0) {
52 114 el.config = { ...el.config, ...configOverride };
53 115 }
... ... @@ -56,7 +118,7 @@ export function LabelTemplateEditor({
56 118 elements: [...prev.elements, el],
57 119 }));
58 120 setSelectedId(el.id);
59   - }, []);
  121 + }, [template.width, template.height, template.unit, template.elements]);
60 122  
61 123 const updateElement = useCallback((id: string, patch: Partial<LabelElement>) => {
62 124 setTemplate((prev) => ({
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplatesView.tsx
... ... @@ -45,7 +45,8 @@ export function LabelTemplatesView() {
45 45 });
46 46  
47 47 const handleNewTemplate = () => {
48   - // 点击不跳转,仅保留按钮展示
  48 + setEditingTemplateId(null);
  49 + setViewMode('editor');
49 50 };
50 51  
51 52 const handleEditTemplate = (id: string) => {
... ...
美国版/Food Labeling Management Platform/src/components/people/PeopleView.tsx
... ... @@ -59,11 +59,21 @@ import { cn } from &quot;../ui/utils&quot;;
59 59 import { toast } from "sonner";
60 60  
61 61 import { getRbacMenuTree } from "../../services/systemMenuService";
62   -import { deleteRoleMenus, getRoleMenuIds, setRoleMenus } from "../../services/rbacRoleMenuService";
  62 +import { deleteRoleMenus, setRoleMenus } from "../../services/rbacRoleMenuService";
63 63 import type { RbacMenuTreeNode } from "../../types/systemMenu";
64 64 import { getRoles } from "../../services/roleService";
65   -import { createRbacRole, deleteRbacRole, updateRbacRole } from "../../services/rbacRoleService";
  65 +import { createRbacRole, deleteRbacRole, getRbacRoleMenuIds, updateRbacRole } from "../../services/rbacRoleService";
66 66 import type { RoleDto } from "../../types/role";
  67 +import { getLocations } from "../../services/locationService";
  68 +import {
  69 + createTeamMember,
  70 + deleteTeamMember,
  71 + getTeamMemberById,
  72 + getTeamMembers,
  73 + updateTeamMember,
  74 +} from "../../services/teamMemberService";
  75 +import type { LocationDto } from "../../types/location";
  76 +import type { TeamMemberCreateInput, TeamMemberDto, TeamMemberUpdateInput } from "../../types/teamMember";
67 77  
68 78 // --- Mock Data ---
69 79  
... ... @@ -161,13 +171,31 @@ export function PeopleView() {
161 171 const [debouncedRoleKeyword, setDebouncedRoleKeyword] = useState("");
162 172 const [partners, setPartners] = useState(MOCK_PARTNERS);
163 173 const [groups, setGroups] = useState(MOCK_GROUPS);
164   - const [members, setMembers] = useState(MOCK_MEMBERS);
  174 +
  175 + const [members, setMembers] = useState<TeamMemberDto[]>([]);
  176 + const [membersLoading, setMembersLoading] = useState(false);
  177 + const [memberTotal, setMemberTotal] = useState(0);
  178 + const [memberRefreshSeq, setMemberRefreshSeq] = useState(0);
  179 + const [memberPageIndex, setMemberPageIndex] = useState(1);
  180 + const [memberPageSize, setMemberPageSize] = useState(10);
  181 + const memberTotalPages = Math.max(1, Math.ceil(memberTotal / memberPageSize));
  182 + const membersAbortRef = useRef<AbortController | null>(null);
  183 +
  184 + const [memberKeyword, setMemberKeyword] = useState("");
  185 + const memberKeywordTimerRef = useRef<number | null>(null);
  186 + const [debouncedMemberKeyword, setDebouncedMemberKeyword] = useState("");
  187 +
  188 + const [editingMember, setEditingMember] = useState<TeamMemberDto | null>(null);
  189 + const [isDeleteMemberDialogOpen, setIsDeleteMemberDialogOpen] = useState(false);
  190 + const [deletingMember, setDeletingMember] = useState<TeamMemberDto | null>(null);
165 191  
166 192 // Dialog States
167 193 const [isRoleDialogOpen, setIsRoleDialogOpen] = useState(false);
168 194 const [editingRole, setEditingRole] = useState<RoleDto | null>(null);
169 195 const [isRoleMenuDialogOpen, setIsRoleMenuDialogOpen] = useState(false);
170 196 const [menuRole, setMenuRole] = useState<RoleDto | null>(null);
  197 + const [isDeleteRoleDialogOpen, setIsDeleteRoleDialogOpen] = useState(false);
  198 + const [deletingRole, setDeletingRole] = useState<RoleDto | null>(null);
171 199 const [isPartnerDialogOpen, setIsPartnerDialogOpen] = useState(false);
172 200 const [isGroupDialogOpen, setIsGroupDialogOpen] = useState(false);
173 201 const [isMemberDialogOpen, setIsMemberDialogOpen] = useState(false);
... ... @@ -186,10 +214,22 @@ export function PeopleView() {
186 214 }, [roleKeyword]);
187 215  
188 216 useEffect(() => {
  217 + if (memberKeywordTimerRef.current) window.clearTimeout(memberKeywordTimerRef.current);
  218 + memberKeywordTimerRef.current = window.setTimeout(() => setDebouncedMemberKeyword(memberKeyword.trim()), 300);
  219 + return () => {
  220 + if (memberKeywordTimerRef.current) window.clearTimeout(memberKeywordTimerRef.current);
  221 + };
  222 + }, [memberKeyword]);
  223 +
  224 + useEffect(() => {
189 225 setRolePageIndex(1);
190 226 }, [debouncedRoleKeyword, rolePageSize]);
191 227  
192 228 useEffect(() => {
  229 + setMemberPageIndex(1);
  230 + }, [debouncedMemberKeyword, memberPageSize]);
  231 +
  232 + useEffect(() => {
193 233 if (activeTab !== "Roles") return;
194 234 const run = async () => {
195 235 rolesAbortRef.current?.abort();
... ... @@ -224,6 +264,41 @@ export function PeopleView() {
224 264 return () => rolesAbortRef.current?.abort();
225 265 }, [activeTab, debouncedRoleKeyword, rolePageIndex, rolePageSize, roleRefreshSeq]);
226 266  
  267 + useEffect(() => {
  268 + if (activeTab !== "Team Member") return;
  269 + const run = async () => {
  270 + membersAbortRef.current?.abort();
  271 + const ac = new AbortController();
  272 + membersAbortRef.current = ac;
  273 +
  274 + setMembersLoading(true);
  275 + try {
  276 + const res = await getTeamMembers(
  277 + {
  278 + skipCount: Math.max(1, memberPageIndex),
  279 + maxResultCount: memberPageSize,
  280 + keyword: debouncedMemberKeyword || undefined,
  281 + },
  282 + ac.signal,
  283 + );
  284 + setMembers(res.items ?? []);
  285 + setMemberTotal(res.totalCount ?? 0);
  286 + } catch (e: any) {
  287 + if (e?.name === "AbortError") return;
  288 + toast.error("Failed to load team members.", {
  289 + description: e?.message ? String(e.message) : "Please try again.",
  290 + });
  291 + setMembers([]);
  292 + setMemberTotal(0);
  293 + } finally {
  294 + setMembersLoading(false);
  295 + }
  296 + };
  297 +
  298 + run();
  299 + return () => membersAbortRef.current?.abort();
  300 + }, [activeTab, debouncedMemberKeyword, memberPageIndex, memberPageSize, memberRefreshSeq]);
  301 +
227 302 const openCreateDialog = () => {
228 303 switch (activeTab) {
229 304 case 'Roles':
... ... @@ -232,7 +307,7 @@ export function PeopleView() {
232 307 break;
233 308 case 'Partner': setIsPartnerDialogOpen(true); break;
234 309 case 'Group': setIsGroupDialogOpen(true); break;
235   - case 'Team Member': setIsMemberDialogOpen(true); break;
  310 + case 'Team Member': setEditingMember(null); setIsMemberDialogOpen(true); break;
236 311 }
237 312 };
238 313  
... ... @@ -245,9 +320,10 @@ export function PeopleView() {
245 320 <div className="flex flex-nowrap items-center gap-3">
246 321 <Input
247 322 placeholder="Search"
248   - value={activeTab === "Roles" ? roleKeyword : ""}
  323 + value={activeTab === "Roles" ? roleKeyword : activeTab === "Team Member" ? memberKeyword : ""}
249 324 onChange={(e) => {
250 325 if (activeTab === "Roles") setRoleKeyword(e.target.value);
  326 + if (activeTab === "Team Member") setMemberKeyword(e.target.value);
251 327 }}
252 328 style={{ height: 40, boxSizing: 'border-box' }}
253 329 className="border border-gray-300 rounded-md w-40 shrink-0 bg-white placeholder:text-gray-500"
... ... @@ -357,17 +433,8 @@ export function PeopleView() {
357 433 variant="ghost"
358 434 size="sm"
359 435 onClick={async () => {
360   - const ok = window.confirm(`Delete role "${r.roleName ?? r.id}"? This cannot be undone.`);
361   - if (!ok) return;
362   - try {
363   - await deleteRbacRole(r.id);
364   - toast.success("Role deleted.", { description: "The role has been removed successfully." });
365   - setRoleRefreshSeq((x) => x + 1);
366   - } catch (e: any) {
367   - toast.error("Failed to delete role.", {
368   - description: e?.message ? String(e.message) : "Please try again.",
369   - });
370   - }
  436 + setDeletingRole(r);
  437 + setIsDeleteRoleDialogOpen(true);
371 438 }}
372 439 title="Delete role"
373 440 >
... ... @@ -502,7 +569,8 @@ export function PeopleView() {
502 569  
503 570 case 'Team Member':
504 571 return (
505   - <Table>
  572 + <>
  573 + <Table>
506 574 <TableHeader>
507 575 <TableRow className="bg-gray-100">
508 576 <TableHead className="font-bold text-black border-r">Name</TableHead>
... ... @@ -515,35 +583,134 @@ export function PeopleView() {
515 583 </TableRow>
516 584 </TableHeader>
517 585 <TableBody>
518   - {members.map(m => (
519   - <TableRow key={m.id}>
520   - <TableCell className="font-medium border-r">{m.name}</TableCell>
521   - <TableCell className="border-r text-gray-600">{m.email}</TableCell>
522   - <TableCell className="border-r text-gray-600">{m.phone}</TableCell>
523   - <TableCell className="border-r">
524   - <Badge variant="outline" className="font-normal">{m.role}</Badge>
  586 + {membersLoading ? (
  587 + <TableRow>
  588 + <TableCell colSpan={7} className="text-center text-sm text-gray-500 py-10">
  589 + Loading...
525 590 </TableCell>
526   - <TableCell className="border-r">
527   - <div className="flex flex-col gap-1">
528   - {m.locations.map(loc => (
529   - <div key={loc} className="flex items-center gap-1 text-xs text-gray-600">
530   - <MapPin className="w-3 h-3" /> {loc}
531   - </div>
532   - ))}
533   - </div>
534   - </TableCell>
535   - <TableCell className="border-r">
536   - <Badge className={m.status === 'active' ? "bg-green-600" : "bg-gray-400"}>
537   - {m.status}
538   - </Badge>
539   - </TableCell>
540   - <TableCell className="text-center">
541   - <Button variant="ghost" size="sm"><Edit className="w-4 h-4 text-gray-500" /></Button>
  591 + </TableRow>
  592 + ) : members.length === 0 ? (
  593 + <TableRow>
  594 + <TableCell colSpan={7} className="text-center text-sm text-gray-500 py-10">
  595 + No results.
542 596 </TableCell>
543 597 </TableRow>
544   - ))}
  598 + ) : (
  599 + members.map((m) => (
  600 + <TableRow key={m.id}>
  601 + <TableCell className="font-medium border-r">{m.fullName ?? m.userName ?? "N/A"}</TableCell>
  602 + <TableCell className="border-r text-gray-600">{m.email ?? "N/A"}</TableCell>
  603 + <TableCell className="border-r text-gray-600">{m.phone ?? "N/A"}</TableCell>
  604 + <TableCell className="border-r">
  605 + <Badge variant="outline" className="font-normal">
  606 + {m.roleName ?? m.roleId ?? "N/A"}
  607 + </Badge>
  608 + </TableCell>
  609 + <TableCell className="border-r">
  610 + <div className="flex flex-col gap-1">
  611 + {(m.locations?.length ? m.locations : m.locationIds ?? []).map((loc) => (
  612 + <div key={loc} className="flex items-center gap-1 text-xs text-gray-600">
  613 + <MapPin className="w-3 h-3" /> {loc}
  614 + </div>
  615 + ))}
  616 + {(!m.locations?.length && !(m.locationIds?.length ?? 0)) && (
  617 + <div className="text-xs text-gray-500">无</div>
  618 + )}
  619 + </div>
  620 + </TableCell>
  621 + <TableCell className="border-r">
  622 + <Badge className={m.state ? "bg-green-600" : "bg-gray-400"}>
  623 + {m.state ? "Active" : "Inactive"}
  624 + </Badge>
  625 + </TableCell>
  626 + <TableCell className="text-center">
  627 + <div className="flex items-center justify-center gap-2">
  628 + <Button
  629 + variant="ghost"
  630 + size="sm"
  631 + onClick={() => {
  632 + setEditingMember(m);
  633 + setIsMemberDialogOpen(true);
  634 + }}
  635 + title="Edit"
  636 + >
  637 + <Edit className="w-4 h-4 text-gray-500" />
  638 + </Button>
  639 + <Button
  640 + variant="ghost"
  641 + size="sm"
  642 + onClick={() => {
  643 + setDeletingMember(m);
  644 + setIsDeleteMemberDialogOpen(true);
  645 + }}
  646 + title="Delete"
  647 + >
  648 + <Trash2 className="w-4 h-4 text-red-600" />
  649 + </Button>
  650 + </div>
  651 + </TableCell>
  652 + </TableRow>
  653 + ))
  654 + )}
545 655 </TableBody>
546 656 </Table>
  657 +
  658 + <div className="px-4 py-3 border-t border-gray-200 bg-white flex flex-wrap items-center justify-between gap-3">
  659 + <div className="text-sm text-gray-600">
  660 + Showing {memberTotal === 0 ? 0 : (memberPageIndex - 1) * memberPageSize + 1}-
  661 + {Math.min(memberPageIndex * memberPageSize, memberTotal)} of {memberTotal}
  662 + </div>
  663 +
  664 + <div className="flex items-center gap-3">
  665 + <Select value={String(memberPageSize)} onValueChange={(v) => setMemberPageSize(Number(v))}>
  666 + <SelectTrigger className="w-[110px] h-9 rounded-md border border-gray-300 bg-white text-gray-900">
  667 + <SelectValue />
  668 + </SelectTrigger>
  669 + <SelectContent>
  670 + {[10, 20, 50].map((n) => (
  671 + <SelectItem key={n} value={String(n)}>
  672 + {n} / page
  673 + </SelectItem>
  674 + ))}
  675 + </SelectContent>
  676 + </Select>
  677 +
  678 + <Pagination className="mx-0 w-auto justify-end">
  679 + <PaginationContent>
  680 + <PaginationItem>
  681 + <PaginationPrevious
  682 + href="#"
  683 + size="default"
  684 + onClick={(e) => {
  685 + e.preventDefault();
  686 + setMemberPageIndex((p) => Math.max(1, p - 1));
  687 + }}
  688 + aria-disabled={memberPageIndex <= 1}
  689 + className={memberPageIndex <= 1 ? "pointer-events-none opacity-50" : ""}
  690 + />
  691 + </PaginationItem>
  692 + <PaginationItem>
  693 + <PaginationLink href="#" isActive size="default" onClick={(e) => e.preventDefault()}>
  694 + Page {memberPageIndex} / {memberTotalPages}
  695 + </PaginationLink>
  696 + </PaginationItem>
  697 + <PaginationItem>
  698 + <PaginationNext
  699 + href="#"
  700 + size="default"
  701 + onClick={(e) => {
  702 + e.preventDefault();
  703 + setMemberPageIndex((p) => Math.min(memberTotalPages, p + 1));
  704 + }}
  705 + aria-disabled={memberPageIndex >= memberTotalPages}
  706 + className={memberPageIndex >= memberTotalPages ? "pointer-events-none opacity-50" : ""}
  707 + />
  708 + </PaginationItem>
  709 + </PaginationContent>
  710 + </Pagination>
  711 + </div>
  712 + </div>
  713 + </>
547 714 );
548 715 }
549 716 };
... ... @@ -579,9 +746,40 @@ export function PeopleView() {
579 746 if (!open) setMenuRole(null);
580 747 }}
581 748 />
  749 + <DeleteRoleDialog
  750 + open={isDeleteRoleDialogOpen}
  751 + role={deletingRole}
  752 + onOpenChange={(open) => {
  753 + setIsDeleteRoleDialogOpen(open);
  754 + if (!open) setDeletingRole(null);
  755 + }}
  756 + onDeleted={() => setRoleRefreshSeq((x) => x + 1)}
  757 + />
582 758 <CreatePartnerDialog open={isPartnerDialogOpen} onOpenChange={setIsPartnerDialogOpen} />
583 759 <CreateGroupDialog open={isGroupDialogOpen} onOpenChange={setIsGroupDialogOpen} />
584   - <CreateMemberDialog open={isMemberDialogOpen} onOpenChange={setIsMemberDialogOpen} roles={roles} />
  760 + <MemberDialog
  761 + open={isMemberDialogOpen}
  762 + member={editingMember}
  763 + onOpenChange={(open) => {
  764 + setIsMemberDialogOpen(open);
  765 + if (!open) setEditingMember(null);
  766 + }}
  767 + onSaved={() => {
  768 + setMemberPageIndex(1);
  769 + setMemberRefreshSeq((x) => x + 1);
  770 + }}
  771 + />
  772 + <DeleteMemberDialog
  773 + open={isDeleteMemberDialogOpen}
  774 + member={deletingMember}
  775 + onOpenChange={(open) => {
  776 + setIsDeleteMemberDialogOpen(open);
  777 + if (!open) setDeletingMember(null);
  778 + }}
  779 + onDeleted={() => {
  780 + setMemberRefreshSeq((x) => x + 1);
  781 + }}
  782 + />
585 783 </div>
586 784 );
587 785 }
... ... @@ -629,6 +827,7 @@ function RoleDialog({
629 827 };
630 828  
631 829 const submit = async () => {
  830 + console.log("submit", role);
632 831 if (!canSubmit) {
633 832 toast.error("Please fill in required fields.", {
634 833 description: "Role Name and Role Code are required.",
... ... @@ -759,7 +958,7 @@ function RoleMenuPermissionsDialog({
759 958 const tree = await getRbacMenuTree(ac.signal);
760 959 setMenuTree(tree ?? []);
761 960 if (roleId) {
762   - const checked = await getRoleMenuIds(roleId);
  961 + const checked = await getRbacRoleMenuIds(roleId, ac.signal);
763 962 setSelectedIds(new Set(checked));
764 963 }
765 964 } catch (e: any) {
... ... @@ -911,6 +1110,7 @@ function RoleMenuPermissionsDialog({
911 1110 };
912 1111  
913 1112 const submit = async () => {
  1113 + console.log("submit", role);
914 1114 if (!roleId) return;
915 1115 setSubmitting(true);
916 1116 try {
... ... @@ -1002,6 +1202,74 @@ function RoleMenuPermissionsDialog({
1002 1202 );
1003 1203 }
1004 1204  
  1205 +function DeleteRoleDialog({
  1206 + open,
  1207 + role,
  1208 + onOpenChange,
  1209 + onDeleted,
  1210 +}: {
  1211 + open: boolean;
  1212 + role: RoleDto | null;
  1213 + onOpenChange: (open: boolean) => void;
  1214 + onDeleted: () => void;
  1215 +}) {
  1216 + const [submitting, setSubmitting] = useState(false);
  1217 +
  1218 + const name = useMemo(() => {
  1219 + const n = (role?.roleName ?? "").trim();
  1220 + return n || role?.roleCode || role?.id || "this role";
  1221 + }, [role]);
  1222 +
  1223 + const submit = async () => {
  1224 + console.log("submit", role);
  1225 + if (!role?.id) return;
  1226 + setSubmitting(true);
  1227 + try {
  1228 + await deleteRbacRole(role.id);
  1229 + toast.success("Role deleted.", {
  1230 + description: "The role has been removed successfully.",
  1231 + });
  1232 + onOpenChange(false);
  1233 + onDeleted();
  1234 + } catch (e: any) {
  1235 + toast.error("Failed to delete role.", {
  1236 + description: e?.message ? String(e.message) : "Please try again.",
  1237 + });
  1238 + } finally {
  1239 + setSubmitting(false);
  1240 + }
  1241 + };
  1242 +
  1243 + return (
  1244 + <Dialog open={open} onOpenChange={onOpenChange}>
  1245 + <DialogContent className="sm:max-w-none" style={{ width: "30%" }}>
  1246 + <DialogHeader>
  1247 + <DialogTitle>Delete Role</DialogTitle>
  1248 + <DialogDescription>This action cannot be undone.</DialogDescription>
  1249 + </DialogHeader>
  1250 +
  1251 + <div className="text-sm text-gray-700">
  1252 + Are you sure you want to delete <span className="font-medium">{name}</span>?
  1253 + </div>
  1254 +
  1255 + <DialogFooter className="flex-row flex-wrap justify-end">
  1256 + <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}>
  1257 + Cancel
  1258 + </Button>
  1259 + <Button
  1260 + className="min-w-24"
  1261 + variant="destructive"
  1262 + disabled={submitting}
  1263 + onClick={submit}
  1264 + >
  1265 + {submitting ? "Deleting..." : "Delete"}
  1266 + </Button>
  1267 + </DialogFooter>
  1268 + </DialogContent>
  1269 + </Dialog>
  1270 + );
  1271 +}
  1272 +
1005 1273 function CreatePartnerDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
1006 1274 return (
1007 1275 <Dialog open={open} onOpenChange={onOpenChange}>
... ... @@ -1071,70 +1339,474 @@ function CreateGroupDialog({ open, onOpenChange }: { open: boolean; onOpenChange
1071 1339 );
1072 1340 }
1073 1341  
1074   -function CreateMemberDialog({ open, onOpenChange, roles }: { open: boolean; onOpenChange: (open: boolean) => void; roles: any[] }) {
  1342 +function MemberDialog({
  1343 + open,
  1344 + member,
  1345 + onOpenChange,
  1346 + onSaved,
  1347 +}: {
  1348 + open: boolean;
  1349 + member: TeamMemberDto | null;
  1350 + onOpenChange: (open: boolean) => void;
  1351 + onSaved: () => void;
  1352 +}) {
  1353 + const isEdit = !!member?.id;
  1354 + const [submitting, setSubmitting] = useState(false);
  1355 +
  1356 + const [fullName, setFullName] = useState("");
  1357 + const [userName, setUserName] = useState("");
  1358 + const [password, setPassword] = useState("");
  1359 + const [email, setEmail] = useState("");
  1360 + const [phone, setPhone] = useState("");
  1361 + const [roleId, setRoleId] = useState("");
  1362 + const [state, setState] = useState(true);
  1363 + const [selectedLocationIds, setSelectedLocationIds] = useState<Set<string>>(new Set());
  1364 +
  1365 + const [roleOptions, setRoleOptions] = useState<RoleDto[]>([]);
  1366 + const [loadingRoles, setLoadingRoles] = useState(false);
  1367 +
  1368 + const [locationOptions, setLocationOptions] = useState<LocationDto[]>([]);
  1369 + const [loadingLocations, setLoadingLocations] = useState(false);
  1370 + const [locationKeyword, setLocationKeyword] = useState("");
  1371 +
  1372 + const abortRef = useRef<AbortController | null>(null);
  1373 +
  1374 + const resetForm = () => {
  1375 + setFullName("");
  1376 + setUserName("");
  1377 + setPassword("");
  1378 + setEmail("");
  1379 + setPhone("");
  1380 + setRoleId("");
  1381 + setState(true);
  1382 + setSelectedLocationIds(new Set());
  1383 + };
  1384 +
  1385 + const loadAllRolesForSelect = async (signal: AbortSignal) => {
  1386 + const out: RoleDto[] = [];
  1387 + let page = 1;
  1388 + const size = 100;
  1389 + for (;;) {
  1390 + const res = await getRoles({ skipCount: page, maxResultCount: size }, signal);
  1391 + out.push(...(res.items ?? []));
  1392 + if (!res.items || res.items.length < size) break;
  1393 + page += 1;
  1394 + if (page > 200) break; // safety bound
  1395 + }
  1396 + // 去重(防止后端实现差异)
  1397 + const m = new Map<string, RoleDto>();
  1398 + for (const r of out) if (r.id && !m.has(r.id)) m.set(r.id, r);
  1399 + return Array.from(m.values());
  1400 + };
  1401 +
  1402 + const loadAllLocationsForSelect = async (signal: AbortSignal) => {
  1403 + const out: LocationDto[] = [];
  1404 + let page = 1;
  1405 + const size = 200;
  1406 + for (;;) {
  1407 + const res = await getLocations({ skipCount: page, maxResultCount: size }, signal);
  1408 + out.push(...(res.items ?? []));
  1409 + if (!res.items || res.items.length < size) break;
  1410 + page += 1;
  1411 + if (page > 200) break;
  1412 + }
  1413 + const m = new Map<string, LocationDto>();
  1414 + for (const l of out) if (l.id && !m.has(l.id)) m.set(l.id, l);
  1415 + return Array.from(m.values());
  1416 + };
  1417 +
  1418 + useEffect(() => {
  1419 + if (!open) return;
  1420 + abortRef.current?.abort();
  1421 + const ac = new AbortController();
  1422 + abortRef.current = ac;
  1423 +
  1424 + setSubmitting(false);
  1425 + resetForm();
  1426 + setLoadingRoles(true);
  1427 + setLoadingLocations(true);
  1428 +
  1429 + const run = async () => {
  1430 + try {
  1431 + const [rolesRes, locationsRes] = await Promise.all([
  1432 + loadAllRolesForSelect(ac.signal),
  1433 + loadAllLocationsForSelect(ac.signal),
  1434 + ]);
  1435 + setRoleOptions(rolesRes);
  1436 + setLocationOptions(locationsRes);
  1437 +
  1438 + if (member?.id) {
  1439 + const detail = await getTeamMemberById(member.id, ac.signal);
  1440 + setFullName(detail.fullName ?? "");
  1441 + setUserName(detail.userName ?? "");
  1442 + setEmail(detail.email ?? "");
  1443 + setPhone(detail.phone != null ? String(detail.phone) : "");
  1444 + // 有些后端只返回 roleName 而不返回 roleId;这里做兜底匹配,避免编辑后 Save 变成 disabled
  1445 + let nextRoleId = (detail.roleId ?? "").toString().trim();
  1446 + if (!nextRoleId && detail.roleName) {
  1447 + const roleName = String(detail.roleName).trim().toLowerCase();
  1448 + const matched = rolesRes.find((r) => {
  1449 + const rn = String(r.roleName ?? "").trim().toLowerCase();
  1450 + const rc = String(r.roleCode ?? "").trim().toLowerCase();
  1451 + const rid = String(r.id ?? "").trim().toLowerCase();
  1452 + return rn === roleName || rc === roleName || rid === roleName;
  1453 + });
  1454 + if (matched?.id) nextRoleId = matched.id;
  1455 + }
  1456 + setRoleId(nextRoleId);
  1457 + setState(!!detail.state);
  1458 +
  1459 + const ids = detail.locationIds && detail.locationIds.length ? detail.locationIds : [];
  1460 + if (ids.length) {
  1461 + setSelectedLocationIds(new Set(ids));
  1462 + } else if (detail.locations?.length) {
  1463 + // 如果后端只返回 locations(名字),则尽力映射回 id
  1464 + const labels = new Set(detail.locations);
  1465 + const inferred = new Set<string>();
  1466 + for (const l of locationsRes) {
  1467 + const label1 = `${(l.locationCode ?? "").trim()} - ${(l.locationName ?? "").trim()}`.trim();
  1468 + const label2 = (l.locationName ?? "").trim();
  1469 + const label3 = (l.locationCode ?? "").trim();
  1470 + if (labels.has(label1) || labels.has(label2) || labels.has(label3)) inferred.add(l.id);
  1471 + }
  1472 + setSelectedLocationIds(inferred);
  1473 + }
  1474 + }
  1475 + } catch (e: any) {
  1476 + if (e?.name !== "AbortError") {
  1477 + toast.error("Failed to load user form.", {
  1478 + description: e?.message ? String(e.message) : "Please try again.",
  1479 + });
  1480 + }
  1481 + } finally {
  1482 + setLoadingRoles(false);
  1483 + setLoadingLocations(false);
  1484 + }
  1485 + };
  1486 +
  1487 + run();
  1488 + return () => ac.abort();
  1489 + // eslint-disable-next-line react-hooks/exhaustive-deps
  1490 + }, [open, member?.id]);
  1491 +
  1492 + // 与新增一致:只判断必填项是否有值,有值即可点保存;无值则提交时弹提示
  1493 + const canSubmit = useMemo(() => {
  1494 + if (!fullName.trim()) return false;
  1495 + if (!userName.trim()) return false;
  1496 + if (!roleId.trim()) return false;
  1497 + if (selectedLocationIds.size === 0) return false;
  1498 + if (!isEdit && !password.trim()) return false;
  1499 + return true;
  1500 + }, [fullName, userName, roleId, selectedLocationIds, isEdit, password]);
  1501 +
  1502 + const toggleLocation = (id: string, checked: boolean) => {
  1503 + setSelectedLocationIds((prev) => {
  1504 + const next = new Set(prev);
  1505 + if (checked) next.add(id);
  1506 + else next.delete(id);
  1507 + return next;
  1508 + });
  1509 + };
  1510 +
  1511 + const submit = async (e?: React.MouseEvent) => {
  1512 + e?.preventDefault();
  1513 + e?.stopPropagation();
  1514 +
  1515 + console.log("[MemberDialog] submit called", { isEdit, memberId: member?.id, canSubmit, roleId, fullName, userName, selectedLocationIds: selectedLocationIds.size });
  1516 +
  1517 + // 先校验必填项
  1518 + if (!canSubmit) {
  1519 + const missing: string[] = [];
  1520 + if (!fullName.trim()) missing.push("Full Name");
  1521 + if (!userName.trim()) missing.push("User Name");
  1522 + if (!roleId.trim()) missing.push("Role");
  1523 + if (selectedLocationIds.size === 0) missing.push("Locations");
  1524 + if (!isEdit && !password.trim()) missing.push("Password");
  1525 + toast.error("Missing required fields.", {
  1526 + description: `Please fill: ${missing.join("、")}.`,
  1527 + });
  1528 + return;
  1529 + }
  1530 +
  1531 + if (!isEdit && !member?.id) {
  1532 + // 新增模式
  1533 + setSubmitting(true);
  1534 + try {
  1535 + const locationIds = Array.from(selectedLocationIds);
  1536 + console.log("[MemberDialog] Creating user", { fullName, userName, roleId, locationIds });
  1537 + await createTeamMember({
  1538 + fullName: fullName.trim(),
  1539 + userName: userName.trim(),
  1540 + password: password.trim(),
  1541 + email: email.trim() ? email.trim() : null,
  1542 + phone: phone != null && String(phone).trim() ? String(phone).trim() : null,
  1543 + roleId: roleId.trim(),
  1544 + locationIds,
  1545 + state,
  1546 + });
  1547 + toast.success("User created.", { description: "A new user has been created successfully." });
  1548 + onOpenChange(false);
  1549 + onSaved();
  1550 + } catch (e: any) {
  1551 + console.error("[MemberDialog] Create error", e);
  1552 + toast.error("Failed to create user.", {
  1553 + description: e?.message ? String(e.message) : "Please try again.",
  1554 + });
  1555 + } finally {
  1556 + setSubmitting(false);
  1557 + }
  1558 + } else if (isEdit && member?.id) {
  1559 + // 编辑模式
  1560 + setSubmitting(true);
  1561 + try {
  1562 + const locationIds = Array.from(selectedLocationIds);
  1563 + console.log("[MemberDialog] Updating user", { id: member.id, fullName, userName, roleId, locationIds });
  1564 + await updateTeamMember(member.id, {
  1565 + fullName: fullName.trim(),
  1566 + userName: userName.trim(),
  1567 + password: password.trim() ? password.trim() : null,
  1568 + email: email.trim() ? email.trim() : null,
  1569 + phone: phone != null && String(phone).trim() ? String(phone).trim() : null,
  1570 + roleId: roleId.trim(),
  1571 + locationIds,
  1572 + state,
  1573 + });
  1574 + toast.success("User updated.", { description: "Changes have been saved successfully." });
  1575 + onOpenChange(false);
  1576 + onSaved();
  1577 + } catch (e: any) {
  1578 + console.error("[MemberDialog] Update error", e);
  1579 + toast.error("Failed to update user.", {
  1580 + description: e?.message ? String(e.message) : "Please try again.",
  1581 + });
  1582 + } finally {
  1583 + setSubmitting(false);
  1584 + }
  1585 + } else {
  1586 + console.error("[MemberDialog] Invalid state", { isEdit, memberId: member?.id });
  1587 + toast.error("Invalid form state.", {
  1588 + description: "Please refresh and try again.",
  1589 + });
  1590 + }
  1591 + };
  1592 +
  1593 + const locationLabel = (l: LocationDto) => {
  1594 + const code = (l.locationCode ?? "").trim();
  1595 + const name = (l.locationName ?? "").trim();
  1596 + return code && name ? `${code} - ${name}` : name || code || l.id;
  1597 + };
  1598 +
  1599 + const filteredLocations = useMemo(() => {
  1600 + const kw = locationKeyword.trim().toLowerCase();
  1601 + if (!kw) return locationOptions;
  1602 + return locationOptions.filter((l) => locationLabel(l).toLowerCase().includes(kw));
  1603 + }, [locationOptions, locationKeyword]);
  1604 +
1075 1605 return (
1076 1606 <Dialog open={open} onOpenChange={onOpenChange}>
1077   - <DialogContent className="sm:max-w-[500px]">
  1607 + <DialogContent className="sm:max-w-none" style={{ width: "50%" }}>
1078 1608 <DialogHeader>
1079   - <DialogTitle>Add Team Member / Manager</DialogTitle>
1080   - <DialogDescription>Create a user account and assign them to locations.</DialogDescription>
  1609 + <DialogTitle>{isEdit ? "Edit User" : "New User"}</DialogTitle>
  1610 + <DialogDescription>Role is single-select; Locations is multi-select.</DialogDescription>
1081 1611 </DialogHeader>
1082   - <div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto pr-1">
  1612 +
  1613 + <div className="space-y-4 py-4 max-h-[70vh] overflow-y-auto pr-1">
1083 1614 <div className="grid grid-cols-2 gap-4">
1084 1615 <div className="space-y-2">
1085   - <Label>Full Name</Label>
1086   - <Input placeholder="John Doe" />
  1616 + <Label>Full Name *</Label>
  1617 + <Input value={fullName} onChange={(e) => setFullName(e.target.value)} placeholder="John Doe" />
1087 1618 </div>
1088   - <div className="space-y-2">
1089   - <Label>Role</Label>
1090   - <Select>
1091   - <SelectTrigger><SelectValue placeholder="Select Role" /></SelectTrigger>
1092   - <SelectContent>
1093   - {roles.map(r => <SelectItem key={r.id} value={r.name}>{r.name}</SelectItem>)}
1094   - </SelectContent>
1095   - </Select>
  1619 + <div className="space-y-2">
  1620 + <Label>User Name *</Label>
  1621 + <Input value={userName} onChange={(e) => setUserName(e.target.value)} placeholder="username" />
1096 1622 </div>
1097 1623 </div>
1098 1624  
1099   - <div className="space-y-2">
1100   - <Label>Password</Label>
1101   - <Input type="password" placeholder="Enter password" autoComplete="new-password" className="w-full" />
1102   - </div>
1103   -
1104   - <div className="space-y-2">
1105   - <Label>Email Address</Label>
1106   - <Input type="email" placeholder="john@example.com" />
  1625 + {!isEdit && (
  1626 + <div className="space-y-2">
  1627 + <Label>Password *</Label>
  1628 + <Input
  1629 + type="password"
  1630 + value={password}
  1631 + onChange={(e) => setPassword(e.target.value)}
  1632 + placeholder="Enter password"
  1633 + autoComplete="new-password"
  1634 + className="w-full"
  1635 + />
  1636 + </div>
  1637 + )}
  1638 +
  1639 + {isEdit && (
  1640 + <div className="space-y-2">
  1641 + <Label>Password (Optional)</Label>
  1642 + <Input
  1643 + type="password"
  1644 + value={password}
  1645 + onChange={(e) => setPassword(e.target.value)}
  1646 + placeholder="Enter new password (optional)"
  1647 + autoComplete="new-password"
  1648 + className="w-full"
  1649 + />
  1650 + </div>
  1651 + )}
  1652 +
  1653 + <div className="grid grid-cols-2 gap-4">
  1654 + <div className="space-y-2">
  1655 + <Label>Email</Label>
  1656 + <Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="john@example.com" />
  1657 + </div>
  1658 + <div className="space-y-2">
  1659 + <Label>Phone</Label>
  1660 + <Input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+1 (555) 000-0000" />
  1661 + </div>
1107 1662 </div>
1108 1663  
1109 1664 <div className="space-y-2">
1110   - <Label>Phone Number</Label>
1111   - <Input type="tel" placeholder="+1 (555) 000-0000" />
  1665 + <Label>Role *</Label>
  1666 + <Select
  1667 + value={roleId ? roleId : ""}
  1668 + onValueChange={(v) => {
  1669 + const newRoleId = (v && v.trim()) ? v.trim() : "";
  1670 + console.log("[MemberDialog] Role changed", { old: roleId, new: newRoleId, v });
  1671 + setRoleId(newRoleId);
  1672 + }}
  1673 + disabled={loadingRoles}
  1674 + >
  1675 + <SelectTrigger className="h-10 rounded-md border border-gray-200 bg-white">
  1676 + <SelectValue placeholder={loadingRoles ? "Loading roles..." : "Select role"} />
  1677 + </SelectTrigger>
  1678 + <SelectContent>
  1679 + {roleOptions.map((r) => (
  1680 + <SelectItem key={r.id} value={r.id}>
  1681 + {r.roleName ?? r.roleCode ?? r.id}
  1682 + </SelectItem>
  1683 + ))}
  1684 + </SelectContent>
  1685 + </Select>
1112 1686 </div>
1113 1687  
1114 1688 <div className="space-y-2">
1115   - <ScrollArea className="h-[120px] w-full border rounded-md p-2">
  1689 + <Label>Locations *</Label>
  1690 + <div className="flex items-center justify-between gap-2">
  1691 + <Input
  1692 + value={locationKeyword}
  1693 + onChange={(e) => setLocationKeyword(e.target.value)}
  1694 + placeholder="Search locations"
  1695 + className="h-9"
  1696 + />
  1697 + <div className="text-xs text-gray-500 shrink-0">{selectedLocationIds.size} selected</div>
  1698 + </div>
  1699 + <ScrollArea className="h-[180px] w-full border rounded-md p-2">
1116 1700 <div className="space-y-2">
1117   - {MOCK_LOCATIONS.map(loc => (
1118   - <div key={loc.id} className="flex items-center space-x-2">
1119   - <Checkbox id={`loc-${loc.id}`} />
1120   - <label htmlFor={`loc-${loc.id}`} className="text-sm cursor-pointer w-full hover:bg-gray-50 p-1 rounded">
1121   - {loc.name}
1122   - </label>
1123   - </div>
1124   - ))}
  1701 + {loadingLocations ? (
  1702 + <div className="text-sm text-gray-500 py-2">Loading...</div>
  1703 + ) : (
  1704 + filteredLocations.map((l) => (
  1705 + <div key={l.id} className="flex items-center space-x-2">
  1706 + <Checkbox
  1707 + id={`loc-${l.id}`}
  1708 + checked={selectedLocationIds.has(l.id)}
  1709 + onCheckedChange={(v) => toggleLocation(l.id, !!v)}
  1710 + />
  1711 + <label htmlFor={`loc-${l.id}`} className="text-sm cursor-pointer w-full hover:bg-gray-50 p-1 rounded">
  1712 + {locationLabel(l)}
  1713 + </label>
  1714 + </div>
  1715 + ))
  1716 + )}
1125 1717 </div>
1126 1718 </ScrollArea>
1127 1719 <p className="text-xs text-gray-500">* Users must be assigned to at least one location.</p>
1128 1720 </div>
1129 1721  
1130   - <div className="flex items-center gap-2">
1131   - <Switch id="member-status" defaultChecked />
1132   - <Label htmlFor="member-status">Active Account</Label>
  1722 + <div className="flex items-center gap-2 pt-2">
  1723 + <Switch id="member-status" checked={state} onCheckedChange={setState} />
  1724 + <Label htmlFor="member-status">{state ? "Active" : "Inactive"}</Label>
1133 1725 </div>
1134 1726 </div>
  1727 +
1135 1728 <DialogFooter>
1136   - <Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
1137   - <Button onClick={() => onOpenChange(false)} className="bg-blue-600 text-white hover:bg-blue-700">Create User</Button>
  1729 + <Button variant="outline" onClick={() => onOpenChange(false)}>
  1730 + Cancel
  1731 + </Button>
  1732 + <Button
  1733 + disabled={submitting || !canSubmit}
  1734 + onClick={(e) => {
  1735 + e.preventDefault();
  1736 + e.stopPropagation();
  1737 + submit(e);
  1738 + }}
  1739 + className="bg-blue-600 text-white hover:bg-blue-700"
  1740 + >
  1741 + {submitting ? "Saving..." : isEdit ? "Save" : "Create"}
  1742 + </Button>
  1743 + </DialogFooter>
  1744 + </DialogContent>
  1745 + </Dialog>
  1746 + );
  1747 +}
  1748 +
  1749 +function DeleteMemberDialog({
  1750 + open,
  1751 + member,
  1752 + onOpenChange,
  1753 + onDeleted,
  1754 +}: {
  1755 + open: boolean;
  1756 + member: TeamMemberDto | null;
  1757 + onOpenChange: (open: boolean) => void;
  1758 + onDeleted: () => void;
  1759 +}) {
  1760 + const [submitting, setSubmitting] = useState(false);
  1761 +
  1762 + const name = useMemo(() => {
  1763 + const n = (member?.fullName ?? "").trim();
  1764 + const code = (member?.userName ?? "").trim();
  1765 + return n || code || "this user";
  1766 + }, [member?.fullName, member?.userName]);
  1767 +
  1768 + const submit = async () => {
  1769 + console.log("submit", member);
  1770 + if (!member?.id) return;
  1771 + setSubmitting(true);
  1772 + try {
  1773 + await deleteTeamMember(member.id);
  1774 + toast.success("User deleted.", { description: "The user has been removed successfully." });
  1775 + onOpenChange(false);
  1776 + onDeleted();
  1777 + } catch (e: any) {
  1778 + toast.error("Failed to delete user.", {
  1779 + description: e?.message ? String(e.message) : "Please try again.",
  1780 + });
  1781 + } finally {
  1782 + setSubmitting(false);
  1783 + }
  1784 + };
  1785 +
  1786 + return (
  1787 + <Dialog open={open} onOpenChange={onOpenChange}>
  1788 + <DialogContent className="sm:max-w-none" style={{ width: "30%" }}>
  1789 + <DialogHeader>
  1790 + <DialogTitle>Delete User</DialogTitle>
  1791 + <DialogDescription>This action cannot be undone.</DialogDescription>
  1792 + </DialogHeader>
  1793 +
  1794 + <div className="text-sm text-gray-700">
  1795 + Are you sure you want to delete <span className="font-medium">{name}</span>?
  1796 + </div>
  1797 +
  1798 + <DialogFooter className="flex-row flex-wrap justify-end">
  1799 + <Button variant="outline" className="min-w-24" onClick={() => onOpenChange(false)}>
  1800 + Cancel
  1801 + </Button>
  1802 + <Button
  1803 + variant="destructive"
  1804 + className="min-w-24"
  1805 + disabled={submitting}
  1806 + onClick={submit}
  1807 + >
  1808 + {submitting ? "Deleting..." : "Delete"}
  1809 + </Button>
1138 1810 </DialogFooter>
1139 1811 </DialogContent>
1140 1812 </Dialog>
... ...
美国版/Food Labeling Management Platform/src/components/ui/dialog.tsx
... ... @@ -30,12 +30,13 @@ function DialogClose({
30 30 return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
31 31 }
32 32  
33   -function DialogOverlay({
34   - className,
35   - ...props
36   -}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
  33 +const DialogOverlay = React.forwardRef<
  34 + React.ElementRef<typeof DialogPrimitive.Overlay>,
  35 + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
  36 +>(({ className, ...props }, ref) => {
37 37 return (
38 38 <DialogPrimitive.Overlay
  39 + ref={ref}
39 40 data-slot="dialog-overlay"
40 41 className={cn(
41 42 "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
... ... @@ -44,17 +45,18 @@ function DialogOverlay({
44 45 {...props}
45 46 />
46 47 );
47   -}
  48 +});
  49 +DialogOverlay.displayName = "DialogOverlay";
48 50  
49   -function DialogContent({
50   - className,
51   - children,
52   - ...props
53   -}: React.ComponentProps<typeof DialogPrimitive.Content>) {
  51 +const DialogContent = React.forwardRef<
  52 + React.ElementRef<typeof DialogPrimitive.Content>,
  53 + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
  54 +>(({ className, children, ...props }, ref) => {
54 55 return (
55 56 <DialogPortal data-slot="dialog-portal">
56 57 <DialogOverlay />
57 58 <DialogPrimitive.Content
  59 + ref={ref}
58 60 data-slot="dialog-content"
59 61 className={cn(
60 62 "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
... ... @@ -70,7 +72,8 @@ function DialogContent({
70 72 </DialogPrimitive.Content>
71 73 </DialogPortal>
72 74 );
73   -}
  75 +});
  76 +DialogContent.displayName = "DialogContent";
74 77  
75 78 function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
76 79 return (
... ...
美国版/Food Labeling Management Platform/src/services/rbacRoleService.ts
... ... @@ -38,6 +38,41 @@ export async function updateRbacRole(id: string, input: RbacRoleUpsertInput): Pr
38 38 });
39 39 }
40 40  
  41 +function extractMenuIdsFromRoleDetail(raw: unknown): string[] {
  42 + if (!raw || typeof raw !== "object") return [];
  43 + const r = raw as Record<string, unknown>;
  44 +
  45 + // 常见命名:menuIds / MenuIds
  46 + const direct =
  47 + Array.isArray(r.menuIds) ? r.menuIds :
  48 + Array.isArray((r as any).MenuIds) ? (r as any).MenuIds :
  49 + undefined;
  50 + if (direct) return (direct as unknown[]).map(String);
  51 +
  52 + // 包一层:{ data: { menuIds } }
  53 + const data = (r as any).data ?? (r as any).Data;
  54 + if (data && typeof data === "object") {
  55 + const dd = data as Record<string, unknown>;
  56 + const fromData = Array.isArray(dd.menuIds) ? dd.menuIds : Array.isArray(dd.MenuIds) ? dd.MenuIds : undefined;
  57 + if (fromData) return (fromData as unknown[]).map(String);
  58 + }
  59 +
  60 + // 可能是 roleMenus: [{ menuId: '...' }] 之类
  61 + const roleMenus = Array.isArray((r as any).roleMenus) ? (r as any).roleMenus : Array.isArray((r as any).RoleMenus) ? (r as any).RoleMenus : undefined;
  62 + if (Array.isArray(roleMenus)) {
  63 + const ids: string[] = [];
  64 + for (const x of roleMenus) {
  65 + if (!x || typeof x !== "object") continue;
  66 + const rr = x as Record<string, unknown>;
  67 + const mid = rr.menuId ?? rr.MenuId ?? rr.id ?? rr.Id;
  68 + if (mid) ids.push(String(mid));
  69 + }
  70 + return ids;
  71 + }
  72 +
  73 + return [];
  74 +}
  75 +
41 76 /**
42 77 * Swagger(常见约定):DELETE /api/app/rbac-role,Body 为 ID 数组
43 78 */
... ... @@ -54,3 +89,16 @@ export async function deleteRbacRole(id: string): Promise&lt;void&gt; {
54 89 await deleteRbacRoles([id]);
55 90 }
56 91  
  92 +/**
  93 + * Swagger: GET /api/app/rbac-role/{id}
  94 + * 用于“查看当前角色菜单权限”,尽量从返回对象里提取 menuIds 字段。
  95 + */
  96 +export async function getRbacRoleMenuIds(roleId: string, signal?: AbortSignal): Promise<string[]> {
  97 + const raw = await api.requestJson<unknown>({
  98 + path: `${PATH}/${encodeURIComponent(roleId)}`,
  99 + method: "GET",
  100 + signal,
  101 + });
  102 + return extractMenuIdsFromRoleDetail(raw);
  103 +}
  104 +
... ...
美国版/Food Labeling Management Platform/src/services/teamMemberService.ts 0 → 100644
  1 +import { createApiClient } from "../lib/apiClient";
  2 +import type {
  3 + PagedResultDto,
  4 + TeamMemberCreateInput,
  5 + TeamMemberDto,
  6 + TeamMemberGetListInput,
  7 + TeamMemberUpdateInput,
  8 +} from "../types/teamMember";
  9 +
  10 +const api = createApiClient({
  11 + getToken: () => {
  12 + try {
  13 + return localStorage.getItem("access_token") ?? localStorage.getItem("token") ?? null;
  14 + } catch {
  15 + return null;
  16 + }
  17 + },
  18 +});
  19 +
  20 +const PATH = "/team-member";
  21 +
  22 +function toStringArray(v: unknown): string[] {
  23 + if (Array.isArray(v)) return v.map((x) => String(x));
  24 + return [];
  25 +}
  26 +
  27 +function toIdArray(v: unknown): string[] {
  28 + if (!Array.isArray(v)) return [];
  29 + const out: string[] = [];
  30 + for (const x of v) {
  31 + if (x === null || x === undefined) continue;
  32 + if (typeof x === "string" || typeof x === "number") {
  33 + out.push(String(x));
  34 + continue;
  35 + }
  36 + if (typeof x === "object") {
  37 + const o = x as Record<string, unknown>;
  38 + const id = o.id ?? o.Id ?? o.locationId ?? o.LocationId ?? o.location_id ?? o.locationID;
  39 + if (id !== null && id !== undefined) out.push(String(id));
  40 + }
  41 + }
  42 + return out;
  43 +}
  44 +
  45 +function toLocationLabels(v: unknown): string[] {
  46 + if (!Array.isArray(v)) return [];
  47 + const out: string[] = [];
  48 + for (const x of v) {
  49 + if (x === null || x === undefined) continue;
  50 + if (typeof x === "string" || typeof x === "number") {
  51 + out.push(String(x));
  52 + continue;
  53 + }
  54 + if (typeof x === "object") {
  55 + const o = x as Record<string, unknown>;
  56 + const code =
  57 + (o.locationCode ?? o.LocationCode ?? o.code ?? o.Code ?? o.location_code ?? o.locationCodeId) as unknown;
  58 + const name =
  59 + (o.locationName ?? o.LocationName ?? o.name ?? o.Name ?? o.location_name) as unknown;
  60 + const id = (o.id ?? o.Id ?? o.locationId ?? o.LocationId) as unknown;
  61 +
  62 + const codeS = code === null || code === undefined ? "" : String(code).trim();
  63 + const nameS = name === null || name === undefined ? "" : String(name).trim();
  64 + const idS = id === null || id === undefined ? "" : String(id).trim();
  65 +
  66 + if (codeS && nameS) out.push(`${codeS} - ${nameS}`);
  67 + else if (nameS) out.push(nameS);
  68 + else if (codeS) out.push(codeS);
  69 + else if (idS) out.push(idS);
  70 + }
  71 + }
  72 + return out;
  73 +}
  74 +
  75 +function normalizeTeamMemberDto(row: unknown): TeamMemberDto {
  76 + if (!row || typeof row !== "object") return { id: "" };
  77 + const r = row as Record<string, unknown>;
  78 +
  79 + // ABP 常见 camelCase + 少量 PascalCase 兼容
  80 + const id = String(
  81 + r.id ?? r.Id ?? r.userId ?? r.UserId ?? r.user_id ?? r.UserID ?? r.memberId ?? r.MemberId ?? "",
  82 + );
  83 +
  84 + // name fields: fullName or name
  85 + const fullName = (r.fullName ?? r.FullName ?? r.name ?? r.Name) as string | null | undefined;
  86 + const userName = (r.userName ?? r.UserName ?? r.username ?? r.UserName) as string | null | undefined;
  87 + const email = (r.email ?? r.Email) as string | null | undefined;
  88 + const phone = (r.phone ?? r.Phone) as string | null | undefined;
  89 +
  90 + // role 可能是扁平字段(roleId/roleName),也可能是嵌套对象(role: { id, roleName })
  91 + let roleId = (r.roleId ?? r.RoleId) as string | null | undefined;
  92 + let roleName = (r.roleName ?? r.RoleName ?? r.roleName ?? r.Role) as string | null | undefined;
  93 + const roleObj = (r.role ?? r.Role) as unknown;
  94 + if ((!roleId || !roleName) && roleObj && typeof roleObj === "object") {
  95 + const ro = roleObj as Record<string, unknown>;
  96 + roleId = (ro.id ?? ro.Id ?? ro.roleId ?? ro.RoleId ?? roleId) as string | null | undefined;
  97 + roleName =
  98 + (ro.roleName ?? ro.RoleName ?? ro.name ?? ro.Name ?? ro.role ?? ro.Role ?? roleName) as
  99 + | string
  100 + | null
  101 + | undefined;
  102 + }
  103 +
  104 + const stateRaw = r.state ?? r.State;
  105 + const state =
  106 + typeof stateRaw === "boolean" ? (stateRaw as boolean) : stateRaw === "true" ? true : stateRaw === "false" ? false : undefined;
  107 +
  108 + const rawLocationIds =
  109 + r.locationIds ?? r.LocationIds ?? r.assignedLocationIds ?? r.AssignedLocationIds ?? r.location_id_list ?? r.LocationIdList;
  110 + let locationIds = toIdArray(rawLocationIds);
  111 +
  112 + const rawLocations =
  113 + r.locations ?? r.Locations ?? r.assignedLocations ?? r.AssignedLocations ?? r.locationNames ?? r.LocationNames;
  114 + // locations 可能包含对象数组:{ id, locationCode, locationName } 或类似
  115 + let locations = toLocationLabels(rawLocations);
  116 +
  117 + // 如果 locations 返回的是对象数组但没有显式 locationIds,则从 locations 对象里抽 id
  118 + if (locationIds.length === 0 && Array.isArray(rawLocations)) {
  119 + const inferredIds: string[] = [];
  120 + for (const x of rawLocations) {
  121 + if (typeof x !== "object" || !x) continue;
  122 + const o = x as Record<string, unknown>;
  123 + const id = o.id ?? o.Id ?? o.locationId ?? o.LocationId;
  124 + if (id !== null && id !== undefined) inferredIds.push(String(id));
  125 + }
  126 + if (inferredIds.length) locationIds = inferredIds;
  127 + }
  128 +
  129 + // 有些接口可能只返回一个 locations,但我们仍尽量填充
  130 + return {
  131 + id,
  132 + fullName,
  133 + userName,
  134 + email,
  135 + phone,
  136 + roleId,
  137 + roleName,
  138 + locationIds,
  139 + locations,
  140 + state: state ?? (r.status ? (String(r.status).toLowerCase() === "active") : undefined),
  141 + };
  142 +}
  143 +
  144 +export async function getTeamMembers(
  145 + input: TeamMemberGetListInput,
  146 + signal?: AbortSignal,
  147 +): Promise<PagedResultDto<TeamMemberDto>> {
  148 + const raw = await api.requestJson<PagedResultDto<unknown>>({
  149 + path: PATH,
  150 + method: "GET",
  151 + query: {
  152 + SkipCount: input.skipCount,
  153 + MaxResultCount: input.maxResultCount,
  154 + Keyword: input.keyword,
  155 + },
  156 + signal,
  157 + });
  158 +
  159 + // requestJson 已做分页 shape 规范化,但这里做 DTO 规范
  160 + const items = (raw.items ?? []) as unknown[];
  161 + return {
  162 + totalCount: raw.totalCount ?? 0,
  163 + items: items.map(normalizeTeamMemberDto),
  164 + };
  165 +}
  166 +
  167 +export async function getTeamMemberById(id: string, signal?: AbortSignal): Promise<TeamMemberDto> {
  168 + const raw = await api.requestJson<unknown>({
  169 + path: `${PATH}/${encodeURIComponent(id)}`,
  170 + method: "GET",
  171 + signal,
  172 + });
  173 + return normalizeTeamMemberDto(raw);
  174 +}
  175 +
  176 +function toPhoneNumber(v: string | number | null | undefined): number | null {
  177 + if (v === null || v === undefined || v === "") return null;
  178 + const s = String(v).trim();
  179 + if (!s) return null;
  180 + const num = Number(s.replace(/\D/g, "")) || 0;
  181 + return num;
  182 +}
  183 +
  184 +function buildCreatePayload(input: TeamMemberCreateInput): Record<string, unknown> {
  185 + const phoneVal = input.phone != null && input.phone !== "" ? toPhoneNumber(String(input.phone)) : null;
  186 + return {
  187 + fullName: input.fullName,
  188 + userName: input.userName,
  189 + password: input.password,
  190 + email: input.email ?? null,
  191 + phone: phoneVal,
  192 + roleId: input.roleId,
  193 + // 兼容:后端可能叫 locationIds 或 locations
  194 + locationIds: input.locationIds,
  195 + locations: input.locationIds,
  196 + state: input.state,
  197 + };
  198 +}
  199 +
  200 +function buildUpdatePayload(input: TeamMemberUpdateInput): Record<string, unknown> {
  201 + const phoneVal = input.phone != null && input.phone !== "" ? toPhoneNumber(String(input.phone)) : null;
  202 + const payload: Record<string, unknown> = {
  203 + fullName: input.fullName,
  204 + userName: input.userName,
  205 + email: input.email ?? null,
  206 + phone: phoneVal,
  207 + roleId: input.roleId,
  208 + locationIds: input.locationIds,
  209 + locations: input.locationIds,
  210 + state: input.state,
  211 + };
  212 + if (input.password) payload.password = input.password;
  213 + return payload;
  214 +}
  215 +
  216 +export async function createTeamMember(input: TeamMemberCreateInput): Promise<TeamMemberDto> {
  217 + const raw = await api.requestJson<unknown>({
  218 + path: PATH,
  219 + method: "POST",
  220 + body: buildCreatePayload(input),
  221 + });
  222 + return normalizeTeamMemberDto(raw);
  223 +}
  224 +
  225 +export async function updateTeamMember(id: string, input: TeamMemberUpdateInput): Promise<TeamMemberDto> {
  226 + const raw = await api.requestJson<unknown>({
  227 + path: `${PATH}/${encodeURIComponent(id)}`,
  228 + method: "PUT",
  229 + body: buildUpdatePayload(input),
  230 + });
  231 + return normalizeTeamMemberDto(raw);
  232 +}
  233 +
  234 +export async function deleteTeamMember(id: string): Promise<void> {
  235 + await api.requestJson<unknown>({
  236 + path: `${PATH}/${encodeURIComponent(id)}`,
  237 + method: "DELETE",
  238 + });
  239 +}
  240 +
... ...
美国版/Food Labeling Management Platform/src/types/teamMember.ts 0 → 100644
  1 +export type TeamMemberDto = {
  2 + id: string;
  3 +
  4 + // Basic fields
  5 + fullName?: string | null;
  6 + userName?: string | null;
  7 + email?: string | null;
  8 + phone?: string | null;
  9 +
  10 + // Role (single)
  11 + roleId?: string | null;
  12 + roleName?: string | null;
  13 +
  14 + // Locations (multi)
  15 + locationIds?: string[];
  16 + locations?: string[];
  17 +
  18 + state?: boolean | null;
  19 +};
  20 +
  21 +export type PagedResultDto<T> = {
  22 + totalCount: number;
  23 + items: T[];
  24 +};
  25 +
  26 +export type TeamMemberGetListInput = {
  27 + skipCount: number; // pageIndex (1-based)
  28 + maxResultCount: number; // pageSize
  29 + keyword?: string;
  30 +};
  31 +
  32 +export type TeamMemberCreateInput = {
  33 + fullName: string;
  34 + userName: string;
  35 + password: string;
  36 + email?: string | null;
  37 + phone?: string | null;
  38 + roleId: string;
  39 + locationIds: string[];
  40 + state: boolean;
  41 +};
  42 +
  43 +export type TeamMemberUpdateInput = {
  44 + fullName: string;
  45 + userName: string;
  46 + password?: string | null;
  47 + email?: string | null;
  48 + phone?: string | null;
  49 + roleId: string;
  50 + locationIds: string[];
  51 + state: boolean;
  52 +};
  53 +
... ...