forked from wangziqi/gongxue-base
feat: add tenant student operations console
This commit is contained in:
@@ -58,13 +58,13 @@ pages/student/profile/index 个人中心、会员、订单、签到、激
|
||||
```text
|
||||
pages/tenant-admin/workbench/index 租户后台工作台、权限驱动模块入口
|
||||
pages/tenant-admin/dashboard/index 数据看板
|
||||
pages/tenant-admin/students/index 学生与班级
|
||||
pages/tenant-admin/students/index 学生、班级、批量导入/分班、备注和跟进
|
||||
pages/tenant-admin/content/index 内容入口、导入任务、字段模板、异步轮询、复检、公共题库采纳/同步/单条和批量冲突处理
|
||||
pages/tenant-admin/marketing/index 优惠券、激活码、CRM 配置/队列、分佣结算
|
||||
pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板和成员绑定
|
||||
```
|
||||
|
||||
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步任务轮询、导入问题查看、模板预览/下载、导入后复检详情,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销中心已接入 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、生成结算单、审核通过/驳回和标记线下打款第一版。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
|
||||
当前后台页面已经从只读联调推进到第一批运营写操作。学生运营页已接入学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步任务轮询、导入问题查看、模板预览/下载、导入后复检详情,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销中心已接入 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、生成结算单、审核通过/驳回和标记线下打款第一版。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
|
||||
|
||||
租户工作台会读取 `/api/tenant-admin/permissions`,按菜单权限和有效权限隐藏不可见模块。租户设置页已接入角色模板与成员绑定写操作第一版:可新建、编辑、停用非系统模板,配置权限点、菜单可见、模块可见、字段可见和基础数据范围,也可以搜索/新建成员、绑定角色模板、设置成员状态和额外权限覆盖。前端只负责操作体验,`tenant_owner`、`tenant_admin`、通配权限、系统模板保护和最后一名 owner 保护仍以后端校验与审计为准。
|
||||
|
||||
|
||||
@@ -1,22 +1,128 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Input, Text, View } from '@tarojs/components';
|
||||
import { loadTenantClasses, loadTenantStudents, type TenantClassItem, type TenantStudentItem } from '@/services/tenantAdmin';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Input, Text, Textarea, View } from '@tarojs/components';
|
||||
import {
|
||||
bulkAssignTenantClassMembers,
|
||||
bulkUpsertTenantStudents,
|
||||
loadTenantClasses,
|
||||
loadTenantStudentFollowups,
|
||||
loadTenantStudentNotes,
|
||||
loadTenantStudents,
|
||||
loadTenantTeachers,
|
||||
updateTenantStudentStatus,
|
||||
upsertTenantStudent,
|
||||
upsertTenantStudentFollowup,
|
||||
upsertTenantStudentNote,
|
||||
type BulkOperationResult,
|
||||
type TenantClassItem,
|
||||
type TenantStudentFollowupItem,
|
||||
type TenantStudentInput,
|
||||
type TenantStudentItem,
|
||||
type TenantStudentNoteItem,
|
||||
type TenantTeacherItem,
|
||||
} from '@/services/tenantAdmin';
|
||||
import '../admin.css';
|
||||
|
||||
const statusOptions = ['', 'active', 'invited', 'disabled'];
|
||||
const noteTypes = ['general', 'learning', 'service', 'sales', 'risk', 'follow_up'];
|
||||
const followupTypes = ['learning', 'service', 'sales', 'renewal', 'risk', 'custom'];
|
||||
const followupPriorities = ['low', 'normal', 'high', 'urgent'];
|
||||
const followupStatuses = ['open', 'in_progress', 'done', 'cancelled'];
|
||||
|
||||
function emptyStudentForm(): TenantStudentInput {
|
||||
return {
|
||||
userId: '',
|
||||
username: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
regionId: '',
|
||||
selectedSchoolId: '',
|
||||
selectedMajorId: '',
|
||||
status: 'active',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBlank(value: string | null | undefined) {
|
||||
return value && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function parseJsonOrRows(text: string): TenantStudentInput[] {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return [];
|
||||
if (trimmed.startsWith('[')) {
|
||||
const parsed = JSON.parse(trimmed) as TenantStudentInput[];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
return trimmed
|
||||
.split(/\r?\n/)
|
||||
.map(row => row.trim())
|
||||
.filter(Boolean)
|
||||
.map(row => {
|
||||
const [name, phone, email, status] = row.split(/[\t,,]/).map(part => part.trim());
|
||||
return {
|
||||
name,
|
||||
phone,
|
||||
email,
|
||||
status: status || 'active',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resultSummary(result: BulkOperationResult | null) {
|
||||
if (!result) return '';
|
||||
return `总数 ${result.total || 0} · 成功 ${result.successCount || 0} · 失败 ${result.errorCount || 0}`;
|
||||
}
|
||||
|
||||
function studentTitle(item: TenantStudentItem | null) {
|
||||
if (!item) return '未选择学生';
|
||||
return item.name || item.phone || item.email || item.username || item.userId;
|
||||
}
|
||||
|
||||
export default function TenantStudentsPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [classes, setClasses] = useState<TenantClassItem[]>([]);
|
||||
const [students, setStudents] = useState<TenantStudentItem[]>([]);
|
||||
const [teachers, setTeachers] = useState<TenantTeacherItem[]>([]);
|
||||
const [notes, setNotes] = useState<TenantStudentNoteItem[]>([]);
|
||||
const [followups, setFollowups] = useState<TenantStudentFollowupItem[]>([]);
|
||||
const [selectedClassId, setSelectedClassId] = useState('');
|
||||
const [studentStatus, setStudentStatus] = useState('');
|
||||
const [selectedStudent, setSelectedStudent] = useState<TenantStudentItem | null>(null);
|
||||
const [studentForm, setStudentForm] = useState<TenantStudentInput>(emptyStudentForm());
|
||||
const [bulkText, setBulkText] = useState('');
|
||||
const [assignText, setAssignText] = useState('');
|
||||
const [noteForm, setNoteForm] = useState({ noteType: 'learning', content: '', visibility: 'tenant_staff', isPinned: false });
|
||||
const [followupForm, setFollowupForm] = useState({ title: '', description: '', followupType: 'learning', priority: 'normal', status: 'open', dueAt: '', assignedToUserId: '', classId: '' });
|
||||
const [bulkResult, setBulkResult] = useState<BulkOperationResult | null>(null);
|
||||
const [assignResult, setAssignResult] = useState<BulkOperationResult | null>(null);
|
||||
const [scoped, setScoped] = useState(false);
|
||||
const [busy, setBusy] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function reload(nextClassId = selectedClassId, nextKeyword = keyword) {
|
||||
const activeClass = useMemo(
|
||||
() => classes.find(item => item.id === selectedClassId) || null,
|
||||
[classes, selectedClassId],
|
||||
);
|
||||
|
||||
function reload(nextClassId = selectedClassId, nextKeyword = keyword, nextStatus = studentStatus) {
|
||||
setError('');
|
||||
Promise.all([
|
||||
loadTenantClasses(80).catch(() => ({ items: [] })),
|
||||
loadTenantStudents({ classId: nextClassId || undefined, keyword: nextKeyword || undefined, limit: 80 }),
|
||||
]).then(([classPayload, studentPayload]) => {
|
||||
loadTenantTeachers({ limit: 80 }).catch(() => ({ items: [] })),
|
||||
loadTenantStudents({
|
||||
classId: nextClassId || undefined,
|
||||
keyword: nextKeyword || undefined,
|
||||
status: nextStatus || undefined,
|
||||
limit: 80,
|
||||
}),
|
||||
loadTenantStudentFollowups({ status: 'open', limit: 30 }).catch(() => ({ items: [] })),
|
||||
]).then(([classPayload, teacherPayload, studentPayload, followupPayload]) => {
|
||||
setClasses(classPayload.items || []);
|
||||
setTeachers(teacherPayload.items || []);
|
||||
setStudents(studentPayload.items || []);
|
||||
setScoped(studentPayload.scoped === true);
|
||||
setFollowups(followupPayload.items || []);
|
||||
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '学生数据加载失败'));
|
||||
}
|
||||
|
||||
@@ -26,7 +132,215 @@ export default function TenantStudentsPage() {
|
||||
|
||||
function chooseClass(classId: string) {
|
||||
setSelectedClassId(classId);
|
||||
reload(classId, keyword);
|
||||
setFollowupForm(prev => ({ ...prev, classId }));
|
||||
reload(classId, keyword, studentStatus);
|
||||
}
|
||||
|
||||
function chooseStatus(status: string) {
|
||||
setStudentStatus(status);
|
||||
reload(selectedClassId, keyword, status);
|
||||
}
|
||||
|
||||
async function selectStudent(item: TenantStudentItem) {
|
||||
setSelectedStudent(item);
|
||||
setStudentForm({
|
||||
userId: item.userId,
|
||||
username: item.username || '',
|
||||
name: item.name || '',
|
||||
phone: item.phone || '',
|
||||
email: item.email || '',
|
||||
regionId: item.regionId || '',
|
||||
selectedSchoolId: item.selectedSchoolId || '',
|
||||
selectedMajorId: item.selectedMajorId || '',
|
||||
status: item.status || 'active',
|
||||
});
|
||||
setFollowupForm(prev => ({ ...prev, studentUserId: item.userId, classId: selectedClassId || String((item.classes || [])[0]?.classId || '') } as typeof prev));
|
||||
setError('');
|
||||
try {
|
||||
const [notePayload, followupPayload] = await Promise.all([
|
||||
loadTenantStudentNotes(item.userId).catch(() => ({ items: [] })),
|
||||
loadTenantStudentFollowups({ studentUserId: item.userId, limit: 20 }).catch(() => ({ items: [] })),
|
||||
]);
|
||||
setNotes(notePayload.items || []);
|
||||
setFollowups(followupPayload.items || []);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '学生详情加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
function newStudent() {
|
||||
setSelectedStudent(null);
|
||||
setNotes([]);
|
||||
setFollowups([]);
|
||||
setStudentForm(emptyStudentForm());
|
||||
setNoteForm({ noteType: 'learning', content: '', visibility: 'tenant_staff', isPinned: false });
|
||||
setFollowupForm({ title: '', description: '', followupType: 'learning', priority: 'normal', status: 'open', dueAt: '', assignedToUserId: '', classId: selectedClassId });
|
||||
}
|
||||
|
||||
async function saveStudent() {
|
||||
setBusy('student');
|
||||
setError('');
|
||||
try {
|
||||
await upsertTenantStudent({
|
||||
...studentForm,
|
||||
userId: normalizeBlank(studentForm.userId),
|
||||
username: normalizeBlank(studentForm.username),
|
||||
name: normalizeBlank(studentForm.name),
|
||||
phone: normalizeBlank(studentForm.phone),
|
||||
email: normalizeBlank(studentForm.email),
|
||||
regionId: normalizeBlank(studentForm.regionId),
|
||||
selectedSchoolId: normalizeBlank(studentForm.selectedSchoolId),
|
||||
selectedMajorId: normalizeBlank(studentForm.selectedMajorId),
|
||||
status: studentForm.status || 'active',
|
||||
});
|
||||
Taro.showToast({ title: '学生已保存', icon: 'success' });
|
||||
reload();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '学生保存失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function changeStudentStatus(item: TenantStudentItem, status: string) {
|
||||
const modal = await Taro.showModal({
|
||||
title: status === 'disabled' ? '禁用学生' : '恢复学生',
|
||||
content: studentTitle(item),
|
||||
confirmText: status === 'disabled' ? '禁用' : '恢复',
|
||||
cancelText: '取消',
|
||||
});
|
||||
if (!modal.confirm) return;
|
||||
setBusy(`status:${item.userId}`);
|
||||
setError('');
|
||||
try {
|
||||
await updateTenantStudentStatus({ userId: item.userId, status, reason: 'taro-tenant-admin' });
|
||||
Taro.showToast({ title: '状态已更新', icon: 'success' });
|
||||
reload();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '状态更新失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitBulkUpsert() {
|
||||
setBusy('bulk');
|
||||
setError('');
|
||||
try {
|
||||
const rows = parseJsonOrRows(bulkText);
|
||||
const result = await bulkUpsertTenantStudents(rows);
|
||||
setBulkResult(result);
|
||||
Taro.showToast({ title: '批量导入已完成', icon: result.errorCount ? 'none' : 'success' });
|
||||
reload();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '批量导入失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitBulkAssign() {
|
||||
if (!selectedClassId) {
|
||||
Taro.showToast({ title: '请选择班级', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
setBusy('assign');
|
||||
setError('');
|
||||
try {
|
||||
const rows = parseJsonOrRows(assignText).map(item => ({ ...item, memberType: 'student', status: 'active' }));
|
||||
const result = await bulkAssignTenantClassMembers({ classId: selectedClassId, assignments: rows });
|
||||
setAssignResult(result);
|
||||
Taro.showToast({ title: '批量分班已完成', icon: result.errorCount ? 'none' : 'success' });
|
||||
reload();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '批量分班失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
if (!selectedStudent) {
|
||||
Taro.showToast({ title: '请选择学生', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
setBusy('note');
|
||||
setError('');
|
||||
try {
|
||||
await upsertTenantStudentNote({
|
||||
studentUserId: selectedStudent.userId,
|
||||
noteType: noteForm.noteType,
|
||||
content: noteForm.content,
|
||||
visibility: noteForm.visibility,
|
||||
isPinned: noteForm.isPinned,
|
||||
metadata: { source: 'taro-tenant-admin' },
|
||||
});
|
||||
setNoteForm(prev => ({ ...prev, content: '' }));
|
||||
await selectStudent(selectedStudent);
|
||||
Taro.showToast({ title: '备注已保存', icon: 'success' });
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '备注保存失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFollowup() {
|
||||
if (!selectedStudent) {
|
||||
Taro.showToast({ title: '请选择学生', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
setBusy('followup');
|
||||
setError('');
|
||||
try {
|
||||
await upsertTenantStudentFollowup({
|
||||
studentUserId: selectedStudent.userId,
|
||||
assignedToUserId: followupForm.assignedToUserId || null,
|
||||
classId: followupForm.classId || null,
|
||||
title: followupForm.title,
|
||||
description: followupForm.description || null,
|
||||
followupType: followupForm.followupType,
|
||||
priority: followupForm.priority,
|
||||
status: followupForm.status,
|
||||
dueAt: followupForm.dueAt || null,
|
||||
metadata: { source: 'taro-tenant-admin' },
|
||||
});
|
||||
setFollowupForm(prev => ({ ...prev, title: '', description: '' }));
|
||||
await selectStudent(selectedStudent);
|
||||
Taro.showToast({ title: '跟进已保存', icon: 'success' });
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '跟进保存失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function completeFollowup(item: TenantStudentFollowupItem) {
|
||||
if (!selectedStudent && !item.studentUserId) return;
|
||||
setBusy(`followup:${item.id}`);
|
||||
setError('');
|
||||
try {
|
||||
await upsertTenantStudentFollowup({
|
||||
id: item.id,
|
||||
studentUserId: item.studentUserId || selectedStudent?.userId || '',
|
||||
assignedToUserId: item.assignedToUserId || null,
|
||||
classId: item.classId || null,
|
||||
title: item.title || '跟进任务',
|
||||
description: item.description || null,
|
||||
followupType: item.followupType || 'learning',
|
||||
priority: item.priority || 'normal',
|
||||
status: 'done',
|
||||
dueAt: item.dueAt || null,
|
||||
metadata: item.metadata || { source: 'taro-tenant-admin' },
|
||||
});
|
||||
if (selectedStudent) await selectStudent(selectedStudent);
|
||||
else reload();
|
||||
Taro.showToast({ title: '已完成', icon: 'success' });
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '跟进更新失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -40,7 +354,8 @@ export default function TenantStudentsPage() {
|
||||
|
||||
<View className='admin-actions'>
|
||||
<Input className='admin-input' placeholder='手机号、姓名、邮箱' value={keyword} onInput={event => setKeyword(String(event.detail.value || ''))} />
|
||||
<Button className='admin-button primary' onClick={() => reload(selectedClassId, keyword)}>搜索</Button>
|
||||
<Button className='admin-button primary' onClick={() => reload(selectedClassId, keyword, studentStatus)}>搜索</Button>
|
||||
<Button className='admin-button' onClick={newStudent}>新建</Button>
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
@@ -64,19 +379,166 @@ export default function TenantStudentsPage() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>状态</Text>
|
||||
<View className='admin-actions compact'>
|
||||
{statusOptions.map(status => (
|
||||
<Button key={status || 'all'} className={`admin-button ${studentStatus === status ? 'active' : ''}`} onClick={() => chooseStatus(status)}>
|
||||
{status || '全部'}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
{scoped ? <Text className='admin-row-meta'>当前角色已启用班级/学生范围过滤。</Text> : null}
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>学生档案</Text>
|
||||
<View className='admin-form-grid'>
|
||||
<Input className='admin-input' placeholder='userId,可留空' value={studentForm.userId || ''} onInput={event => setStudentForm(prev => ({ ...prev, userId: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' placeholder='姓名' value={studentForm.name || ''} onInput={event => setStudentForm(prev => ({ ...prev, name: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' placeholder='手机号' value={studentForm.phone || ''} onInput={event => setStudentForm(prev => ({ ...prev, phone: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' placeholder='邮箱' value={studentForm.email || ''} onInput={event => setStudentForm(prev => ({ ...prev, email: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' placeholder='地区 ID' value={studentForm.regionId || ''} onInput={event => setStudentForm(prev => ({ ...prev, regionId: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' placeholder='院校 ID' value={studentForm.selectedSchoolId || ''} onInput={event => setStudentForm(prev => ({ ...prev, selectedSchoolId: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' placeholder='专业 ID' value={studentForm.selectedMajorId || ''} onInput={event => setStudentForm(prev => ({ ...prev, selectedMajorId: String(event.detail.value || '') }))} />
|
||||
</View>
|
||||
<View className='admin-actions compact'>
|
||||
{['active', 'invited', 'disabled'].map(status => (
|
||||
<Button key={status} className={`admin-button ${studentForm.status === status ? 'active' : ''}`} onClick={() => setStudentForm(prev => ({ ...prev, status }))}>{status}</Button>
|
||||
))}
|
||||
<Button className='admin-button primary' loading={busy === 'student'} onClick={saveStudent}>保存学生</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>批量导入</Text>
|
||||
<Textarea
|
||||
className='admin-textarea'
|
||||
placeholder='张三,13800000001,student@example.com,active'
|
||||
value={bulkText}
|
||||
onInput={event => setBulkText(String(event.detail.value || ''))}
|
||||
/>
|
||||
<View className='admin-actions compact'>
|
||||
<Button className='admin-button primary' loading={busy === 'bulk'} onClick={submitBulkUpsert}>执行导入</Button>
|
||||
{bulkResult ? <Text className='admin-row-meta'>{resultSummary(bulkResult)}</Text> : null}
|
||||
</View>
|
||||
{bulkResult?.errors?.slice(0, 3).map(item => (
|
||||
<Text className='admin-row-meta break-line' key={`${item.index}:${item.code}`}>第 {Number(item.index || 0) + 1} 行 · {item.code} · {item.message}</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>批量分班</Text>
|
||||
<View className='admin-row'>
|
||||
<Text className='admin-row-main'>{activeClass?.name || '未选择班级'}</Text>
|
||||
<Text className='admin-row-meta'>{activeClass?.studentCount || 0} 学生 · {activeClass?.teacherCount || 0} 教师</Text>
|
||||
</View>
|
||||
<Textarea
|
||||
className='admin-textarea'
|
||||
placeholder='李四,13800000002,student2@example.com'
|
||||
value={assignText}
|
||||
onInput={event => setAssignText(String(event.detail.value || ''))}
|
||||
/>
|
||||
<View className='admin-actions compact'>
|
||||
<Button className='admin-button primary' loading={busy === 'assign'} onClick={submitBulkAssign}>执行分班</Button>
|
||||
{assignResult ? <Text className='admin-row-meta'>{resultSummary(assignResult)}</Text> : null}
|
||||
</View>
|
||||
{assignResult?.errors?.slice(0, 3).map(item => (
|
||||
<Text className='admin-row-meta break-line' key={`${item.index}:${item.code}`}>第 {Number(item.index || 0) + 1} 行 · {item.code} · {item.message}</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>学生列表</Text>
|
||||
<View className='admin-list'>
|
||||
{students.map(item => (
|
||||
<View className='admin-row' key={item.userId}>
|
||||
<Text className='admin-row-main'>{item.name || item.phone || item.email || item.userId}</Text>
|
||||
<Text className='admin-row-meta'>{item.regionName || '未选地区'}{item.selectedSchoolName ? ` · ${item.selectedSchoolName}` : ''}{item.selectedMajorName ? ` · ${item.selectedMajorName}` : ''}</Text>
|
||||
<Text className='admin-row-meta'>{item.status || 'active'} · {item.regionName || '未选地区'}{item.selectedSchoolName ? ` · ${item.selectedSchoolName}` : ''}{item.selectedMajorName ? ` · ${item.selectedMajorName}` : ''}</Text>
|
||||
<Text className='admin-row-meta'>今日答题 {item.questionsAnsweredToday || 0} · 掌握单词 {item.masteredWordsCount || 0} · 班级 {(item.classes || []).length}</Text>
|
||||
<View className='admin-row-actions'>
|
||||
<Button className='admin-mini-button primary' onClick={() => void selectStudent(item)}>详情</Button>
|
||||
{item.status === 'disabled' ? (
|
||||
<Button className='admin-mini-button' loading={busy === `status:${item.userId}`} onClick={() => changeStudentStatus(item, 'active')}>恢复</Button>
|
||||
) : (
|
||||
<Button className='admin-mini-button' loading={busy === `status:${item.userId}`} onClick={() => changeStudentStatus(item, 'disabled')}>禁用</Button>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!students.length ? <View className='admin-empty'>暂无学生,或当前角色没有可见学生范围。</View> : null}
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>学生跟进</Text>
|
||||
<View className='admin-row'>
|
||||
<Text className='admin-row-main'>{studentTitle(selectedStudent)}</Text>
|
||||
<Text className='admin-row-meta'>{selectedStudent?.regionName || '未选地区'} · {selectedStudent?.status || '-'}</Text>
|
||||
</View>
|
||||
<View className='admin-actions compact'>
|
||||
{noteTypes.map(item => (
|
||||
<Button key={item} className={`admin-button ${noteForm.noteType === item ? 'active' : ''}`} onClick={() => setNoteForm(prev => ({ ...prev, noteType: item }))}>{item}</Button>
|
||||
))}
|
||||
<Button className={`admin-button ${noteForm.isPinned ? 'active' : ''}`} onClick={() => setNoteForm(prev => ({ ...prev, isPinned: !prev.isPinned }))}>置顶</Button>
|
||||
</View>
|
||||
<Textarea className='admin-textarea' placeholder='学生备注' value={noteForm.content} onInput={event => setNoteForm(prev => ({ ...prev, content: String(event.detail.value || '') }))} />
|
||||
<View className='admin-actions compact'>
|
||||
<Button className='admin-button primary' loading={busy === 'note'} onClick={saveNote}>保存备注</Button>
|
||||
</View>
|
||||
<View className='admin-list'>
|
||||
{notes.slice(0, 5).map(item => (
|
||||
<View className='admin-row' key={item.id}>
|
||||
<Text className='admin-row-main'>{item.noteType || 'note'}{item.isPinned ? ' · 置顶' : ''}</Text>
|
||||
<Text className='admin-row-meta break-line'>{item.content}</Text>
|
||||
<Text className='admin-row-meta'>{item.createdByName || item.createdByUsername || '成员'} · {item.createdAt || '-'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>跟进任务</Text>
|
||||
<View className='admin-form-grid'>
|
||||
<Input className='admin-input' placeholder='标题' value={followupForm.title} onInput={event => setFollowupForm(prev => ({ ...prev, title: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' placeholder='到期时间,例如 2026-07-01T10:00:00.000Z' value={followupForm.dueAt} onInput={event => setFollowupForm(prev => ({ ...prev, dueAt: String(event.detail.value || '') }))} />
|
||||
</View>
|
||||
<View className='admin-actions compact'>
|
||||
{followupTypes.map(item => <Button key={item} className={`admin-button ${followupForm.followupType === item ? 'active' : ''}`} onClick={() => setFollowupForm(prev => ({ ...prev, followupType: item }))}>{item}</Button>)}
|
||||
</View>
|
||||
<View className='admin-actions compact'>
|
||||
{followupPriorities.map(item => <Button key={item} className={`admin-button ${followupForm.priority === item ? 'active' : ''}`} onClick={() => setFollowupForm(prev => ({ ...prev, priority: item }))}>{item}</Button>)}
|
||||
</View>
|
||||
<View className='admin-actions compact'>
|
||||
{followupStatuses.map(item => <Button key={item} className={`admin-button ${followupForm.status === item ? 'active' : ''}`} onClick={() => setFollowupForm(prev => ({ ...prev, status: item }))}>{item}</Button>)}
|
||||
</View>
|
||||
<View className='admin-actions compact'>
|
||||
<Button className={`admin-button ${followupForm.assignedToUserId ? '' : 'active'}`} onClick={() => setFollowupForm(prev => ({ ...prev, assignedToUserId: '' }))}>不指派</Button>
|
||||
{teachers.slice(0, 10).map(item => (
|
||||
<Button key={item.userId} className={`admin-button ${followupForm.assignedToUserId === item.userId ? 'active' : ''}`} onClick={() => setFollowupForm(prev => ({ ...prev, assignedToUserId: item.userId }))}>{item.name || item.phone || item.username || '教师'}</Button>
|
||||
))}
|
||||
</View>
|
||||
<Textarea className='admin-textarea' placeholder='描述' value={followupForm.description} onInput={event => setFollowupForm(prev => ({ ...prev, description: String(event.detail.value || '') }))} />
|
||||
<View className='admin-actions compact'>
|
||||
<Button className='admin-button primary' loading={busy === 'followup'} onClick={saveFollowup}>保存跟进</Button>
|
||||
</View>
|
||||
<View className='admin-list'>
|
||||
{followups.slice(0, 8).map(item => (
|
||||
<View className='admin-row' key={item.id}>
|
||||
<Text className='admin-row-main'>{item.title || '跟进任务'} · {item.status || 'open'}</Text>
|
||||
<Text className='admin-row-meta'>{item.studentName || item.studentUserId || '-'} · {item.className || '未绑定班级'} · {item.priority || 'normal'}</Text>
|
||||
<Text className='admin-row-meta'>{item.assignedToName || item.assignedToUserId || '未指派'} · {item.dueAt || '无到期时间'}</Text>
|
||||
{item.description ? <Text className='admin-row-meta break-line'>{item.description}</Text> : null}
|
||||
{item.status !== 'done' ? (
|
||||
<View className='admin-row-actions'>
|
||||
<Button className='admin-mini-button primary' loading={busy === `followup:${item.id}`} onClick={() => completeFollowup(item)}>完成</Button>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{error ? <Text className='admin-error'>{error}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -30,19 +30,100 @@ export interface TenantClassItem {
|
||||
}
|
||||
|
||||
export interface TenantStudentItem {
|
||||
membershipId?: string;
|
||||
userId: string;
|
||||
username?: string | null;
|
||||
name?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
status?: string;
|
||||
profileId?: string;
|
||||
regionId?: string | null;
|
||||
regionName?: string | null;
|
||||
selectedSchoolId?: string | null;
|
||||
selectedSchoolName?: string | null;
|
||||
selectedMajorId?: string | null;
|
||||
selectedMajorName?: string | null;
|
||||
questionsAnsweredToday?: number;
|
||||
masteredWordsCount?: number;
|
||||
lastSeenAt?: string | null;
|
||||
classes?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface TenantStudentInput {
|
||||
userId?: string;
|
||||
username?: string | null;
|
||||
name?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
regionId?: string | null;
|
||||
selectedSchoolId?: string | null;
|
||||
selectedMajorId?: string | null;
|
||||
status?: string;
|
||||
stats?: Record<string, unknown>;
|
||||
progress?: Record<string, unknown>;
|
||||
moduleSelections?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TenantTeacherItem {
|
||||
membershipId?: string;
|
||||
userId: string;
|
||||
username?: string | null;
|
||||
name?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
status?: string;
|
||||
classCount?: number;
|
||||
}
|
||||
|
||||
export interface BulkOperationResult {
|
||||
total?: number;
|
||||
successCount?: number;
|
||||
errorCount?: number;
|
||||
items?: Record<string, unknown>[];
|
||||
errors?: Array<{
|
||||
index?: number;
|
||||
code?: string;
|
||||
message?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TenantStudentNoteItem {
|
||||
id: string;
|
||||
studentUserId?: string;
|
||||
noteType?: string;
|
||||
content?: string;
|
||||
visibility?: string;
|
||||
isPinned?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdByName?: string | null;
|
||||
createdByUsername?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TenantStudentFollowupItem {
|
||||
id: string;
|
||||
studentUserId?: string;
|
||||
studentName?: string | null;
|
||||
studentPhone?: string | null;
|
||||
assignedToUserId?: string | null;
|
||||
assignedToName?: string | null;
|
||||
classId?: string | null;
|
||||
className?: string | null;
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
followupType?: string;
|
||||
priority?: string;
|
||||
status?: string;
|
||||
dueAt?: string | null;
|
||||
completedAt?: string | null;
|
||||
completedBy?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ImportJobItem {
|
||||
id: string;
|
||||
importType?: string;
|
||||
@@ -447,12 +528,109 @@ export async function loadTenantClasses(limit = 50) {
|
||||
return apiRequest<{ items?: TenantClassItem[]; scoped?: boolean }>('/api/tenant-admin/classes', { query: { limit } });
|
||||
}
|
||||
|
||||
export async function loadTenantStudents(query: { keyword?: string; classId?: string; limit?: number } = {}) {
|
||||
export async function loadTenantStudents(query: { keyword?: string; classId?: string; status?: string; limit?: number } = {}) {
|
||||
return apiRequest<{ items?: TenantStudentItem[]; scoped?: boolean }>('/api/tenant-admin/students', {
|
||||
query: { ...query, limit: query.limit || 50 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertTenantStudent(input: TenantStudentInput) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-admin/students', {
|
||||
method: 'PUT',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function bulkUpsertTenantStudents(students: TenantStudentInput[]) {
|
||||
return apiRequest<BulkOperationResult>('/api/tenant-admin/students/bulk-upsert', {
|
||||
method: 'POST',
|
||||
body: { students },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTenantStudentStatus(input: { userId: string; status: string; reason?: string | null }) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-admin/students/status', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function bulkAssignTenantClassMembers(input: {
|
||||
classId: string;
|
||||
assignments: Array<{
|
||||
userId?: string;
|
||||
username?: string | null;
|
||||
name?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
memberType?: string;
|
||||
status?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}>;
|
||||
}) {
|
||||
return apiRequest<BulkOperationResult>('/api/tenant-admin/classes/members/bulk-assign', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadTenantTeachers(query: { keyword?: string; limit?: number } = {}) {
|
||||
return apiRequest<{ items?: TenantTeacherItem[] }>('/api/tenant-admin/teachers', {
|
||||
query: { ...query, limit: query.limit || 50 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadTenantStudentNotes(studentUserId: string, limit = 20) {
|
||||
return apiRequest<{ items?: TenantStudentNoteItem[] }>('/api/tenant-admin/students/notes', {
|
||||
query: { studentUserId, limit },
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertTenantStudentNote(input: {
|
||||
id?: string;
|
||||
studentUserId: string;
|
||||
noteType?: string;
|
||||
content: string;
|
||||
visibility?: string;
|
||||
isPinned?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
return apiRequest<{ item?: TenantStudentNoteItem }>('/api/tenant-admin/students/notes', {
|
||||
method: 'PUT',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadTenantStudentFollowups(query: {
|
||||
studentUserId?: string;
|
||||
status?: string;
|
||||
assignedToUserId?: string;
|
||||
limit?: number;
|
||||
} = {}) {
|
||||
return apiRequest<{ items?: TenantStudentFollowupItem[] }>('/api/tenant-admin/students/followups', {
|
||||
query: { ...query, limit: query.limit || 50 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertTenantStudentFollowup(input: {
|
||||
id?: string;
|
||||
studentUserId: string;
|
||||
assignedToUserId?: string | null;
|
||||
classId?: string | null;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
followupType?: string;
|
||||
priority?: string;
|
||||
status?: string;
|
||||
dueAt?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
return apiRequest<{ item?: TenantStudentFollowupItem }>('/api/tenant-admin/students/followups', {
|
||||
method: 'PUT',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadContentEntriesAdmin() {
|
||||
return apiRequest<{ items?: ContentEntryAdminItem[] }>('/api/tenant-content/content-entries');
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
| --- | --- | --- | --- |
|
||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
||||
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API | 自动计费、平台审计、公共题库版本通知和运营消息 |
|
||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列和分佣结算操作台第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列和分佣结算操作台第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、JSON/试卷 payload 导出 | 专项策略、PDF/Word 导出 worker、公共题库版本通知、排行榜防刷/预聚合 |
|
||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
||||
| 知识手册 | 可联调 | 科目、章节、条目、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 执行验收、抽样校验和导入性能压测 |
|
||||
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
||||
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 |
|
||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户工作台已接权限驱动模块入口,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 刷题细节 UI、更细数据范围 UI、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
|
||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 刷题细节 UI、更细数据范围 UI、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
|
||||
|
||||
## 前端接入建议
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名和 assets worker 复检已完成,继续补 PDF 渲染、视频播放防盗链、杀毒扫描和水印。
|
||||
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
|
||||
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户工作台已接权限驱动模块入口,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列和分佣结算操作台第一版;下一步补刷题细节 UI、更细数据范围 UI、平台后台审计增强和小程序兼容验证。
|
||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列和分佣结算操作台第一版;下一步补刷题细节 UI、更细数据范围 UI、平台后台审计增强和小程序兼容验证。
|
||||
|
||||
### P1:商用收费和运营能力
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
## 当前可进入的前端工作
|
||||
|
||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;题库内容页已具备公共题库采纳/同步、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款第一版;租户设置页已具备角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款第一版;租户设置页已具备角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
||||
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。
|
||||
- 可以继续复刻旧题库学生端主要视觉和交互:刷题细节、勋章展示和小程序端分享/支付体验。地区选择、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。
|
||||
- 可以按新后端主模型接入内容导航:
|
||||
@@ -86,12 +86,12 @@
|
||||
| --- | --- | --- |
|
||||
| 工作台 | `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/students/index.tsx` | `tenant-admin/classes`、`tenant-admin/students` |
|
||||
| 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes`、`tenant-admin/teachers`、`tenant-admin/students`、`students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups` |
|
||||
| 题库内容 | `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/config`、`crm/queue`、`commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status` |
|
||||
| 租户设置 | `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 导入预览和执行、字段别名覆盖和导入后复检详情;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款;租户设置页支持角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补完整后台写入表单、学生批量导入、更细数据范围 UI、真实打款 provider、结算导出和凭证。
|
||||
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进;题库内容页支持公共题库采纳/同步、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款;租户设置页支持角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补更精细的学生导入模板体验、更细数据范围 UI、真实打款 provider、结算导出和凭证。
|
||||
|
||||
## 已落地的 Taro 平台后台页面
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
- 内容导入:题目、单词、知识手册、分数线、视频 JSON/CSV/Excel 预览、校验、导入、幂等、审计。
|
||||
- 内容导出:题库 JSON、`paper_json`、打印 payload 导出基础已完成,支持集合/入口/分类范围、导出 job、审计、跨租户拒绝、答案/解析开关和复合题子题脱敏。
|
||||
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
||||
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;后续补批量 CRM 推送和自动学习督导。
|
||||
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。
|
||||
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。
|
||||
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;后续补自动发放规则和活动联动。
|
||||
- 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。
|
||||
@@ -125,7 +125,8 @@
|
||||
|
||||
12. 学生运营管理
|
||||
- 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注和跟进任务。
|
||||
- 继续补批量 CRM 推送、学习督导自动化、跟进效果统计和前端 UI。
|
||||
- 已完成 Taro 学生运营页第一版:学生创建/更新、状态禁用/恢复、批量导入、批量分班、备注、跟进任务和完成跟进。
|
||||
- 继续补批量 CRM 推送、学习督导自动化、跟进效果统计和更细导入模板体验。
|
||||
|
||||
13. AI 择校推荐
|
||||
- 地区考试数据上下文。
|
||||
@@ -164,7 +165,7 @@
|
||||
- H5 和小程序共用同一套业务 API client。
|
||||
- 租户通过域名、小程序配置或启动参数解析。
|
||||
- 页面主题、品牌、功能开关都从后端租户配置读取。
|
||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、会员收银台、订单详情和售后入口第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、角色模板、成员绑定和 CRM/分佣操作台第一版;下一步补刷题细节 UI、状态管理、更细数据范围 UI、学生批量运营和小程序兼容验证。
|
||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、会员收银台、订单详情和售后入口第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、角色模板、成员绑定和 CRM/分佣操作台第一版;下一步补刷题细节 UI、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
|
||||
|
||||
### 第一批页面
|
||||
|
||||
@@ -213,7 +214,7 @@
|
||||
|
||||
## 推荐下一步顺序
|
||||
|
||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、角色模板配置、成员绑定模板、CRM 配置/队列、分佣规则/成员比例/结算生成审核打款已接第一版;继续补成员批量运营、学生批量导入、更细数据范围 UI、真实打款/导出/凭证。
|
||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列、分佣规则/成员比例/结算生成审核打款已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款/导出/凭证。
|
||||
2. 继续补 Taro 学生端旧体验:地区选择、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补刷题细节 UI、小程序支付容器、分享场景和状态管理。
|
||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
|
||||
@@ -1408,7 +1408,7 @@ src/services/learning.ts 练习 session、答题、收藏、错题/收藏复
|
||||
src/services/commerce.ts 套餐、优惠券、下单、支付参数、订单详情、权益、激活码
|
||||
src/services/profile.ts 个人中心、地区目标、签到、反馈、勋章、倒计时
|
||||
src/services/video.ts 题目视频列表、播放签名
|
||||
src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算
|
||||
src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生创建/批量导入/分班/备注/跟进、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算
|
||||
src/services/platformAdmin.ts 平台后台租户、套餐账单、用量、公共题库授权
|
||||
```
|
||||
|
||||
@@ -1426,7 +1426,7 @@ npm run build:taro:h5:platform
|
||||
下一批前端开发重点:
|
||||
|
||||
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情和售后入口已接第一版;下一批继续补刷题细节 UI、小程序支付容器和分享场景。
|
||||
- 租户后台:工作台已接权限驱动模块入口;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款第一版;租户设置页已接角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补完整写入表单、学生批量导入、CRM 分配策略、真实打款/导出/凭证和更细数据范围 UI。
|
||||
- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款第一版;租户设置页已接角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、CRM 分配策略、真实打款/导出/凭证和更细数据范围 UI。
|
||||
- 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。
|
||||
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user