diff --git a/apps/taro/README.md b/apps/taro/README.md index 1efed0c1..0113939b 100644 --- a/apps/taro/README.md +++ b/apps/taro/README.md @@ -61,11 +61,13 @@ pages/tenant-admin/dashboard/index 数据看板 pages/tenant-admin/students/index 学生与班级 pages/tenant-admin/content/index 内容入口、导入任务、字段模板、异步轮询、复检、公共题库采纳/同步/单条和批量冲突处理 pages/tenant-admin/marketing/index 优惠券、激活码、CRM、分佣摘要 -pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板 +pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板创建/编辑/停用 ``` 当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步任务轮询、导入问题查看、模板预览/下载、导入后复检详情,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。 +租户设置页已接入角色模板写操作第一版:可新建、编辑、停用非系统模板,并配置权限点、菜单可见、模块可见、字段可见和基础数据范围。前端只负责操作体验,`tenant_owner`、`tenant_admin`、通配权限和系统模板保护仍以后端校验与审计为准。 + ## 当前平台后台页面 已接入第一批真实后端 API: diff --git a/apps/taro/src/pages/tenant-admin/settings/index.tsx b/apps/taro/src/pages/tenant-admin/settings/index.tsx index 4ee9e3ac..6889f44f 100644 --- a/apps/taro/src/pages/tenant-admin/settings/index.tsx +++ b/apps/taro/src/pages/tenant-admin/settings/index.tsx @@ -1,39 +1,289 @@ -import { useEffect, useState } from 'react'; -import { Text, View } from '@tarojs/components'; +import { useEffect, useMemo, useState } from 'react'; +import Taro from '@tarojs/taro'; +import { Button, Input, Text, Textarea, View } from '@tarojs/components'; import { + disableRoleTemplate, loadAuthProviders, loadPaymentAccounts, loadRoleTemplates, loadTenantDomains, loadTenantOverview, + loadTenantPermissions, + upsertRoleTemplate, type TenantOverview, + type TenantPermissionCatalogItem, + type TenantPermissionsPayload, + type TenantRoleTemplateItem, } from '@/services/tenantAdmin'; import '../admin.css'; +type BooleanMapField = 'permissions' | 'menuPermissions' | 'modulePermissions' | 'fieldPermissions'; + +interface RoleTemplateForm { + id?: string; + code: string; + name: string; + description: string; + baseRole: string; + status: string; + sortOrder: string; + permissions: Record; + menuPermissions: Record; + modulePermissions: Record; + fieldPermissions: Record; + dataScopeMode: string; + ownLeadsOnly: boolean; +} + +const BASE_ROLE_OPTIONS = [ + { key: 'tenant_operator', label: '运营' }, + { key: 'teacher', label: '教师' }, + { key: 'sales', label: '销售' }, + { key: 'agent', label: '代理' }, + { key: 'student', label: '学生' }, + { key: 'tenant_admin', label: '管理员' }, +]; + +const STATUS_OPTIONS = [ + { key: 'active', label: '启用' }, + { key: 'disabled', label: '停用' }, + { key: 'archived', label: '归档' }, +]; + +const DATA_SCOPE_OPTIONS = [ + { key: 'tenant', label: '全租户' }, + { key: 'own', label: '本人' }, + { key: 'class', label: '班级' }, + { key: 'sales_team', label: '销售团队' }, +]; + +const MODULE_CATALOG: TenantPermissionCatalogItem[] = [ + { key: 'banners', label: 'Banner' }, + { key: 'announcements', label: '公告' }, + { key: 'faqs', label: 'FAQ' }, + { key: 'coupons', label: '优惠券' }, + { key: 'activation_codes', label: '激活码' }, + { key: 'badges', label: '勋章' }, + { key: 'imports', label: '导入' }, + { key: 'public_question_banks', label: '公共题库' }, + { key: 'assets', label: '资料' }, + { key: 'videos', label: '视频' }, + { key: 'scoreline', label: '分数线' }, + { key: 'vocabulary', label: '单词' }, + { key: 'handbook', label: '知识手册' }, + { key: 'classes', label: '班级' }, + { key: 'followups', label: '跟进' }, + { key: 'commissions', label: '分佣' }, + { key: 'crm_queue', label: 'CRM 队列' }, +]; + +function boolRecord(value: unknown) { + const source = value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + const result: Record = {}; + Object.entries(source).forEach(([key, raw]) => { + if (typeof raw === 'boolean') result[key] = raw; + }); + return result; +} + +function emptyRoleTemplateForm(): RoleTemplateForm { + return { + code: '', + name: '', + description: '', + baseRole: 'tenant_operator', + status: 'active', + sortOrder: '100', + permissions: {}, + menuPermissions: {}, + modulePermissions: {}, + fieldPermissions: {}, + dataScopeMode: 'tenant', + ownLeadsOnly: false, + }; +} + +function roleTemplateFormFrom(item?: TenantRoleTemplateItem | null): RoleTemplateForm { + if (!item) return emptyRoleTemplateForm(); + const dataScope = item.dataScope && typeof item.dataScope === 'object' ? item.dataScope : {}; + return { + id: item.id, + code: item.code || '', + name: item.name || '', + description: item.description || '', + baseRole: item.baseRole || 'tenant_operator', + status: item.status || 'active', + sortOrder: String(item.sortOrder ?? 100), + permissions: boolRecord(item.permissions), + menuPermissions: boolRecord(item.menuPermissions), + modulePermissions: boolRecord(item.modulePermissions), + fieldPermissions: boolRecord(item.fieldPermissions), + dataScopeMode: String(dataScope.mode || 'tenant'), + ownLeadsOnly: dataScope.ownLeadsOnly === true, + }; +} + +function catalogLabel(item: TenantPermissionCatalogItem) { + return item.label ? `${item.label}` : item.key; +} + export default function TenantSettingsPage() { const [overview, setOverview] = useState(null); const [domains, setDomains] = useState[]>([]); const [payments, setPayments] = useState[]>([]); const [authProviders, setAuthProviders] = useState[]>([]); - const [roles, setRoles] = useState[]>([]); + const [roles, setRoles] = useState([]); + const [permissionsPayload, setPermissionsPayload] = useState({}); + const [selectedRoleId, setSelectedRoleId] = useState(''); + const [roleForm, setRoleForm] = useState(() => emptyRoleTemplateForm()); + const [busy, setBusy] = useState(''); const [error, setError] = useState(''); - useEffect(() => { - Promise.all([ - loadTenantOverview().catch(() => ({ item: null })), - loadTenantDomains().catch(() => ({ items: [] })), - loadPaymentAccounts().catch(() => ({ items: [] })), - loadAuthProviders().catch(() => ({ items: [] })), - loadRoleTemplates().catch(() => ({ items: [] })), - ]).then(([overviewPayload, domainPayload, paymentPayload, authPayload, rolePayload]) => { + const selectedRole = useMemo( + () => roles.find(item => item.id === selectedRoleId) || null, + [roles, selectedRoleId], + ); + const fieldCatalog = permissionsPayload.fieldGroups || permissionsPayload.fieldCatalog || []; + const editingSystemRole = Boolean(selectedRole?.isSystem); + + async function reloadSettings(preferredRoleId = selectedRoleId) { + setError(''); + try { + const [overviewPayload, domainPayload, paymentPayload, authPayload, rolePayload, permissionPayload] = await Promise.all([ + loadTenantOverview().catch(() => ({ item: null })), + loadTenantDomains().catch(() => ({ items: [] })), + loadPaymentAccounts().catch(() => ({ items: [] })), + loadAuthProviders().catch(() => ({ items: [] })), + loadRoleTemplates().catch(() => ({ items: [] })), + loadTenantPermissions().catch(() => ({})), + ]); + const nextRoles = rolePayload.items || []; + const nextSelected = nextRoles.find(item => item.id === preferredRoleId) || nextRoles[0] || null; setOverview(overviewPayload.item || null); setDomains(domainPayload.items || []); setPayments(paymentPayload.items || []); setAuthProviders(authPayload.items || []); - setRoles(rolePayload.items || []); - }).catch(nextError => setError(nextError instanceof Error ? nextError.message : '设置加载失败')); + setRoles(nextRoles); + setPermissionsPayload(permissionPayload); + setSelectedRoleId(nextSelected?.id || ''); + setRoleForm(nextSelected ? roleTemplateFormFrom(nextSelected) : emptyRoleTemplateForm()); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '设置加载失败'); + } + } + + useEffect(() => { + void reloadSettings(''); }, []); + function startNewRole() { + setSelectedRoleId(''); + setRoleForm(emptyRoleTemplateForm()); + } + + function selectRole(item: TenantRoleTemplateItem) { + setSelectedRoleId(item.id); + setRoleForm(roleTemplateFormFrom(item)); + } + + function updateBooleanMap(field: BooleanMapField, key: string) { + setRoleForm(prev => ({ + ...prev, + [field]: { + ...prev[field], + [key]: !prev[field][key], + }, + })); + } + + async function submitRoleTemplate() { + if (!roleForm.name.trim() || !roleForm.code.trim()) { + Taro.showToast({ title: '名称和编码必填', icon: 'none' }); + return; + } + if (editingSystemRole) { + Taro.showToast({ title: '系统模板不可编辑', icon: 'none' }); + return; + } + const sortOrder = Number(roleForm.sortOrder || 100); + if (!Number.isFinite(sortOrder)) { + Taro.showToast({ title: '排序值无效', icon: 'none' }); + return; + } + + setBusy('save-role'); + setError(''); + try { + const result = await upsertRoleTemplate({ + id: roleForm.id, + code: roleForm.code.trim(), + name: roleForm.name.trim(), + description: roleForm.description.trim() || null, + baseRole: roleForm.baseRole, + status: roleForm.status, + sortOrder: Math.trunc(sortOrder), + permissions: roleForm.permissions, + menuPermissions: roleForm.menuPermissions, + modulePermissions: roleForm.modulePermissions, + fieldPermissions: roleForm.fieldPermissions, + dataScope: { + mode: roleForm.dataScopeMode, + ownLeadsOnly: roleForm.ownLeadsOnly, + }, + }); + const nextId = result.item?.id || roleForm.id || ''; + Taro.showToast({ title: '角色已保存', icon: 'success' }); + await reloadSettings(nextId); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '角色保存失败'); + } finally { + setBusy(''); + } + } + + async function disableTemplate(item: TenantRoleTemplateItem) { + if (item.isSystem) { + Taro.showToast({ title: '系统模板不可停用', icon: 'none' }); + return; + } + const confirmed = await Taro.showModal({ + title: '停用角色模板', + content: `确认停用 ${item.name || item.code}?`, + confirmText: '停用', + cancelText: '取消', + }); + if (!confirmed.confirm) return; + + setBusy(`disable:${item.id}`); + setError(''); + try { + await disableRoleTemplate(item.id); + Taro.showToast({ title: '已停用', icon: 'success' }); + await reloadSettings(item.id); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '角色停用失败'); + } finally { + setBusy(''); + } + } + + function renderToggleGroup(items: TenantPermissionCatalogItem[], field: BooleanMapField) { + if (!items.length) return 暂无可配置项。; + return ( + + {items.map(item => ( + + ))} + + ); + } + return ( @@ -91,17 +341,138 @@ export default function TenantSettingsPage() { - 角色模板 + + 角色模板 + + - {roles.map((item, index) => ( - - {String(item.name || item.roleKey || '角色模板')} - {String(item.status || 'active')} · 菜单和字段权限以后端模板为准 + {roles.map(item => ( + + {item.name || item.code} + {item.code} · {item.baseRole || '-'} · {item.status || 'active'}{item.isSystem ? ' · system' : ''} + + + {!item.isSystem ? ( + + ) : null} + ))} {!roles.length ? 暂无自定义角色模板。 : null} + + + {selectedRoleId ? '编辑角色模板' : '新建角色模板'} + {editingSystemRole ? 系统模板不可编辑,可新建自定义模板承载租户权限方案。 : null} + + setRoleForm(prev => ({ ...prev, name: String(event.detail.value || '') }))} + /> + setRoleForm(prev => ({ ...prev, code: String(event.detail.value || '') }))} + /> + setRoleForm(prev => ({ ...prev, sortOrder: String(event.detail.value || '') }))} + /> +