forked from wangziqi/gongxue-base
feat: add scoreline dynamic filters
This commit is contained in:
@@ -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],
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user