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