feat: add student crm push

This commit is contained in:
Codex
2026-06-30 16:21:17 +08:00
parent d94b20b8ed
commit 7f97b52165
15 changed files with 774 additions and 27 deletions

View File

@@ -9,6 +9,8 @@ import {
loadTenantStudentNotes,
loadTenantStudents,
loadTenantTeachers,
loadTenantMembers,
pushTenantStudentsToCrm,
updateTenantStudentStatus,
upsertTenantStudent,
upsertTenantStudentFollowup,
@@ -19,7 +21,9 @@ import {
type TenantStudentInput,
type TenantStudentItem,
type TenantStudentNoteItem,
type TenantStudentCrmPushInput,
type TenantTeacherItem,
type TenantMemberItem,
} from '@/services/tenantAdmin';
import '../admin.css';
@@ -28,6 +32,7 @@ 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'];
const crmAssignableRoles = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent'];
function emptyStudentForm(): TenantStudentInput {
return {
@@ -74,6 +79,13 @@ function resultSummary(result: BulkOperationResult | null) {
return `总数 ${result.total || 0} · 成功 ${result.successCount || 0} · 失败 ${result.errorCount || 0}`;
}
function parseIdRows(text: string) {
return Array.from(new Set(text
.split(/\r?\n|,||\s+/)
.map(item => item.trim())
.filter(Boolean)));
}
function studentTitle(item: TenantStudentItem | null) {
if (!item) return '未选择学生';
return item.name || item.phone || item.email || item.username || item.userId;
@@ -84,6 +96,7 @@ export default function TenantStudentsPage() {
const [classes, setClasses] = useState<TenantClassItem[]>([]);
const [students, setStudents] = useState<TenantStudentItem[]>([]);
const [teachers, setTeachers] = useState<TenantTeacherItem[]>([]);
const [crmAssignees, setCrmAssignees] = useState<TenantMemberItem[]>([]);
const [notes, setNotes] = useState<TenantStudentNoteItem[]>([]);
const [followups, setFollowups] = useState<TenantStudentFollowupItem[]>([]);
const [selectedClassId, setSelectedClassId] = useState('');
@@ -94,8 +107,21 @@ export default function TenantStudentsPage() {
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 [crmPushText, setCrmPushText] = useState('');
const [crmPushForm, setCrmPushForm] = useState<TenantStudentCrmPushInput>({
studentUserIds: [],
source: 'manual_batch',
title: '',
message: '',
followupType: 'sales',
priority: 'normal',
assignedToUserId: '',
classId: '',
dueAt: '',
});
const [bulkResult, setBulkResult] = useState<BulkOperationResult | null>(null);
const [assignResult, setAssignResult] = useState<BulkOperationResult | null>(null);
const [crmPushResult, setCrmPushResult] = useState<BulkOperationResult | null>(null);
const [scoped, setScoped] = useState(false);
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
@@ -110,6 +136,7 @@ export default function TenantStudentsPage() {
Promise.all([
loadTenantClasses(80).catch(() => ({ items: [] })),
loadTenantTeachers({ limit: 80 }).catch(() => ({ items: [] })),
loadTenantMembers({ status: 'active', limit: 80 }).catch(() => ({ items: [] })),
loadTenantStudents({
classId: nextClassId || undefined,
keyword: nextKeyword || undefined,
@@ -117,9 +144,10 @@ export default function TenantStudentsPage() {
limit: 80,
}),
loadTenantStudentFollowups({ status: 'open', limit: 30 }).catch(() => ({ items: [] })),
]).then(([classPayload, teacherPayload, studentPayload, followupPayload]) => {
]).then(([classPayload, teacherPayload, memberPayload, studentPayload, followupPayload]) => {
setClasses(classPayload.items || []);
setTeachers(teacherPayload.items || []);
setCrmAssignees((memberPayload.items || []).filter(item => crmAssignableRoles.includes(String(item.role || ''))));
setStudents(studentPayload.items || []);
setScoped(studentPayload.scoped === true);
setFollowups(followupPayload.items || []);
@@ -155,6 +183,12 @@ export default function TenantStudentsPage() {
status: item.status || 'active',
});
setFollowupForm(prev => ({ ...prev, studentUserId: item.userId, classId: selectedClassId || String((item.classes || [])[0]?.classId || '') } as typeof prev));
setCrmPushText(item.userId);
setCrmPushForm(prev => ({
...prev,
studentUserIds: [item.userId],
classId: selectedClassId || String((item.classes || [])[0]?.classId || ''),
}));
setError('');
try {
const [notePayload, followupPayload] = await Promise.all([
@@ -259,6 +293,40 @@ export default function TenantStudentsPage() {
}
}
async function submitCrmPush() {
const studentUserIds = parseIdRows(crmPushText);
if (!studentUserIds.length) {
Taro.showToast({ title: '请填写学生ID', icon: 'none' });
return;
}
if (!crmPushForm.title?.trim()) {
Taro.showToast({ title: '请填写任务标题', icon: 'none' });
return;
}
setBusy('crmPush');
setError('');
try {
const result = await pushTenantStudentsToCrm({
...crmPushForm,
studentUserIds,
title: crmPushForm.title.trim(),
message: crmPushForm.message?.trim() || null,
assignedToUserId: crmPushForm.assignedToUserId || null,
classId: crmPushForm.classId || selectedClassId || null,
dueAt: crmPushForm.dueAt || null,
metadata: { source: 'taro-tenant-admin' },
idempotencyKey: `taro-${Date.now()}`,
});
setCrmPushResult(result);
Taro.showToast({ title: 'CRM推送已入队', icon: result.errorCount ? 'none' : 'success' });
reload();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'CRM推送失败');
} finally {
setBusy('');
}
}
async function saveNote() {
if (!selectedStudent) {
Taro.showToast({ title: '请选择学生', icon: 'none' });
@@ -470,6 +538,46 @@ export default function TenantStudentsPage() {
{!students.length ? <View className='admin-empty'></View> : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'>CRM </Text>
<View className='admin-form-grid'>
<Input className='admin-input' placeholder='跟进标题' value={crmPushForm.title} onInput={event => setCrmPushForm(prev => ({ ...prev, title: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='到期时间,例如 2026-07-01T10:00:00.000Z' value={crmPushForm.dueAt || ''} onInput={event => setCrmPushForm(prev => ({ ...prev, dueAt: String(event.detail.value || '') }))} />
</View>
<Textarea
className='admin-textarea'
placeholder='每行一个学生 userId'
value={crmPushText}
onInput={event => setCrmPushText(String(event.detail.value || ''))}
/>
<Textarea
className='admin-textarea'
placeholder='推送说明'
value={crmPushForm.message || ''}
onInput={event => setCrmPushForm(prev => ({ ...prev, message: String(event.detail.value || '') }))}
/>
<View className='admin-actions compact'>
{followupTypes.map(item => <Button key={item} className={`admin-button ${crmPushForm.followupType === item ? 'active' : ''}`} onClick={() => setCrmPushForm(prev => ({ ...prev, followupType: item }))}>{item}</Button>)}
</View>
<View className='admin-actions compact'>
{followupPriorities.map(item => <Button key={item} className={`admin-button ${crmPushForm.priority === item ? 'active' : ''}`} onClick={() => setCrmPushForm(prev => ({ ...prev, priority: item }))}>{item}</Button>)}
</View>
<View className='admin-actions compact'>
<Button className={`admin-button ${crmPushForm.assignedToUserId ? '' : 'active'}`} onClick={() => setCrmPushForm(prev => ({ ...prev, assignedToUserId: '' }))}></Button>
{crmAssignees.slice(0, 12).map(item => (
<Button key={item.userId} className={`admin-button ${crmPushForm.assignedToUserId === item.userId ? 'active' : ''}`} onClick={() => setCrmPushForm(prev => ({ ...prev, assignedToUserId: item.userId }))}>{item.name || item.phone || item.username || '成员'}</Button>
))}
</View>
<View className='admin-actions compact'>
<Button className='admin-button primary' loading={busy === 'crmPush'} onClick={submitCrmPush}></Button>
{selectedStudent ? <Button className='admin-button' onClick={() => setCrmPushText(selectedStudent.userId)}></Button> : null}
{crmPushResult ? <Text className='admin-row-meta'>{resultSummary(crmPushResult)}</Text> : null}
</View>
{crmPushResult?.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'>

View File

@@ -124,6 +124,20 @@ export interface TenantStudentFollowupItem {
updatedAt?: string;
}
export interface TenantStudentCrmPushInput {
studentUserIds: string[];
source?: string;
title: string;
message?: string | null;
followupType?: string;
priority?: string;
assignedToUserId?: string | null;
classId?: string | null;
dueAt?: string | null;
metadata?: Record<string, unknown>;
idempotencyKey?: string;
}
export interface ImportJobItem {
id: string;
importType?: string;
@@ -985,6 +999,13 @@ export async function updateTenantStudentStatus(input: { userId: string; status:
});
}
export async function pushTenantStudentsToCrm(input: TenantStudentCrmPushInput) {
return apiRequest<BulkOperationResult & { requestId?: string }>('/api/tenant-admin/students/crm-push', {
method: 'POST',
body: input,
});
}
export async function bulkAssignTenantClassMembers(input: {
classId: string;
assignments: Array<{