forked from wangziqi/gongxue-base
feat: add student crm push
This commit is contained in:
@@ -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'>
|
||||
|
||||
@@ -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<{
|
||||
|
||||
Reference in New Issue
Block a user