Files
video-gen/video-gen-admin/src/pages/AdminUsers.tsx
T
2026-07-14 17:58:02 +08:00

987 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, MinusOutlined, 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: 'MB1024 × 1024 字节)' },
{ value: 'GB', label: 'GB1024 × 1024 × 1024 字节)' },
{ value: 'TB', label: 'TB1024 × 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 ?? 50) : 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>
{/* 快捷操作 */}
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>快捷操作</Typography.Text>
<Space wrap>
<Button size="small" icon={<PlusOutlined />} style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 1000, description: '积分赠送' })}>
+1000 / 积分赠送
</Button>
<Button size="small" icon={<PlusOutlined />} style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 500, description: '积分赠送' })}>
+500 / 积分赠送
</Button>
<Button size="small" icon={<MinusOutlined />} style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -500, description: '积分扣除' })}>
-500 / 积分扣除
</Button>
<Button size="small" icon={<MinusOutlined />} style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -1000, description: '积分扣除' })}>
-1000 / 积分扣除
</Button>
</Space>
</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={50}
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;