376 lines
14 KiB
TypeScript
376 lines
14 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Alert,
|
||
Button,
|
||
Card,
|
||
Checkbox,
|
||
Col,
|
||
Form,
|
||
Row,
|
||
Select,
|
||
Space,
|
||
Tabs,
|
||
Tag,
|
||
Typography,
|
||
message,
|
||
} from 'antd';
|
||
import {
|
||
HQ_ADMIN_ROLES,
|
||
HQ_PERMISSION_CATALOG,
|
||
computeHqEffectivePermissionKeys,
|
||
type HqPermissionKey,
|
||
} from '@dukang/shared-types';
|
||
import { request, type HqProfile } from '../lib/api';
|
||
|
||
type RolePermRes = { role: string; permissionKeys: string[] };
|
||
type AccountOption = { id: string; name: string; phone: string; loginName: string | null; adminRole: string };
|
||
type AccountPermRes = {
|
||
account: AccountOption;
|
||
permissionKeys: string[];
|
||
rolePermissionKeys: string[];
|
||
grantKeys?: string[];
|
||
denyKeys?: string[];
|
||
userPermissionKeys: string[];
|
||
effectivePermissionKeys: string[];
|
||
};
|
||
|
||
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
|
||
|
||
const CATALOG_GROUPS = [...new Set(HQ_PERMISSION_CATALOG.map((p) => p.group ?? '其他'))];
|
||
|
||
function groupColSpan(itemCount: number): number {
|
||
if (itemCount <= 2) return 12;
|
||
if (itemCount === 3) return 8;
|
||
if (itemCount === 4) return 6;
|
||
return 8;
|
||
}
|
||
|
||
function PermissionChecklist({
|
||
value,
|
||
onChange,
|
||
disabled,
|
||
roleKeys,
|
||
grantKeys,
|
||
}: {
|
||
value: string[];
|
||
onChange: (keys: string[]) => void;
|
||
disabled?: boolean;
|
||
roleKeys?: string[];
|
||
grantKeys?: string[];
|
||
}) {
|
||
const roleSet = new Set(roleKeys ?? []);
|
||
const grantSet = new Set(grantKeys ?? []);
|
||
return (
|
||
<Checkbox.Group
|
||
style={{ width: '100%' }}
|
||
value={value}
|
||
disabled={disabled}
|
||
onChange={(checked) => onChange(checked as string[])}
|
||
>
|
||
{CATALOG_GROUPS.map((group) => {
|
||
const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group);
|
||
const span = groupColSpan(items.length);
|
||
const compact = items.length <= 3;
|
||
return (
|
||
<div
|
||
key={group}
|
||
style={{
|
||
marginBottom: 16,
|
||
padding: '12px 16px',
|
||
background: '#fafafa',
|
||
borderRadius: 8,
|
||
border: '1px solid #f0f0f0',
|
||
}}
|
||
>
|
||
<Typography.Text strong style={{ display: 'block', marginBottom: 10 }}>
|
||
{group}
|
||
</Typography.Text>
|
||
{compact ? (
|
||
<Space size={[24, 8]} wrap>
|
||
{items.map((item) => (
|
||
<Checkbox key={item.key} value={item.key}>
|
||
{item.label}
|
||
{roleSet.has(item.key) && !value.includes(item.key) ? (
|
||
<Tag style={{ marginLeft: 6 }} color="red">已撤销</Tag>
|
||
) : grantSet.has(item.key) ? (
|
||
<Tag style={{ marginLeft: 6 }} color="orange">追加</Tag>
|
||
) : roleSet.has(item.key) ? (
|
||
<Tag style={{ marginLeft: 6 }} color="blue">角色</Tag>
|
||
) : null}
|
||
</Checkbox>
|
||
))}
|
||
</Space>
|
||
) : (
|
||
<Row gutter={[12, 10]}>
|
||
{items.map((item) => (
|
||
<Col key={item.key} xs={24} sm={12} md={span}>
|
||
<Checkbox value={item.key}>
|
||
{item.label}
|
||
{roleSet.has(item.key) && !value.includes(item.key) ? (
|
||
<Tag style={{ marginLeft: 6 }} color="red">已撤销</Tag>
|
||
) : grantSet.has(item.key) ? (
|
||
<Tag style={{ marginLeft: 6 }} color="orange">追加</Tag>
|
||
) : roleSet.has(item.key) ? (
|
||
<Tag style={{ marginLeft: 6 }} color="blue">角色</Tag>
|
||
) : null}
|
||
</Checkbox>
|
||
</Col>
|
||
))}
|
||
</Row>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</Checkbox.Group>
|
||
);
|
||
}
|
||
|
||
function splitAccountOverrides(roleKeys: string[], effectiveKeys: string[]) {
|
||
const roleSet = new Set(roleKeys);
|
||
const effectiveSet = new Set(effectiveKeys);
|
||
const grantKeys = effectiveKeys.filter((k) => !roleSet.has(k));
|
||
const denyKeys = roleKeys.filter((k) => !effectiveSet.has(k));
|
||
return { grantKeys, denyKeys };
|
||
}
|
||
|
||
export default function HqPermissionsPage() {
|
||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||
const [role, setRole] = useState<string>('OPS');
|
||
const [roleKeys, setRoleKeys] = useState<string[]>([]);
|
||
const [roleLoading, setRoleLoading] = useState(false);
|
||
const [roleSaving, setRoleSaving] = useState(false);
|
||
|
||
const [accounts, setAccounts] = useState<AccountOption[]>([]);
|
||
const [accountId, setAccountId] = useState<string>();
|
||
const [accountKeys, setAccountKeys] = useState<string[]>([]);
|
||
const [roleInheritedKeys, setRoleInheritedKeys] = useState<string[]>([]);
|
||
const [grantKeys, setGrantKeys] = useState<string[]>([]);
|
||
const [denyKeys, setDenyKeys] = useState<string[]>([]);
|
||
const [accountLoading, setAccountLoading] = useState(false);
|
||
const [accountSaving, setAccountSaving] = useState(false);
|
||
|
||
const selectedAccount = useMemo(
|
||
() => accounts.find((a) => a.id === accountId),
|
||
[accounts, accountId],
|
||
);
|
||
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
|
||
|
||
const previewEffectiveKeys = useMemo(
|
||
() => computeHqEffectivePermissionKeys(roleInheritedKeys, grantKeys, denyKeys),
|
||
[roleInheritedKeys, grantKeys, denyKeys],
|
||
);
|
||
|
||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||
|
||
useEffect(() => {
|
||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!isSuperAdmin) return;
|
||
request<{ items: AccountOption[] }>('/admin/hq-accounts?page=1&pageSize=100')
|
||
.then((res) => setAccounts(res.items))
|
||
.catch(() => {});
|
||
}, [isSuperAdmin]);
|
||
|
||
useEffect(() => {
|
||
if (!isSuperAdmin || !role) return;
|
||
setRoleLoading(true);
|
||
request<RolePermRes>(`/admin/hq-permissions/roles/${role}`)
|
||
.then((res) => setRoleKeys(res.permissionKeys))
|
||
.finally(() => setRoleLoading(false));
|
||
}, [isSuperAdmin, role]);
|
||
|
||
useEffect(() => {
|
||
if (!isSuperAdmin || !accountId) return;
|
||
setAccountLoading(true);
|
||
request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`)
|
||
.then((res) => {
|
||
setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []);
|
||
setDenyKeys(res.denyKeys ?? []);
|
||
setRoleInheritedKeys(res.rolePermissionKeys);
|
||
setAccountKeys(res.effectivePermissionKeys);
|
||
})
|
||
.finally(() => setAccountLoading(false));
|
||
}, [isSuperAdmin, accountId]);
|
||
|
||
async function saveRolePermissions() {
|
||
setRoleSaving(true);
|
||
try {
|
||
const res = await request<RolePermRes>(`/admin/hq-permissions/roles/${role}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ permissionKeys: roleKeys }),
|
||
});
|
||
setRoleKeys(res.permissionKeys);
|
||
message.success('角色权限已保存');
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '保存失败');
|
||
} finally {
|
||
setRoleSaving(false);
|
||
}
|
||
}
|
||
|
||
async function saveAccountPermissions() {
|
||
if (!accountId) return;
|
||
setAccountSaving(true);
|
||
try {
|
||
const res = await request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ grantKeys, denyKeys }),
|
||
});
|
||
setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []);
|
||
setDenyKeys(res.denyKeys ?? []);
|
||
setRoleInheritedKeys(res.rolePermissionKeys);
|
||
setAccountKeys(res.effectivePermissionKeys);
|
||
message.success('用户权限已保存');
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '保存失败');
|
||
} finally {
|
||
setAccountSaving(false);
|
||
}
|
||
}
|
||
|
||
if (!isSuperAdmin) {
|
||
return (
|
||
<div>
|
||
<Typography.Title level={4}>权限分配</Typography.Title>
|
||
<Alert type="warning" showIcon message="仅超级管理员可配置权限" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<Typography.Title level={4}>权限分配</Typography.Title>
|
||
<Typography.Paragraph type="secondary">
|
||
按角色配置基础权限;按用户可追加或撤销。最终生效权限 =(角色权限 ∪ 追加)− 撤销。
|
||
超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市需在「按用户分配」中单独勾选(默认均无)。
|
||
运营/财务默认可删除门店分类;城市门店服务可新增分类,不可删除。
|
||
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。
|
||
</Typography.Paragraph>
|
||
|
||
<Tabs
|
||
items={[
|
||
{
|
||
key: 'role',
|
||
label: '按角色分配',
|
||
children: (
|
||
<Card loading={roleLoading}>
|
||
<Form layout="inline" style={{ marginBottom: 16 }}>
|
||
<Form.Item label="角色">
|
||
<Select
|
||
style={{ width: 180 }}
|
||
value={role}
|
||
onChange={setRole}
|
||
options={HQ_ADMIN_ROLES.map((r) => ({
|
||
value: r.value,
|
||
label: r.label,
|
||
disabled: r.value === 'SUPER_ADMIN',
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
{role === 'SUPER_ADMIN' ? (
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。"
|
||
/>
|
||
) : (
|
||
<>
|
||
<PermissionChecklist value={roleKeys} onChange={setRoleKeys} />
|
||
<div style={{ marginTop: 16 }}>
|
||
<Button type="primary" loading={roleSaving} onClick={() => void saveRolePermissions()}>
|
||
保存角色权限
|
||
</Button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</Card>
|
||
),
|
||
},
|
||
{
|
||
key: 'user',
|
||
label: '按用户分配',
|
||
children: (
|
||
<Card loading={accountLoading}>
|
||
<Form layout="inline" style={{ marginBottom: 16 }}>
|
||
<Form.Item label="HQ 账户">
|
||
<Select
|
||
showSearch
|
||
allowClear
|
||
placeholder="选择账户"
|
||
style={{ width: 320 }}
|
||
value={accountId}
|
||
onChange={setAccountId}
|
||
optionFilterProp="label"
|
||
options={accounts.map((a) => ({
|
||
value: a.id,
|
||
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
{!accountId ? (
|
||
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
|
||
) : (
|
||
<>
|
||
<Typography.Paragraph type="secondary">
|
||
勾选表示该账号最终拥有该权限。取消角色已有项即为撤销;勾选角色没有的项即为追加。
|
||
切换角色会清空账号级追加/撤销。
|
||
</Typography.Paragraph>
|
||
<PermissionChecklist
|
||
value={accountKeys}
|
||
roleKeys={roleInheritedKeys}
|
||
grantKeys={grantKeys}
|
||
disabled={selectedIsSuperAdmin}
|
||
onChange={(nextEffective) => {
|
||
if (selectedIsSuperAdmin) return;
|
||
const next = splitAccountOverrides(roleInheritedKeys, nextEffective);
|
||
setGrantKeys(next.grantKeys);
|
||
setDenyKeys(next.denyKeys);
|
||
setAccountKeys(nextEffective);
|
||
}}
|
||
/>
|
||
{selectedIsSuperAdmin ? (
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
style={{ marginTop: 12 }}
|
||
message="超级管理员基础权限不可撤销。危险操作请到下方以外的方式:当前勾选仅展示生效权限。"
|
||
/>
|
||
) : null}
|
||
<div style={{ marginTop: 12 }}>
|
||
<span style={{ marginRight: 8 }}>合并生效:</span>
|
||
<Space wrap size={[4, 4]}>
|
||
{previewEffectiveKeys.map((key) => {
|
||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||
return (
|
||
<Tag key={key}>
|
||
{item?.label || key}
|
||
</Tag>
|
||
);
|
||
})}
|
||
</Space>
|
||
</div>
|
||
<div style={{ marginTop: 16 }}>
|
||
<Button
|
||
type="primary"
|
||
loading={accountSaving}
|
||
disabled={selectedIsSuperAdmin}
|
||
onClick={() => void saveAccountPermissions()}
|
||
>
|
||
保存用户权限
|
||
</Button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</Card>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|