feat: add student supervision automation

This commit is contained in:
Codex
2026-06-30 17:07:51 +08:00
parent 7473c7a6d7
commit 0204c934d8
14 changed files with 803 additions and 16 deletions

View File

@@ -4,6 +4,7 @@ import { Button, Input, Text, Textarea, View } from '@tarojs/components';
import {
bulkAssignTenantClassMembers,
bulkUpsertTenantStudents,
generateTenantStudentSupervision,
loadTenantClasses,
loadTenantStudentFollowups,
loadTenantStudentFollowupReport,
@@ -11,6 +12,7 @@ import {
loadTenantStudents,
loadTenantTeachers,
loadTenantMembers,
previewTenantStudentSupervision,
pushTenantStudentsToCrm,
updateTenantStudentStatus,
upsertTenantStudent,
@@ -24,6 +26,7 @@ import {
type TenantStudentNoteItem,
type TenantStudentCrmPushInput,
type TenantStudentFollowupReport,
type TenantStudentSupervisionCandidate,
type TenantTeacherItem,
type TenantMemberItem,
} from '@/services/tenantAdmin';
@@ -35,6 +38,15 @@ const followupTypes = ['learning', 'service', 'sales', 'renewal', 'risk', 'custo
const followupPriorities = ['low', 'normal', 'high', 'urgent'];
const followupStatuses = ['open', 'in_progress', 'done', 'cancelled'];
const crmAssignableRoles = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent'];
const defaultSupervisionRules = {
windowDays: 14,
inactivityDays: 7,
minAnswers: 10,
lowAccuracyThreshold: 0.6,
wrongQuestionThreshold: 5,
vocabularyDueThreshold: 20,
staleSessionDays: 3,
};
function emptyStudentForm(): TenantStudentInput {
return {
@@ -124,7 +136,10 @@ export default function TenantStudentsPage() {
const [bulkResult, setBulkResult] = useState<BulkOperationResult | null>(null);
const [assignResult, setAssignResult] = useState<BulkOperationResult | null>(null);
const [crmPushResult, setCrmPushResult] = useState<BulkOperationResult | null>(null);
const [supervisionResult, setSupervisionResult] = useState<BulkOperationResult | null>(null);
const [followupReport, setFollowupReport] = useState<TenantStudentFollowupReport['item'] | null>(null);
const [supervisionCandidates, setSupervisionCandidates] = useState<TenantStudentSupervisionCandidate[]>([]);
const [supervisionRules, setSupervisionRules] = useState(defaultSupervisionRules);
const [scoped, setScoped] = useState(false);
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
@@ -332,6 +347,50 @@ export default function TenantStudentsPage() {
}
}
async function previewSupervision() {
setBusy('supervisionPreview');
setError('');
try {
const payload = await previewTenantStudentSupervision({
...supervisionRules,
classId: selectedClassId || undefined,
limit: 20,
});
setSupervisionCandidates(payload.item?.candidates || []);
Taro.showToast({ title: '督导候选已刷新', icon: 'success' });
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '学习督导预览失败');
} finally {
setBusy('');
}
}
async function generateSupervision() {
if (!supervisionCandidates.length) {
Taro.showToast({ title: '请先预览候选', icon: 'none' });
return;
}
setBusy('supervisionGenerate');
setError('');
try {
const result = await generateTenantStudentSupervision({
rules: supervisionRules,
classId: selectedClassId || null,
studentUserIds: supervisionCandidates.map(item => item.studentUserId),
batchKey: `taro-supervision-${new Date().toISOString().slice(0, 10)}-${selectedClassId || 'all'}`,
limit: supervisionCandidates.length,
metadata: { source: 'taro-tenant-admin' },
});
setSupervisionResult(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' });
@@ -575,6 +634,32 @@ export default function TenantStudentsPage() {
))}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-form-grid'>
<Input className='admin-input' placeholder='观察天数' value={String(supervisionRules.windowDays)} onInput={event => setSupervisionRules(prev => ({ ...prev, windowDays: Number(event.detail.value || prev.windowDays) }))} />
<Input className='admin-input' placeholder='未学习天数' value={String(supervisionRules.inactivityDays)} onInput={event => setSupervisionRules(prev => ({ ...prev, inactivityDays: Number(event.detail.value || prev.inactivityDays) }))} />
<Input className='admin-input' placeholder='最低答题数' value={String(supervisionRules.minAnswers)} onInput={event => setSupervisionRules(prev => ({ ...prev, minAnswers: Number(event.detail.value || prev.minAnswers) }))} />
<Input className='admin-input' placeholder='低正确率阈值' value={String(supervisionRules.lowAccuracyThreshold)} onInput={event => setSupervisionRules(prev => ({ ...prev, lowAccuracyThreshold: Number(event.detail.value || prev.lowAccuracyThreshold) }))} />
<Input className='admin-input' placeholder='错题阈值' value={String(supervisionRules.wrongQuestionThreshold)} onInput={event => setSupervisionRules(prev => ({ ...prev, wrongQuestionThreshold: Number(event.detail.value || prev.wrongQuestionThreshold) }))} />
<Input className='admin-input' placeholder='待复习单词阈值' value={String(supervisionRules.vocabularyDueThreshold)} onInput={event => setSupervisionRules(prev => ({ ...prev, vocabularyDueThreshold: Number(event.detail.value || prev.vocabularyDueThreshold) }))} />
</View>
<View className='admin-actions compact'>
<Button className='admin-button primary' loading={busy === 'supervisionPreview'} onClick={previewSupervision}></Button>
<Button className='admin-button' loading={busy === 'supervisionGenerate'} onClick={generateSupervision}></Button>
{supervisionResult ? <Text className='admin-row-meta'>{resultSummary(supervisionResult)}</Text> : null}
</View>
<View className='admin-list'>
{supervisionCandidates.slice(0, 6).map(item => (
<View className='admin-row' key={item.studentUserId}>
<Text className='admin-row-main'>{item.studentName || item.studentUserId} · {String(item.riskScore || 0)}</Text>
<Text className='admin-row-meta'>{item.priority || 'normal'} · {item.description || '学习状态需要跟进'}</Text>
</View>
))}
</View>
{!supervisionCandidates.length ? <Text className='admin-row-meta'></Text> : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'>CRM </Text>
<View className='admin-form-grid'>

View File

@@ -174,6 +174,40 @@ export interface TenantStudentFollowupReport {
};
}
export interface TenantStudentSupervisionRules {
windowDays?: number;
inactivityDays?: number;
minAnswers?: number;
lowAccuracyThreshold?: number;
wrongQuestionThreshold?: number;
vocabularyDueThreshold?: number;
staleSessionDays?: number;
}
export interface TenantStudentSupervisionCandidate {
studentUserId: string;
studentName?: string | null;
studentPhone?: string | null;
assignedToUserId?: string | null;
classId?: string | null;
title?: string;
description?: string;
followupType?: string;
priority?: string;
riskScore?: number;
reasons?: Record<string, unknown>[];
evidence?: Record<string, unknown>;
}
export interface TenantStudentSupervisionPreview {
item?: {
rules?: TenantStudentSupervisionRules;
filters?: Record<string, unknown>;
totalCandidates?: number;
candidates?: TenantStudentSupervisionCandidate[];
};
}
export interface ImportJobItem {
id: string;
importType?: string;
@@ -1113,6 +1147,32 @@ export async function loadTenantStudentFollowupReport(query: {
});
}
export async function previewTenantStudentSupervision(query: TenantStudentSupervisionRules & {
classId?: string;
assignedToUserId?: string;
limit?: number;
} = {}) {
return apiRequest<TenantStudentSupervisionPreview>('/api/tenant-admin/students/supervision/preview', {
query: { ...query, limit: query.limit || 20 },
});
}
export async function generateTenantStudentSupervision(input: {
rules?: TenantStudentSupervisionRules;
classId?: string | null;
assignedToUserId?: string | null;
studentUserIds?: string[];
dueAt?: string | null;
batchKey?: string;
limit?: number;
metadata?: Record<string, unknown>;
}) {
return apiRequest<BulkOperationResult>('/api/tenant-admin/students/supervision/generate', {
method: 'POST',
body: input,
});
}
export async function upsertTenantStudentFollowup(input: {
id?: string;
studentUserId: string;