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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user