feat: add scoreline dynamic filters

This commit is contained in:
Codex
2026-06-30 18:49:32 +08:00
parent 2f9763d352
commit ba737d41f5
14 changed files with 795 additions and 43 deletions

View File

@@ -1,41 +1,341 @@
import { useEffect, useState } from 'react';
import { Text, View } from '@tarojs/components';
import { loadScorelineRecords, type ScorelineRecord } from '@/services/catalog';
import { useEffect, useMemo, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Input, Text, View } from '@tarojs/components';
import {
loadScorelineFields,
loadScorelineMajors,
loadScorelineRecords,
loadScorelineSchools,
loadScorelineTrend,
loadScorelineYears,
type ScorelineField,
type ScorelineMajor,
type ScorelineRecord,
type ScorelineSchool,
} from '@/services/catalog';
import { loadProfile, type StudentProfile } from '@/services/profile';
import '../student.css';
function fieldText(values: Record<string, unknown> | undefined) {
if (!values) return '暂无字段';
return Object.entries(values).slice(0, 4).map(([key, value]) => `${key}: ${String(value)}`).join(' · ');
function valueText(value: unknown) {
if (value === null || value === undefined || value === '') return '暂无';
if (typeof value === 'boolean') return value ? '是' : '否';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function fieldValue(record: ScorelineRecord, field: ScorelineField) {
const value = record.fieldValues?.[field.fieldKey];
const suffix = field.unit && value !== null && value !== undefined && value !== '' ? field.unit : '';
return `${valueText(value)}${suffix}`;
}
function normalizeFieldType(field: ScorelineField) {
return (field.fieldType || 'text').toLowerCase();
}
function fieldPlaceholder(field: ScorelineField) {
if (field.placeholder) return field.placeholder;
if (['number', 'integer', 'decimal', 'float'].includes(normalizeFieldType(field))) return `${field.fieldName}下限`;
return field.fieldName;
}
function visibleFields(fields: ScorelineField[]) {
return fields.filter(item => item.isVisible !== false).sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
}
function trendFields(fields: ScorelineField[]) {
const marked = fields.filter(item => item.isTrend);
return (marked.length ? marked : visibleFields(fields)).slice(0, 3);
}
function optionLabels(options: unknown) {
if (!Array.isArray(options)) return [];
return options
.map(item => {
if (typeof item === 'string' || typeof item === 'number') return { label: String(item), value: String(item) };
if (item && typeof item === 'object') {
const record = item as Record<string, unknown>;
const value = record.value ?? record.label ?? record.name;
const label = record.label ?? record.name ?? record.value;
if (value !== undefined && label !== undefined) return { label: String(label), value: String(value) };
}
return null;
})
.filter((item): item is { label: string; value: string } => Boolean(item));
}
function recordTitle(item: ScorelineRecord) {
return `${item.year} · ${item.schoolName || '院校'}${item.majorName ? ` · ${item.majorName}` : ''}`;
}
export default function StudentScorelinePage() {
const [profile, setProfile] = useState<StudentProfile | null>(null);
const [fields, setFields] = useState<ScorelineField[]>([]);
const [schools, setSchools] = useState<ScorelineSchool[]>([]);
const [majors, setMajors] = useState<ScorelineMajor[]>([]);
const [years, setYears] = useState<number[]>([]);
const [records, setRecords] = useState<ScorelineRecord[]>([]);
const [trend, setTrend] = useState<ScorelineRecord[]>([]);
const [total, setTotal] = useState(0);
const [selectedSchoolId, setSelectedSchoolId] = useState('');
const [selectedMajorId, setSelectedMajorId] = useState('');
const [selectedYear, setSelectedYear] = useState(0);
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
const [refreshNonce, setRefreshNonce] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const regionId = profile?.target?.regionId || undefined;
const currentSchool = useMemo(() => schools.find(item => item.id === selectedSchoolId) || null, [schools, selectedSchoolId]);
const currentMajor = useMemo(() => majors.find(item => item.id === selectedMajorId) || null, [majors, selectedMajorId]);
const shownFields = useMemo(() => visibleFields(fields), [fields]);
const filterFields = useMemo(() => fields.filter(item => item.isFilter).slice(0, 8), [fields]);
const trendMetricFields = useMemo(() => trendFields(fields), [fields]);
useEffect(() => {
loadScorelineRecords({ pageSize: 50 })
.then(payload => setRecords(payload.items || []))
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '分数线加载失败'));
loadProfile()
.then(payload => {
const item = payload.item || null;
setProfile(item);
setSelectedSchoolId(item?.target?.schoolId || '');
setSelectedMajorId(item?.target?.majorId || '');
})
.catch(() => setProfile(null));
}, []);
useEffect(() => {
Promise.all([
loadScorelineFields(regionId).catch(() => ({ items: [] })),
loadScorelineSchools({ regionId, limit: 80 }).catch(() => ({ items: [] })),
loadScorelineYears(regionId).catch(() => ({ items: [] })),
])
.then(([fieldPayload, schoolPayload, yearPayload]) => {
setFields(fieldPayload.items || []);
setSchools(schoolPayload.items || []);
setYears(yearPayload.items || []);
})
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '分数线配置加载失败'));
}, [regionId]);
useEffect(() => {
setSelectedMajorId(previous => (selectedSchoolId ? previous : ''));
loadScorelineMajors({ regionId, schoolId: selectedSchoolId || undefined, limit: 200 })
.then(payload => setMajors(payload.items || []))
.catch(() => setMajors([]));
}, [regionId, selectedSchoolId]);
function loadRecords() {
setLoading(true);
setError('');
const dynamicMinFilters: Record<string, string> = {};
const dynamicFilters: Record<string, string> = {};
filterFields.forEach(field => {
const value = (filterValues[field.fieldKey] || '').trim();
if (!value) return;
if (['number', 'integer', 'decimal', 'float'].includes(normalizeFieldType(field))) {
dynamicMinFilters[field.fieldKey] = value;
} else {
dynamicFilters[field.fieldKey] = value;
}
});
Promise.all([
loadScorelineRecords({
regionId,
schoolId: selectedSchoolId || undefined,
majorId: selectedMajorId || undefined,
year: selectedYear || undefined,
pageSize: 50,
dynamicFilters,
dynamicMinFilters,
}),
loadScorelineTrend({
regionId,
schoolId: selectedSchoolId || undefined,
majorId: selectedMajorId || undefined,
limit: 20,
}).catch(() => ({ items: [] })),
])
.then(([recordPayload, trendPayload]) => {
setRecords(recordPayload.items || []);
setTotal(recordPayload.total || 0);
setTrend(trendPayload.items || []);
})
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '分数线加载失败'))
.finally(() => setLoading(false));
}
useEffect(() => {
loadRecords();
}, [regionId, selectedSchoolId, selectedMajorId, selectedYear, refreshNonce]);
function selectSchool(item: ScorelineSchool) {
setSelectedSchoolId(previous => (previous === item.id ? '' : item.id));
setSelectedMajorId('');
}
function selectMajor(item: ScorelineMajor) {
setSelectedMajorId(previous => (previous === item.id ? '' : item.id));
}
function updateFilter(fieldKey: string, value: string) {
setFilterValues(previous => ({ ...previous, [fieldKey]: value }));
}
function clearFilters() {
setSelectedSchoolId('');
setSelectedMajorId('');
setSelectedYear(0);
setFilterValues({});
setRefreshNonce(value => value + 1);
}
return (
<View className='student-page'>
<View className='student-page scoreline-page'>
<View className='student-topbar'>
<View className='student-title-block'>
<Text className='student-kicker'>Scoreline</Text>
<Text className='student-title'>线</Text>
<Text className='student-subtitle'></Text>
<Text className='student-subtitle'>
{profile?.target?.regionName ? `${profile.target.regionName} · ${currentSchool?.name || profile.target.schoolName || '全部院校'}` : '按租户可见地区展示'}
</Text>
</View>
<Button className='secondary-button' onClick={() => Taro.navigateTo({ url: '/pages/student/region/index' })}></Button>
</View>
<View className='scoreline-filter-panel'>
<View className='amount-row'>
<View>
<Text className='section-heading'></Text>
<Text className='row-meta'>{currentMajor?.name || profile?.target?.majorName || '全部专业'} · {selectedYear || '全部年份'}</Text>
</View>
<Button className='secondary-button' onClick={clearFilters}></Button>
</View>
<View className='scoreline-filter-group'>
<Text className='scoreline-filter-label'></Text>
<View className='scoreline-chip-row'>
{schools.slice(0, 12).map(item => (
<View className={`scoreline-chip ${selectedSchoolId === item.id ? 'active' : ''}`} key={item.id} onClick={() => selectSchool(item)}>
<Text className='scoreline-chip-text'>{item.shortName || item.name}{item.isHot ? ' · 热' : ''}</Text>
</View>
))}
</View>
</View>
{majors.length ? (
<View className='scoreline-filter-group'>
<Text className='scoreline-filter-label'></Text>
<View className='scoreline-chip-row'>
{majors.slice(0, 16).map(item => (
<View className={`scoreline-chip ${selectedMajorId === item.id ? 'active' : ''} ${item.hasRestriction ? 'warn' : ''}`} key={item.id} onClick={() => selectMajor(item)}>
<Text className='scoreline-chip-text'>{item.name}</Text>
</View>
))}
</View>
</View>
) : null}
{years.length ? (
<View className='scoreline-filter-group'>
<Text className='scoreline-filter-label'></Text>
<View className='scoreline-chip-row'>
<View className={`scoreline-chip ${selectedYear === 0 ? 'active' : ''}`} onClick={() => setSelectedYear(0)}>
<Text className='scoreline-chip-text'></Text>
</View>
{years.slice(0, 8).map(item => (
<View className={`scoreline-chip ${selectedYear === item ? 'active' : ''}`} key={item} onClick={() => setSelectedYear(item)}>
<Text className='scoreline-chip-text'>{item}</Text>
</View>
))}
</View>
</View>
) : null}
{filterFields.length ? (
<View className='scoreline-field-grid'>
{filterFields.map(field => {
const options = optionLabels(field.options);
return (
<View className='scoreline-field-filter' key={field.id}>
<Text className='scoreline-filter-label'>{field.fieldName}</Text>
{options.length ? (
<View className='scoreline-chip-row compact'>
{options.slice(0, 8).map(option => (
<View
className={`scoreline-chip small ${filterValues[field.fieldKey] === option.value ? 'active' : ''}`}
key={option.value}
onClick={() => updateFilter(field.fieldKey, filterValues[field.fieldKey] === option.value ? '' : option.value)}
>
<Text className='scoreline-chip-text'>{option.label}</Text>
</View>
))}
</View>
) : (
<Input
className='input scoreline-input'
value={filterValues[field.fieldKey] || ''}
type={['number', 'integer', 'decimal', 'float'].includes(normalizeFieldType(field)) ? 'number' : 'text'}
placeholder={fieldPlaceholder(field)}
onInput={event => updateFilter(field.fieldKey, String(event.detail.value || ''))}
/>
)}
</View>
);
})}
</View>
) : null}
<Button className='primary-button scoreline-search-button' loading={loading} onClick={loadRecords}></Button>
</View>
<View className='grid-two section-block'>
<View className='metric compact-metric'>
<Text className='metric-value'>{String(total)}</Text>
<Text className='metric-label'></Text>
</View>
<View className='metric compact-metric'>
<Text className='metric-value'>{String(trend.length)}</Text>
<Text className='metric-label'></Text>
</View>
</View>
<View className='list-stack'>
{records.map(item => (
<View className='list-row' key={item.id}>
<Text className='row-main'>{item.year} · {item.schoolName || '院校'}{item.majorName ? ` · ${item.majorName}` : ''}</Text>
<Text className='row-meta'>{fieldText(item.fieldValues)}</Text>
{trend.length ? (
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='scoreline-trend-strip'>
{trend.slice(0, 10).map(item => (
<View className='scoreline-trend-item' key={item.id}>
<Text className='scoreline-trend-year'>{item.year}</Text>
<Text className='scoreline-trend-value'>
{trendMetricFields.map(field => `${field.fieldName}${fieldValue(item, field)}`).join(' · ')}
</Text>
</View>
))}
</View>
))}
</View>
) : null}
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='list-stack'>
{records.map(item => (
<View className='list-row' key={item.id}>
<Text className='row-main'>{recordTitle(item)}</Text>
<View className='scoreline-field-chip-grid'>
{shownFields.slice(0, 8).map(field => (
<View className='scoreline-field-chip' key={`${item.id}:${field.fieldKey}`}>
<Text className='scoreline-field-name'>{field.fieldName}</Text>
<Text className='scoreline-field-value'>{fieldValue(item, field)}</Text>
</View>
))}
</View>
</View>
))}
</View>
</View>
{!records.length ? <View className='empty-state'>线</View> : null}
{!records.length && !loading ? <View className='empty-state'>线</View> : null}
{error ? <Text className='error-text'>{error}</Text> : null}
</View>
);

View File

@@ -1000,3 +1000,178 @@
border-radius: 8px;
background: #f8fafc;
}
.scoreline-filter-panel {
padding: 24px;
border: 1px solid #dbe3ee;
border-radius: 8px;
background: #fff;
}
.scoreline-filter-group {
margin-top: 18px;
}
.scoreline-filter-label {
display: block;
margin-bottom: 10px;
color: #334155;
font-size: 22px;
font-weight: 820;
line-height: 1.25;
}
.scoreline-chip-row {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 8px;
}
.scoreline-chip-row.compact {
flex-wrap: wrap;
overflow-x: visible;
padding-bottom: 0;
}
.scoreline-chip {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
max-width: 320px;
min-width: 112px;
height: 58px;
padding: 0 16px;
border: 1px solid #dbe3ee;
border-radius: 8px;
background: #f8fafc;
}
.scoreline-chip.small {
min-width: 96px;
height: 52px;
padding: 0 14px;
}
.scoreline-chip.active {
border-color: #2563eb;
background: #eff6ff;
}
.scoreline-chip.warn {
border-color: #f59e0b;
background: #fffbeb;
}
.scoreline-chip-text {
min-width: 0;
color: #0f172a;
font-size: 22px;
font-weight: 760;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scoreline-chip.active .scoreline-chip-text {
color: #1d4ed8;
}
.scoreline-field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 20px;
}
.scoreline-field-filter {
min-width: 0;
}
.scoreline-input {
box-sizing: border-box;
height: 64px;
font-size: 24px;
}
.scoreline-search-button {
margin-top: 20px;
}
.scoreline-trend-strip {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 8px;
}
.scoreline-trend-item {
flex: 0 0 230px;
min-height: 116px;
padding: 18px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #f8fbff;
}
.scoreline-trend-year {
display: block;
color: #1d4ed8;
font-size: 24px;
font-weight: 850;
line-height: 1.25;
}
.scoreline-trend-value {
display: block;
margin-top: 8px;
color: #334155;
font-size: 20px;
line-height: 1.35;
word-break: break-word;
}
.scoreline-field-chip-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}
.scoreline-field-chip {
min-width: 0;
min-height: 74px;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.scoreline-field-name {
display: block;
color: #64748b;
font-size: 19px;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scoreline-field-value {
display: block;
margin-top: 4px;
color: #0f172a;
font-size: 23px;
font-weight: 820;
line-height: 1.25;
word-break: break-word;
}
@media (max-width: 640px) {
.scoreline-field-grid,
.scoreline-field-chip-grid {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -107,12 +107,55 @@ export interface HandbookEntry {
export interface ScorelineRecord {
id: string;
regionId?: string | null;
schoolId?: string | null;
majorId?: string | null;
year: number;
schoolName?: string | null;
majorName?: string | null;
fieldValues?: Record<string, unknown>;
}
export interface ScorelineField {
id: string;
legacyId?: string | null;
regionId?: string | null;
fieldKey: string;
fieldName: string;
fieldType?: string | null;
unit?: string | null;
isFilter?: boolean;
isRequired?: boolean;
isVisible?: boolean;
isTrend?: boolean;
options?: unknown;
placeholder?: string | null;
description?: string | null;
sortOrder?: number;
}
export interface ScorelineSchool {
id: string;
legacyId?: string | null;
regionId?: string | null;
name: string;
shortName?: string | null;
type?: string | null;
isHot?: boolean;
order?: number;
}
export interface ScorelineMajor {
id: string;
legacyId?: string | null;
regionId?: string | null;
schoolId?: string | null;
name: string;
order?: number;
hasRestriction?: boolean;
restrictionDesc?: string | null;
}
export interface ContentAsset {
id: string;
title?: string | null;
@@ -204,9 +247,66 @@ export async function loadHandbookEntries(chapterId: string, includeContent = tr
return apiRequest<{ items?: HandbookEntry[] }>('/api/catalog/handbook-entries', { query: { chapterId, includeContent }, authMode: 'none' });
}
export async function loadScorelineRecords(query: { regionId?: string; schoolId?: string; majorId?: string; year?: number; pageSize?: number } = {}) {
export async function loadScorelineFields(regionId?: string) {
return apiRequest<{ items?: ScorelineField[] }>('/api/scoreline/fields', { query: { regionId }, authMode: 'none' });
}
export async function loadScorelineSchools(query: { regionId?: string; q?: string; limit?: number } = {}) {
return apiRequest<{ items?: ScorelineSchool[] }>('/api/scoreline/schools', {
query: { ...query, limit: query.limit || 200 },
authMode: 'none',
});
}
export async function loadScorelineMajors(query: { regionId?: string; schoolId?: string; limit?: number } = {}) {
return apiRequest<{ items?: ScorelineMajor[] }>('/api/scoreline/majors', {
query: { ...query, limit: query.limit || 500 },
authMode: 'none',
});
}
export async function loadScorelineYears(regionId?: string) {
return apiRequest<{ items?: number[] }>('/api/scoreline/years', { query: { regionId }, authMode: 'none' });
}
export async function loadScorelineTrend(query: { regionId?: string; schoolId?: string; majorId?: string; limit?: number } = {}) {
return apiRequest<{ items?: ScorelineRecord[] }>('/api/scoreline/trend', {
query: { ...query, limit: query.limit || 20 },
authMode: 'none',
});
}
export async function loadScorelineRecords(query: {
regionId?: string;
schoolId?: string;
majorId?: string;
year?: number;
page?: number;
pageSize?: number;
dynamicFilters?: Record<string, string | number | boolean | null | undefined>;
dynamicMinFilters?: Record<string, string | number | null | undefined>;
dynamicMaxFilters?: Record<string, string | number | null | undefined>;
} = {}) {
const dynamicQuery: Record<string, string | number | boolean | null | undefined> = {};
Object.entries(query.dynamicFilters || {}).forEach(([key, value]) => {
dynamicQuery[`field.${key}`] = value;
});
Object.entries(query.dynamicMinFilters || {}).forEach(([key, value]) => {
dynamicQuery[`min.${key}`] = value;
});
Object.entries(query.dynamicMaxFilters || {}).forEach(([key, value]) => {
dynamicQuery[`max.${key}`] = value;
});
return apiRequest<{ items?: ScorelineRecord[]; total?: number }>('/api/scoreline/records', {
query: { ...query, pageSize: query.pageSize || 20 },
query: {
regionId: query.regionId,
schoolId: query.schoolId,
majorId: query.majorId,
year: query.year,
page: query.page,
pageSize: query.pageSize || 20,
...dynamicQuery,
},
authMode: 'none',
});
}