浏览代码

feat: 页面优化

BaiLuoYan 2 天之前
父节点
当前提交
62bc8779f2

+ 108 - 0
src/pages/Admin/Dept/components/UserTable.tsx

@@ -0,0 +1,108 @@
+import { BindRolesDrawer } from '@/pages/Admin/_shared/BindRolesDrawer';
+import { UserPermDrawer } from '@/pages/Admin/_shared/UserPermDrawer';
+import { useUserProductRoles } from '@/pages/Admin/_shared/useUserProductRoles';
+import { Spin, Table, Tag } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { useMemo, useState } from 'react';
+
+interface DeptUserTableProps {
+  users: API.UserItem[];
+}
+
+export const DeptUserTable = ({ users }: DeptUserTableProps) => {
+  const [rolesDrawer, setRolesDrawer] = useState<{ open: boolean; userId: number }>({
+    open: false,
+    userId: 0,
+  });
+  const [permDrawer, setPermDrawer] = useState<{ open: boolean; userId: number }>({
+    open: false,
+    userId: 0,
+  });
+  const [refreshKey, setRefreshKey] = useState(0);
+
+  const userIds = useMemo(() => users.map((u) => u.id), [users]);
+  const { userProductInfo, loading: infoLoading } = useUserProductRoles(userIds, refreshKey);
+
+  const columns: ColumnsType<API.UserItem> = [
+    { title: '用户名', dataIndex: 'username', width: 200 },
+    { title: '昵称', dataIndex: 'nickname', width: 200 },
+    {
+      title: '产品角色',
+      key: 'productRoles',
+      render: (_, r) => {
+        const info = userProductInfo.get(r.id) ?? [];
+        if (info.length === 0) return <span className="text-gray-400 text-xs">无</span>;
+        return (
+          <div className="flex flex-col gap-2">
+            {info.map((p) => (
+              <div
+                key={p.productName}
+                className="flex flex-row gap-1 rounded border border-dashed border-gray-400 bg-gray-50 px-2 py-2"
+              >
+                <Tag color="blue" className="mr-0 self-center">
+                  {p.productName}
+                </Tag>
+                <Tag
+                  color={p.memberType === 'ADMIN' ? 'red' : 'green'}
+                  className="mr-0 self-center"
+                >
+                  {p.memberType === 'ADMIN' ? '管理员' : '开发者'}
+                </Tag>
+                <div className="flex flex-row flex-wrap gap-1 items-center">
+                  {p.roleNames.length > 0 ? (
+                    p.roleNames.map((name) => (
+                      <Tag key={name} className="mr-0">
+                        {name}
+                      </Tag>
+                    ))
+                  ) : (
+                    <span className="text-gray-400 text-xs whitespace-nowrap">无角色</span>
+                  )}
+                </div>
+              </div>
+            ))}
+          </div>
+        );
+      },
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 160,
+      render: (_, r) => (
+        <span className="flex gap-3">
+          <a onClick={() => setRolesDrawer({ open: true, userId: r.id })}>分配角色</a>
+          <a onClick={() => setPermDrawer({ open: true, userId: r.id })}>设置权限</a>
+        </span>
+      ),
+    },
+  ];
+
+  return (
+    <Spin spinning={infoLoading}>
+      <Table<API.UserItem>
+        columns={columns}
+        dataSource={users}
+        rowKey="id"
+        pagination={{
+          defaultPageSize: 20,
+          pageSizeOptions: [10, 20, 50, 100],
+          showSizeChanger: true,
+          showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条/总共 ${total} 条`,
+        }}
+        size="small"
+        scroll={{ x: 900 }}
+        tableLayout="fixed"
+      />
+      <BindRolesDrawer
+        {...rolesDrawer}
+        onClose={() => setRolesDrawer((p) => ({ ...p, open: false }))}
+        onSuccess={() => setRefreshKey((k) => k + 1)}
+      />
+      <UserPermDrawer
+        {...permDrawer}
+        onClose={() => setPermDrawer((p) => ({ ...p, open: false }))}
+      />
+    </Spin>
+  );
+};

+ 51 - 0
src/pages/Admin/Dept/hooks/useDeptPage.ts

@@ -0,0 +1,51 @@
+import { fetchDeptTree } from '@/services/dept';
+import { fetchUserList } from '@/services/user';
+import { useEffect, useMemo, useState } from 'react';
+import { collectDeptIds, findDeptNode } from '../lib/deptUtils';
+
+export const useDeptPage = () => {
+  const [loading, setLoading] = useState(false);
+  const [treeData, setTreeData] = useState<API.DeptItem[]>([]);
+  const [allUsers, setAllUsers] = useState<API.UserItem[]>([]);
+  const [selectedDeptId, setSelectedDeptId] = useState<number | undefined>();
+
+  const loadData = async () => {
+    setLoading(true);
+    try {
+      const [treeRes, userRes] = await Promise.all([
+        fetchDeptTree(),
+        fetchUserList({ pageSize: 9999 }),
+      ]);
+      setTreeData(treeRes.data ?? []);
+      setAllUsers(userRes.data ?? []);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    loadData();
+  }, []);
+
+  const selectedDeptIds = useMemo(() => {
+    if (!selectedDeptId) return undefined;
+    const node = findDeptNode(treeData, selectedDeptId);
+    if (!node) return undefined;
+    return new Set(collectDeptIds(node));
+  }, [treeData, selectedDeptId]);
+
+  const filteredUsers = useMemo(() => {
+    if (!selectedDeptIds) return [];
+    return allUsers.filter((u) => selectedDeptIds.has(u.deptId));
+  }, [allUsers, selectedDeptIds]);
+
+  return {
+    loading,
+    treeData,
+    selectedDeptId,
+    setSelectedDeptId,
+    selectedDeptIds,
+    filteredUsers,
+    loadData,
+  };
+};

+ 40 - 26
src/pages/Admin/Dept/index.tsx

@@ -1,35 +1,30 @@
-import { fetchDeleteDept, fetchDeptTree } from '@/services/dept';
+import { fetchDeleteDept } from '@/services/dept';
 import { PageContainer } from '@ant-design/pro-components';
 import { useIntl } from '@umijs/max';
-import { Button, Popconfirm, Space, Spin, Tree, message } from 'antd';
+import { Button, Empty, Popconfirm, Space, Spin, Tree, message } from 'antd';
 import type { DataNode } from 'antd/es/tree';
-import { useEffect, useState } from 'react';
 import { DeptForm } from './components/Form';
+import { DeptUserTable } from './components/UserTable';
+import { useDeptPage } from './hooks/useDeptPage';
 
 export default function DeptPage() {
   const intl = useIntl();
-  const [loading, setLoading] = useState(false);
-  const [treeData, setTreeData] = useState<API.DeptItem[]>([]);
-
-  const loadTree = async () => {
-    setLoading(true);
-    try {
-      const res = await fetchDeptTree();
-      setTreeData(res.data ?? []);
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  useEffect(() => {
-    loadTree();
-  }, []);
+  const {
+    loading,
+    treeData,
+    selectedDeptId,
+    setSelectedDeptId,
+    selectedDeptIds,
+    filteredUsers,
+    loadData,
+  } = useDeptPage();
 
   const handleDelete = async (id: number) => {
     const res = await fetchDeleteDept({ id });
     if (res.success) {
       message.success('删除成功');
-      loadTree();
+      if (selectedDeptId === id) setSelectedDeptId(undefined);
+      loadData();
     }
   };
 
@@ -39,13 +34,13 @@ export default function DeptPage() {
       <DeptForm
         mode="edit"
         initialValues={node}
-        onSuccess={loadTree}
+        onSuccess={loadData}
         trigger={<a className="text-xs">编辑</a>}
       />
       <DeptForm
         mode="add"
         initialValues={{ parentId: node.id }}
-        onSuccess={loadTree}
+        onSuccess={loadData}
         trigger={<a className="text-xs">新建子部门</a>}
       />
       <Popconfirm title="确认删除?" onConfirm={() => handleDelete(node.id)}>
@@ -67,15 +62,34 @@ export default function DeptPage() {
         <DeptForm
           mode="add"
           initialValues={{ parentId: 0 }}
-          onSuccess={loadTree}
+          onSuccess={loadData}
           trigger={
             <Button type="primary">{intl.formatMessage({ id: 'admin.dept.createRoot' })}</Button>
           }
         />
       </div>
-      <Spin spinning={loading}>
-        <Tree treeData={renderNodes(treeData)} defaultExpandAll showLine />
-      </Spin>
+      <div className="flex gap-4">
+        <div className="w-80 shrink-0 border border-gray-100 rounded p-3 bg-white">
+          <Spin spinning={loading}>
+            {treeData.length > 0 && (
+              <Tree
+                treeData={renderNodes(treeData)}
+                defaultExpandAll
+                showLine
+                selectedKeys={selectedDeptId ? [selectedDeptId] : []}
+                onSelect={(keys) => setSelectedDeptId(keys[0] as number | undefined)}
+              />
+            )}
+          </Spin>
+        </div>
+        <div className="flex-1 min-w-0">
+          {selectedDeptIds ? (
+            <DeptUserTable users={filteredUsers} />
+          ) : (
+            <Empty description="请在左侧选择部门" className="py-16" />
+          )}
+        </div>
+      </div>
     </PageContainer>
   );
 }

+ 18 - 0
src/pages/Admin/Dept/lib/deptUtils.ts

@@ -0,0 +1,18 @@
+export const collectDeptIds = (node: API.DeptItem): number[] => {
+  const ids = [node.id];
+  if (node.children) {
+    node.children.forEach((child) => ids.push(...collectDeptIds(child)));
+  }
+  return ids;
+};
+
+export const findDeptNode = (items: API.DeptItem[], id: number): API.DeptItem | undefined => {
+  for (const item of items) {
+    if (item.id === id) return item;
+    if (item.children) {
+      const found = findDeptNode(item.children, id);
+      if (found) return found;
+    }
+  }
+  return undefined;
+};

+ 12 - 2
src/pages/Admin/Product/Detail/tabs/MemberTab.tsx

@@ -39,16 +39,18 @@ export const MemberTab = ({ productCode }: MemberTabProps) => {
   }, []);
 
   const columns: ProColumns<API.MemberItem>[] = [
-    { title: '用户名', dataIndex: 'username' },
-    { title: '昵称', dataIndex: 'nickname' },
+    { title: '用户名', dataIndex: 'username', width: 200 },
+    { title: '昵称', dataIndex: 'nickname', width: 200 },
     {
       title: '成员类型',
       dataIndex: 'memberType',
+      width: 100,
       render: (_, r) => <Tag>{MEMBER_TYPE_LABELS[r.memberType] ?? r.memberType}</Tag>,
     },
     {
       title: '状态',
       dataIndex: 'status',
+      width: 80,
       render: (_, r) => (
         <Tag color={r.status === 1 ? 'success' : 'default'}>{r.status === 1 ? '启用' : '禁用'}</Tag>
       ),
@@ -56,6 +58,7 @@ export const MemberTab = ({ productCode }: MemberTabProps) => {
     {
       title: '操作',
       valueType: 'option',
+      width: 120,
       render: (_, r) => [
         <DrawerForm
           key="edit"
@@ -110,6 +113,13 @@ export const MemberTab = ({ productCode }: MemberTabProps) => {
       request={async (params, sorter, filter) => {
         return fetchMemberList({ ...params, productCode }, sorter, filter);
       }}
+      scroll={{ x: 700 }}
+      tableLayout="fixed"
+      pagination={{
+        defaultPageSize: 20,
+        pageSizeOptions: [10, 20, 50, 100],
+        showSizeChanger: true,
+      }}
       toolBarRender={() => [
         <DrawerForm
           key="add"

+ 15 - 3
src/pages/Admin/Product/Detail/tabs/RoleTab.tsx

@@ -24,7 +24,7 @@ const RoleForm = ({ mode, record, trigger, productCode, onSuccess }: RoleFormPro
     title={mode === 'edit' ? '编辑角色' : mode === 'copy' ? '复制角色' : '新建角色'}
     trigger={trigger}
     initialValues={
-      mode === 'copy' ? { ...record, id: undefined } : mode === 'edit' ? record : undefined
+      mode === 'copy' ? { ...record, id: undefined } : mode === 'edit' ? record : { permsLevel: 2 }
     }
     onFinish={async (values) => {
       if (mode === 'edit' && record?.id) {
@@ -52,7 +52,10 @@ const RoleForm = ({ mode, record, trigger, productCode, onSuccess }: RoleFormPro
       name="permsLevel"
       label="权限级别"
       rules={[{ required: true }]}
+      min={2}
+      max={999}
       fieldProps={{ precision: 0 }}
+      tooltip="数字越小权限越高,范围 2-999"
     />
     <ProFormText name="remark" label="备注" />
   </DrawerForm>
@@ -72,11 +75,12 @@ export const RoleTab = ({ productCode }: RoleTabProps) => {
   const reload = () => actionRef.current?.reload();
 
   const columns: ProColumns<API.RoleItem>[] = [
-    { title: '名称', dataIndex: 'name' },
-    { title: '权限级别', dataIndex: 'permsLevel' },
+    { title: '名称', dataIndex: 'name', width: 160 },
+    { title: '权限级别', dataIndex: 'permsLevel', width: 100 },
     {
       title: '状态',
       dataIndex: 'status',
+      width: 80,
       render: (_, r) => (
         <Tag color={r.status === 1 ? 'success' : 'default'}>{r.status === 1 ? '启用' : '禁用'}</Tag>
       ),
@@ -84,6 +88,7 @@ export const RoleTab = ({ productCode }: RoleTabProps) => {
     {
       title: '操作',
       valueType: 'option',
+      width: 160,
       render: (_, r) => [
         <RoleForm
           key="edit"
@@ -129,6 +134,13 @@ export const RoleTab = ({ productCode }: RoleTabProps) => {
         request={async (params, sorter, filter) => {
           return fetchRoleList({ ...params, productCode }, sorter, filter);
         }}
+        scroll={{ x: 500 }}
+        tableLayout="fixed"
+        pagination={{
+          defaultPageSize: 20,
+          pageSizeOptions: [10, 20, 50, 100],
+          showSizeChanger: true,
+        }}
         toolBarRender={() => [
           <RoleForm
             key="create"

+ 12 - 2
src/pages/Admin/Product/index.tsx

@@ -35,21 +35,24 @@ export default function ProductPage() {
     {
       title: '名称',
       dataIndex: 'name',
+      width: 160,
       render: (_, r) => <a onClick={() => navigate(`/admin/products/${r.id}`)}>{r.name}</a>,
     },
-    { title: 'Code', dataIndex: 'code', copyable: true },
+    { title: 'Code', dataIndex: 'code', width: 160, copyable: true },
     { title: 'App Key', dataIndex: 'appKey', copyable: true },
     {
       title: '状态',
       dataIndex: 'status',
+      width: 80,
       render: (_, r) => (
         <Tag color={r.status === 1 ? 'success' : 'default'}>{r.status === 1 ? '启用' : '禁用'}</Tag>
       ),
     },
-    { title: '创建时间', dataIndex: 'createTime', valueType: 'dateTime' },
+    { title: '创建时间', dataIndex: 'createTime', valueType: 'dateTime', width: 180 },
     {
       title: '操作',
       valueType: 'option',
+      width: 120,
       render: (_, r) => [
         <ProductForm
           key="edit"
@@ -77,6 +80,13 @@ export default function ProductPage() {
         rowKey="id"
         search={false}
         request={fetchProductList}
+        scroll={{ x: 900 }}
+        tableLayout="fixed"
+        pagination={{
+          defaultPageSize: 20,
+          pageSizeOptions: [10, 20, 50, 100],
+          showSizeChanger: true,
+        }}
         toolBarRender={() => [
           <ProductForm
             key="create"

+ 0 - 71
src/pages/Admin/User/components/BindRolesDrawer.tsx

@@ -1,71 +0,0 @@
-import { fetchRoleList } from '@/services/role';
-import { fetchBindRoles, fetchUserDetail } from '@/services/user';
-import { Button, Checkbox, Drawer, Spin, message } from 'antd';
-import { useEffect, useState } from 'react';
-
-interface BindRolesDrawerProps {
-  userId: number;
-  productCode: string;
-  open: boolean;
-  onClose: () => void;
-}
-
-export const BindRolesDrawer = ({ userId, productCode, open, onClose }: BindRolesDrawerProps) => {
-  const [loading, setLoading] = useState(false);
-  const [saving, setSaving] = useState(false);
-  const [roles, setRoles] = useState<API.RoleItem[]>([]);
-  const [checkedIds, setCheckedIds] = useState<number[]>([]);
-
-  useEffect(() => {
-    if (!open) return;
-    setLoading(true);
-    Promise.all([fetchUserDetail({ id: userId }), fetchRoleList({ productCode, pageSize: 999 })])
-      .then(([userRes, roleRes]) => {
-        setRoles(roleRes.data ?? []);
-        setCheckedIds(userRes.data?.roleIds ?? []);
-      })
-      .finally(() => setLoading(false));
-  }, [open, userId, productCode]);
-
-  const handleSave = async () => {
-    setSaving(true);
-    try {
-      await fetchBindRoles({ userId, roleIds: checkedIds });
-      message.success('保存成功');
-      onClose();
-    } finally {
-      setSaving(false);
-    }
-  };
-
-  const toggle = (id: number) =>
-    setCheckedIds((prev) => (prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]));
-
-  return (
-    <Drawer
-      title="分配角色"
-      open={open}
-      onClose={onClose}
-      width={400}
-      extra={
-        <Button type="primary" loading={saving} onClick={handleSave}>
-          保存
-        </Button>
-      }
-    >
-      <Spin spinning={loading}>
-        <div className="flex flex-col gap-2">
-          {roles.map((r) => (
-            <Checkbox key={r.id} checked={checkedIds.includes(r.id)} onChange={() => toggle(r.id)}>
-              <span>{r.name}</span>
-              <span className="text-xs text-gray-400 ml-2">级别 {r.permsLevel}</span>
-            </Checkbox>
-          ))}
-          {roles.length === 0 && !loading && (
-            <span className="text-gray-400 text-sm">该产品暂无角色</span>
-          )}
-        </div>
-      </Spin>
-    </Drawer>
-  );
-};

+ 0 - 97
src/pages/Admin/User/components/UserPermDrawer.tsx

@@ -1,97 +0,0 @@
-import { PermTree } from '@/pages/Admin/_shared/PermTree';
-import { calcUserPermDelta } from '@/pages/Admin/_shared/PermTree/lib/permUtils';
-import { fetchPermList } from '@/services/perm';
-import { fetchRoleDetail } from '@/services/role';
-import { fetchGetUserPerms, fetchSetUserPerms, fetchUserDetail } from '@/services/user';
-import { Button, Drawer, Spin, message } from 'antd';
-import { useEffect, useState } from 'react';
-
-interface UserPermDrawerProps {
-  userId: number;
-  productCode: string;
-  open: boolean;
-  onClose: () => void;
-}
-
-export const UserPermDrawer = ({ userId, productCode, open, onClose }: UserPermDrawerProps) => {
-  const [loading, setLoading] = useState(false);
-  const [saving, setSaving] = useState(false);
-  const [allPerms, setAllPerms] = useState<API.PermItem[]>([]);
-  const [checkedIds, setCheckedIds] = useState<number[]>([]);
-  const [roleInheritedIds, setRoleInheritedIds] = useState<number[]>([]);
-
-  useEffect(() => {
-    if (!open) return;
-    setLoading(true);
-
-    const load = async () => {
-      const [userRes, permRes, userPermsRes] = await Promise.all([
-        fetchUserDetail({ id: userId }),
-        fetchPermList({ productCode, pageSize: 9999 }),
-        fetchGetUserPerms({ userId }),
-      ]);
-      const perms = permRes.data ?? [];
-      setAllPerms(perms);
-
-      // 用 GetUserPerms 返回的覆盖项重建 checkedIds:
-      // ALLOW 项 = 用户独有授权,加入选中; DENY 项 = 用户主动撤销,不加入。
-      // 角色继承的权限直接从 roleDetails 读出,最终 checkedIds = roleInherited ∪ ALLOW - DENY。
-      const idToId = new Map(perms.map((p: API.PermItem) => [p.id, p.id]));
-      const userPerms = userPermsRes.data?.perms ?? [];
-      const allowIds = new Set(
-        userPerms
-          .filter((p) => p.effect === 'ALLOW')
-          .map((p) => p.permId)
-          .filter((id) => idToId.has(id)),
-      );
-      const denyIds = new Set(userPerms.filter((p) => p.effect === 'DENY').map((p) => p.permId));
-
-      const roleIds = userRes.data?.roleIds ?? [];
-      const roleDetails = await Promise.all(roleIds.map((id) => fetchRoleDetail({ id })));
-      const inherited = new Set<number>();
-      roleDetails.forEach((r) => (r.data?.permIds ?? []).forEach((id) => inherited.add(id)));
-      setRoleInheritedIds([...inherited]);
-
-      // 有效选中 = 角色继承 + ALLOW - DENY
-      const effective = [...inherited, ...allowIds].filter((id) => !denyIds.has(id));
-      setCheckedIds([...new Set(effective)]);
-    };
-
-    load().finally(() => setLoading(false));
-  }, [open, userId, productCode]);
-
-  const handleSave = async () => {
-    setSaving(true);
-    try {
-      const perms = calcUserPermDelta(checkedIds, roleInheritedIds);
-      await fetchSetUserPerms({ userId, perms });
-      message.success('保存成功');
-      onClose();
-    } finally {
-      setSaving(false);
-    }
-  };
-
-  return (
-    <Drawer
-      title="设置权限"
-      open={open}
-      onClose={onClose}
-      width={560}
-      extra={
-        <Button type="primary" loading={saving} onClick={handleSave}>
-          保存
-        </Button>
-      }
-    >
-      <Spin spinning={loading}>
-        <PermTree
-          mode="edit"
-          allPerms={allPerms}
-          checkedPermIds={checkedIds}
-          onChange={setCheckedIds}
-        />
-      </Spin>
-    </Drawer>
-  );
-};

+ 99 - 74
src/pages/Admin/User/index.tsx

@@ -1,18 +1,17 @@
-import { fetchProductList } from '@/services/product';
+import { BindRolesDrawer } from '@/pages/Admin/_shared/BindRolesDrawer';
+import { UserPermDrawer } from '@/pages/Admin/_shared/UserPermDrawer';
+import { useUserProductRoles } from '@/pages/Admin/_shared/useUserProductRoles';
 import { fetchUpdateUserStatus, fetchUserList } from '@/services/user';
 import { ActionType, PageContainer, ProColumns, ProTable } from '@ant-design/pro-components';
 import { useIntl } from '@umijs/max';
-import { Button, Popconfirm, Select, Tag, message } from 'antd';
-import { useEffect, useRef, useState } from 'react';
-import { BindRolesDrawer } from './components/BindRolesDrawer';
+import { Button, Popconfirm, Tag, message } from 'antd';
+import { useMemo, useRef, useState } from 'react';
 import { UserForm } from './components/Form';
-import { UserPermDrawer } from './components/UserPermDrawer';
 
 export default function UserPage() {
   const intl = useIntl();
   const actionRef = useRef<ActionType>();
-  const [products, setProducts] = useState<API.ProductItem[]>([]);
-  const [productCode, setProductCode] = useState<string | undefined>();
+  const [pageUsers, setPageUsers] = useState<API.UserItem[]>([]);
   const [rolesDrawer, setRolesDrawer] = useState<{ open: boolean; userId: number }>({
     open: false,
     userId: 0,
@@ -21,10 +20,10 @@ export default function UserPage() {
     open: false,
     userId: 0,
   });
+  const [refreshKey, setRefreshKey] = useState(0);
 
-  useEffect(() => {
-    fetchProductList({ pageSize: 999 }).then((res) => setProducts(res.data ?? []));
-  }, []);
+  const userIds = useMemo(() => pageUsers.map((u) => u.id), [pageUsers]);
+  const { userProductInfo } = useUserProductRoles(userIds, refreshKey);
 
   const handleToggleStatus = async (r: API.UserItem) => {
     await fetchUpdateUserStatus({ id: r.id, status: r.status === 1 ? 0 : 1 });
@@ -33,77 +32,108 @@ export default function UserPage() {
   };
 
   const columns: ProColumns<API.UserItem>[] = [
-    { title: '用户名', dataIndex: 'username' },
-    { title: '昵称', dataIndex: 'nickname' },
+    { title: '用户名', dataIndex: 'username', width: 200 },
+    { title: '昵称', dataIndex: 'nickname', width: 200 },
     {
       title: '状态',
       dataIndex: 'status',
+      width: 80,
       render: (_, r) => (
         <Tag color={r.status === 1 ? 'success' : 'default'}>{r.status === 1 ? '启用' : '禁用'}</Tag>
       ),
     },
-    { title: '创建时间', dataIndex: 'createTime', valueType: 'dateTime' },
+    {
+      title: '产品角色',
+      key: 'productRoles',
+      render: (_, r) => {
+        const info = userProductInfo.get(r.id) ?? [];
+        if (info.length === 0) return <span className="text-gray-400 text-xs">无</span>;
+        return (
+          <div className="flex flex-col gap-1">
+            {info.map((p) => (
+              <div
+                key={p.productName}
+                className="flex flex-row gap-1 rounded border border-dashed border-gray-400 bg-gray-50 px-2 py-2"
+              >
+                <Tag color="blue" className="mr-0 self-center">
+                  {p.productName}
+                </Tag>
+                <Tag
+                  color={p.memberType === 'ADMIN' ? 'red' : 'green'}
+                  className="mr-0 self-center"
+                >
+                  {p.memberType === 'ADMIN' ? '管理员' : '开发者'}
+                </Tag>
+                <div className="flex flex-row flex-wrap gap-1 items-center">
+                  {p.roleNames.length > 0 ? (
+                    p.roleNames.map((name) => (
+                      <Tag key={name} className="mr-0">
+                        {name}
+                      </Tag>
+                    ))
+                  ) : (
+                    <span className="text-gray-400 text-xs whitespace-nowrap">无角色</span>
+                  )}
+                </div>
+              </div>
+            ))}
+          </div>
+        );
+      },
+    },
+    { title: '创建时间', dataIndex: 'createTime', valueType: 'dateTime', width: 180 },
     {
       title: '操作',
       valueType: 'option',
-      render: (_, r) =>
-        [
-          <UserForm
-            key="edit"
-            mode="edit"
-            initialValues={r}
-            trigger={<a>编辑</a>}
-            onSuccess={() => actionRef.current?.reload()}
-          />,
-          <UserForm
-            key="copy"
-            mode="copy"
-            initialValues={r}
-            trigger={<a>复制</a>}
-            onSuccess={() => actionRef.current?.reload()}
-          />,
-          productCode ? (
-            <a key="roles" onClick={() => setRolesDrawer({ open: true, userId: r.id })}>
-              分配角色
-            </a>
-          ) : null,
-          productCode ? (
-            <a key="perms" onClick={() => setPermDrawer({ open: true, userId: r.id })}>
-              设置权限
-            </a>
-          ) : null,
-          <Popconfirm
-            key="status"
-            title={r.status === 1 ? '确认冻结?' : '确认解冻?'}
-            onConfirm={() => handleToggleStatus(r)}
-          >
-            <a className={r.status === 1 ? 'text-red-500' : ''}>
-              {r.status === 1 ? '冻结' : '解冻'}
-            </a>
-          </Popconfirm>,
-        ].filter(Boolean),
+      width: 260,
+      render: (_, r) => [
+        <UserForm
+          key="edit"
+          mode="edit"
+          initialValues={r}
+          trigger={<a>编辑</a>}
+          onSuccess={() => actionRef.current?.reload()}
+        />,
+        <UserForm
+          key="copy"
+          mode="copy"
+          initialValues={r}
+          trigger={<a>复制</a>}
+          onSuccess={() => actionRef.current?.reload()}
+        />,
+        <a key="roles" onClick={() => setRolesDrawer({ open: true, userId: r.id })}>
+          分配角色
+        </a>,
+        <a key="perms" onClick={() => setPermDrawer({ open: true, userId: r.id })}>
+          设置权限
+        </a>,
+        <Popconfirm
+          key="status"
+          title={r.status === 1 ? '确认冻结?' : '确认解冻?'}
+          onConfirm={() => handleToggleStatus(r)}
+        >
+          <a className={r.status === 1 ? 'text-red-500' : ''}>{r.status === 1 ? '冻结' : '解冻'}</a>
+        </Popconfirm>,
+      ],
     },
   ];
 
   return (
     <PageContainer title={intl.formatMessage({ id: 'admin.user.title' })}>
-      <div className="mb-3 flex items-center gap-2">
-        <span className="text-sm text-gray-500">当前产品:</span>
-        <Select
-          allowClear
-          placeholder="选择产品(用于角色/权限操作)"
-          options={products.map((p) => ({ label: p.name, value: p.code }))}
-          value={productCode}
-          onChange={setProductCode}
-          className="w-64"
-        />
-      </div>
       <ProTable<API.UserItem>
         actionRef={actionRef}
         columns={columns}
         rowKey="id"
         search={false}
         request={fetchUserList}
+        onDataSourceChange={setPageUsers}
+        scroll={{ x: 1220 }}
+        tableLayout="fixed"
+        pagination={{
+          defaultPageSize: 20,
+          pageSizeOptions: [10, 20, 50, 100],
+          showSizeChanger: true,
+        }}
         toolBarRender={() => [
           <UserForm
             key="create"
@@ -113,20 +143,15 @@ export default function UserPage() {
           />,
         ]}
       />
-      {productCode && (
-        <>
-          <BindRolesDrawer
-            {...rolesDrawer}
-            productCode={productCode}
-            onClose={() => setRolesDrawer((p) => ({ ...p, open: false }))}
-          />
-          <UserPermDrawer
-            {...permDrawer}
-            productCode={productCode}
-            onClose={() => setPermDrawer((p) => ({ ...p, open: false }))}
-          />
-        </>
-      )}
+      <BindRolesDrawer
+        {...rolesDrawer}
+        onClose={() => setRolesDrawer((p) => ({ ...p, open: false }))}
+        onSuccess={() => setRefreshKey((k) => k + 1)}
+      />
+      <UserPermDrawer
+        {...permDrawer}
+        onClose={() => setPermDrawer((p) => ({ ...p, open: false }))}
+      />
     </PageContainer>
   );
 }

+ 142 - 0
src/pages/Admin/_shared/BindRolesDrawer.tsx

@@ -0,0 +1,142 @@
+import { fetchMemberUserProducts } from '@/services/member';
+import { fetchRoleList } from '@/services/role';
+import { fetchBindRoles, fetchUserDetail } from '@/services/user';
+import { Button, Checkbox, Drawer, Modal, Select, Spin, message } from 'antd';
+import { useEffect, useState } from 'react';
+
+interface BindRolesDrawerProps {
+  userId: number;
+  open: boolean;
+  onClose: () => void;
+  onSuccess?: () => void;
+}
+
+export const BindRolesDrawer = ({ userId, open, onClose, onSuccess }: BindRolesDrawerProps) => {
+  const [products, setProducts] = useState<API.UserProductItem[]>([]);
+  const [productCode, setProductCode] = useState<string | undefined>();
+  const [loading, setLoading] = useState(false);
+  const [saving, setSaving] = useState(false);
+  const [roles, setRoles] = useState<API.RoleItem[]>([]);
+  const [checkedIds, setCheckedIds] = useState<number[]>([]);
+  const [initialCheckedIds, setInitialCheckedIds] = useState<number[]>([]);
+
+  useEffect(() => {
+    if (!open) return;
+    setProductCode(undefined);
+    setRoles([]);
+    setCheckedIds([]);
+    setInitialCheckedIds([]);
+    fetchMemberUserProducts({ userId }).then((res) => {
+      const list = res.data?.list ?? [];
+      setProducts(list);
+      if (list.length > 0) setProductCode(list[0].productCode);
+    });
+  }, [open]);
+
+  useEffect(() => {
+    if (!open || !productCode) return;
+    setLoading(true);
+    Promise.all([
+      fetchUserDetail({ id: userId, productCode }),
+      fetchRoleList({ productCode, pageSize: 999 }),
+    ])
+      .then(([userRes, roleRes]) => {
+        setRoles(roleRes.data ?? []);
+        const ids = userRes.data?.roleIds ?? [];
+        setCheckedIds(ids);
+        setInitialCheckedIds(ids);
+      })
+      .finally(() => setLoading(false));
+  }, [open, userId, productCode]);
+
+  const isDirty =
+    checkedIds.length !== initialCheckedIds.length ||
+    checkedIds.some((id) => !initialCheckedIds.includes(id));
+
+  const handleProductChange = (code: string) => {
+    if (productCode && isDirty) {
+      Modal.confirm({
+        title: '未保存的修改',
+        content: '当前产品的角色分配已修改但未保存,切换将丢失修改。确认切换?',
+        okText: '确认切换',
+        cancelText: '取消',
+        onOk: () => setProductCode(code),
+      });
+      return;
+    }
+    setProductCode(code);
+  };
+
+  const handleSave = async () => {
+    setSaving(true);
+    try {
+      const res = await fetchBindRoles({ userId, roleIds: checkedIds, productCode: productCode! });
+      if (!res.success) return;
+      message.success('保存成功');
+      onSuccess?.();
+      onClose();
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const toggle = (id: number) =>
+    setCheckedIds((prev) => (prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]));
+
+  return (
+    <Drawer
+      title="分配角色"
+      open={open}
+      onClose={onClose}
+      width={400}
+      footer={
+        <div className="flex justify-end gap-2">
+          <Button onClick={onClose}>取消</Button>
+          <Button
+            type="primary"
+            loading={saving}
+            onClick={handleSave}
+            disabled={!productCode || !isDirty}
+          >
+            确定
+          </Button>
+        </div>
+      }
+    >
+      {products.length === 0 ? (
+        <span className="text-gray-400 text-sm">用户不属于任何产品,无法分配角色</span>
+      ) : (
+        <>
+          <div className="mb-4">
+            <Select
+              placeholder="请选择产品"
+              options={products.map((p) => ({ label: p.productName, value: p.productCode }))}
+              value={productCode}
+              onChange={handleProductChange}
+              className="w-full"
+            />
+          </div>
+          {productCode && (
+            <Spin spinning={loading}>
+              <div className="flex flex-col gap-2">
+                {roles.map((r) => (
+                  <Checkbox
+                    key={r.id}
+                    checked={checkedIds.includes(r.id)}
+                    onChange={() => toggle(r.id)}
+                  >
+                    <span>{r.name}</span>
+                    <span className="text-xs text-gray-400 ml-2">级别 {r.permsLevel}</span>
+                  </Checkbox>
+                ))}
+                {roles.length === 0 && !loading && (
+                  <span className="text-gray-400 text-sm">该产品暂无角色</span>
+                )}
+              </div>
+            </Spin>
+          )}
+        </>
+      )}
+    </Drawer>
+  );
+};

+ 154 - 0
src/pages/Admin/_shared/UserPermDrawer.tsx

@@ -0,0 +1,154 @@
+import { PermTree } from '@/pages/Admin/_shared/PermTree';
+import { calcUserPermDelta } from '@/pages/Admin/_shared/PermTree/lib/permUtils';
+import { fetchMemberUserProducts } from '@/services/member';
+import { fetchPermList } from '@/services/perm';
+import { fetchRoleDetail } from '@/services/role';
+import { fetchGetUserPerms, fetchSetUserPerms, fetchUserDetail } from '@/services/user';
+import { Button, Drawer, Modal, Select, Spin, message } from 'antd';
+import { useEffect, useState } from 'react';
+
+interface UserPermDrawerProps {
+  userId: number;
+  open: boolean;
+  onClose: () => void;
+}
+
+export const UserPermDrawer = ({ userId, open, onClose }: UserPermDrawerProps) => {
+  const [products, setProducts] = useState<API.UserProductItem[]>([]);
+  const [productCode, setProductCode] = useState<string | undefined>();
+  const [loading, setLoading] = useState(false);
+  const [saving, setSaving] = useState(false);
+  const [allPerms, setAllPerms] = useState<API.PermItem[]>([]);
+  const [checkedIds, setCheckedIds] = useState<number[]>([]);
+  const [initialCheckedIds, setInitialCheckedIds] = useState<number[]>([]);
+  const [roleInheritedIds, setRoleInheritedIds] = useState<number[]>([]);
+
+  useEffect(() => {
+    if (!open) return;
+    setProductCode(undefined);
+    setAllPerms([]);
+    setCheckedIds([]);
+    setInitialCheckedIds([]);
+    setRoleInheritedIds([]);
+    fetchMemberUserProducts({ userId }).then((res) => {
+      const list = res.data?.list ?? [];
+      setProducts(list);
+      if (list.length > 0) setProductCode(list[0].productCode);
+    });
+  }, [open]);
+
+  useEffect(() => {
+    if (!open || !productCode) return;
+    setLoading(true);
+
+    const load = async () => {
+      const [userRes, permRes, userPermsRes] = await Promise.all([
+        fetchUserDetail({ id: userId }),
+        fetchPermList({ productCode, pageSize: 9999 }),
+        fetchGetUserPerms({ userId }),
+      ]);
+      const perms = permRes.data ?? [];
+      setAllPerms(perms);
+
+      const permIdSet = new Set(perms.map((p: API.PermItem) => p.id));
+      const userPerms = userPermsRes.data?.perms ?? [];
+      const allowIds = new Set(
+        userPerms
+          .filter((p) => p.effect === 'ALLOW')
+          .map((p) => p.permId)
+          .filter((id) => permIdSet.has(id)),
+      );
+      const denyIds = new Set(userPerms.filter((p) => p.effect === 'DENY').map((p) => p.permId));
+
+      const roleIds = userRes.data?.roleIds ?? [];
+      const roleDetails = await Promise.all(roleIds.map((id) => fetchRoleDetail({ id })));
+      const inherited = new Set<number>();
+      roleDetails.forEach((r) => (r.data?.permIds ?? []).forEach((id) => inherited.add(id)));
+      setRoleInheritedIds([...inherited]);
+
+      const effective = [...inherited, ...allowIds].filter((id) => !denyIds.has(id));
+      setCheckedIds([...new Set(effective)]);
+      setInitialCheckedIds([...new Set(effective)]);
+    };
+
+    load().finally(() => setLoading(false));
+  }, [open, userId, productCode]);
+
+  const isDirty =
+    checkedIds.length !== initialCheckedIds.length ||
+    checkedIds.some((id) => !initialCheckedIds.includes(id));
+
+  const handleProductChange = (code: string) => {
+    if (productCode && isDirty) {
+      Modal.confirm({
+        title: '未保存的修改',
+        content: '当前产品的权限设置已修改但未保存,切换将丢失修改。确认切换?',
+        okText: '确认切换',
+        cancelText: '取消',
+        onOk: () => setProductCode(code),
+      });
+      return;
+    }
+    setProductCode(code);
+  };
+
+  const handleSave = async () => {
+    setSaving(true);
+    try {
+      const perms = calcUserPermDelta(checkedIds, roleInheritedIds);
+      await fetchSetUserPerms({ userId, perms });
+      message.success('保存成功');
+      onClose();
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  return (
+    <Drawer
+      title="设置权限"
+      open={open}
+      onClose={onClose}
+      width={560}
+      footer={
+        <div className="flex justify-end gap-2">
+          <Button onClick={onClose}>取消</Button>
+          <Button
+            type="primary"
+            loading={saving}
+            onClick={handleSave}
+            disabled={!productCode || !isDirty}
+          >
+            确定
+          </Button>
+        </div>
+      }
+    >
+      {products.length === 0 ? (
+        <span className="text-gray-400 text-sm">用户不属于任何产品,无法设置权限</span>
+      ) : (
+        <>
+          <div className="mb-4">
+            <Select
+              placeholder="请选择产品"
+              options={products.map((p) => ({ label: p.productName, value: p.productCode }))}
+              value={productCode}
+              onChange={handleProductChange}
+              className="w-full"
+            />
+          </div>
+          {productCode && (
+            <Spin spinning={loading}>
+              <PermTree
+                mode="edit"
+                allPerms={allPerms}
+                checkedPermIds={checkedIds}
+                onChange={setCheckedIds}
+              />
+            </Spin>
+          )}
+        </>
+      )}
+    </Drawer>
+  );
+};

+ 117 - 0
src/pages/Admin/_shared/useUserProductRoles.ts

@@ -0,0 +1,117 @@
+import { fetchMemberList } from '@/services/member';
+import { fetchProductList } from '@/services/product';
+import { fetchRoleList } from '@/services/role';
+import { fetchUserDetail } from '@/services/user';
+import { useEffect, useMemo, useState } from 'react';
+
+export interface ProductRoleInfo {
+  productName: string;
+  productCode: string;
+  memberType?: string;
+  roleNames: string[];
+}
+
+export const useUserProductRoles = (userIds: number[], refreshKey?: number) => {
+  const [roleMap, setRoleMap] = useState<Map<number, { productName: string; roleName: string }>>(
+    new Map(),
+  );
+  const [memberMap, setMemberMap] = useState<
+    Map<number, { productName: string; memberType: string }[]>
+  >(new Map());
+  const [userRoleIds, setUserRoleIds] = useState<Map<number, number[]>>(new Map());
+  const [loading, setLoading] = useState(false);
+
+  useEffect(() => {
+    const load = async () => {
+      setLoading(true);
+      try {
+        const productRes = await fetchProductList({ pageSize: 999 });
+        const products = productRes.data ?? [];
+
+        const [memberResults, roleResults] = await Promise.all([
+          Promise.all(
+            products.map((p) => fetchMemberList({ productCode: p.code, pageSize: 9999 })),
+          ),
+          Promise.all(products.map((p) => fetchRoleList({ productCode: p.code, pageSize: 9999 }))),
+        ]);
+
+        const newRoleMap = new Map<number, { productName: string; roleName: string }>();
+        roleResults.forEach((res, i) => {
+          (res.data ?? []).forEach((role: API.RoleItem) => {
+            newRoleMap.set(role.id, { productName: products[i].name, roleName: role.name });
+          });
+        });
+        setRoleMap(newRoleMap);
+
+        const newMemberMap = new Map<number, { productName: string; memberType: string }[]>();
+        memberResults.forEach((res, i) => {
+          (res.data ?? []).forEach((member: API.MemberItem) => {
+            const list = newMemberMap.get(member.userId) ?? [];
+            list.push({ productName: products[i].name, memberType: member.memberType });
+            newMemberMap.set(member.userId, list);
+          });
+        });
+        setMemberMap(newMemberMap);
+      } finally {
+        setLoading(false);
+      }
+    };
+    load();
+  }, []);
+
+  useEffect(() => {
+    if (userIds.length === 0) {
+      setUserRoleIds(new Map());
+      return;
+    }
+    const load = async () => {
+      const details = await Promise.all(userIds.map((id) => fetchUserDetail({ id })));
+      const map = new Map<number, number[]>();
+      details.forEach((res, i) => {
+        map.set(userIds[i], res.data?.roleIds ?? []);
+      });
+      setUserRoleIds(map);
+    };
+    load();
+  }, [userIds, refreshKey]);
+
+  const userProductInfo = useMemo(() => {
+    const map = new Map<number, ProductRoleInfo[]>();
+    for (const userId of userIds) {
+      const roleIds = userRoleIds.get(userId) ?? [];
+      const productRoles = new Map<string, ProductRoleInfo>();
+
+      for (const roleId of roleIds) {
+        const roleInfo = roleMap.get(roleId);
+        if (!roleInfo) continue;
+        if (!productRoles.has(roleInfo.productName)) {
+          productRoles.set(roleInfo.productName, {
+            productName: roleInfo.productName,
+            productCode: '',
+            roleNames: [],
+          });
+        }
+        productRoles.get(roleInfo.productName)!.roleNames.push(roleInfo.roleName);
+      }
+
+      const memberInfo = memberMap.get(userId) ?? [];
+      for (const m of memberInfo) {
+        if (!productRoles.has(m.productName)) {
+          productRoles.set(m.productName, {
+            productName: m.productName,
+            productCode: '',
+            memberType: m.memberType,
+            roleNames: [],
+          });
+        } else {
+          productRoles.get(m.productName)!.memberType = m.memberType;
+        }
+      }
+
+      map.set(userId, [...productRoles.values()]);
+    }
+    return map;
+  }, [userIds, userRoleIds, roleMap, memberMap]);
+
+  return { userProductInfo, loading };
+};

+ 4 - 0
src/services/member/index.ts

@@ -19,3 +19,7 @@ export async function fetchMemberList(
 ) {
   return pageList<API.MemberListResult>('/member/list', params, sorter, filter);
 }
+
+export async function fetchMemberUserProducts(body: API.UserProductsReq) {
+  return postJson<API.UserProductsResult>('/member/userProducts', body);
+}

+ 11 - 0
src/services/member/typings.d.ts

@@ -31,4 +31,15 @@ declare namespace API {
   }
   type MemberListResult = ResultList<MemberItem>;
   type MemberAddResult = Result<{ id: number }>;
+
+  interface UserProductItem {
+    productCode: string;
+    productName: string;
+    memberType: string;
+    status: number;
+  }
+  interface UserProductsReq {
+    userId: number;
+  }
+  type UserProductsResult = Result<{ list: UserProductItem[] }>;
 }

+ 2 - 0
src/services/user/typings.d.ts

@@ -39,10 +39,12 @@ declare namespace API {
   }
   interface UserDetailReq {
     id: number;
+    productCode?: string;
   }
   interface BindRolesReq {
     userId: number;
     roleIds: number[];
+    productCode: string;
   }
   interface UserPermItem {
     permId: number;