feat: add AI school recommendation foundation

This commit is contained in:
Codex
2026-06-29 21:22:53 +08:00
parent 8d4428214a
commit aeca84b260
16 changed files with 1003 additions and 12 deletions

View File

@@ -14,6 +14,7 @@ export default defineAppConfig({
'pages/student/vocabulary/index',
'pages/student/handbook/index',
'pages/student/scoreline/index',
'pages/student/ai-school/index',
'pages/student/assets/index',
'pages/student/profile/index',
'pages/tenant-admin/workbench/index',

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: 'AI择校推荐',
});

View File

@@ -0,0 +1,154 @@
import { useEffect, useState } from 'react';
import { Button, Input, Picker, Text, Textarea, View } from '@tarojs/components';
import {
generateSchoolRecommendation,
loadSchoolRecommendationReports,
type SchoolRecommendationReport,
} from '@/services/ai';
import { loadProfile, type StudentProfile } from '@/services/profile';
import '../student.css';
const riskOptions = [
{ label: '均衡', value: 'balanced' },
{ label: '稳妥', value: 'safe' },
{ label: '冲刺', value: 'sprint' },
] as const;
function riskLabel(value?: string) {
return riskOptions.find(item => item.value === value)?.label || '未知';
}
function recommendationRows(report: SchoolRecommendationReport | null) {
return report?.resultPayload?.recommendedSchools || [];
}
export default function StudentAiSchoolPage() {
const [profile, setProfile] = useState<StudentProfile | null>(null);
const [reports, setReports] = useState<SchoolRecommendationReport[]>([]);
const [current, setCurrent] = useState<SchoolRecommendationReport | null>(null);
const [estimatedScore, setEstimatedScore] = useState('');
const [constraints, setConstraints] = useState('');
const [riskIndex, setRiskIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
loadProfile().then(payload => setProfile(payload.item || null)).catch(() => setProfile(null));
loadSchoolRecommendationReports({ limit: 5 })
.then(payload => {
const items = payload.items || [];
setReports(items);
setCurrent(items[0] || null);
})
.catch(() => setReports([]));
}, []);
async function handleGenerate() {
setLoading(true);
setError('');
try {
const payload = await generateSchoolRecommendation({
regionId: profile?.target?.regionId || undefined,
estimatedScore: estimatedScore ? Number(estimatedScore) : undefined,
riskPreference: riskOptions[riskIndex].value,
constraints: constraints || undefined,
recommendationLimit: 5,
});
if (payload.item) {
setCurrent(payload.item);
setReports(previous => [payload.item!, ...previous.filter(item => item.id !== payload.item!.id)].slice(0, 5));
}
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '生成失败');
} finally {
setLoading(false);
}
}
const rows = recommendationRows(current);
return (
<View className='student-page'>
<View className='student-topbar'>
<View className='student-title-block'>
<Text className='student-kicker'>AI Advisor</Text>
<Text className='student-title'>AI择校推荐</Text>
<Text className='student-subtitle'>
{profile?.target?.regionName ? `${profile.target.regionName} · ${profile.membership?.isSvip ? 'SVIP' : '需SVIP'}` : '先在个人中心选择目标地区'}
</Text>
</View>
</View>
<View className='section'>
<Text className='section-title'></Text>
<View className='list-stack'>
<View className='list-row'>
<Text className='row-meta'></Text>
<Input
type='number'
value={estimatedScore}
placeholder='例如 210'
onInput={event => setEstimatedScore(String(event.detail.value || ''))}
/>
</View>
<View className='list-row'>
<Text className='row-meta'></Text>
<Picker
mode='selector'
range={riskOptions.map(item => item.label)}
value={riskIndex}
onChange={event => setRiskIndex(Number(event.detail.value || 0))}
>
<Text className='row-main'>{riskOptions[riskIndex].label}</Text>
</Picker>
</View>
<View className='list-row'>
<Text className='row-meta'></Text>
<Textarea
value={constraints}
placeholder='例如城市、专业限制、跨考顾虑'
maxlength={500}
onInput={event => setConstraints(String(event.detail.value || ''))}
/>
</View>
</View>
<Button className='primary-button' loading={loading} onClick={handleGenerate}></Button>
{error ? <Text className='error-text'>{error}</Text> : null}
</View>
{current ? (
<View className='section'>
<Text className='section-title'></Text>
<View className='hero-band'>
<Text className='hero-title'>{riskLabel(current.resultPayload.riskLevel)}</Text>
<Text className='hero-copy'>{current.resultPayload.summary}</Text>
</View>
<View className='list-stack'>
{rows.map(item => (
<View className='list-row' key={`${item.schoolId || item.schoolName}:${item.majorId || item.majorName || ''}`}>
<Text className='row-main'>{item.schoolName}{item.majorName ? ` · ${item.majorName}` : ''}</Text>
<Text className='row-meta'>
{riskLabel(item.riskLevel)} · {Math.round((item.confidence || 0) * 100)}% · {item.scoreGap ?? '未知'}
</Text>
<Text className='row-meta'>{item.reason}</Text>
</View>
))}
</View>
</View>
) : null}
<View className='section'>
<Text className='section-title'></Text>
<View className='list-stack'>
{reports.map(item => (
<View className='list-row' key={item.id} onClick={() => setCurrent(item)}>
<Text className='row-main'>{item.resultPayload?.summary || '择校推荐报告'}</Text>
<Text className='row-meta'>{item.generatedAt || item.createdAt}</Text>
</View>
))}
</View>
{!reports.length ? <View className='empty-state'></View> : null}
</View>
</View>
);
}

View File

@@ -26,6 +26,7 @@ export default function StudentHomePage() {
{ name: '背单词', path: '/pages/student/vocabulary/index', meta: '复习计划' },
{ name: '知识手册', path: '/pages/student/handbook/index', meta: '章节阅读' },
{ name: '分数线', path: '/pages/student/scoreline/index', meta: '院校趋势' },
{ name: 'AI择校', path: '/pages/student/ai-school/index', meta: 'SVIP报告' },
{ name: '资料', path: '/pages/student/assets/index', meta: 'PDF 预览' },
{ name: '个人中心', path: '/pages/student/profile/index', meta: '会员 / 订单' },
];

View File

@@ -0,0 +1,83 @@
import { apiRequest } from './api';
export type SchoolRecommendationRisk = 'safe' | 'balanced' | 'sprint' | 'unknown';
export interface GenerateSchoolRecommendationInput {
regionId?: string;
estimatedScore?: number;
examTrack?: string;
preferredCity?: string;
targetSchoolId?: string;
targetMajorId?: string;
riskPreference?: 'safe' | 'balanced' | 'sprint';
constraints?: string;
notes?: string;
recommendationLimit?: number;
}
export interface SchoolRecommendationCandidate {
schoolId?: string | null;
schoolName: string;
majorId?: string | null;
majorName?: string | null;
latestYear?: number | null;
latestScore?: number | null;
averageScore?: number | null;
scoreGap?: number | null;
riskLevel: SchoolRecommendationRisk;
confidence: number;
reason: string;
scorelineTrend?: {
years: number[];
scores: Array<number | null>;
direction: 'up' | 'down' | 'flat' | 'unknown';
};
tags?: string[];
}
export interface SchoolRecommendationReportResult {
schemaVersion: 'school-recommendation-report-v1';
summary: string;
riskLevel: SchoolRecommendationRisk;
recommendedSchools: SchoolRecommendationCandidate[];
actionPlan: string[];
disclaimers: string[];
dataCoverage: Record<string, unknown>;
}
export interface SchoolRecommendationReport {
id: string;
tenantId: string;
userId: string;
regionId?: string | null;
status: 'draft' | 'generated' | 'failed';
provider: string;
model?: string | null;
promptVersion: string;
inputPayload: GenerateSchoolRecommendationInput;
contextPayload: Record<string, unknown>;
resultPayload: SchoolRecommendationReportResult;
errorMessage?: string | null;
generatedAt?: string | null;
createdAt: string;
updatedAt: string;
}
export async function generateSchoolRecommendation(input: GenerateSchoolRecommendationInput) {
return apiRequest<{ item?: SchoolRecommendationReport }>('/api/ai/school-recommendations/generate', {
method: 'POST',
body: input,
});
}
export async function loadSchoolRecommendationReports(query: { regionId?: string; limit?: number } = {}) {
return apiRequest<{ items?: SchoolRecommendationReport[] }>('/api/ai/school-recommendations', {
query,
});
}
export async function loadSchoolRecommendationReport(reportId: string) {
return apiRequest<{ item?: SchoolRecommendationReport }>('/api/ai/school-recommendations/detail', {
query: { reportId },
});
}