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

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