forked from wangziqi/gongxue-base
feat: add student crm push
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||
@@ -22,13 +23,33 @@ const STUDENT_FOLLOWUP_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
|
||||
const STUDENT_FOLLOWUP_STATUSES = ['open', 'in_progress', 'done', 'cancelled'];
|
||||
const MAX_BULK_STUDENTS = 200;
|
||||
const MAX_BULK_CLASS_ASSIGNMENTS = 500;
|
||||
const MAX_BULK_CRM_PUSH = 100;
|
||||
const CRM_ASSIGNABLE_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent'];
|
||||
const STUDENT_AVATAR_FIELD_KEYS = ['avatarUrl', 'avatar_url', 'avatar', 'headimgurl', 'headImgUrl', 'figureurl', 'figureurl_qq_1', 'figureurl_qq_2'];
|
||||
const STUDENT_PRIMARY_ROLE_FIELD_KEYS = ['primaryRole', 'primary_role'];
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function limitedNullableString(value: unknown, maxLength: number, fieldName: string) {
|
||||
const candidate = nullableString(value);
|
||||
if (!candidate) return null;
|
||||
if (candidate.length > maxLength) {
|
||||
throw new HttpError(400, `${fieldName} is too long`, 'FIELD_TOO_LONG');
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function requiredLimitedString(body: JsonBody, key: string, maxLength: number) {
|
||||
const value = requiredString(body, key);
|
||||
if (value.length > maxLength) {
|
||||
throw new HttpError(400, `${key} is too long`, 'FIELD_TOO_LONG');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
@@ -80,7 +101,42 @@ function uuidArrayValue(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map(item => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(item => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(item));
|
||||
.filter(item => UUID_PATTERN.test(item));
|
||||
}
|
||||
|
||||
function strictUuidArrayValue(value: unknown, fieldName: string, maxLength: number) {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new HttpError(400, `${fieldName} must be an array`, 'INVALID_ARRAY_FIELD');
|
||||
}
|
||||
if (value.length > maxLength) {
|
||||
throw new HttpError(413, `${fieldName} supports at most ${maxLength} items`, 'BULK_LIMIT_EXCEEDED');
|
||||
}
|
||||
|
||||
const items: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const item = typeof value[index] === 'string' ? value[index].trim() : '';
|
||||
if (!UUID_PATTERN.test(item)) {
|
||||
throw new HttpError(400, `${fieldName}[${index}] must be a uuid`, 'INVALID_UUID_FIELD');
|
||||
}
|
||||
const normalized = item.toLowerCase();
|
||||
if (!seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
items.push(normalized);
|
||||
}
|
||||
}
|
||||
if (!items.length) throw new HttpError(400, `${fieldName} is required`, 'REQUIRED_FIELD');
|
||||
return items;
|
||||
}
|
||||
|
||||
function optionalTimestampString(value: unknown, fieldName: string) {
|
||||
const candidate = nullableString(value);
|
||||
if (!candidate) return null;
|
||||
const time = Date.parse(candidate);
|
||||
if (!Number.isFinite(time)) {
|
||||
throw new HttpError(400, `${fieldName} must be a valid datetime`, 'INVALID_DATETIME');
|
||||
}
|
||||
return new Date(time).toISOString();
|
||||
}
|
||||
|
||||
function classCodeValue(value: unknown) {
|
||||
@@ -353,6 +409,150 @@ async function ensureTenantMembership(
|
||||
);
|
||||
}
|
||||
|
||||
interface StudentCrmPushConfig {
|
||||
enabled: boolean;
|
||||
url: string | null;
|
||||
formName: string | null;
|
||||
examType: string | null;
|
||||
delaySec: number | null;
|
||||
}
|
||||
|
||||
async function loadCrmPushConfig(client: pg.PoolClient, tenantId: string) {
|
||||
const result = await client.query<StudentCrmPushConfig>(
|
||||
`
|
||||
select enabled, url, form_name as "formName", exam_type as "examType", delay_sec as "delaySec"
|
||||
from public.crm_config
|
||||
where tenant_id = $1
|
||||
limit 1
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
const config = result.rows[0] || null;
|
||||
if (!config?.enabled || !config.url) {
|
||||
throw new HttpError(409, 'CRM config is disabled or missing webhook URL', 'CRM_CONFIG_REQUIRED');
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function crmPushIdempotencyKey(inputKey: string | null, requestId: string, studentUserId: string) {
|
||||
const rawKey = inputKey || requestId;
|
||||
const normalized = rawKey.length <= 80 ? rawKey : createHash('sha256').update(rawKey).digest('hex');
|
||||
return `student_crm_push:${normalized}:${studentUserId}`;
|
||||
}
|
||||
|
||||
async function loadStudentCrmPayload(
|
||||
client: pg.PoolClient,
|
||||
auth: TenantAdminAuth,
|
||||
studentUserId: string,
|
||||
includePhone: boolean,
|
||||
) {
|
||||
const result = await client.query<Record<string, unknown>>(
|
||||
`
|
||||
with class_agg as (
|
||||
select tcm.tenant_id, tcm.user_id,
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'classId', tc.id,
|
||||
'className', tc.name,
|
||||
'classCode', tc.code
|
||||
)
|
||||
order by tc.sort_order asc, tc.created_at desc
|
||||
) filter (where tcm.status = 'active') as classes
|
||||
from public.tenant_class_members tcm
|
||||
join public.tenant_classes tc on tc.tenant_id = tcm.tenant_id and tc.id = tcm.class_id
|
||||
where tcm.tenant_id = $1
|
||||
and tcm.user_id = $2::uuid
|
||||
and tcm.member_type = 'student'
|
||||
group by tcm.tenant_id, tcm.user_id
|
||||
)
|
||||
select u.id as "userId", u.username, u.name,
|
||||
case when $3::boolean then u.phone else null end as phone,
|
||||
u.email::text as email,
|
||||
coalesce(sp.avatar_preset, 'male') as "avatarPreset",
|
||||
sp.region_id as "regionId", r.name as "regionName",
|
||||
sp.selected_school_id as "selectedSchoolId", s.name as "selectedSchoolName",
|
||||
sp.selected_major_id as "selectedMajorId", m.name as "selectedMajorName",
|
||||
coalesce(ca.classes, '[]'::jsonb) as classes
|
||||
from public.tenant_memberships tm
|
||||
join public.platform_users u on u.id = tm.user_id
|
||||
left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id
|
||||
left join public.regions r on r.tenant_id = tm.tenant_id and r.id = sp.region_id
|
||||
left join public.schools s on s.tenant_id = tm.tenant_id and s.id = sp.selected_school_id
|
||||
left join public.majors m on m.tenant_id = tm.tenant_id and m.id = sp.selected_major_id
|
||||
left join class_agg ca on ca.tenant_id = tm.tenant_id and ca.user_id = tm.user_id
|
||||
where tm.tenant_id = $1
|
||||
and tm.user_id = $2::uuid
|
||||
and tm.role = 'student'
|
||||
and tm.status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[auth.tenantId, studentUserId, includePhone],
|
||||
);
|
||||
if (!result.rows[0]) throw new HttpError(404, 'Student not found', 'STUDENT_NOT_FOUND');
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function loadCrmAssigneePayload(client: pg.PoolClient, tenantId: string, userId: string | null) {
|
||||
if (!userId) return null;
|
||||
const result = await client.query<Record<string, unknown>>(
|
||||
`
|
||||
select u.id as "userId", u.username, u.name, tm.role
|
||||
from public.tenant_memberships tm
|
||||
join public.platform_users u on u.id = tm.user_id
|
||||
where tm.tenant_id = $1
|
||||
and tm.user_id = $2::uuid
|
||||
and tm.role = any($3::text[])
|
||||
and tm.status = 'active'
|
||||
order by case tm.role
|
||||
when 'tenant_owner' then 1
|
||||
when 'tenant_admin' then 2
|
||||
when 'tenant_operator' then 3
|
||||
when 'teacher' then 4
|
||||
when 'sales' then 5
|
||||
when 'agent' then 6
|
||||
else 9
|
||||
end
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, CRM_ASSIGNABLE_ROLES],
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function loadCrmClassPayload(client: pg.PoolClient, tenantId: string, classId: string | null) {
|
||||
if (!classId) return null;
|
||||
const result = await client.query<Record<string, unknown>>(
|
||||
`
|
||||
select id as "classId", code as "classCode", name as "className"
|
||||
from public.tenant_classes
|
||||
where tenant_id = $1 and id = $2::uuid
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, classId],
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function ensureStudentInClass(client: pg.PoolClient, tenantId: string, studentUserId: string, classId: string | null) {
|
||||
if (!classId) return;
|
||||
const result = await client.query<{ id: string }>(
|
||||
`
|
||||
select id
|
||||
from public.tenant_class_members
|
||||
where tenant_id = $1
|
||||
and class_id = $2::uuid
|
||||
and user_id = $3::uuid
|
||||
and member_type = 'student'
|
||||
and status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, classId, studentUserId],
|
||||
);
|
||||
if (!result.rows[0]) {
|
||||
throw new HttpError(400, 'Student is not an active member of the selected class', 'STUDENT_CLASS_REQUIRED');
|
||||
}
|
||||
}
|
||||
|
||||
function membershipRoleForClassMember(memberType: string) {
|
||||
return memberType === 'student' ? 'student' : 'teacher';
|
||||
}
|
||||
@@ -1267,3 +1467,209 @@ export async function upsertTenantStudentFollowupRoute(ctx: RequestContext) {
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function pushTenantStudentsToCrmRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'crm:write');
|
||||
requireTenantPermission(auth, 'students:read');
|
||||
requireTenantPermission(auth, 'students:followups:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
|
||||
const studentUserIds = strictUuidArrayValue(body.studentUserIds || body.userIds, 'studentUserIds', MAX_BULK_CRM_PUSH);
|
||||
const classId = nullableString(body.classId);
|
||||
if (classId) await ensureReadableClass(auth, classId);
|
||||
|
||||
const source = limitedNullableString(body.source, 80, 'source') || 'manual_batch';
|
||||
const title = requiredLimitedString(body, 'title', 120);
|
||||
const message = limitedNullableString(body.message ?? body.description, 1000, 'message');
|
||||
const followupType = optionalChoice(body.followupType, STUDENT_FOLLOWUP_TYPES, 'sales');
|
||||
const priority = optionalChoice(body.priority, STUDENT_FOLLOWUP_PRIORITIES, 'normal');
|
||||
const assignedToUserId = nullableString(body.assignedToUserId);
|
||||
const dueAt = optionalTimestampString(body.dueAt, 'dueAt');
|
||||
const metadata = objectValue(body.metadata);
|
||||
const requestId = randomUUID();
|
||||
const idempotencyInput = limitedNullableString(body.idempotencyKey, 160, 'idempotencyKey');
|
||||
const includePhone = canSeeStudentPhone(auth);
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const config = await loadCrmPushConfig(client, auth.tenantId);
|
||||
if (classId) await ensureTenantClass(client, auth.tenantId, classId);
|
||||
await ensureUserTenantMembership(client, auth.tenantId, assignedToUserId, CRM_ASSIGNABLE_ROLES, 'ASSIGNEE_NOT_FOUND');
|
||||
const assignee = await loadCrmAssigneePayload(client, auth.tenantId, assignedToUserId);
|
||||
const classPayload = await loadCrmClassPayload(client, auth.tenantId, classId);
|
||||
const delaySeconds = Math.max(0, Number(config.delaySec ?? 60));
|
||||
const items: unknown[] = [];
|
||||
const errors: unknown[] = [];
|
||||
|
||||
for (let index = 0; index < studentUserIds.length; index += 1) {
|
||||
const studentUserId = studentUserIds[index];
|
||||
try {
|
||||
await ensureStudentInScope(auth, studentUserId);
|
||||
await ensureStudentInClass(client, auth.tenantId, studentUserId, classId);
|
||||
const student = await loadStudentCrmPayload(client, auth, studentUserId, includePhone);
|
||||
const idempotencyKey = crmPushIdempotencyKey(idempotencyInput, requestId, studentUserId);
|
||||
const followupMetadata = {
|
||||
...metadata,
|
||||
crmPush: {
|
||||
requestId,
|
||||
idempotencyKey,
|
||||
source,
|
||||
},
|
||||
};
|
||||
|
||||
let followup = (await client.query<{
|
||||
id: string;
|
||||
studentUserId: string;
|
||||
assignedToUserId: string | null;
|
||||
classId: string | null;
|
||||
title: string;
|
||||
dueAt: string | null;
|
||||
createdAt: string;
|
||||
}>(
|
||||
`
|
||||
select id, student_user_id as "studentUserId",
|
||||
assigned_to_user_id as "assignedToUserId", class_id as "classId",
|
||||
title, due_at as "dueAt", created_at as "createdAt"
|
||||
from public.tenant_student_followups
|
||||
where tenant_id = $1
|
||||
and student_user_id = $2::uuid
|
||||
and metadata->'crmPush'->>'idempotencyKey' = $3
|
||||
limit 1
|
||||
`,
|
||||
[auth.tenantId, studentUserId, idempotencyKey],
|
||||
)).rows[0];
|
||||
|
||||
if (!followup) {
|
||||
followup = (await client.query(
|
||||
`
|
||||
insert into public.tenant_student_followups (
|
||||
tenant_id, student_user_id, assigned_to_user_id, class_id,
|
||||
title, description, followup_type, priority, status, due_at,
|
||||
metadata, created_by, updated_by
|
||||
)
|
||||
values (
|
||||
$1, $2::uuid, $3::uuid, $4::uuid,
|
||||
$5, $6, $7, $8, 'open', $9::timestamptz,
|
||||
$10::jsonb, $11, $11
|
||||
)
|
||||
returning id, student_user_id as "studentUserId",
|
||||
assigned_to_user_id as "assignedToUserId", class_id as "classId",
|
||||
title, due_at as "dueAt", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
studentUserId,
|
||||
assignedToUserId,
|
||||
classId,
|
||||
title,
|
||||
message,
|
||||
followupType,
|
||||
priority,
|
||||
dueAt,
|
||||
JSON.stringify(followupMetadata),
|
||||
auth.userId,
|
||||
],
|
||||
)).rows[0];
|
||||
}
|
||||
|
||||
const payload = {
|
||||
eventType: 'student.crm_push',
|
||||
formName: config.formName || '刷题题库',
|
||||
examType: config.examType || '成人本科',
|
||||
source,
|
||||
title,
|
||||
message,
|
||||
student,
|
||||
assignee,
|
||||
class: classPayload,
|
||||
followup: {
|
||||
id: followup.id,
|
||||
title: followup.title,
|
||||
followupType,
|
||||
priority,
|
||||
dueAt: followup.dueAt,
|
||||
},
|
||||
actor: {
|
||||
userId: auth.userId,
|
||||
role: auth.role,
|
||||
},
|
||||
metadata,
|
||||
};
|
||||
|
||||
const queued = await client.query<{
|
||||
id: string;
|
||||
status: string;
|
||||
scheduledAt: string | null;
|
||||
idempotencyKey: string;
|
||||
}>(
|
||||
`
|
||||
insert into public.crm_webhook_queue (
|
||||
tenant_id, record_id, status, scheduled_at, next_attempt_at, lead_id,
|
||||
source, payload, idempotency_key, target_url
|
||||
)
|
||||
values ($1, $2, 'pending', now() + ($3::int * interval '1 second'), null, null, $4, $5::jsonb, $6, $7)
|
||||
on conflict (tenant_id, idempotency_key) where idempotency_key is not null
|
||||
do update set status = case
|
||||
when public.crm_webhook_queue.status = 'sent' then public.crm_webhook_queue.status
|
||||
else 'pending'
|
||||
end,
|
||||
scheduled_at = excluded.scheduled_at,
|
||||
next_attempt_at = null,
|
||||
payload = excluded.payload,
|
||||
target_url = excluded.target_url,
|
||||
updated_at = now()
|
||||
returning id, status, scheduled_at as "scheduledAt", idempotency_key as "idempotencyKey"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
studentUserId,
|
||||
delaySeconds,
|
||||
'tenant.student.crm_push',
|
||||
JSON.stringify(payload),
|
||||
idempotencyKey,
|
||||
config.url,
|
||||
],
|
||||
);
|
||||
|
||||
items.push({
|
||||
index,
|
||||
studentUserId,
|
||||
followupId: followup.id,
|
||||
queueId: queued.rows[0].id,
|
||||
queueStatus: queued.rows[0].status,
|
||||
scheduledAt: queued.rows[0].scheduledAt,
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
index,
|
||||
studentUserId,
|
||||
code: error instanceof HttpError ? error.code : 'STUDENT_CRM_PUSH_ITEM_FAILED',
|
||||
message: error instanceof Error ? error.message : 'Student CRM push item failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await recordAudit(client, auth, 'tenant.students.crm_pushed', 'crm_webhook_queue', null, {
|
||||
requestId,
|
||||
total: studentUserIds.length,
|
||||
successCount: items.length,
|
||||
errorCount: errors.length,
|
||||
source,
|
||||
followupType,
|
||||
priority,
|
||||
assignedToUserId,
|
||||
classId,
|
||||
});
|
||||
|
||||
return {
|
||||
requestId,
|
||||
total: studentUserIds.length,
|
||||
successCount: items.length,
|
||||
errorCount: errors.length,
|
||||
items,
|
||||
errors,
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
tenantStudentNotesRoute,
|
||||
tenantStudentsRoute,
|
||||
tenantTeachersRoute,
|
||||
pushTenantStudentsToCrmRoute,
|
||||
updateTenantStudentStatusRoute,
|
||||
upsertTenantStudentFollowupRoute,
|
||||
upsertTenantStudentNoteRoute,
|
||||
@@ -97,6 +98,7 @@ export const tenantAdminRoutes: RouteDefinition[] = [
|
||||
['PUT', '/api/tenant-admin/students', upsertTenantStudentRoute],
|
||||
['POST', '/api/tenant-admin/students/bulk-upsert', bulkUpsertTenantStudentsRoute],
|
||||
['POST', '/api/tenant-admin/students/status', updateTenantStudentStatusRoute],
|
||||
['POST', '/api/tenant-admin/students/crm-push', pushTenantStudentsToCrmRoute],
|
||||
['GET', '/api/tenant-admin/students/notes', tenantStudentNotesRoute],
|
||||
['PUT', '/api/tenant-admin/students/notes', upsertTenantStudentNoteRoute],
|
||||
['GET', '/api/tenant-admin/students/followups', tenantStudentFollowupsRoute],
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -145,6 +145,64 @@ function compactLeadPayload(task: CrmQueueRow, configRow: CrmConfigRow | null) {
|
||||
};
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function compactStudentCrmPushPayload(task: CrmQueueRow, configRow: CrmConfigRow | null) {
|
||||
const payload = objectValue(task.payload);
|
||||
const student = objectValue(payload.student);
|
||||
const assignee = objectValue(payload.assignee);
|
||||
const classInfo = objectValue(payload.class);
|
||||
const followup = objectValue(payload.followup);
|
||||
const classes = arrayValue(student.classes).slice(0, 5).map(item => {
|
||||
const classItem = objectValue(item);
|
||||
return {
|
||||
classId: stringValue(classItem.classId),
|
||||
className: stringValue(classItem.className),
|
||||
classCode: stringValue(classItem.classCode),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
formName: stringValue(payload.formName, configRow?.formName || '刷题题库'),
|
||||
examType: stringValue(payload.examType, configRow?.examType || '成人本科'),
|
||||
source: stringValue(payload.source, task.source || 'manual_batch'),
|
||||
title: stringValue(payload.title, '学生跟进提醒'),
|
||||
message: stringValue(payload.message),
|
||||
student: {
|
||||
id: stringValue(student.userId) || stringValue(student.id, task.recordId || ''),
|
||||
username: stringValue(student.username),
|
||||
name: stringValue(student.name) || stringValue(student.username) || '未填写',
|
||||
phone: stringValue(student.phone),
|
||||
email: stringValue(student.email),
|
||||
avatarPreset: stringValue(student.avatarPreset, 'male'),
|
||||
regionName: stringValue(student.regionName),
|
||||
selectedSchoolName: stringValue(student.selectedSchoolName),
|
||||
selectedMajorName: stringValue(student.selectedMajorName),
|
||||
classes,
|
||||
},
|
||||
assignee: stringValue(assignee.userId) || stringValue(assignee.id) ? {
|
||||
id: stringValue(assignee.userId) || stringValue(assignee.id),
|
||||
name: stringValue(assignee.name) || stringValue(assignee.username),
|
||||
role: stringValue(assignee.role),
|
||||
} : null,
|
||||
class: stringValue(classInfo.classId) ? {
|
||||
classId: stringValue(classInfo.classId),
|
||||
className: stringValue(classInfo.className),
|
||||
classCode: stringValue(classInfo.classCode),
|
||||
} : null,
|
||||
followup: {
|
||||
id: stringValue(followup.id),
|
||||
title: stringValue(followup.title) || stringValue(payload.title),
|
||||
followupType: stringValue(followup.followupType),
|
||||
priority: stringValue(followup.priority),
|
||||
dueAt: stringValue(followup.dueAt),
|
||||
},
|
||||
metadata: objectValue(payload.metadata),
|
||||
};
|
||||
}
|
||||
|
||||
function leadMarkdown(task: CrmQueueRow, configRow: CrmConfigRow | null) {
|
||||
const payload = compactLeadPayload(task, configRow);
|
||||
const student = payload.student;
|
||||
@@ -162,6 +220,36 @@ function leadMarkdown(task: CrmQueueRow, configRow: CrmConfigRow | null) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function studentCrmPushMarkdown(task: CrmQueueRow, configRow: CrmConfigRow | null) {
|
||||
const payload = compactStudentCrmPushPayload(task, configRow);
|
||||
const student = payload.student;
|
||||
const target = [student.regionName, student.selectedSchoolName, student.selectedMajorName].filter(Boolean).join(' / ');
|
||||
const classes = student.classes
|
||||
.map(item => item.className || item.classCode)
|
||||
.filter(Boolean)
|
||||
.join('、');
|
||||
const lines = [
|
||||
`### ${payload.formName}学生跟进`,
|
||||
`- 考试类型:${payload.examType}`,
|
||||
`- 来源:${payload.source}`,
|
||||
`- 任务:${payload.title}`,
|
||||
`- 学生:${student.name}`,
|
||||
`- 手机:${student.phone || '未填写'}`,
|
||||
`- 意向:${target || '未填写'}`,
|
||||
`- 班级:${payload.class?.className || classes || '无'}`,
|
||||
`- 指派:${payload.assignee?.name || payload.assignee?.id || '未指派'}`,
|
||||
`- 优先级:${payload.followup.priority || 'normal'}`,
|
||||
`- 到期时间:${payload.followup.dueAt || '无'}`,
|
||||
`- 跟进ID:${payload.followup.id || '无'}`,
|
||||
];
|
||||
if (payload.message) lines.splice(4, 0, `- 说明:${payload.message}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function taskEventType(task: CrmQueueRow) {
|
||||
return stringValue(objectValue(task.payload).eventType, 'lead.created');
|
||||
}
|
||||
|
||||
function dingtalkSign(secret: string): Record<string, string> {
|
||||
if (!secret) return {};
|
||||
const timestamp = String(Date.now());
|
||||
@@ -193,7 +281,10 @@ function prepareRequest(task: CrmQueueRow, configRow: CrmConfigRow | null, secre
|
||||
const target = validateWebhookUrl(task.targetUrl || configRow?.url || '');
|
||||
const provider = providerFrom(configRow, task);
|
||||
const secretValue = secretText(secret, ['secret', 'signSecret', 'webhookSecret']);
|
||||
const markdown = leadMarkdown(task, configRow);
|
||||
const eventType = taskEventType(task);
|
||||
const isStudentPush = eventType === 'student.crm_push';
|
||||
const markdown = isStudentPush ? studentCrmPushMarkdown(task, configRow) : leadMarkdown(task, configRow);
|
||||
const title = isStudentPush ? '学生跟进提醒' : '新客资提醒';
|
||||
const headers = { 'content-type': 'application/json' };
|
||||
|
||||
if (provider === 'dingtalk') {
|
||||
@@ -204,7 +295,7 @@ function prepareRequest(task: CrmQueueRow, configRow: CrmConfigRow | null, secre
|
||||
body: {
|
||||
msgtype: 'markdown',
|
||||
markdown: {
|
||||
title: '新客资提醒',
|
||||
title,
|
||||
text: markdown,
|
||||
},
|
||||
},
|
||||
@@ -221,7 +312,7 @@ function prepareRequest(task: CrmQueueRow, configRow: CrmConfigRow | null, secre
|
||||
...feishuSign(secretValue),
|
||||
card: {
|
||||
config: { wide_screen_mode: true },
|
||||
header: { title: { tag: 'plain_text', content: '新客资提醒' }, template: 'blue' },
|
||||
header: { title: { tag: 'plain_text', content: title }, template: isStudentPush ? 'wathet' : 'blue' },
|
||||
elements: [{ tag: 'markdown', content: markdown }],
|
||||
},
|
||||
},
|
||||
@@ -244,7 +335,10 @@ function prepareRequest(task: CrmQueueRow, configRow: CrmConfigRow | null, secre
|
||||
provider,
|
||||
url: target.toString(),
|
||||
headers,
|
||||
body: {
|
||||
body: isStudentPush ? {
|
||||
event: 'student.crm_push',
|
||||
data: compactStudentCrmPushPayload(task, configRow),
|
||||
} : {
|
||||
event: 'lead.created',
|
||||
data: compactLeadPayload(task, configRow),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user