feat: bind tenant members to role templates

This commit is contained in:
Codex
2026-06-29 14:37:09 +08:00
parent 1b01b923e8
commit da7b461702
8 changed files with 440 additions and 26 deletions

View File

@@ -56,17 +56,17 @@ pages/student/profile/index 个人中心、会员、订单、签到、激
已接入第一批真实后端 API
```text
pages/tenant-admin/workbench/index 租户后台工作台
pages/tenant-admin/workbench/index 租户后台工作台、权限驱动模块入口
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/tenant-admin/permissions`,按菜单权限和有效权限隐藏不可见模块。租户设置页已接入角色模板与成员绑定写操作第一版:可新建、编辑、停用非系统模板,配置权限点、菜单可见、模块可见、字段可见和基础数据范围,也可以搜索/新建成员、绑定角色模板、设置成员状态和额外权限覆盖。前端只负责操作体验,`tenant_owner``tenant_admin`、通配权限系统模板保护和最后一名 owner 保护仍以后端校验与审计为准。
## 当前平台后台页面

View File

@@ -2,14 +2,18 @@ import { useEffect, useMemo, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Input, Text, Textarea, View } from '@tarojs/components';
import {
disableTenantMember,
disableRoleTemplate,
loadAuthProviders,
loadTenantMembers,
loadPaymentAccounts,
loadRoleTemplates,
loadTenantDomains,
loadTenantOverview,
loadTenantPermissions,
upsertTenantMember,
upsertRoleTemplate,
type TenantMemberItem,
type TenantOverview,
type TenantPermissionCatalogItem,
type TenantPermissionsPayload,
@@ -35,6 +39,19 @@ interface RoleTemplateForm {
ownLeadsOnly: boolean;
}
interface MemberForm {
membershipId?: string;
userId: string;
username: string;
name: string;
phone: string;
email: string;
role: string;
status: string;
roleTemplateId: string;
permissions: Record<string, boolean>;
}
const BASE_ROLE_OPTIONS = [
{ key: 'tenant_operator', label: '运营' },
{ key: 'teacher', label: '教师' },
@@ -44,6 +61,12 @@ const BASE_ROLE_OPTIONS = [
{ key: 'tenant_admin', label: '管理员' },
];
const MEMBER_STATUS_OPTIONS = [
{ key: 'active', label: '启用' },
{ key: 'invited', label: '邀请中' },
{ key: 'disabled', label: '停用' },
];
const STATUS_OPTIONS = [
{ key: 'active', label: '启用' },
{ key: 'disabled', label: '停用' },
@@ -103,6 +126,20 @@ function emptyRoleTemplateForm(): RoleTemplateForm {
};
}
function emptyMemberForm(): MemberForm {
return {
userId: '',
username: '',
name: '',
phone: '',
email: '',
role: 'tenant_operator',
status: 'active',
roleTemplateId: '',
permissions: {},
};
}
function roleTemplateFormFrom(item?: TenantRoleTemplateItem | null): RoleTemplateForm {
if (!item) return emptyRoleTemplateForm();
const dataScope = item.dataScope && typeof item.dataScope === 'object' ? item.dataScope : {};
@@ -123,6 +160,22 @@ function roleTemplateFormFrom(item?: TenantRoleTemplateItem | null): RoleTemplat
};
}
function memberFormFrom(item?: TenantMemberItem | null): MemberForm {
if (!item) return emptyMemberForm();
return {
membershipId: item.id,
userId: item.userId || '',
username: item.username || '',
name: item.name || '',
phone: item.phone || '',
email: item.email || '',
role: item.role || 'tenant_operator',
status: item.status || 'active',
roleTemplateId: item.roleTemplateId || '',
permissions: boolRecord(item.permissions),
};
}
function catalogLabel(item: TenantPermissionCatalogItem) {
return item.label ? `${item.label}` : item.key;
}
@@ -133,9 +186,13 @@ export default function TenantSettingsPage() {
const [payments, setPayments] = useState<Record<string, unknown>[]>([]);
const [authProviders, setAuthProviders] = useState<Record<string, unknown>[]>([]);
const [roles, setRoles] = useState<TenantRoleTemplateItem[]>([]);
const [members, setMembers] = useState<TenantMemberItem[]>([]);
const [permissionsPayload, setPermissionsPayload] = useState<TenantPermissionsPayload>({});
const [selectedRoleId, setSelectedRoleId] = useState('');
const [roleForm, setRoleForm] = useState<RoleTemplateForm>(() => emptyRoleTemplateForm());
const [memberKeyword, setMemberKeyword] = useState('');
const [selectedMemberId, setSelectedMemberId] = useState('');
const [memberForm, setMemberForm] = useState<MemberForm>(() => emptyMemberForm());
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
@@ -145,6 +202,7 @@ export default function TenantSettingsPage() {
);
const fieldCatalog = permissionsPayload.fieldGroups || permissionsPayload.fieldCatalog || [];
const editingSystemRole = Boolean(selectedRole?.isSystem);
const activeRoleTemplates = roles.filter(item => item.status === 'active');
async function reloadSettings(preferredRoleId = selectedRoleId) {
setError('');
@@ -172,8 +230,23 @@ export default function TenantSettingsPage() {
}
}
async function reloadMembers(preferredMemberId = selectedMemberId, keyword = memberKeyword) {
setError('');
try {
const payload = await loadTenantMembers({ keyword, limit: 80 });
const nextMembers = payload.items || [];
const nextSelected = nextMembers.find(item => item.id === preferredMemberId) || nextMembers[0] || null;
setMembers(nextMembers);
setSelectedMemberId(nextSelected?.id || '');
setMemberForm(nextSelected ? memberFormFrom(nextSelected) : emptyMemberForm());
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '成员加载失败');
}
}
useEffect(() => {
void reloadSettings('');
void reloadMembers('');
}, []);
function startNewRole() {
@@ -186,6 +259,16 @@ export default function TenantSettingsPage() {
setRoleForm(roleTemplateFormFrom(item));
}
function startNewMember() {
setSelectedMemberId('');
setMemberForm(emptyMemberForm());
}
function selectMember(item: TenantMemberItem) {
setSelectedMemberId(item.id);
setMemberForm(memberFormFrom(item));
}
function updateBooleanMap(field: BooleanMapField, key: string) {
setRoleForm(prev => ({
...prev,
@@ -196,6 +279,16 @@ export default function TenantSettingsPage() {
}));
}
function updateMemberPermission(key: string) {
setMemberForm(prev => ({
...prev,
permissions: {
...prev.permissions,
[key]: !prev.permissions[key],
},
}));
}
async function submitRoleTemplate() {
if (!roleForm.name.trim() || !roleForm.code.trim()) {
Taro.showToast({ title: '名称和编码必填', icon: 'none' });
@@ -267,6 +360,60 @@ export default function TenantSettingsPage() {
}
}
async function submitMember() {
const hasIdentity = memberForm.userId.trim() || memberForm.phone.trim() || memberForm.email.trim() || memberForm.username.trim() || memberForm.name.trim();
if (!hasIdentity) {
Taro.showToast({ title: '请填写成员身份', icon: 'none' });
return;
}
setBusy('save-member');
setError('');
try {
const selectedTemplate = activeRoleTemplates.find(item => item.id === memberForm.roleTemplateId);
const result = await upsertTenantMember({
membershipId: memberForm.membershipId,
userId: memberForm.userId.trim() || undefined,
username: memberForm.username.trim() || null,
name: memberForm.name.trim() || null,
phone: memberForm.phone.trim() || null,
email: memberForm.email.trim() || null,
primaryRole: selectedTemplate?.baseRole || memberForm.role,
role: selectedTemplate?.baseRole || memberForm.role,
status: memberForm.status,
roleTemplateId: memberForm.roleTemplateId || null,
permissions: memberForm.permissions,
});
const nextId = result.item?.id || memberForm.membershipId || '';
Taro.showToast({ title: '成员已保存', icon: 'success' });
await reloadMembers(nextId);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '成员保存失败');
} finally {
setBusy('');
}
}
async function disableMember(item: TenantMemberItem) {
const confirmed = await Taro.showModal({
title: '停用成员',
content: `确认停用 ${item.name || item.username || item.phone || item.email || item.userId}`,
confirmText: '停用',
cancelText: '取消',
});
if (!confirmed.confirm) return;
setBusy(`disable-member:${item.id}`);
setError('');
try {
await disableTenantMember(item.id);
Taro.showToast({ title: '成员已停用', icon: 'success' });
await reloadMembers(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 (
@@ -284,6 +431,23 @@ export default function TenantSettingsPage() {
);
}
function renderMemberPermissionGroup(items: TenantPermissionCatalogItem[]) {
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 ${memberForm.permissions[item.key] ? 'primary' : ''}`}
onClick={() => updateMemberPermission(item.key)}
>
{catalogLabel(item)}
</Button>
))}
</View>
);
}
return (
<View className='admin-page'>
<View className='admin-shell'>
@@ -473,6 +637,139 @@ export default function TenantSettingsPage() {
</View>
</View>
<View className='admin-section'>
<View className='admin-actions compact'>
<Text className='admin-section-title'></Text>
<Button className='admin-button primary' onClick={startNewMember}></Button>
</View>
<View className='admin-form-grid'>
<Input
className='admin-input'
placeholder='搜索姓名、手机号、邮箱、用户名'
value={memberKeyword}
onInput={event => setMemberKeyword(String(event.detail.value || ''))}
/>
<Button className='admin-button primary' onClick={() => reloadMembers('', memberKeyword)}></Button>
</View>
<View className='admin-list'>
{members.map(item => (
<View className='admin-row' key={item.id}>
<Text className='admin-row-main'>{item.name || item.username || item.phone || item.email || item.userId}</Text>
<Text className='admin-row-meta'>{item.role} · {item.status || 'active'} · {item.roleTemplateName || item.roleTemplateCode || '未绑定模板'}</Text>
<Text className='admin-row-meta'>{item.phone || '-'} · {item.email || '-'}</Text>
<View className='admin-row-actions'>
<Button className='admin-mini-button primary' onClick={() => selectMember(item)}></Button>
{item.status !== 'disabled' ? (
<Button className='admin-mini-button' loading={busy === `disable-member:${item.id}`} onClick={() => disableMember(item)}></Button>
) : null}
</View>
</View>
))}
</View>
{!members.length ? <View className='admin-empty'></View> : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'>{selectedMemberId ? '编辑成员权限' : '新建成员权限'}</Text>
<View className='admin-form-grid'>
<Input
className='admin-input'
placeholder='已有用户 ID可留空用手机号/邮箱创建或匹配'
value={memberForm.userId}
disabled={Boolean(memberForm.membershipId)}
onInput={event => setMemberForm(prev => ({ ...prev, userId: String(event.detail.value || '') }))}
/>
<Input
className='admin-input'
placeholder='姓名'
value={memberForm.name}
onInput={event => setMemberForm(prev => ({ ...prev, name: String(event.detail.value || '') }))}
/>
<Input
className='admin-input'
placeholder='用户名'
value={memberForm.username}
onInput={event => setMemberForm(prev => ({ ...prev, username: String(event.detail.value || '') }))}
/>
<Input
className='admin-input'
placeholder='手机号'
type='number'
value={memberForm.phone}
onInput={event => setMemberForm(prev => ({ ...prev, phone: String(event.detail.value || '') }))}
/>
<Input
className='admin-input'
placeholder='邮箱'
value={memberForm.email}
onInput={event => setMemberForm(prev => ({ ...prev, email: String(event.detail.value || '') }))}
/>
</View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-actions compact'>
<Button
className={`admin-button ${memberForm.roleTemplateId ? '' : 'active'}`}
onClick={() => setMemberForm(prev => ({ ...prev, roleTemplateId: '' }))}
>
</Button>
{activeRoleTemplates.map(item => (
<Button
key={item.id}
className={`admin-button ${memberForm.roleTemplateId === item.id ? 'active' : ''}`}
onClick={() => setMemberForm(prev => ({ ...prev, roleTemplateId: item.id, role: item.baseRole || prev.role }))}
>
{item.name || item.code}
</Button>
))}
</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 ${memberForm.role === item.key ? 'active' : ''}`}
onClick={() => setMemberForm(prev => ({ ...prev, role: item.key, roleTemplateId: '' }))}
>
{item.label}
</Button>
))}
</View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-actions compact'>
{MEMBER_STATUS_OPTIONS.map(item => (
<Button
key={item.key}
className={`admin-button ${memberForm.status === item.key ? 'active' : ''}`}
onClick={() => setMemberForm(prev => ({ ...prev, status: item.key }))}
>
{item.label}
</Button>
))}
</View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
{renderMemberPermissionGroup(permissionsPayload.permissions || [])}
</View>
<View className='admin-section'>
<View className='admin-actions compact'>
<Button className='admin-button primary' loading={busy === 'save-member'} onClick={submitMember}></Button>
<Button className='admin-button' onClick={() => reloadMembers(selectedMemberId)}></Button>
</View>
</View>
{error ? <Text className='admin-error'>{error}</Text> : null}
</View>
</View>

View File

@@ -2,28 +2,84 @@ import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import { getTenantContext } from '@/services/api';
import { loadTenantDashboard, loadTenantOverview, type TenantDashboard, type TenantOverview } from '@/services/tenantAdmin';
import {
loadTenantDashboard,
loadTenantOverview,
loadTenantPermissions,
type TenantDashboard,
type TenantOverview,
type TenantPermissionsPayload,
} from '@/services/tenantAdmin';
import '../admin.css';
interface AdminModule {
key: string;
name: string;
path: string;
meta: string;
permission?: string;
}
const MODULES: AdminModule[] = [
{ key: 'dashboard', name: '数据看板', path: '/pages/tenant-admin/dashboard/index', meta: '收益、注册、学习、内容', permission: 'dashboard:read' },
{ key: 'students', name: '学生运营', path: '/pages/tenant-admin/students/index', meta: '学生、班级、教师范围', permission: 'students:read' },
{ key: 'content', name: '题库内容', path: '/pages/tenant-admin/content/index', meta: '入口、导入、公共题库', permission: 'content:*' },
{ key: 'marketing', name: '营销中心', path: '/pages/tenant-admin/marketing/index', meta: '优惠券、激活码、分佣', permission: 'marketing:read' },
{ key: 'settings', name: '租户设置', path: '/pages/tenant-admin/settings/index', meta: '品牌、域名、支付、登录、角色', permission: 'tenant:overview:read' },
];
function boolRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function explicitPermission(permissions: Record<string, unknown>, permission: string) {
const parts = permission.split(':').filter(Boolean);
const candidates = [permission];
for (let index = parts.length - 1; index >= 1; index -= 1) {
candidates.push(`${parts.slice(0, index).join(':')}:*`);
}
candidates.push('*');
for (const key of candidates) {
if (typeof permissions[key] === 'boolean') return permissions[key] as boolean;
}
return null;
}
function canOpenModule(payload: TenantPermissionsPayload, item: AdminModule) {
const current = payload.current || {};
const menuPermissions = boolRecord(current.menuPermissions);
if (typeof menuPermissions[item.key] === 'boolean') return menuPermissions[item.key] as boolean;
if (!item.permission) return true;
const effectivePermissions = boolRecord(current.effectivePermissions);
const explicit = explicitPermission(effectivePermissions, item.permission);
if (explicit !== null) return explicit;
const role = String(current.role || '');
const defaults = payload.roleDefaults?.[role] || [];
return defaults.some(permission => {
if (permission === '*') return true;
if (permission === item.permission) return true;
if (permission.endsWith(':*')) return item.permission?.startsWith(permission.slice(0, -1));
return false;
});
}
export default function TenantWorkbenchPage() {
const tenant = getTenantContext();
const [dashboard, setDashboard] = useState<TenantDashboard | null>(null);
const [overview, setOverview] = useState<TenantOverview | null>(null);
const [permissions, setPermissions] = useState<TenantPermissionsPayload>({});
useEffect(() => {
loadTenantDashboard('30d').then(payload => setDashboard(payload.item || null)).catch(() => setDashboard(null));
loadTenantOverview().then(payload => setOverview(payload.item || null)).catch(() => setOverview(null));
loadTenantPermissions().then(payload => setPermissions(payload)).catch(() => setPermissions({}));
}, []);
const cards = dashboard?.cards || {};
const payment = dashboard?.paymentStats || {};
const modules = [
{ name: '数据看板', path: '/pages/tenant-admin/dashboard/index', meta: '收益、注册、学习、内容' },
{ name: '学生运营', path: '/pages/tenant-admin/students/index', meta: '学生、班级、教师范围' },
{ name: '题库内容', path: '/pages/tenant-admin/content/index', meta: '入口、导入、公共题库' },
{ name: '营销中心', path: '/pages/tenant-admin/marketing/index', meta: '优惠券、激活码、分佣' },
{ name: '租户设置', path: '/pages/tenant-admin/settings/index', meta: '品牌、域名、支付、登录' },
];
const modules = MODULES.filter(item => canOpenModule(permissions, item));
return (
<View className='admin-page'>
@@ -63,6 +119,7 @@ export default function TenantWorkbenchPage() {
</View>
))}
</View>
{!modules.length ? <View className='admin-empty'></View> : null}
</View>
<View className='admin-actions'>

View File

@@ -297,6 +297,45 @@ export interface TenantRoleTemplateInput {
sortOrder?: number;
}
export interface TenantMemberItem {
id: string;
userId: string;
username?: string | null;
name?: string | null;
phone?: string | null;
email?: string | null;
avatarUrl?: string | null;
primaryRole?: string | null;
role: string;
status?: string;
permissions?: Record<string, boolean>;
roleTemplateId?: string | null;
roleTemplateCode?: string | null;
roleTemplateName?: string | null;
menuPermissions?: Record<string, boolean>;
modulePermissions?: Record<string, boolean>;
fieldPermissions?: Record<string, boolean>;
dataScope?: Record<string, unknown>;
legacyRole?: string | null;
lastSeenAt?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface TenantMemberInput {
membershipId?: string;
userId?: string;
username?: string | null;
name?: string | null;
phone?: string | null;
email?: string | null;
primaryRole?: string | null;
role: string;
status?: string;
permissions?: Record<string, boolean>;
roleTemplateId?: string | null;
}
export async function loadTenantDashboard(timeRange = '30d') {
return apiRequest<{ item?: TenantDashboard }>('/api/tenant-admin/dashboard', { query: { timeRange } });
}
@@ -459,6 +498,26 @@ export async function disableRoleTemplate(roleTemplateId: string) {
});
}
export async function loadTenantMembers(query: { keyword?: string; role?: string; status?: string; limit?: number } = {}) {
return apiRequest<{ items?: TenantMemberItem[] }>('/api/tenant-admin/members', {
query: { ...query, limit: query.limit || 50 },
});
}
export async function upsertTenantMember(input: TenantMemberInput) {
return apiRequest<{ item?: TenantMemberItem }>('/api/tenant-admin/members', {
method: 'PUT',
body: input,
});
}
export async function disableTenantMember(membershipId: string) {
return apiRequest<{ item?: TenantMemberItem }>('/api/tenant-admin/members/disable', {
method: 'POST',
body: { membershipId },
});
}
export async function loadCrmQueue(limit = 20) {
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/crm/queue', { query: { limit } });
}