969 lines
41 KiB
TypeScript
969 lines
41 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import {
|
||
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
||
} from 'antd';
|
||
import {
|
||
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
|
||
} from '@ant-design/icons';
|
||
import {
|
||
adjustCredits,
|
||
adminGetPrivatePortraitConfig,
|
||
adminUpdatePrivatePortraitConfig,
|
||
createUser,
|
||
deleteUserResourceCapacity,
|
||
getAdminUsers,
|
||
getMenuConfigs,
|
||
getTeamOptions,
|
||
getSystemConfigs,
|
||
getUserResourceCapacity,
|
||
resetUserPassword,
|
||
saveUserResourceCapacity,
|
||
toggleUserStatus,
|
||
updateFrontendUserKind,
|
||
updateUserTeam,
|
||
updateSystemConfig,
|
||
updateUserMenus,
|
||
updateUserAdminStatus,
|
||
} from '../api';
|
||
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
|
||
import { formatDate } from '../utils/formatDate';
|
||
|
||
const TEAM_UNASSIGNED_VALUE = '__none__';
|
||
|
||
const capacityUnitOptions: { value: ResourceCapacityUnit; label: string }[] = [
|
||
{ value: 'MB', label: 'MB(1024 × 1024 字节)' },
|
||
{ value: 'GB', label: 'GB(1024 × 1024 × 1024 字节)' },
|
||
{ value: 'TB', label: 'TB(1024 × 1024 × 1024 × 1024 字节)' },
|
||
];
|
||
|
||
function formatBytes(bytes?: number | null): string {
|
||
if (bytes === null || bytes === undefined) return '-';
|
||
const value = Number(bytes || 0);
|
||
if (value < 1024) return `${value} B`;
|
||
const units = ['KB', 'MB', 'GB', 'TB', 'PB'];
|
||
let size = value;
|
||
let unitIndex = -1;
|
||
do {
|
||
size /= 1024;
|
||
unitIndex += 1;
|
||
} while (size >= 1024 && unitIndex < units.length - 1);
|
||
return `${size.toFixed(size >= 100 ? 0 : size >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
||
}
|
||
|
||
function capacitySourceLabel(capacity?: ResourceCapacityUsage | null): string {
|
||
if (!capacity || !capacity.enabled) return '未开启';
|
||
if (capacity.source === 'user') return '个人';
|
||
if (capacity.source === 'global') return '全局';
|
||
return '未开启';
|
||
}
|
||
|
||
const AdminUsers: React.FC = () => {
|
||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [search, setSearch] = useState('');
|
||
const [activeTab, setActiveTab] = useState<string>('frontend');
|
||
const [frontendKindFilter, setFrontendKindFilter] = useState<string>('');
|
||
const [teamFilter, setTeamFilter] = useState<string>('');
|
||
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
|
||
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||
const [createModal, setCreateModal] = useState(false);
|
||
const [createType, setCreateType] = useState<string>('frontend');
|
||
const [menuModal, setMenuModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||
const [allMenus, setAllMenus] = useState<any[]>([]);
|
||
const [checkedMenus, setCheckedMenus] = useState<string[]>([]);
|
||
const [resetPwdModal, setResetPwdModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||
const [capacityModal, setCapacityModal] = useState<{ open: boolean; user: AdminUser | null; detail: AdminUserResourceCapacityOut | null }>({ open: false, user: null, detail: null });
|
||
const [teamModal, setTeamModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||
const [portraitModal, setPortraitModal] = useState<{ open: boolean; user: AdminUser | null; config: PrivatePortraitConfig | null }>({ open: false, user: null, config: null });
|
||
const [capacityLoading, setCapacityLoading] = useState(false);
|
||
const [capacitySaving, setCapacitySaving] = useState(false);
|
||
const [teamSaving, setTeamSaving] = useState(false);
|
||
const [portraitLoading, setPortraitLoading] = useState(false);
|
||
const [portraitSaving, setPortraitSaving] = useState(false);
|
||
const [form] = Form.useForm();
|
||
const [createForm] = Form.useForm();
|
||
const [resetPwdForm] = Form.useForm();
|
||
const [capacityForm] = Form.useForm();
|
||
const [teamForm] = Form.useForm();
|
||
const [portraitForm] = Form.useForm();
|
||
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(20);
|
||
const [total, setTotal] = useState(0);
|
||
|
||
const [creditConfigs, setCreditConfigs] = useState<SystemConfig[]>([]);
|
||
const [configForm] = Form.useForm();
|
||
const [configSaving, setConfigSaving] = useState(false);
|
||
|
||
const load = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const data = await getAdminUsers(
|
||
page,
|
||
pageSize,
|
||
search || undefined,
|
||
activeTab,
|
||
activeTab === 'frontend' ? (frontendKindFilter || undefined) : undefined,
|
||
activeTab === 'frontend' ? (teamFilter || undefined) : undefined,
|
||
);
|
||
setUsers(data.items || []);
|
||
setTotal(data.total || 0);
|
||
} catch { /* auth error handled by client */ }
|
||
setLoading(false);
|
||
};
|
||
|
||
useEffect(() => { load(); }, [page, pageSize, search, activeTab, frontendKindFilter, teamFilter]);
|
||
|
||
useEffect(() => {
|
||
getTeamOptions(true).then(setTeamOptions).catch(() => {});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const loadCreditConfigs = async () => {
|
||
try {
|
||
const configs = await getSystemConfigs();
|
||
const credit = configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits'));
|
||
setCreditConfigs(credit);
|
||
const formValues: Record<string, string> = {};
|
||
credit.forEach(c => { formValues[c.key] = c.value; });
|
||
configForm.setFieldsValue(formValues);
|
||
} catch { /* auth error handled by client */ }
|
||
};
|
||
loadCreditConfigs();
|
||
}, []);
|
||
|
||
const handleSaveCreditConfigs = async () => {
|
||
try {
|
||
const values = await configForm.validateFields();
|
||
setConfigSaving(true);
|
||
for (const config of creditConfigs) {
|
||
const newVal = values[config.key];
|
||
if (newVal !== undefined && String(newVal) !== config.value) {
|
||
await updateSystemConfig(config.id, String(newVal ?? ''));
|
||
}
|
||
}
|
||
message.success('积分配置已保存');
|
||
const configs = await getSystemConfigs();
|
||
setCreditConfigs(configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')));
|
||
} catch (e: any) {
|
||
message.error(e?.message || '保存失败');
|
||
} finally {
|
||
setConfigSaving(false);
|
||
}
|
||
};
|
||
|
||
const handlePageChange = (p: number, ps: number) => {
|
||
setPage(p);
|
||
setPageSize(ps);
|
||
};
|
||
|
||
const handleSearch = () => load();
|
||
|
||
const handleAdjustCredits = async () => {
|
||
try {
|
||
const values = await form.validateFields();
|
||
const { user } = creditModal;
|
||
if (!user) return;
|
||
await adjustCredits(user.id, values.amount, values.description);
|
||
message.success(`已${values.amount > 0 ? '增加' : '扣除'} ${Math.abs(values.amount)} 积分`);
|
||
setCreditModal({ open: false, user: null });
|
||
form.resetFields();
|
||
load();
|
||
} catch { /* validation */ }
|
||
};
|
||
|
||
const handleToggleStatus = async (user: AdminUser) => {
|
||
await toggleUserStatus(user.id, !user.isActive);
|
||
message.success(user.isActive ? '已禁用该用户' : '已启用该用户');
|
||
load();
|
||
};
|
||
|
||
const handleCreateUser = async () => {
|
||
try {
|
||
const values = await createForm.validateFields();
|
||
const userType = values.user_type || 'frontend';
|
||
await createUser({
|
||
username: userType === 'admin' ? values.username : undefined,
|
||
password: values.password,
|
||
email: values.email || undefined,
|
||
phone: userType === 'frontend' ? values.phone : (values.phone || undefined),
|
||
credits: values.credits || 0,
|
||
user_type: userType,
|
||
is_admin: userType === 'admin' ? (values.is_admin ?? false) : false,
|
||
frontend_user_kind: values.frontend_user_kind || 'external',
|
||
private_portrait_asset_limit: userType === 'frontend' ? Number(values.private_portrait_asset_limit ?? 5) : 0,
|
||
});
|
||
message.success('用户创建成功');
|
||
setCreateModal(false);
|
||
createForm.resetFields();
|
||
setCreateType('frontend');
|
||
load();
|
||
} catch { /* validation */ }
|
||
};
|
||
|
||
const openMenuModal = async (user: AdminUser) => {
|
||
try {
|
||
const menus = await getMenuConfigs();
|
||
const isAdminUser = user.userType === 'admin';
|
||
setAllMenus(menus.filter((m: any) => {
|
||
const target = m.menu_target ?? m.menuTarget ?? 'frontend';
|
||
return isAdminUser ? (target === 'admin' || target === 'both') : (target === 'frontend' || target === 'both');
|
||
}));
|
||
setCheckedMenus(user.allowedMenus || []);
|
||
setMenuModal({ open: true, user });
|
||
} catch {
|
||
message.error('加载菜单失败');
|
||
}
|
||
};
|
||
|
||
const openCapacityModal = async (user: AdminUser) => {
|
||
setCapacityLoading(true);
|
||
setCapacityModal({ open: true, user, detail: null });
|
||
try {
|
||
const detail = await getUserResourceCapacity(user.id);
|
||
const initialConfig = detail.userConfig;
|
||
capacityForm.setFieldsValue({
|
||
enabled: initialConfig?.enabled ?? false,
|
||
limitValue: initialConfig?.limitValue ?? detail.effective.limitValue ?? detail.globalConfig.limitValue ?? '1.000',
|
||
limitUnit: initialConfig?.limitUnit ?? detail.effective.limitUnit ?? detail.globalConfig.limitUnit ?? 'GB',
|
||
});
|
||
setCapacityModal({ open: true, user, detail });
|
||
} catch (e: any) {
|
||
message.error(e?.message || '加载容量配置失败');
|
||
setCapacityModal({ open: false, user: null, detail: null });
|
||
} finally {
|
||
setCapacityLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSaveCapacity = async () => {
|
||
const { user } = capacityModal;
|
||
if (!user) return;
|
||
try {
|
||
const values = await capacityForm.validateFields();
|
||
setCapacitySaving(true);
|
||
await saveUserResourceCapacity(user.id, {
|
||
enabled: !!values.enabled,
|
||
limitValue: String(values.limitValue ?? '1.000'),
|
||
limitUnit: values.limitUnit || 'GB',
|
||
});
|
||
message.success('用户容量配置已保存');
|
||
setCapacityModal({ open: false, user: null, detail: null });
|
||
capacityForm.resetFields();
|
||
load();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '保存失败');
|
||
} finally {
|
||
setCapacitySaving(false);
|
||
}
|
||
};
|
||
|
||
const handleRestoreGlobalCapacity = async () => {
|
||
const { user } = capacityModal;
|
||
if (!user) return;
|
||
try {
|
||
setCapacitySaving(true);
|
||
await deleteUserResourceCapacity(user.id);
|
||
message.success('已恢复为全局容量配置');
|
||
setCapacityModal({ open: false, user: null, detail: null });
|
||
capacityForm.resetFields();
|
||
load();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '恢复失败');
|
||
} finally {
|
||
setCapacitySaving(false);
|
||
}
|
||
};
|
||
|
||
const openPortraitModal = async (user: AdminUser) => {
|
||
setPortraitLoading(true);
|
||
setPortraitModal({ open: true, user, config: null });
|
||
portraitForm.setFieldsValue({ privatePortraitAssetLimit: user.privatePortraitAssetLimit ?? 5 });
|
||
try {
|
||
const config = await adminGetPrivatePortraitConfig(user.id);
|
||
portraitForm.setFieldsValue({ privatePortraitAssetLimit: config.assetLimit });
|
||
setPortraitModal({ open: true, user, config });
|
||
} catch (e: any) {
|
||
message.error(e?.message || '加载私域人像素材库配置失败');
|
||
setPortraitModal({ open: false, user: null, config: null });
|
||
} finally {
|
||
setPortraitLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSavePortraitConfig = async () => {
|
||
const { user } = portraitModal;
|
||
if (!user) return;
|
||
try {
|
||
const values = await portraitForm.validateFields();
|
||
const limit = Number(values.privatePortraitAssetLimit ?? 0);
|
||
setPortraitSaving(true);
|
||
const config = await adminUpdatePrivatePortraitConfig(user.id, limit);
|
||
message.success(limit > 0 ? `私域人像素材库已开启,限制 ${limit} 个` : '私域人像素材库已关闭');
|
||
setPortraitModal({ open: false, user: null, config });
|
||
portraitForm.resetFields();
|
||
load();
|
||
} catch (e: any) {
|
||
if (e?.errorFields) return;
|
||
message.error(e?.message || '保存私域人像素材库配置失败');
|
||
} finally {
|
||
setPortraitSaving(false);
|
||
}
|
||
};
|
||
|
||
const openTeamModal = (user: AdminUser) => {
|
||
teamForm.setFieldsValue({ teamId: user.teamId || '' });
|
||
setTeamModal({ open: true, user });
|
||
};
|
||
|
||
const handleSaveTeam = async () => {
|
||
const { user } = teamModal;
|
||
if (!user) return;
|
||
try {
|
||
const values = await teamForm.validateFields();
|
||
setTeamSaving(true);
|
||
await updateUserTeam(user.id, values.teamId || null);
|
||
message.success('用户团队已更新');
|
||
setTeamModal({ open: false, user: null });
|
||
teamForm.resetFields();
|
||
load();
|
||
} catch (e: any) {
|
||
if (e?.errorFields) return;
|
||
message.error(e?.message || '保存团队失败');
|
||
} finally {
|
||
setTeamSaving(false);
|
||
}
|
||
};
|
||
|
||
const menuGroups = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) === 'group');
|
||
const menuPages = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) !== 'group');
|
||
const childMap: Record<string, any[]> = {};
|
||
menuPages.filter((m: any) => m.parent_id ?? m.parentId).forEach((m: any) => {
|
||
const pid = m.parent_id ?? m.parentId;
|
||
if (!childMap[pid]) childMap[pid] = [];
|
||
childMap[pid].push(m);
|
||
});
|
||
const topLevelPages = menuPages.filter((m: any) => !(m.parent_id ?? m.parentId));
|
||
|
||
const handleSaveMenus = async () => {
|
||
const { user } = menuModal;
|
||
if (!user) return;
|
||
try {
|
||
await updateUserMenus(user.id, checkedMenus.length > 0 ? checkedMenus : null);
|
||
message.success('菜单权限已更新');
|
||
setMenuModal({ open: false, user: null });
|
||
load();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '保存失败');
|
||
}
|
||
};
|
||
|
||
const handleResetPassword = async () => {
|
||
try {
|
||
const values = await resetPwdForm.validateFields();
|
||
const { user } = resetPwdModal;
|
||
if (!user) return;
|
||
await resetUserPassword(user.id, values.newPassword);
|
||
message.success(`已重置 ${user.username} 的密码`);
|
||
setResetPwdModal({ open: false, user: null });
|
||
resetPwdForm.resetFields();
|
||
} catch { /* validation */ }
|
||
};
|
||
|
||
const handleUpdateFrontendKind = async (user: AdminUser, kind: 'internal' | 'external') => {
|
||
try {
|
||
await updateFrontendUserKind(user.id, kind);
|
||
message.success(kind === 'internal' ? '已设为前台内部用户' : '已设为前台外部用户');
|
||
load();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '设置失败');
|
||
}
|
||
};
|
||
|
||
const handleToggleAdminStatus = async (user: AdminUser) => {
|
||
try {
|
||
await updateUserAdminStatus(user.id, !user.isAdmin);
|
||
message.success(user.isAdmin ? '已取消超级管理员' : '已设为超级管理员');
|
||
load();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '设置失败');
|
||
}
|
||
};
|
||
|
||
const isAdminTab = activeTab === 'admin';
|
||
|
||
const columns = [
|
||
{
|
||
title: '用户', key: 'user', width: 200,
|
||
render: (_: any, r: AdminUser) => (
|
||
<Space>
|
||
<div style={{
|
||
width: 32, height: 32, borderRadius: 8,
|
||
background: r.isAdmin ? 'linear-gradient(135deg, #f59e0b, #f97316)' : 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
color: '#fff', fontSize: 13, fontWeight: 700,
|
||
}}>
|
||
{r.username.charAt(0).toUpperCase()}
|
||
</div>
|
||
<div>
|
||
<div style={{ fontWeight: 600 }}>
|
||
{r.username}
|
||
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}>超级管理员</Tag>}
|
||
</div>
|
||
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div>
|
||
</div>
|
||
</Space>
|
||
),
|
||
},
|
||
...(!isAdminTab ? [{
|
||
title: '积分余额', dataIndex: 'credits', width: 120, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
|
||
render: (v: number) => (
|
||
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
|
||
{v.toLocaleString()}
|
||
</Typography.Text>
|
||
),
|
||
}] : []),
|
||
{
|
||
title: '手机号', dataIndex: 'phone', width: 130,
|
||
render: (v: string) => <Typography.Text type="secondary">{v || '-'}</Typography.Text>,
|
||
},
|
||
...(!isAdminTab ? [{
|
||
title: '前台归类', dataIndex: 'frontendUserKind', width: 110,
|
||
render: (v: string) => <Tag color={v === 'internal' ? 'geekblue' : 'default'}>{v === 'internal' ? '内部用户' : '外部用户'}</Tag>,
|
||
}] : []),
|
||
...(!isAdminTab ? [{
|
||
title: '团队', dataIndex: 'teamName', width: 140,
|
||
render: (v: string | null | undefined) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary">未分配</Typography.Text>,
|
||
}] : []),
|
||
...(!isAdminTab ? [{
|
||
title: '私域人像素材库', dataIndex: 'privatePortraitAssetLimit', width: 150,
|
||
render: (v: number) => {
|
||
const limit = Number(v || 0);
|
||
return limit > 0 ? <Tag color="purple">开启:{limit} 个</Tag> : <Tag>未开启</Tag>;
|
||
},
|
||
}] : []),
|
||
...(!isAdminTab ? [{
|
||
title: '资源容量', dataIndex: 'resourceCapacity', width: 230,
|
||
render: (capacity: ResourceCapacityUsage | null | undefined) => {
|
||
const usedText = formatBytes(capacity?.usedBytes || 0);
|
||
if (!capacity || !capacity.enabled) {
|
||
return (
|
||
<div>
|
||
<Space size={6} style={{ marginBottom: 4 }}>
|
||
<Tag>未开启</Tag>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>已用 {usedText}</Typography.Text>
|
||
</Space>
|
||
<Progress percent={0} size="small" showInfo={false} />
|
||
</div>
|
||
);
|
||
}
|
||
const percent = Math.min(Number(capacity.usagePercent || 0), 100);
|
||
return (
|
||
<div>
|
||
<Space size={6} style={{ marginBottom: 4 }}>
|
||
<Tag color={capacity.source === 'user' ? 'blue' : 'purple'}>{capacitySourceLabel(capacity)}</Tag>
|
||
{capacity.exceeded && <Tag color="red">已超额</Tag>}
|
||
</Space>
|
||
<Progress percent={percent} size="small" status={capacity.exceeded ? 'exception' : 'active'} />
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
{usedText} / {formatBytes(capacity.totalBytes)},可用 {formatBytes(capacity.availableBytes)}
|
||
</Typography.Text>
|
||
</div>
|
||
);
|
||
},
|
||
}] : []),
|
||
{
|
||
title: '状态', dataIndex: 'isActive', width: 80,
|
||
render: (v: boolean) => (
|
||
<Tag color={v ? 'green' : 'red'}>{v ? '正常' : '禁用'}</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '注册时间', dataIndex: 'createdAt', width: 120,
|
||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
||
},
|
||
{
|
||
title: '最后登录', dataIndex: 'lastLoginAt', width: 140,
|
||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
||
},
|
||
{
|
||
title: '操作', key: 'action', width: 560, fixed: 'right' as const,
|
||
render: (_: any, r: AdminUser) => (
|
||
<Space size={4} wrap>
|
||
{!isAdminTab && (
|
||
<Button type="link" size="small" icon={<WalletOutlined />}
|
||
onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
|
||
调整积分
|
||
</Button>
|
||
)}
|
||
{!isAdminTab && (
|
||
<Button type="link" size="small" icon={<DatabaseOutlined />}
|
||
onClick={() => openCapacityModal(r)}>
|
||
容量设置
|
||
</Button>
|
||
)}
|
||
{!isAdminTab && (
|
||
<Button type="link" size="small" icon={<PictureOutlined />}
|
||
onClick={() => openPortraitModal(r)}>
|
||
私域人像素材
|
||
</Button>
|
||
)}
|
||
{!isAdminTab && (
|
||
<Button type="link" size="small" icon={<TeamOutlined />}
|
||
onClick={() => openTeamModal(r)}>
|
||
团队设置
|
||
</Button>
|
||
)}
|
||
{!isAdminTab && r.frontendUserKind !== 'internal' && (
|
||
<Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'internal')}>设为内部</Button>
|
||
)}
|
||
{!isAdminTab && r.frontendUserKind === 'internal' && (
|
||
<Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'external')}>取消内部</Button>
|
||
)}
|
||
{isAdminTab && (
|
||
<Popconfirm
|
||
title={r.isAdmin ? '确定取消该用户的超级管理员权限?' : '确定将该用户设为超级管理员?'}
|
||
onConfirm={() => handleToggleAdminStatus(r)}
|
||
>
|
||
<Button type="link" size="small" style={{ color: r.isAdmin ? '#f59e0b' : '#6366f1' }}
|
||
icon={<SecurityScanOutlined />}>
|
||
{r.isAdmin ? '取消超级管理员' : '设为超级管理员'}
|
||
</Button>
|
||
</Popconfirm>
|
||
)}
|
||
<Button type="link" size="small" icon={<MenuOutlined />}
|
||
onClick={() => openMenuModal(r)}>
|
||
菜单权限
|
||
</Button>
|
||
<Button type="link" size="small" icon={<LockOutlined />}
|
||
onClick={() => { setResetPwdModal({ open: true, user: r }); resetPwdForm.resetFields(); }}>
|
||
重置密码
|
||
</Button>
|
||
<Popconfirm
|
||
title={r.isActive ? '确定禁用该用户?' : '确定启用该用户?'}
|
||
onConfirm={() => handleToggleStatus(r)}
|
||
>
|
||
<Button type="link" size="small" danger={r.isActive}
|
||
icon={r.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
|
||
{r.isActive ? '禁用' : '启用'}
|
||
</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
{/* <Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
|
||
<div style={{
|
||
width: 44, height: 44, borderRadius: 10,
|
||
background: 'rgba(99,102,241,0.08)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 20, color: '#6366f1',
|
||
}}>
|
||
<SettingOutlined />
|
||
</div>
|
||
<div>
|
||
<Typography.Title level={4} style={{ margin: 0 }}>积分配置</Typography.Title>
|
||
<Typography.Text type="secondary">设置用户注册和登录赠送的积分</Typography.Text>
|
||
</div>
|
||
</div>
|
||
|
||
<Form form={configForm} layout="vertical">
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 20 }}>
|
||
<Form.Item
|
||
name="user_register_credits"
|
||
label={<span style={{ fontWeight: 500 }}>注册赠送积分</span>}
|
||
extra="用户注册时赠送的初始积分"
|
||
>
|
||
<InputNumber min={0} style={{ width: '100%' }} size="large" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="user_login_credits"
|
||
label={<span style={{ fontWeight: 500 }}>每日登录赠送积分</span>}
|
||
extra="用户每日首次登录赠送的积分"
|
||
>
|
||
<InputNumber min={0} style={{ width: '100%' }} size="large" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="user_login_credits_enabled"
|
||
label={<span style={{ fontWeight: 500 }}>启用每日登录积分</span>}
|
||
extra="是否开启每日登录赠送积分功能"
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<Switch defaultChecked={creditConfigs.find(c => c.key === 'user_login_credits_enabled')?.value === 'true'} />
|
||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||
{creditConfigs.find(c => c.key === 'user_login_credits_enabled')?.value === 'true' ? '已启用' : '已禁用'}
|
||
</Typography.Text>
|
||
</div>
|
||
</Form.Item>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSaveCreditConfigs} loading={configSaving} size="large" style={{ borderRadius: 8 }}>
|
||
保存配置
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</Card> */}
|
||
|
||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||
<div style={{ display: 'flex', gap: 12 }}>
|
||
<Input
|
||
placeholder="搜索用户名或手机号"
|
||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
onPressEnter={handleSearch}
|
||
style={{ width: 280, borderRadius: 8 }}
|
||
allowClear
|
||
/>
|
||
{!isAdminTab && (
|
||
<Select
|
||
value={frontendKindFilter}
|
||
onChange={(v) => { setPage(1); setFrontendKindFilter(v); }}
|
||
style={{ width: 140 }}
|
||
options={[
|
||
{ value: '', label: '全部前台用户' },
|
||
{ value: 'internal', label: '内部用户' },
|
||
{ value: 'external', label: '外部用户' },
|
||
]}
|
||
/>
|
||
)}
|
||
{!isAdminTab && (
|
||
<Select
|
||
value={teamFilter}
|
||
onChange={(v) => { setPage(1); setTeamFilter(v); }}
|
||
style={{ width: 170 }}
|
||
options={[
|
||
{ value: '', label: '全部团队' },
|
||
{ value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' },
|
||
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
|
||
]}
|
||
/>
|
||
)}
|
||
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>搜索</Button>
|
||
</div>
|
||
<Button type="primary" icon={<PlusOutlined />}
|
||
onClick={() => { setCreateType(activeTab); createForm.setFieldsValue({ user_type: activeTab }); setCreateModal(true); }}
|
||
style={{ borderRadius: 8 }}>
|
||
创建用户
|
||
</Button>
|
||
</div>
|
||
|
||
<Tabs
|
||
activeKey={activeTab}
|
||
onChange={(key) => { setActiveTab(key); setFrontendKindFilter(''); setTeamFilter(''); setPage(1); }}
|
||
items={[
|
||
{ key: 'frontend', label: '前台用户' },
|
||
{ key: 'admin', label: '后台用户' },
|
||
]}
|
||
/>
|
||
|
||
<Table
|
||
columns={columns}
|
||
dataSource={users}
|
||
rowKey="id"
|
||
loading={loading}
|
||
pagination={{
|
||
current: page,
|
||
pageSize: pageSize,
|
||
total: total,
|
||
onChange: handlePageChange,
|
||
showSizeChanger: true,
|
||
showTotal: (t) => `共 ${t} 个用户`,
|
||
}}
|
||
scroll={{ x: 1280 }}
|
||
/>
|
||
</Card>
|
||
|
||
<Modal
|
||
title={<Space><WalletOutlined />调整积分 - {creditModal.user?.username}</Space>}
|
||
open={creditModal.open}
|
||
onOk={handleAdjustCredits}
|
||
onCancel={() => { setCreditModal({ open: false, user: null }); form.resetFields(); }}
|
||
okText="确认" cancelText="取消" width={440}
|
||
>
|
||
<div style={{ marginBottom: 16, padding: '12px 16px', background: '#f8fafc', borderRadius: 8 }}>
|
||
<span style={{ color: '#64748b' }}>当前积分:</span>
|
||
<span style={{ fontWeight: 800, fontSize: 18, color: '#6366f1' }}>
|
||
{creditModal.user?.credits.toLocaleString()}
|
||
</span>
|
||
</div>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item name="amount" label="积分变动"
|
||
rules={[{ required: true, message: '请输入积分数量' }]}>
|
||
<InputNumber
|
||
style={{ width: '100%' }}
|
||
size="large"
|
||
placeholder="正数增加,负数扣除"
|
||
formatter={v => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="description" label="原因"
|
||
rules={[{ required: true, message: '请输入调整原因' }]}>
|
||
<Input.TextArea rows={2} placeholder="请输入调整原因" size="large" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={<Space><TeamOutlined />团队设置 - {teamModal.user?.username}</Space>}
|
||
open={teamModal.open}
|
||
confirmLoading={teamSaving}
|
||
onOk={handleSaveTeam}
|
||
onCancel={() => { setTeamModal({ open: false, user: null }); teamForm.resetFields(); }}
|
||
okText="保存" cancelText="取消" width={460}
|
||
>
|
||
<Form form={teamForm} layout="vertical" style={{ marginTop: 16 }}>
|
||
<Form.Item name="teamId" label="所属团队" extra="团队归属独立于前台内部/外部归类,不会改变用户内外部设置。">
|
||
<Select
|
||
size="large"
|
||
allowClear
|
||
placeholder="未分配团队"
|
||
options={[
|
||
{ value: '', label: '未分配团队' },
|
||
...teamOptions
|
||
.filter(t => t.status === 'active' || t.id === teamModal.user?.teamId)
|
||
.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={<Space><DatabaseOutlined />容量设置 - {capacityModal.user?.username}</Space>}
|
||
open={capacityModal.open}
|
||
confirmLoading={capacitySaving}
|
||
onOk={handleSaveCapacity}
|
||
onCancel={() => { setCapacityModal({ open: false, user: null, detail: null }); capacityForm.resetFields(); }}
|
||
okText="保存个人配置" cancelText="取消" width={560}
|
||
>
|
||
<Card loading={capacityLoading} variant="outlined" style={{ marginBottom: 16 }}>
|
||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||
<Typography.Text type="secondary">
|
||
当前生效来源:{capacitySourceLabel(capacityModal.detail?.effective)}
|
||
{capacityModal.detail?.effective.hasUserConfig ? '(已单独设置)' : '(未单独设置)'}
|
||
</Typography.Text>
|
||
<Typography.Text>
|
||
已用:{formatBytes(capacityModal.detail?.effective.usedBytes)};
|
||
总量:{formatBytes(capacityModal.detail?.effective.totalBytes)};
|
||
可用:{formatBytes(capacityModal.detail?.effective.availableBytes)}
|
||
</Typography.Text>
|
||
{capacityModal.detail?.effective.enabled && (
|
||
<Progress
|
||
percent={Math.min(Number(capacityModal.detail.effective.usagePercent || 0), 100)}
|
||
status={capacityModal.detail.effective.exceeded ? 'exception' : 'active'}
|
||
/>
|
||
)}
|
||
</Space>
|
||
</Card>
|
||
<Form form={capacityForm} layout="vertical">
|
||
<Form.Item
|
||
name="enabled"
|
||
label="启用个人容量限制"
|
||
valuePropName="checked"
|
||
extra="保存后会生成用户个人配置,优先级高于全局;关闭并保存表示该用户个人明确不限制,不再走全局。"
|
||
>
|
||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||
</Form.Item>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 16 }}>
|
||
<Form.Item
|
||
name="limitValue"
|
||
label="容量数值"
|
||
rules={[{ required: true, message: '请输入容量数值' }]}
|
||
extra="最小为1,不能为负数,最多支持3位小数。"
|
||
>
|
||
<InputNumber min={1} precision={3} style={{ width: '100%' }} size="large" placeholder="例如 10.500" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="limitUnit"
|
||
label="容量单位"
|
||
rules={[{ required: true, message: '请选择容量单位' }]}
|
||
extra="MB / GB / TB 固定枚举"
|
||
>
|
||
<Select size="large" options={capacityUnitOptions} />
|
||
</Form.Item>
|
||
</div>
|
||
</Form>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 }}>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
恢复全局设置会删除该用户个人配置,让用户重新按全局规则判断。
|
||
</Typography.Text>
|
||
<Popconfirm
|
||
title="确定恢复为全局容量配置?"
|
||
onConfirm={handleRestoreGlobalCapacity}
|
||
disabled={!capacityModal.detail?.hasUserConfig}
|
||
>
|
||
<Button disabled={!capacityModal.detail?.hasUserConfig} loading={capacitySaving}>
|
||
恢复全局设置
|
||
</Button>
|
||
</Popconfirm>
|
||
</div>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={<Space><PictureOutlined />私域人像素材库设置 - {portraitModal.user?.username}</Space>}
|
||
open={portraitModal.open}
|
||
confirmLoading={portraitSaving}
|
||
onOk={handleSavePortraitConfig}
|
||
onCancel={() => { setPortraitModal({ open: false, user: null, config: null }); portraitForm.resetFields(); }}
|
||
okText="保存" cancelText="取消" width={520}
|
||
>
|
||
<Card loading={portraitLoading} variant="outlined" style={{ marginBottom: 16 }}>
|
||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||
<Typography.Text>
|
||
当前状态:{portraitModal.config?.enabled ? <Tag color="purple">已开启</Tag> : <Tag>未开启</Tag>}
|
||
</Typography.Text>
|
||
<Typography.Text type="secondary">
|
||
已用:{portraitModal.config?.usedAssetCount ?? '-'} 个;
|
||
剩余:{portraitModal.config?.enabled ? portraitModal.config.remainingAssetCount : 0} 个
|
||
</Typography.Text>
|
||
</Space>
|
||
</Card>
|
||
<Form form={portraitForm} layout="vertical">
|
||
<Form.Item
|
||
name="privatePortraitAssetLimit"
|
||
label="私域人像素材总量上限"
|
||
extra="0 表示关闭私域人像素材库;大于 0 表示开启,并限制该用户所有私域人像素材总量。"
|
||
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
||
>
|
||
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={<Space><UserOutlined />创建用户</Space>}
|
||
open={createModal}
|
||
onOk={handleCreateUser}
|
||
onCancel={() => { setCreateModal(false); createForm.resetFields(); setCreateType('frontend'); }}
|
||
okText="创建" cancelText="取消" width={480}
|
||
>
|
||
<Form form={createForm} layout="vertical" style={{ marginTop: 16 }}
|
||
onValuesChange={(changed) => { if (changed.user_type) setCreateType(changed.user_type); }}
|
||
>
|
||
<Form.Item name="user_type" label="用户类型" initialValue="frontend" rules={[{ required: true }]}>
|
||
<Select size="large" options={[
|
||
{ value: 'frontend', label: '前端用户' },
|
||
{ value: 'admin', label: '后台管理员' },
|
||
]} />
|
||
</Form.Item>
|
||
{createType === 'frontend' && (
|
||
<Form.Item name="frontend_user_kind" label="前台归类" initialValue="external">
|
||
<Select size="large" options={[
|
||
{ value: 'external', label: '外部用户' },
|
||
{ value: 'internal', label: '内部用户' },
|
||
]} />
|
||
</Form.Item>
|
||
)}
|
||
{createType === 'frontend' ? (
|
||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
|
||
<Input placeholder="请输入手机号" maxLength={11} size="large" />
|
||
</Form.Item>
|
||
) : (
|
||
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
|
||
<Input placeholder="请输入用户名" size="large" />
|
||
</Form.Item>
|
||
)}
|
||
<Form.Item name="password" label="密码" rules={[{ required: true, min: 6, message: '密码至少6位' }]}>
|
||
<Input.Password placeholder="请输入密码(至少6位)" size="large" />
|
||
</Form.Item>
|
||
{createType === 'frontend' && (
|
||
<Form.Item name="credits" label="初始积分" initialValue={0}>
|
||
<InputNumber min={0} style={{ width: '100%' }} size="large" />
|
||
</Form.Item>
|
||
)}
|
||
{createType === 'frontend' && (
|
||
<Form.Item
|
||
name="private_portrait_asset_limit"
|
||
label="私域人像素材总量上限"
|
||
initialValue={5}
|
||
extra="0 表示关闭私域人像素材库;大于 0 表示开启并限制该用户所有私域人像素材总量。"
|
||
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
||
>
|
||
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
|
||
</Form.Item>
|
||
)}
|
||
{createType === 'admin' && (
|
||
<Form.Item name="is_admin" label="超级管理员" valuePropName="checked" initialValue={false}>
|
||
<Switch
|
||
checkedChildren="是"
|
||
unCheckedChildren="否"
|
||
/>
|
||
</Form.Item>
|
||
)}
|
||
<Form.Item name="email" label="邮箱">
|
||
<Input placeholder="选填" size="large" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={<Space><MenuOutlined />菜单权限 - {menuModal.user?.username} ({menuModal.user?.userType === 'admin' ? '后台菜单' : '前台菜单'})</Space>}
|
||
open={menuModal.open}
|
||
onOk={handleSaveMenus}
|
||
onCancel={() => { setMenuModal({ open: false, user: null }); }}
|
||
okText="保存" cancelText="取消" width={520}
|
||
>
|
||
<div style={{ marginBottom: 12 }}>
|
||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||
勾选该用户可访问的菜单,不勾选则显示全部菜单
|
||
</Typography.Text>
|
||
</div>
|
||
<div style={{ padding: '12px 16px', background: '#f8fafc', borderRadius: 8, maxHeight: 400, overflow: 'auto' }}>
|
||
<Checkbox.Group value={checkedMenus} onChange={(vals) => setCheckedMenus(vals as string[])}>
|
||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||
{topLevelPages.map((m: any) => (
|
||
<Checkbox key={m.path} value={m.path} style={{ width: '100%' }}>
|
||
{m.label}
|
||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{m.path}</Typography.Text>
|
||
</Checkbox>
|
||
))}
|
||
{menuGroups.map((g: any) => {
|
||
const children = childMap[g.id] || [];
|
||
if (children.length === 0) return null;
|
||
return (
|
||
<div key={g.id}>
|
||
<div style={{ fontWeight: 600, fontSize: 13, color: '#6366f1', marginBottom: 4, marginTop: 4 }}>
|
||
{g.label}
|
||
</div>
|
||
<Space direction="vertical" size={4} style={{ paddingLeft: 12, width: '100%' }}>
|
||
{children.map((c: any) => (
|
||
<Checkbox key={c.path} value={c.path} style={{ width: '100%' }}>
|
||
{c.label}
|
||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{c.path}</Typography.Text>
|
||
</Checkbox>
|
||
))}
|
||
</Space>
|
||
</div>
|
||
);
|
||
})}
|
||
</Space>
|
||
</Checkbox.Group>
|
||
</div>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={<Space><LockOutlined />重置密码 - {resetPwdModal.user?.username}</Space>}
|
||
open={resetPwdModal.open}
|
||
onOk={handleResetPassword}
|
||
onCancel={() => { setResetPwdModal({ open: false, user: null }); resetPwdForm.resetFields(); }}
|
||
okText="确认重置" cancelText="取消" width={420}
|
||
>
|
||
<Form form={resetPwdForm} layout="vertical" style={{ marginTop: 16 }}>
|
||
<Form.Item name="newPassword" label="新密码" rules={[{ required: true, min: 6, message: '密码至少6位' }]}>
|
||
<Input.Password placeholder="请输入新密码(至少6位)" size="large" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AdminUsers;
|