forked from wangziqi/gongxue-base
feat: bind tenant members to role templates
This commit is contained in:
@@ -56,17 +56,17 @@ pages/student/profile/index 个人中心、会员、订单、签到、激
|
|||||||
已接入第一批真实后端 API:
|
已接入第一批真实后端 API:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
pages/tenant-admin/workbench/index 租户后台工作台
|
pages/tenant-admin/workbench/index 租户后台工作台、权限驱动模块入口
|
||||||
pages/tenant-admin/dashboard/index 数据看板
|
pages/tenant-admin/dashboard/index 数据看板
|
||||||
pages/tenant-admin/students/index 学生与班级
|
pages/tenant-admin/students/index 学生与班级
|
||||||
pages/tenant-admin/content/index 内容入口、导入任务、字段模板、异步轮询、复检、公共题库采纳/同步/单条和批量冲突处理
|
pages/tenant-admin/content/index 内容入口、导入任务、字段模板、异步轮询、复检、公共题库采纳/同步/单条和批量冲突处理
|
||||||
pages/tenant-admin/marketing/index 优惠券、激活码、CRM、分佣摘要
|
pages/tenant-admin/marketing/index 优惠券、激活码、CRM、分佣摘要
|
||||||
pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板创建/编辑/停用
|
pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板和成员绑定
|
||||||
```
|
```
|
||||||
|
|
||||||
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步任务轮询、导入问题查看、模板预览/下载、导入后复检详情,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
|
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步任务轮询、导入问题查看、模板预览/下载、导入后复检详情,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
|
||||||
|
|
||||||
租户设置页已接入角色模板写操作第一版:可新建、编辑、停用非系统模板,并配置权限点、菜单可见、模块可见、字段可见和基础数据范围。前端只负责操作体验,`tenant_owner`、`tenant_admin`、通配权限和系统模板保护仍以后端校验与审计为准。
|
租户工作台会读取 `/api/tenant-admin/permissions`,按菜单权限和有效权限隐藏不可见模块。租户设置页已接入角色模板与成员绑定写操作第一版:可新建、编辑、停用非系统模板,配置权限点、菜单可见、模块可见、字段可见和基础数据范围,也可以搜索/新建成员、绑定角色模板、设置成员状态和额外权限覆盖。前端只负责操作体验,`tenant_owner`、`tenant_admin`、通配权限、系统模板保护和最后一名 owner 保护仍以后端校验与审计为准。
|
||||||
|
|
||||||
## 当前平台后台页面
|
## 当前平台后台页面
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import { Button, Input, Text, Textarea, View } from '@tarojs/components';
|
import { Button, Input, Text, Textarea, View } from '@tarojs/components';
|
||||||
import {
|
import {
|
||||||
|
disableTenantMember,
|
||||||
disableRoleTemplate,
|
disableRoleTemplate,
|
||||||
loadAuthProviders,
|
loadAuthProviders,
|
||||||
|
loadTenantMembers,
|
||||||
loadPaymentAccounts,
|
loadPaymentAccounts,
|
||||||
loadRoleTemplates,
|
loadRoleTemplates,
|
||||||
loadTenantDomains,
|
loadTenantDomains,
|
||||||
loadTenantOverview,
|
loadTenantOverview,
|
||||||
loadTenantPermissions,
|
loadTenantPermissions,
|
||||||
|
upsertTenantMember,
|
||||||
upsertRoleTemplate,
|
upsertRoleTemplate,
|
||||||
|
type TenantMemberItem,
|
||||||
type TenantOverview,
|
type TenantOverview,
|
||||||
type TenantPermissionCatalogItem,
|
type TenantPermissionCatalogItem,
|
||||||
type TenantPermissionsPayload,
|
type TenantPermissionsPayload,
|
||||||
@@ -35,6 +39,19 @@ interface RoleTemplateForm {
|
|||||||
ownLeadsOnly: boolean;
|
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 = [
|
const BASE_ROLE_OPTIONS = [
|
||||||
{ key: 'tenant_operator', label: '运营' },
|
{ key: 'tenant_operator', label: '运营' },
|
||||||
{ key: 'teacher', label: '教师' },
|
{ key: 'teacher', label: '教师' },
|
||||||
@@ -44,6 +61,12 @@ const BASE_ROLE_OPTIONS = [
|
|||||||
{ key: 'tenant_admin', label: '管理员' },
|
{ key: 'tenant_admin', label: '管理员' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const MEMBER_STATUS_OPTIONS = [
|
||||||
|
{ key: 'active', label: '启用' },
|
||||||
|
{ key: 'invited', label: '邀请中' },
|
||||||
|
{ key: 'disabled', label: '停用' },
|
||||||
|
];
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
{ key: 'active', label: '启用' },
|
{ key: 'active', label: '启用' },
|
||||||
{ key: 'disabled', 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 {
|
function roleTemplateFormFrom(item?: TenantRoleTemplateItem | null): RoleTemplateForm {
|
||||||
if (!item) return emptyRoleTemplateForm();
|
if (!item) return emptyRoleTemplateForm();
|
||||||
const dataScope = item.dataScope && typeof item.dataScope === 'object' ? item.dataScope : {};
|
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) {
|
function catalogLabel(item: TenantPermissionCatalogItem) {
|
||||||
return item.label ? `${item.label}` : item.key;
|
return item.label ? `${item.label}` : item.key;
|
||||||
}
|
}
|
||||||
@@ -133,9 +186,13 @@ export default function TenantSettingsPage() {
|
|||||||
const [payments, setPayments] = useState<Record<string, unknown>[]>([]);
|
const [payments, setPayments] = useState<Record<string, unknown>[]>([]);
|
||||||
const [authProviders, setAuthProviders] = useState<Record<string, unknown>[]>([]);
|
const [authProviders, setAuthProviders] = useState<Record<string, unknown>[]>([]);
|
||||||
const [roles, setRoles] = useState<TenantRoleTemplateItem[]>([]);
|
const [roles, setRoles] = useState<TenantRoleTemplateItem[]>([]);
|
||||||
|
const [members, setMembers] = useState<TenantMemberItem[]>([]);
|
||||||
const [permissionsPayload, setPermissionsPayload] = useState<TenantPermissionsPayload>({});
|
const [permissionsPayload, setPermissionsPayload] = useState<TenantPermissionsPayload>({});
|
||||||
const [selectedRoleId, setSelectedRoleId] = useState('');
|
const [selectedRoleId, setSelectedRoleId] = useState('');
|
||||||
const [roleForm, setRoleForm] = useState<RoleTemplateForm>(() => emptyRoleTemplateForm());
|
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 [busy, setBusy] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
@@ -145,6 +202,7 @@ export default function TenantSettingsPage() {
|
|||||||
);
|
);
|
||||||
const fieldCatalog = permissionsPayload.fieldGroups || permissionsPayload.fieldCatalog || [];
|
const fieldCatalog = permissionsPayload.fieldGroups || permissionsPayload.fieldCatalog || [];
|
||||||
const editingSystemRole = Boolean(selectedRole?.isSystem);
|
const editingSystemRole = Boolean(selectedRole?.isSystem);
|
||||||
|
const activeRoleTemplates = roles.filter(item => item.status === 'active');
|
||||||
|
|
||||||
async function reloadSettings(preferredRoleId = selectedRoleId) {
|
async function reloadSettings(preferredRoleId = selectedRoleId) {
|
||||||
setError('');
|
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(() => {
|
useEffect(() => {
|
||||||
void reloadSettings('');
|
void reloadSettings('');
|
||||||
|
void reloadMembers('');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function startNewRole() {
|
function startNewRole() {
|
||||||
@@ -186,6 +259,16 @@ export default function TenantSettingsPage() {
|
|||||||
setRoleForm(roleTemplateFormFrom(item));
|
setRoleForm(roleTemplateFormFrom(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startNewMember() {
|
||||||
|
setSelectedMemberId('');
|
||||||
|
setMemberForm(emptyMemberForm());
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMember(item: TenantMemberItem) {
|
||||||
|
setSelectedMemberId(item.id);
|
||||||
|
setMemberForm(memberFormFrom(item));
|
||||||
|
}
|
||||||
|
|
||||||
function updateBooleanMap(field: BooleanMapField, key: string) {
|
function updateBooleanMap(field: BooleanMapField, key: string) {
|
||||||
setRoleForm(prev => ({
|
setRoleForm(prev => ({
|
||||||
...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() {
|
async function submitRoleTemplate() {
|
||||||
if (!roleForm.name.trim() || !roleForm.code.trim()) {
|
if (!roleForm.name.trim() || !roleForm.code.trim()) {
|
||||||
Taro.showToast({ title: '名称和编码必填', icon: 'none' });
|
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) {
|
function renderToggleGroup(items: TenantPermissionCatalogItem[], field: BooleanMapField) {
|
||||||
if (!items.length) return <View className='admin-empty'>暂无可配置项。</View>;
|
if (!items.length) return <View className='admin-empty'>暂无可配置项。</View>;
|
||||||
return (
|
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 (
|
return (
|
||||||
<View className='admin-page'>
|
<View className='admin-page'>
|
||||||
<View className='admin-shell'>
|
<View className='admin-shell'>
|
||||||
@@ -473,6 +637,139 @@ export default function TenantSettingsPage() {
|
|||||||
</View>
|
</View>
|
||||||
</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}
|
{error ? <Text className='admin-error'>{error}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -2,28 +2,84 @@ import { useEffect, useState } from 'react';
|
|||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import { Button, Text, View } from '@tarojs/components';
|
import { Button, Text, View } from '@tarojs/components';
|
||||||
import { getTenantContext } from '@/services/api';
|
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';
|
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() {
|
export default function TenantWorkbenchPage() {
|
||||||
const tenant = getTenantContext();
|
const tenant = getTenantContext();
|
||||||
const [dashboard, setDashboard] = useState<TenantDashboard | null>(null);
|
const [dashboard, setDashboard] = useState<TenantDashboard | null>(null);
|
||||||
const [overview, setOverview] = useState<TenantOverview | null>(null);
|
const [overview, setOverview] = useState<TenantOverview | null>(null);
|
||||||
|
const [permissions, setPermissions] = useState<TenantPermissionsPayload>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTenantDashboard('30d').then(payload => setDashboard(payload.item || null)).catch(() => setDashboard(null));
|
loadTenantDashboard('30d').then(payload => setDashboard(payload.item || null)).catch(() => setDashboard(null));
|
||||||
loadTenantOverview().then(payload => setOverview(payload.item || null)).catch(() => setOverview(null));
|
loadTenantOverview().then(payload => setOverview(payload.item || null)).catch(() => setOverview(null));
|
||||||
|
loadTenantPermissions().then(payload => setPermissions(payload)).catch(() => setPermissions({}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cards = dashboard?.cards || {};
|
const cards = dashboard?.cards || {};
|
||||||
const payment = dashboard?.paymentStats || {};
|
const payment = dashboard?.paymentStats || {};
|
||||||
const modules = [
|
const modules = MODULES.filter(item => canOpenModule(permissions, item));
|
||||||
{ 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: '品牌、域名、支付、登录' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='admin-page'>
|
<View className='admin-page'>
|
||||||
@@ -63,6 +119,7 @@ export default function TenantWorkbenchPage() {
|
|||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
{!modules.length ? <View className='admin-empty'>当前角色暂无可见后台模块。</View> : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='admin-actions'>
|
<View className='admin-actions'>
|
||||||
|
|||||||
@@ -297,6 +297,45 @@ export interface TenantRoleTemplateInput {
|
|||||||
sortOrder?: number;
|
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') {
|
export async function loadTenantDashboard(timeRange = '30d') {
|
||||||
return apiRequest<{ item?: TenantDashboard }>('/api/tenant-admin/dashboard', { query: { timeRange } });
|
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) {
|
export async function loadCrmQueue(limit = 20) {
|
||||||
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/crm/queue', { query: { limit } });
|
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/crm/queue', { query: { limit } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
||||||
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API | 自动计费、平台审计、公共题库版本通知和运营消息 |
|
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API | 自动计费、平台审计、公共题库版本通知和运营消息 |
|
||||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 租户设置页已接角色模板操作台第一版 | 成员绑定模板、权限驱动菜单和更细的数据范围组合 |
|
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,租户设置页已接角色模板和成员绑定操作台第一版 | 更细的数据范围组合、成员批量运营和完整权限菜单 |
|
||||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、JSON/试卷 payload 导出 | 专项策略、PDF/Word 导出 worker、公共题库版本通知、排行榜防刷/预聚合 |
|
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、JSON/试卷 payload 导出 | 专项策略、PDF/Word 导出 worker、公共题库版本通知、排行榜防刷/预聚合 |
|
||||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
||||||
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告;Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 |
|
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告;Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 |
|
||||||
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
||||||
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 |
|
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 |
|
||||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板创建、编辑、停用、权限点、菜单、模块、字段和基础数据范围配置第一版 | 刷题细节 UI、权限驱动菜单、成员绑定模板、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
|
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户工作台已接权限驱动模块入口,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版 | 刷题细节 UI、更细数据范围 UI、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
|
||||||
|
|
||||||
## 前端接入建议
|
## 前端接入建议
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名和 assets worker 复检已完成,继续补 PDF 渲染、视频播放防盗链、杀毒扫描和水印。
|
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名和 assets worker 复检已完成,继续补 PDF 渲染、视频播放防盗链、杀毒扫描和水印。
|
||||||
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
|
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
|
||||||
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
||||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用和权限可见性配置第一版;下一步补刷题细节 UI、成员绑定模板、权限驱动菜单、平台后台审计增强和小程序兼容验证。
|
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户工作台已接权限驱动模块入口,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版;下一步补刷题细节 UI、更细数据范围 UI、平台后台审计增强和小程序兼容验证。
|
||||||
|
|
||||||
### P1:商用收费和运营能力
|
### P1:商用收费和运营能力
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
|
|
||||||
### P2:企业级体验和增长闭环
|
### P2:企业级体验和增长闭环
|
||||||
|
|
||||||
- 租户自定义角色模板基础 API 与 Taro 权限配置 UI 第一版已完成;继续补成员绑定模板、权限驱动菜单、班级/教师/学生组合范围 UI 和平台级审计报表。
|
- 租户自定义角色模板基础 API、Taro 权限配置 UI、成员绑定模板和工作台权限驱动入口第一版已完成;继续补班级/教师/学生组合范围 UI、成员批量运营和平台级审计报表。
|
||||||
- 三套默认主题、租户主题预览、Logo/图标/分享图配置。
|
- 三套默认主题、租户主题预览、Logo/图标/分享图配置。
|
||||||
- CRM worker:钉钉、飞书、企微机器人发送、签名、失败重试已落地;继续补轮询/定向分配、富卡片、失败告警和死信运营台。
|
- CRM worker:钉钉、飞书、企微机器人发送、签名、失败重试已落地;继续补轮询/定向分配、富卡片、失败告警和死信运营台。
|
||||||
- 销售/代理分佣结算、销售团队看板、客资跟进效果。
|
- 销售/代理分佣结算、销售团队看板、客资跟进效果。
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
## 当前可进入的前端工作
|
## 当前可进入的前端工作
|
||||||
|
|
||||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
||||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;题库内容页已具备公共题库采纳/同步、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;租户设置页已具备角色模板新建、编辑、停用、权限点、菜单、模块、字段和基础数据范围配置第一版。
|
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;题库内容页已具备公共题库采纳/同步、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;租户设置页已具备角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
||||||
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。
|
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。
|
||||||
- 可以继续复刻旧题库学生端主要视觉和交互:刷题细节、勋章展示和小程序端分享/支付体验。地区选择、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。
|
- 可以继续复刻旧题库学生端主要视觉和交互:刷题细节、勋章展示和小程序端分享/支付体验。地区选择、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。
|
||||||
- 可以按新后端主模型接入内容导航:
|
- 可以按新后端主模型接入内容导航:
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
- H5 可以直接用 Supabase Auth access token 调 `apps/api`;后端已支持 JWT 验签和业务用户映射。
|
- H5 可以直接用 Supabase Auth access token 调 `apps/api`;后端已支持 JWT 验签和业务用户映射。
|
||||||
- H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session;微信小程序端先验证运行时兼容性,业务数据默认仍走 `apps/api`。
|
- H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session;微信小程序端先验证运行时兼容性,业务数据默认仍走 `apps/api`。
|
||||||
- 可以接入租户品牌、主题、功能开关和域名/小程序参数解析。
|
- 可以接入租户品牌、主题、功能开关和域名/小程序参数解析。
|
||||||
- 租户后台可以接入角色模板 API:`/api/tenant-admin/role-templates`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性。
|
- 租户后台可以接入角色模板和成员 API:`/api/tenant-admin/role-templates`、`/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。
|
||||||
- 租户后台可以接入勋章管理和手动发放:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`;学生端用 `GET /api/profile/badges` 展示成就。
|
- 租户后台可以接入勋章管理和手动发放:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`;学生端用 `GET /api/profile/badges` 展示成就。
|
||||||
|
|
||||||
## 不能误认为已商用完成的部分
|
## 不能误认为已商用完成的部分
|
||||||
@@ -84,14 +84,14 @@
|
|||||||
|
|
||||||
| 页面 | 文件 | 已接接口 |
|
| 页面 | 文件 | 已接接口 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 工作台 | `apps/taro/src/pages/tenant-admin/workbench/index.tsx` | `tenant-admin/overview`、`tenant-admin/dashboard` |
|
| 工作台 | `apps/taro/src/pages/tenant-admin/workbench/index.tsx` | `tenant-admin/overview`、`tenant-admin/dashboard`、`tenant-admin/permissions` |
|
||||||
| 数据看板 | `apps/taro/src/pages/tenant-admin/dashboard/index.tsx` | `tenant-admin/dashboard` |
|
| 数据看板 | `apps/taro/src/pages/tenant-admin/dashboard/index.tsx` | `tenant-admin/dashboard` |
|
||||||
| 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes`、`tenant-admin/students` |
|
| 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes`、`tenant-admin/students` |
|
||||||
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch` |
|
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch` |
|
||||||
| 营销中心 | `apps/taro/src/pages/tenant-admin/marketing/index.tsx` | `tenant-admin/coupons`、`code-batches`、`activation-codes`、`crm/queue`、`commission/summary` |
|
| 营销中心 | `apps/taro/src/pages/tenant-admin/marketing/index.tsx` | `tenant-admin/coupons`、`code-batches`、`activation-codes`、`crm/queue`、`commission/summary` |
|
||||||
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`permissions`、`GET/PUT role-templates`、`POST role-templates/disable` |
|
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`permissions`、`GET/PUT role-templates`、`POST role-templates/disable`、`GET/PUT members`、`POST members/disable` |
|
||||||
|
|
||||||
当前租户后台已有第一批运营操作:题库内容页支持公共题库采纳/同步、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情;租户设置页支持角色模板创建、编辑、停用、权限点、菜单、模块、字段和基础数据范围配置。下一批需要继续补完整后台写入表单、学生批量导入、成员绑定角色模板、权限驱动菜单和更细数据范围 UI。
|
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;题库内容页支持公共题库采纳/同步、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情;租户设置页支持角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补完整后台写入表单、学生批量导入、更细数据范围 UI 和成员批量运营。
|
||||||
|
|
||||||
## 已落地的 Taro 平台后台页面
|
## 已落地的 Taro 平台后台页面
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,8 @@
|
|||||||
- 已完成租户内角色模板、菜单可见、模块可见、字段级权限和权限变更审计基础 API。
|
- 已完成租户内角色模板、菜单可见、模块可见、字段级权限和权限变更审计基础 API。
|
||||||
- 已完成班级/教师/学生范围权限 API,教师只能查看自己负责班级的学生。
|
- 已完成班级/教师/学生范围权限 API,教师只能查看自己负责班级的学生。
|
||||||
- 已完成 Taro 租户设置页角色模板可视化配置第一版,支持创建、编辑、停用、权限点、菜单、模块、字段和基础数据范围配置。
|
- 已完成 Taro 租户设置页角色模板可视化配置第一版,支持创建、编辑、停用、权限点、菜单、模块、字段和基础数据范围配置。
|
||||||
- 继续补成员绑定角色模板、权限驱动菜单和更细的数据范围 UI,例如地区、题库、销售团队、本人客资、班级学生组合规则。
|
- 已完成 Taro 成员绑定模板第一版,支持成员搜索/新建、绑定角色模板、成员状态和额外权限覆盖;工作台已按后端权限矩阵隐藏不可见模块。
|
||||||
|
- 继续补更细的数据范围 UI,例如地区、题库、销售团队、本人客资、班级学生组合规则,以及成员批量运营和完整后台菜单权限联动。
|
||||||
|
|
||||||
2. 主题系统
|
2. 主题系统
|
||||||
- 平台默认三套主题。
|
- 平台默认三套主题。
|
||||||
@@ -163,7 +164,7 @@
|
|||||||
- H5 和小程序共用同一套业务 API client。
|
- H5 和小程序共用同一套业务 API client。
|
||||||
- 租户通过域名、小程序配置或启动参数解析。
|
- 租户通过域名、小程序配置或启动参数解析。
|
||||||
- 页面主题、品牌、功能开关都从后端租户配置读取。
|
- 页面主题、品牌、功能开关都从后端租户配置读取。
|
||||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、会员收银台、订单详情和售后入口第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;下一步补租户后台深度写操作、刷题细节 UI、状态管理和小程序兼容验证。
|
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、会员收银台、订单详情和售后入口第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、角色模板和成员绑定第一版;下一步补租户后台 CRM/分佣深度写操作、刷题细节 UI、状态管理和小程序兼容验证。
|
||||||
|
|
||||||
### 第一批页面
|
### 第一批页面
|
||||||
|
|
||||||
@@ -212,7 +213,7 @@
|
|||||||
|
|
||||||
## 推荐下一步顺序
|
## 推荐下一步顺序
|
||||||
|
|
||||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情和角色模板配置已接第一版;继续补成员绑定模板、权限驱动菜单、CRM/分佣。
|
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、角色模板配置和成员绑定模板已接第一版;继续补 CRM/分佣深度写操作、成员批量运营和更细数据范围 UI。
|
||||||
2. 继续补 Taro 学生端旧体验:地区选择、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补刷题细节 UI、小程序支付容器、分享场景和状态管理。
|
2. 继续补 Taro 学生端旧体验:地区选择、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补刷题细节 UI、小程序支付容器、分享场景和状态管理。
|
||||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||||
|
|||||||
@@ -1408,7 +1408,7 @@ src/services/learning.ts 练习 session、答题、收藏、错题/收藏复
|
|||||||
src/services/commerce.ts 套餐、优惠券、下单、支付参数、订单详情、权益、激活码
|
src/services/commerce.ts 套餐、优惠券、下单、支付参数、订单详情、权益、激活码
|
||||||
src/services/profile.ts 个人中心、地区目标、签到、反馈、勋章、倒计时
|
src/services/profile.ts 个人中心、地区目标、签到、反馈、勋章、倒计时
|
||||||
src/services/video.ts 题目视频列表、播放签名
|
src/services/video.ts 题目视频列表、播放签名
|
||||||
src/services/tenantAdmin.ts 租户后台看板、学生、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检
|
src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检
|
||||||
src/services/platformAdmin.ts 平台后台租户、套餐账单、用量、公共题库授权
|
src/services/platformAdmin.ts 平台后台租户、套餐账单、用量、公共题库授权
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1426,7 +1426,7 @@ npm run build:taro:h5:platform
|
|||||||
下一批前端开发重点:
|
下一批前端开发重点:
|
||||||
|
|
||||||
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情和售后入口已接第一版;下一批继续补刷题细节 UI、小程序支付容器和分享场景。
|
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情和售后入口已接第一版;下一批继续补刷题细节 UI、小程序支付容器和分享场景。
|
||||||
- 租户后台:题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;租户设置页已接角色模板创建、编辑、停用、权限点、菜单、模块、字段和基础数据范围配置第一版;下一批继续补完整写入表单、学生批量导入、成员绑定角色模板、权限驱动菜单、CRM 分配和分佣结算操作。
|
- 租户后台:工作台已接权限驱动模块入口;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;租户设置页已接角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补完整写入表单、学生批量导入、CRM 分配和分佣结算操作。
|
||||||
- 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。
|
- 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。
|
||||||
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
|
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
|
||||||
|
|
||||||
@@ -1440,7 +1440,7 @@ npm run build:taro:h5:platform
|
|||||||
- 支付账户/登录 provider/密钥引用
|
- 支付账户/登录 provider/密钥引用
|
||||||
- 用户与成员权限
|
- 用户与成员权限
|
||||||
- 班级/教师/学生:`/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`、`students/notes`、`students/followups`
|
- 班级/教师/学生:`/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`、`students/notes`、`students/followups`
|
||||||
- 角色模板:`GET/PUT /api/tenant-admin/role-templates`、`POST /api/tenant-admin/role-templates/disable`;当前 Taro 租户设置页已提供第一版创建、编辑、停用、权限点、菜单、模块、字段和基础数据范围配置。
|
- 角色模板与成员:`GET/PUT /api/tenant-admin/role-templates`、`POST /api/tenant-admin/role-templates/disable`、`GET/PUT /api/tenant-admin/members`、`POST /api/tenant-admin/members/disable`;当前 Taro 租户设置页已提供第一版创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员绑定模板和成员状态配置。
|
||||||
- 内容入口/分类树/题目集合/练习蓝图
|
- 内容入口/分类树/题目集合/练习蓝图
|
||||||
- 题目/单词/知识手册/分数线/视频维护
|
- 题目/单词/知识手册/分数线/视频维护
|
||||||
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入 preview/import/issues、字段映射、模板下载和导入后复检
|
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入 preview/import/issues、字段映射、模板下载和导入后复检
|
||||||
|
|||||||
Reference in New Issue
Block a user