forked from wangziqi/gongxue-base
feat: add AI school recommendation foundation
This commit is contained in:
@@ -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',
|
||||
|
||||
3
apps/taro/src/pages/student/ai-school/index.config.ts
Normal file
3
apps/taro/src/pages/student/ai-school/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: 'AI择校推荐',
|
||||
});
|
||||
154
apps/taro/src/pages/student/ai-school/index.tsx
Normal file
154
apps/taro/src/pages/student/ai-school/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: '会员 / 订单' },
|
||||
];
|
||||
|
||||
83
apps/taro/src/services/ai.ts
Normal file
83
apps/taro/src/services/ai.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user