feat: add tenant student operations console

This commit is contained in:
Codex
2026-06-29 14:55:40 +08:00
parent 6e182977fc
commit 134f7830dd
7 changed files with 665 additions and 24 deletions

View File

@@ -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 保护仍以后端校验与审计为准。

View File

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

View File

@@ -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');
}

View File

@@ -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商用收费和运营能力

View File

@@ -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 平台后台页面

View File

@@ -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` 的远程等价测试。

View File

@@ -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/*` 作为小程序登录适配层。