feat: add tenant role template console

This commit is contained in:
Codex
2026-06-29 14:29:36 +08:00
parent c00992aab5
commit 1b01b923e8
9 changed files with 471 additions and 36 deletions

View File

@@ -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

View File

@@ -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<string, boolean>;
menuPermissions: Record<string, boolean>;
modulePermissions: Record<string, boolean>;
fieldPermissions: Record<string, boolean>;
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<string, unknown> : {};
const result: Record<string, boolean> = {};
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<TenantOverview | null>(null);
const [domains, setDomains] = useState<Record<string, unknown>[]>([]);
const [payments, setPayments] = useState<Record<string, unknown>[]>([]);
const [authProviders, setAuthProviders] = useState<Record<string, unknown>[]>([]);
const [roles, setRoles] = useState<Record<string, unknown>[]>([]);
const [roles, setRoles] = useState<TenantRoleTemplateItem[]>([]);
const [permissionsPayload, setPermissionsPayload] = useState<TenantPermissionsPayload>({});
const [selectedRoleId, setSelectedRoleId] = useState('');
const [roleForm, setRoleForm] = useState<RoleTemplateForm>(() => 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 <View className='admin-empty'></View>;
return (
<View className='admin-actions compact'>
{items.map(item => (
<Button
key={item.key}
className={`admin-mini-button ${roleForm[field][item.key] ? 'primary' : ''}`}
onClick={() => updateBooleanMap(field, item.key)}
>
{catalogLabel(item)}
</Button>
))}
</View>
);
}
return (
<View className='admin-page'>
<View className='admin-shell'>
@@ -91,17 +341,138 @@ export default function TenantSettingsPage() {
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-actions compact'>
<Text className='admin-section-title'></Text>
<Button className='admin-button primary' onClick={startNewRole}></Button>
</View>
<View className='admin-list'>
{roles.map((item, index) => (
<View className='admin-row' key={String(item.id || index)}>
<Text className='admin-row-main'>{String(item.name || item.roleKey || '角色模板')}</Text>
<Text className='admin-row-meta'>{String(item.status || 'active')} · </Text>
{roles.map(item => (
<View className='admin-row' key={item.id}>
<Text className='admin-row-main'>{item.name || item.code}</Text>
<Text className='admin-row-meta'>{item.code} · {item.baseRole || '-'} · {item.status || 'active'}{item.isSystem ? ' · system' : ''}</Text>
<View className='admin-row-actions'>
<Button className='admin-mini-button primary' onClick={() => selectRole(item)}></Button>
{!item.isSystem ? (
<Button className='admin-mini-button' loading={busy === `disable:${item.id}`} onClick={() => disableTemplate(item)}></Button>
) : null}
</View>
</View>
))}
</View>
{!roles.length ? <View className='admin-empty'></View> : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'>{selectedRoleId ? '编辑角色模板' : '新建角色模板'}</Text>
{editingSystemRole ? <View className='admin-empty'></View> : null}
<View className='admin-form-grid'>
<Input
className='admin-input'
placeholder='模板名称'
value={roleForm.name}
onInput={event => setRoleForm(prev => ({ ...prev, name: String(event.detail.value || '') }))}
/>
<Input
className='admin-input'
placeholder='模板编码,例如 ops-marketing'
value={roleForm.code}
disabled={Boolean(roleForm.id)}
onInput={event => setRoleForm(prev => ({ ...prev, code: String(event.detail.value || '') }))}
/>
<Input
className='admin-input'
placeholder='排序,数字越小越靠前'
type='number'
value={roleForm.sortOrder}
onInput={event => setRoleForm(prev => ({ ...prev, sortOrder: String(event.detail.value || '') }))}
/>
<Textarea
className='admin-textarea'
placeholder='角色说明'
value={roleForm.description}
onInput={event => setRoleForm(prev => ({ ...prev, description: String(event.detail.value || '') }))}
/>
</View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-actions compact'>
{BASE_ROLE_OPTIONS.map(item => (
<Button
key={item.key}
className={`admin-button ${roleForm.baseRole === item.key ? 'active' : ''}`}
onClick={() => setRoleForm(prev => ({ ...prev, baseRole: item.key }))}
>
{item.label}
</Button>
))}
</View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-actions compact'>
{STATUS_OPTIONS.map(item => (
<Button
key={item.key}
className={`admin-button ${roleForm.status === item.key ? 'active' : ''}`}
onClick={() => setRoleForm(prev => ({ ...prev, status: item.key }))}
>
{item.label}
</Button>
))}
</View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
{renderToggleGroup(permissionsPayload.permissions || [], 'permissions')}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
{renderToggleGroup(permissionsPayload.menuGroups || [], 'menuPermissions')}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
{renderToggleGroup(MODULE_CATALOG, 'modulePermissions')}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
{renderToggleGroup(fieldCatalog, 'fieldPermissions')}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-actions compact'>
{DATA_SCOPE_OPTIONS.map(item => (
<Button
key={item.key}
className={`admin-button ${roleForm.dataScopeMode === item.key ? 'active' : ''}`}
onClick={() => setRoleForm(prev => ({ ...prev, dataScopeMode: item.key }))}
>
{item.label}
</Button>
))}
<Button
className={`admin-button ${roleForm.ownLeadsOnly ? 'active' : ''}`}
onClick={() => setRoleForm(prev => ({ ...prev, ownLeadsOnly: !prev.ownLeadsOnly }))}
>
</Button>
</View>
</View>
<View className='admin-section'>
<View className='admin-actions compact'>
<Button className='admin-button primary' loading={busy === 'save-role'} onClick={submitRoleTemplate}></Button>
<Button className='admin-button' onClick={() => reloadSettings(selectedRoleId)}></Button>
</View>
</View>
{error ? <Text className='admin-error'>{error}</Text> : null}
</View>
</View>

View File

@@ -249,6 +249,54 @@ export interface TenantOverview {
publicConfig?: Record<string, unknown>;
}
export interface TenantPermissionCatalogItem {
key: string;
label?: string;
description?: string;
}
export interface TenantPermissionsPayload {
current?: Record<string, unknown>;
permissions?: TenantPermissionCatalogItem[];
menuGroups?: TenantPermissionCatalogItem[];
fieldGroups?: TenantPermissionCatalogItem[];
fieldCatalog?: TenantPermissionCatalogItem[];
roleDefaults?: Record<string, string[]>;
}
export interface TenantRoleTemplateItem {
id: string;
code: string;
name: string;
description?: string | null;
baseRole?: string;
status?: string;
permissions?: Record<string, boolean>;
menuPermissions?: Record<string, boolean>;
modulePermissions?: Record<string, boolean>;
fieldPermissions?: Record<string, boolean>;
dataScope?: Record<string, unknown>;
isSystem?: boolean;
sortOrder?: number;
createdAt?: string;
updatedAt?: string;
}
export interface TenantRoleTemplateInput {
id?: string;
code: string;
name: string;
description?: string | null;
baseRole: string;
status?: string;
permissions?: Record<string, boolean>;
menuPermissions?: Record<string, boolean>;
modulePermissions?: Record<string, boolean>;
fieldPermissions?: Record<string, boolean>;
dataScope?: Record<string, unknown>;
sortOrder?: number;
}
export async function loadTenantDashboard(timeRange = '30d') {
return apiRequest<{ item?: TenantDashboard }>('/api/tenant-admin/dashboard', { query: { timeRange } });
}
@@ -258,7 +306,7 @@ export async function loadTenantOverview() {
}
export async function loadTenantPermissions() {
return apiRequest<{ items?: Record<string, unknown>[]; modules?: Record<string, unknown>[] }>('/api/tenant-admin/permissions');
return apiRequest<TenantPermissionsPayload>('/api/tenant-admin/permissions');
}
export async function loadTenantClasses(limit = 50) {
@@ -394,7 +442,21 @@ export async function loadAuthProviders() {
}
export async function loadRoleTemplates() {
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/tenant-admin/role-templates');
return apiRequest<{ items?: TenantRoleTemplateItem[] }>('/api/tenant-admin/role-templates');
}
export async function upsertRoleTemplate(input: TenantRoleTemplateInput) {
return apiRequest<{ item?: TenantRoleTemplateItem }>('/api/tenant-admin/role-templates', {
method: 'PUT',
body: input,
});
}
export async function disableRoleTemplate(roleTemplateId: string) {
return apiRequest<{ item?: TenantRoleTemplateItem }>('/api/tenant-admin/role-templates/disable', {
method: 'POST',
body: { roleTemplateId },
});
}
export async function loadCrmQueue(limit = 20) {