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,23 +1,172 @@
import type { RequestContext } from '../../core/http.js';
import { HttpError } from '../../core/http.js';
import { intParam, stringParam, tenantIdFrom } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
const SCORELINE_FIELD_QUERY_PREFIX = 'field.';
const SCORELINE_FIELD_MIN_QUERY_PREFIX = 'min.';
const SCORELINE_FIELD_MAX_QUERY_PREFIX = 'max.';
const SCORELINE_FIELD_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
interface ScorelineFilterField {
fieldKey: string;
fieldType?: string | null;
}
interface ScorelineDynamicFilter {
operator: 'eq' | 'min' | 'max';
fieldKey: string;
value: string;
}
function normalizeFieldType(value?: string | null) {
return (value || 'text').trim().toLowerCase();
}
function quoteJsonPathKey(key: string) {
return `'${key.replace(/'/g, "''")}'`;
}
function collectDynamicFilters(ctx: RequestContext) {
const filters: ScorelineDynamicFilter[] = [];
for (const [rawKey, rawValue] of ctx.url.searchParams.entries()) {
const value = rawValue.trim();
if (!value) continue;
if (rawKey.startsWith(SCORELINE_FIELD_QUERY_PREFIX)) {
filters.push({ operator: 'eq', fieldKey: rawKey.slice(SCORELINE_FIELD_QUERY_PREFIX.length), value });
} else if (rawKey.startsWith(SCORELINE_FIELD_MIN_QUERY_PREFIX)) {
filters.push({ operator: 'min', fieldKey: rawKey.slice(SCORELINE_FIELD_MIN_QUERY_PREFIX.length), value });
} else if (rawKey.startsWith(SCORELINE_FIELD_MAX_QUERY_PREFIX)) {
filters.push({ operator: 'max', fieldKey: rawKey.slice(SCORELINE_FIELD_MAX_QUERY_PREFIX.length), value });
}
}
return filters;
}
async function loadFilterableScorelineFields(tenantId: string, regionId: string) {
return query<ScorelineFilterField>(
`
select "fieldKey", "fieldType"
from (
select distinct on (field_key)
field_key as "fieldKey", field_type as "fieldType",
sort_order as "sortOrder"
from public.scoreline_fields
where tenant_id = $1
and ($2::uuid is null or region_id = $2::uuid or region_id is null)
and is_filter = true
order by field_key asc,
case
when $2::uuid is not null and region_id = $2::uuid then 0
when region_id is null then 1
else 2
end asc,
sort_order asc
) fields
order by "sortOrder" asc, "fieldKey" asc
`,
[tenantId, regionId || null],
);
}
async function buildScorelineDynamicWhere(
ctx: RequestContext,
tenantId: string,
regionId: string,
params: unknown[],
) {
const filters = collectDynamicFilters(ctx);
if (!filters.length) return '';
const filterableFields = await loadFilterableScorelineFields(tenantId, regionId);
const fieldMap = new Map(filterableFields.map(item => [item.fieldKey, item]));
const clauses: string[] = [];
for (const filter of filters) {
if (!SCORELINE_FIELD_KEY_PATTERN.test(filter.fieldKey)) {
throw new HttpError(400, 'Scoreline field filter key is invalid', 'SCORELINE_FIELD_FILTER_KEY_INVALID');
}
const field = fieldMap.get(filter.fieldKey);
if (!field) {
throw new HttpError(400, 'Scoreline field filter is not enabled', 'SCORELINE_FIELD_FILTER_NOT_ALLOWED');
}
const type = normalizeFieldType(field.fieldType);
const jsonKey = quoteJsonPathKey(filter.fieldKey);
params.push(filter.value);
const paramIndex = params.length;
if (filter.operator === 'min' || filter.operator === 'max') {
if (!['number', 'integer', 'decimal', 'float'].includes(type)) {
throw new HttpError(400, 'Scoreline range filter requires a numeric field', 'SCORELINE_FIELD_RANGE_TYPE_INVALID');
}
const numericValue = Number(filter.value);
if (!Number.isFinite(numericValue)) {
throw new HttpError(400, 'Scoreline range filter value must be numeric', 'SCORELINE_FIELD_RANGE_VALUE_INVALID');
}
clauses.push(`
case
when jsonb_typeof(field_values -> ${jsonKey}) = 'number' then (field_values ->> ${jsonKey})::numeric
when field_values ->> ${jsonKey} ~ '^-?[0-9]+(\\.[0-9]+)?$' then (field_values ->> ${jsonKey})::numeric
else null
end ${filter.operator === 'min' ? '>=' : '<='} $${paramIndex}::numeric
`);
continue;
}
if (['number', 'integer', 'decimal', 'float'].includes(type)) {
const numericValue = Number(filter.value);
if (!Number.isFinite(numericValue)) {
throw new HttpError(400, 'Scoreline numeric filter value must be numeric', 'SCORELINE_FIELD_VALUE_INVALID');
}
clauses.push(`
case
when jsonb_typeof(field_values -> ${jsonKey}) = 'number' then (field_values ->> ${jsonKey})::numeric
when field_values ->> ${jsonKey} ~ '^-?[0-9]+(\\.[0-9]+)?$' then (field_values ->> ${jsonKey})::numeric
else null
end = $${paramIndex}::numeric
`);
} else if (['text', 'textarea', 'string'].includes(type)) {
clauses.push(`field_values ->> ${jsonKey} ilike '%' || $${paramIndex}::text || '%'`);
} else if (['boolean', 'bool'].includes(type)) {
clauses.push(`lower(field_values ->> ${jsonKey}) = lower($${paramIndex}::text)`);
} else {
clauses.push(`field_values ->> ${jsonKey} = $${paramIndex}::text`);
}
}
return clauses.length ? ` and ${clauses.join(' and ')}` : '';
}
export async function scorelineFieldsRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const items = await query(
`
select id, legacy_id as "legacyId", region_id as "regionId",
field_key as "fieldKey", field_name as "fieldName",
field_type as "fieldType", unit, is_filter as "isFilter",
is_required as "isRequired", is_visible as "isVisible",
is_trend as "isTrend", options, placeholder, description,
sort_order as "sortOrder", created_at as "createdAt",
updated_at as "updatedAt"
from public.scoreline_fields
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
order by sort_order asc, field_name asc
select *
from (
select distinct on (field_key)
id, legacy_id as "legacyId", region_id as "regionId",
field_key as "fieldKey", field_name as "fieldName",
field_type as "fieldType", unit, is_filter as "isFilter",
is_required as "isRequired", is_visible as "isVisible",
is_trend as "isTrend", options, placeholder, description,
sort_order as "sortOrder", created_at as "createdAt",
updated_at as "updatedAt"
from public.scoreline_fields
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid or region_id is null)
order by field_key asc,
case
when $2::uuid is not null and region_id = $2::uuid then 0
when region_id is null then 1
else 2
end asc,
sort_order asc,
field_name asc
) fields
order by "sortOrder" asc, "fieldName" asc
`,
[tenantId, regionId || null],
);
@@ -85,14 +234,18 @@ export async function scorelineRecordsRoute(ctx: RequestContext) {
const pageSize = intParam(ctx, 'pageSize', intParam(ctx, 'perPage', 20, 100), 100);
const offset = (page - 1) * pageSize;
const params = [tenantId, regionId || null, schoolId || null, majorId || null, Number.isFinite(year) ? Math.trunc(year) : 0];
const dynamicWhere = await buildScorelineDynamicWhere(ctx, tenantId, regionId, params);
const where = `
tenant_id = $1
and ($2::uuid is null or region_id = $2::uuid)
and ($3::uuid is null or school_id = $3::uuid)
and ($4::uuid is null or major_id = $4::uuid)
and ($5::integer = 0 or year = $5::integer)
${dynamicWhere}
`;
const params = [tenantId, regionId || null, schoolId || null, majorId || null, Number.isFinite(year) ? Math.trunc(year) : 0];
const limitParamIndex = params.length + 1;
const offsetParamIndex = params.length + 2;
const [countRow, items] = await Promise.all([
queryOne<{ total: string }>(`select count(*)::text as total from public.scoreline_records where ${where}`, params),
@@ -106,7 +259,7 @@ export async function scorelineRecordsRoute(ctx: RequestContext) {
from public.scoreline_records
where ${where}
order by year desc, school_name asc nulls last, major_name asc nulls last
limit $6 offset $7
limit $${limitParamIndex} offset $${offsetParamIndex}
`,
[...params, pageSize, offset],
),

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',
});
}