Files
video-gen/video-gen-admin/src/pages/AdminUsers.tsx
T
2026-08-14 15:23:46 +08:00

1306 lines
58 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, Radio, 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, SafetyOutlined,
} from '@ant-design/icons';
import {
adminDeductCredits,
adminGrantCredits,
adminGetPrivatePortraitConfig,
adminUpdatePrivatePortraitConfig,
createUser,
createAdminOfflineSubscription,
deleteUserResourceCapacity,
getAdminUsers,
getCreditProducts,
getAdminUserCreditBalances,
getAdminUserCreditSummary,
getMenuConfigs,
getTeamOptions,
getSystemConfigs,
getUserResourceCapacity,
resetUserPassword,
saveUserResourceCapacity,
toggleUserStatus,
updateFrontendUserKind,
updateSingleDeviceLoginOverride,
updateUserTeam,
updateSystemConfig,
updateUserMenus,
updateUserAdminStatus,
} from '../api';
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, CreditProduct, 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 [creditDetailModal, setCreditDetailModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [offlineModal, setOfflineModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [offlineProducts, setOfflineProducts] = useState<CreditProduct[]>([]);
const [offlineSaving, setOfflineSaving] = useState(false);
const [creditDetailLoading, setCreditDetailLoading] = useState(false);
const [creditSummary, setCreditSummary] = useState<any>(null);
const [creditBalances, setCreditBalances] = useState<any[]>([]);
const [creditBalanceStatus, setCreditBalanceStatus] = useState<string>('');
const [creditOperation, setCreditOperation] = useState<'grant' | 'deduct'>('grant');
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 [singleDeviceModal, setSingleDeviceModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [singleDeviceValue, setSingleDeviceValue] = useState<boolean | null>(null);
const [singleDeviceSaving, setSingleDeviceSaving] = useState(false);
const [globalSingleDeviceEnabled, setGlobalSingleDeviceEnabled] = useState(false);
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 [offlineForm] = Form.useForm();
const offlineProductId = Form.useWatch('productId', offlineForm);
const selectedOfflineProduct = offlineProducts.find((item) => item.id === offlineProductId);
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);
// 获取全局单设备登录开关状态
const singleDeviceConfig = configs.find(c => c.key === 'single_device_login_enabled');
setGlobalSingleDeviceEnabled(singleDeviceConfig?.value === 'true');
} 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;
if (creditOperation === 'grant') {
await adminGrantCredits(user.id, {
amount: values.amount,
description: values.description,
validity_unit: values.validityUnit || 'month',
validity_value: values.validityValue || 1,
credit_level: 'general',
});
} else {
await adminDeductCredits(user.id, { amount: values.amount, description: values.description });
}
message.success(`已${creditOperation === 'grant' ? '增加' : '扣除'} ${values.amount} 积分`);
setCreditModal({ open: false, user: null });
setCreditOperation('grant');
form.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '积分操作失败');
}
};
const openOfflineModal = async (user: AdminUser) => {
try {
const products = await getCreditProducts();
const available = products.filter((item) =>
(item.productType === 'subscription' || item.productType === 'team_subscription')
&& item.isActive
&& !item.isDeleted
);
setOfflineProducts(available);
offlineForm.resetFields();
offlineForm.setFieldsValue({ quantity: 1, paymentMethod: 'bank_transfer' });
setOfflineModal({ open: true, user });
} catch (e: any) {
message.error(e?.message || '加载可成交套餐失败');
}
};
const handleOfflineSubscription = async () => {
const user = offlineModal.user;
if (!user) return;
try {
const values = await offlineForm.validateFields();
const product = offlineProducts.find((item) => item.id === values.productId);
if (!product) {
message.error('请选择有效套餐');
return;
}
const quantity = product.productType === 'team_subscription' ? Number(values.quantity || 2) : 1;
setOfflineSaving(true);
await createAdminOfflineSubscription(user.id, {
productId: product.id,
quantity,
paymentMethod: values.paymentMethod,
actualPaidAmount: values.actualPaidAmount === undefined || values.actualPaidAmount === null ? undefined : Number(values.actualPaidAmount),
offlineTradeNo: values.offlineTradeNo,
offlinePaymentDetail: values.offlinePaymentDetail,
remark: values.remark,
});
message.success('线下订阅成交已完成,订单、套餐与首期权益已同步生效');
setOfflineModal({ open: false, user: null });
offlineForm.resetFields();
await load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '线下订阅成交失败');
} finally {
setOfflineSaving(false);
}
};
const openCreditDetailModal = async (user: AdminUser, status = '') => {
setCreditDetailModal({ open: true, user });
setCreditBalanceStatus(status);
setCreditDetailLoading(true);
try {
const [summary, balances] = await Promise.all([
getAdminUserCreditSummary(user.id),
getAdminUserCreditBalances(user.id, 1, 200, status || undefined),
]);
setCreditSummary(summary);
setCreditBalances(balances || []);
} catch (e: any) {
message.error(e?.message || '加载用户积分明细失败');
} finally {
setCreditDetailLoading(false);
}
};
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 handleUpdateSingleDeviceOverride = async (user: AdminUser, value: boolean | null) => {
try {
setSingleDeviceSaving(true);
await updateSingleDeviceLoginOverride(user.id, value);
message.success('已更新单设备登录设置');
setSingleDeviceModal({ open: false, user: null });
load();
} catch (e: any) {
message.error(e?.message || '设置失败');
} finally {
setSingleDeviceSaving(false);
}
};
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 style={{ color: '#c0c4cc', fontSize: 11, fontFamily: 'monospace' }}>ID: {r.id}</div>
</div>
</Space>
),
},
...(!isAdminTab ? [{
title: '积分余额', dataIndex: 'credits', width: 210, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
render: (v: number, r: AdminUser) => (
<div style={{ lineHeight: 1.7 }}>
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>可消费 {Number(v || 0).toLocaleString()}</Typography.Text>
<div style={{ color: '#64748b', fontSize: 12 }}>
个人 {Number(r.personalCredits || 0).toLocaleString()} / 团队 {Number(r.teamAvailableCredits || 0).toLocaleString()}
</div>
{Number(r.teamFrozenCredits || 0) > 0 && <div style={{ color: '#f59e0b', fontSize: 12 }}>团队冻结 {Number(r.teamFrozenCredits || 0).toLocaleString()}</div>}
</div>
),
}] : []),
{
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: 650, fixed: 'right' as const,
render: (_: any, r: AdminUser) => (
<Space size={4} wrap>
{!isAdminTab && (
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => {
setCreditOperation('grant');
setCreditModal({ open: true, user: r });
form.setFieldsValue({ amount: undefined, description: '', validityUnit: 'month', validityValue: 1 });
}}>
调整积分
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => openCreditDetailModal(r)}>
积分明细
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<PlusOutlined />} onClick={() => openOfflineModal(r)}>
线下订阅成交
</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>
{!isAdminTab && (
<Button type="link" size="small" icon={<SafetyOutlined />}
onClick={() => {
setSingleDeviceValue(r.singleDeviceLoginOverride ?? null);
setSingleDeviceModal({ open: true, user: r });
}}>
单设备登录
</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 }); setCreditOperation('grant'); form.resetFields(); }}
okText="确认" cancelText="取消" width={480}
>
<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" initialValues={{ validityUnit: 'month', validityValue: 1 }}>
<Form.Item label="操作类型">
<Select
value={creditOperation}
onChange={(value) => setCreditOperation(value)}
options={[
{ value: 'grant', label: '增加积分' },
{ value: 'deduct', label: '扣除积分' },
]}
/>
</Form.Item>
<Form.Item name="amount" label="积分数量" rules={[{ required: true, message: '请输入积分数量' }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} size="large" placeholder="请输入正数积分数量" />
</Form.Item>
{creditOperation === 'grant' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<Form.Item name="validityUnit" label="有效期单位" rules={[{ required: true }]}>
<Select options={[{ value: 'day', label: '天' }, { value: 'month', label: '自然月' }]} />
</Form.Item>
<Form.Item name="validityValue" label="有效期数值" rules={[{ required: true }]}>
<InputNumber min={1} max={120} precision={0} style={{ width: '100%' }} />
</Form.Item>
</div>
)}
<Form.Item name="description" label="原因" rules={[{ required: true, message: '请输入调整原因' }]}>
<Input.TextArea rows={2} placeholder="请输入调整原因" size="large" />
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><WalletOutlined />线下订阅成交 - {offlineModal.user?.username}</Space>}
open={offlineModal.open}
onOk={handleOfflineSubscription}
confirmLoading={offlineSaving}
okText="确认成交"
cancelText="取消"
width={620}
onCancel={() => {
setOfflineModal({ open: false, user: null });
offlineForm.resetFields();
}}
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
该操作属于真实线下成交。系统仍按“首购价 活动价 常规价”计算报价;实际成交总额留空时使用系统报价,也可以手工填写不小于 0 的金额。订单、订阅、首期积分和首购资格在同一事务内一起生效或一起回滚。
</Typography.Paragraph>
<Form form={offlineForm} layout="vertical" initialValues={{ quantity: 1, paymentMethod: 'bank_transfer' }}>
<Form.Item name="productId" label="成交套餐" rules={[{ required: true, message: '请选择成交套餐' }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="请选择个人订阅或团队订阅套餐"
options={offlineProducts.map((item) => ({
value: item.id,
label: `${item.productType === 'team_subscription' ? '团队订阅' : '个人订阅'}${item.name}`,
}))}
onChange={(value) => {
const product = offlineProducts.find((item) => item.id === value);
offlineForm.setFieldValue('quantity', product?.productType === 'team_subscription' ? 2 : 1);
}}
/>
</Form.Item>
{selectedOfflineProduct?.productType === 'team_subscription' && (
<Form.Item
name="quantity"
label="团队席位数量"
rules={[{ required: true, message: '请输入团队席位数量' }]}
extra="后台单张团队订阅允许 2~1000 席;成交后数量永久按订单快照固定。"
>
<InputNumber min={2} max={1000} precision={0} style={{ width: '100%' }} />
</Form.Item>
)}
<Form.Item
name="actualPaidAmount"
label="实际成交总额(元)"
extra="留空使用后端定价器计算出的系统报价;填写后以该整单总额作为实际收入。0 元属于合法真实成交,不按赠送处理。"
>
<InputNumber min={0} precision={2} stringMode style={{ width: '100%' }} placeholder="留空则使用系统报价" />
</Form.Item>
<Form.Item name="paymentMethod" label="线下收款方式" rules={[{ required: true, message: '请选择收款方式' }]}>
<Select options={[
{ value: 'bank_transfer', label: '银行转账' },
{ value: 'cash', label: '现金' },
{ value: 'other', label: '其他' },
]} />
</Form.Item>
<Form.Item name="offlineTradeNo" label="线下流水/凭证号(选填)">
<Input maxLength={128} placeholder="可填写银行流水号、收款凭证号等" />
</Form.Item>
<Form.Item name="offlinePaymentDetail" label="具体收款方式(选填)">
<Input maxLength={128} placeholder="未填写且选择“其他”时,系统显示“其他-线下收款”" />
</Form.Item>
<Form.Item name="remark" label="成交备注(选填)">
<Input.TextArea rows={3} maxLength={500} showCount placeholder="填写商务成交、售后置换等必要说明" />
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><WalletOutlined />积分明细 - {creditDetailModal.user?.username}</Space>}
open={creditDetailModal.open}
footer={null}
width={1180}
onCancel={() => {
setCreditDetailModal({ open: false, user: null });
setCreditSummary(null);
setCreditBalances([]);
setCreditBalanceStatus('');
}}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12 }}>
<Card size="small"><Typography.Text type="secondary">个人可用积分</Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.personalCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary">团队可用积分</Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.teamAvailableCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary">团队冻结积分</Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.teamFrozenCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary">总可消费积分</Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.availableCredits || creditSummary?.credits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary">最近即将过期积分</Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.nextExpiringCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary">最近最后可用时间</Typography.Text><div style={{ fontSize: 15, fontWeight: 600 }}>{creditSummary?.nextLastUsableAt ? formatDate(creditSummary.nextLastUsableAt) : '-'}</div></Card>
</div>
<Space>
<Typography.Text strong>积分批次</Typography.Text>
<Select
value={creditBalanceStatus}
style={{ width: 140 }}
options={[
{ value: '', label: '全部状态' },
{ value: 'scheduled', label: '未生效' },
{ value: 'active', label: '有效' },
{ value: 'consumed', label: '已消费' },
{ value: 'expired', label: '已过期' },
{ value: 'revoked', label: '已撤销' },
]}
onChange={(value) => creditDetailModal.user && openCreditDetailModal(creditDetailModal.user, value)}
/>
</Space>
<Table
size="small"
loading={creditDetailLoading}
rowKey="id"
pagination={false}
dataSource={creditBalances}
columns={[
{ title: '来源', dataIndex: 'sourceTypeLabel', width: 150, render: (v: string, r: any) => v || r.sourceType || '-' },
{ title: '来源ID', dataIndex: 'sourceId', width: 220, ellipsis: true, render: (v: string) => v || '-' },
{ title: '积分等级', dataIndex: 'creditLevelLabel', width: 100, render: (v: string, r: any) => v || r.creditLevel || '-' },
{ title: '发放', dataIndex: 'grantAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '剩余', dataIndex: 'unspentAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已消费', dataIndex: 'consumedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已过期', dataIndex: 'expiredAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已撤销', dataIndex: 'revokedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '生效时间', dataIndex: 'validFrom', width: 170, render: (v: string) => formatDate(v) },
{ title: '最后可用时间', dataIndex: 'lastUsableAt', width: 180, render: (v: string) => formatDate(v) },
{ title: '状态', dataIndex: 'status', width: 90, render: (v: string, r: any) => <Tag color={v === 'active' ? 'green' : v === 'expired' ? 'orange' : v === 'revoked' ? 'red' : 'default'}>{r.statusLabel || '其他状态'}</Tag> },
]}
scroll={{ x: 1410, y: 460 }}
/>
</Space>
</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>
<Modal
title={<Space><SafetyOutlined />单设备登录设置 - {singleDeviceModal.user?.username}</Space>}
open={singleDeviceModal.open}
onOk={() => {
if (singleDeviceModal.user) {
handleUpdateSingleDeviceOverride(singleDeviceModal.user, singleDeviceValue);
}
}}
onCancel={() => setSingleDeviceModal({ open: false, user: null })}
okText="保存" cancelText="取消" width={420}
confirmLoading={singleDeviceSaving}
>
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
当前全局设置:<Tag color={globalSingleDeviceEnabled ? 'green' : 'default'}>{globalSingleDeviceEnabled ? '已开启' : '已关闭'}</Tag>
</Typography.Text>
</div>
<Radio.Group
value={singleDeviceValue}
onChange={e => setSingleDeviceValue(e.target.value)}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Radio value={null}>
<Typography.Text>跟随全局</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
{globalSingleDeviceEnabled ? '当前受单设备登录限制' : '当前不受限制'}
</Typography.Text>
</Radio>
<Radio value={true}>
<Typography.Text>强制启用</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
(该用户始终启用单设备登录,不受全局影响)
</Typography.Text>
</Radio>
<Radio value={false}>
<Typography.Text>强制禁用</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
(该用户不受单设备登录限制,可多设备同时登录)
</Typography.Text>
</Radio>
</Space>
</Radio.Group>
</Modal>
</div>
);
};
export default AdminUsers;