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

@@ -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: '会员 / 订单' },
];