From 134f7830ddf9a5fa6b59bf7191146bc95f8279ca Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 14:55:40 +0800 Subject: [PATCH] feat: add tenant student operations console --- apps/taro/README.md | 4 +- .../src/pages/tenant-admin/students/index.tsx | 480 +++++++++++++++++- apps/taro/src/services/tenantAdmin.ts | 180 ++++++- docs/refactor/backend-handoff-roadmap.md | 6 +- docs/refactor/frontend-handoff-index.md | 6 +- docs/refactor/next-development-todo.md | 9 +- docs/refactor/taro-frontend-integration.md | 4 +- 7 files changed, 665 insertions(+), 24 deletions(-) diff --git a/apps/taro/README.md b/apps/taro/README.md index f5ba8e20..94e73c6e 100644 --- a/apps/taro/README.md +++ b/apps/taro/README.md @@ -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 保护仍以后端校验与审计为准。 diff --git a/apps/taro/src/pages/tenant-admin/students/index.tsx b/apps/taro/src/pages/tenant-admin/students/index.tsx index 8d3ed89e..a5a2aa30 100644 --- a/apps/taro/src/pages/tenant-admin/students/index.tsx +++ b/apps/taro/src/pages/tenant-admin/students/index.tsx @@ -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([]); const [students, setStudents] = useState([]); + const [teachers, setTeachers] = useState([]); + const [notes, setNotes] = useState([]); + const [followups, setFollowups] = useState([]); const [selectedClassId, setSelectedClassId] = useState(''); + const [studentStatus, setStudentStatus] = useState(''); + const [selectedStudent, setSelectedStudent] = useState(null); + const [studentForm, setStudentForm] = useState(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(null); + const [assignResult, setAssignResult] = useState(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() { setKeyword(String(event.detail.value || ''))} /> - + + @@ -64,19 +379,166 @@ export default function TenantStudentsPage() { + + 状态 + + {statusOptions.map(status => ( + + ))} + + {scoped ? 当前角色已启用班级/学生范围过滤。 : null} + + + + 学生档案 + + setStudentForm(prev => ({ ...prev, userId: String(event.detail.value || '') }))} /> + setStudentForm(prev => ({ ...prev, name: String(event.detail.value || '') }))} /> + setStudentForm(prev => ({ ...prev, phone: String(event.detail.value || '') }))} /> + setStudentForm(prev => ({ ...prev, email: String(event.detail.value || '') }))} /> + setStudentForm(prev => ({ ...prev, regionId: String(event.detail.value || '') }))} /> + setStudentForm(prev => ({ ...prev, selectedSchoolId: String(event.detail.value || '') }))} /> + setStudentForm(prev => ({ ...prev, selectedMajorId: String(event.detail.value || '') }))} /> + + + {['active', 'invited', 'disabled'].map(status => ( + + ))} + + + + + + 批量导入 +