diff --git a/README.md b/README.md index 004a27eb..df153d56 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 - 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。 - 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩系统触发勋章已可联调;积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成,Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。学生激励默认以后台配置勋章自动发放为主,排行榜默认不开启也不在学生端默认请求。后续还要补更细活动效果看板、外部微信订阅消息/短信推送、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。 -- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料、独立消息中心和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:`/`content_asset:` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、学习概览、掌握率、收藏数、计划拆分、卡片翻转、发音、美/英音切换和本地位置恢复第一版,知识手册已接章节内搜索、安全文本摘要高亮和目录定位第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、男女预设头像选择、积分任务/兑换/积分明细和消息中心摘要第一版,独立消息中心已接状态/类型筛选、批量已读、归档/忽略和站内安全跳转第一版;学生端不默认请求排行榜,仅在租户显式开启 `enableLeaderboard` 并完成压测后进入独立排行榜页或活动页;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,学生运营页已接跟进看板、学习督导自动化、督导规则保存和批量 CRM 推送第一版,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权、平台员工管理,以及创建租户、租户详情、状态变更、账务资料维护、平台员工创建/编辑/禁用恢复、权限点勾选、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、催缴外部通知渠道/事件摘要、用量和题库授权第一版写操作。 +- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料、独立消息中心和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:`/`content_asset:` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、学习概览、掌握率、收藏数、计划拆分、卡片翻转、发音、美/英音切换和本地位置恢复第一版,知识手册已接章节内搜索、安全文本摘要高亮和目录定位第一版,分数线已接目标地区默认筛选、院校/专业/年份 chip、租户动态字段筛选、结果字段 chip 和趋势摘要第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、男女预设头像选择、积分任务/兑换/积分明细和消息中心摘要第一版,独立消息中心已接状态/类型筛选、批量已读、归档/忽略和站内安全跳转第一版;学生端不默认请求排行榜,仅在租户显式开启 `enableLeaderboard` 并完成压测后进入独立排行榜页或活动页;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,学生运营页已接跟进看板、学习督导自动化、督导规则保存和批量 CRM 推送第一版,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权、平台员工管理,以及创建租户、租户详情、状态变更、账务资料维护、平台员工创建/编辑/禁用恢复、权限点勾选、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、催缴外部通知渠道/事件摘要、用量和题库授权第一版写操作。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 更完整的进度看这些文档: diff --git a/apps/api/src/features/scoreline/routes.ts b/apps/api/src/features/scoreline/routes.ts index 56edb8e4..725a8dbe 100644 --- a/apps/api/src/features/scoreline/routes.ts +++ b/apps/api/src/features/scoreline/routes.ts @@ -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( + ` + 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], ), diff --git a/apps/taro/src/pages/student/scoreline/index.tsx b/apps/taro/src/pages/student/scoreline/index.tsx index 1afeefef..2d098144 100644 --- a/apps/taro/src/pages/student/scoreline/index.tsx +++ b/apps/taro/src/pages/student/scoreline/index.tsx @@ -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 | 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; + 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(null); + const [fields, setFields] = useState([]); + const [schools, setSchools] = useState([]); + const [majors, setMajors] = useState([]); + const [years, setYears] = useState([]); const [records, setRecords] = useState([]); + const [trend, setTrend] = useState([]); + const [total, setTotal] = useState(0); + const [selectedSchoolId, setSelectedSchoolId] = useState(''); + const [selectedMajorId, setSelectedMajorId] = useState(''); + const [selectedYear, setSelectedYear] = useState(0); + const [filterValues, setFilterValues] = useState>({}); + 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 = {}; + const dynamicFilters: Record = {}; + 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 ( - + Scoreline 历年分数线 - 动态字段由租户后台配置,适配不同地区和院校规则。 + + {profile?.target?.regionName ? `${profile.target.regionName} · ${currentSchool?.name || profile.target.schoolName || '全部院校'}` : '按租户可见地区展示'} + + + + + + + + + 筛选 + {currentMajor?.name || profile?.target?.majorName || '全部专业'} · {selectedYear || '全部年份'} + + + + + + 院校 + + {schools.slice(0, 12).map(item => ( + selectSchool(item)}> + {item.shortName || item.name}{item.isHot ? ' · 热' : ''} + + ))} + + + + {majors.length ? ( + + 专业 + + {majors.slice(0, 16).map(item => ( + selectMajor(item)}> + {item.name} + + ))} + + + ) : null} + + {years.length ? ( + + 年份 + + setSelectedYear(0)}> + 全部 + + {years.slice(0, 8).map(item => ( + setSelectedYear(item)}> + {item} + + ))} + + + ) : null} + + {filterFields.length ? ( + + {filterFields.map(field => { + const options = optionLabels(field.options); + return ( + + {field.fieldName} + {options.length ? ( + + {options.slice(0, 8).map(option => ( + updateFilter(field.fieldKey, filterValues[field.fieldKey] === option.value ? '' : option.value)} + > + {option.label} + + ))} + + ) : ( + updateFilter(field.fieldKey, String(event.detail.value || ''))} + /> + )} + + ); + })} + + ) : null} + + + + + + + {String(total)} + 匹配记录 + + + {String(trend.length)} + 趋势样本 - - {records.map(item => ( - - {item.year} · {item.schoolName || '院校'}{item.majorName ? ` · ${item.majorName}` : ''} - {fieldText(item.fieldValues)} + + {trend.length ? ( + + 趋势 + + {trend.slice(0, 10).map(item => ( + + {item.year} + + {trendMetricFields.map(field => `${field.fieldName}${fieldValue(item, field)}`).join(' · ')} + + + ))} - ))} + + ) : null} + + + 结果 + + {records.map(item => ( + + {recordTitle(item)} + + {shownFields.slice(0, 8).map(field => ( + + {field.fieldName} + {fieldValue(item, field)} + + ))} + + + ))} + - {!records.length ? 暂无分数线数据。 : null} + + {!records.length && !loading ? 暂无匹配分数线数据。 : null} {error ? {error} : null} ); diff --git a/apps/taro/src/pages/student/student.css b/apps/taro/src/pages/student/student.css index 5bf8da9f..366620ad 100644 --- a/apps/taro/src/pages/student/student.css +++ b/apps/taro/src/pages/student/student.css @@ -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); + } +} diff --git a/apps/taro/src/services/catalog.ts b/apps/taro/src/services/catalog.ts index 03206094..f600e984 100644 --- a/apps/taro/src/services/catalog.ts +++ b/apps/taro/src/services/catalog.ts @@ -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; } +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; + dynamicMinFilters?: Record; + dynamicMaxFilters?: Record; +} = {}) { + const dynamicQuery: Record = {}; + 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', }); } diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 9a3cdd52..cae01f5f 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -82,7 +82,7 @@ | 单词复习算法/每日计划 | 可联调 | `GET /api/learning/vocabulary/review-plan`、`POST /api/learning/vocabulary/review`;后端计算 `nextReviewDate`、连续正确、掌握状态和待复习计划 | | 知识手册目录/内容 | 可联调 | `/api/catalog/handbook-*` | | 知识手册 JSON 导入 | 可联调 | `/api/tenant-content/imports/*/handbook` | -| 分数线字段/院校/专业/记录/趋势 | 可联调 | `/api/scoreline/*` | +| 分数线字段/院校/专业/记录/趋势 | 可联调 | `/api/scoreline/*`;`records` 支持 `field.`、`min.`、`max.` 动态字段筛选,只允许 `scoreline_fields.is_filter=true` 且字段名安全的字段参与查询 | | 分数线 JSON 导入 | 可联调 | `/api/tenant-content/imports/preview/scoreline`、`/api/tenant-content/imports/scoreline`;支持字段、院校、专业、记录、动态字段值、逐行 issue、幂等和审计 | | AI 择校推荐 | 可联调 | `ai_recommendation_reports` + `/api/ai/school-recommendations*`;默认要求当前学生有有效 SVIP,后端读取学生目标地区和分数线上下文,使用 `local_rules` 生成稳定 JSON 报告并写入报告台账和审计;真实 AI provider、人工 prompt 编排和 PDF 报告渲染待补 | | 题目视频/批量预加载/搜索 | 可联调 | `/api/questions/*/videos`、`/api/videos/search`;付费视频列表不返回可播放 URL | diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index fb53c2c6..fe907169 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -26,7 +26,7 @@ | 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜接口(租户默认关闭)、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、starter 单地区/专业版全国公共题库访问边界、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台;排行榜仅在租户显式开启活动后再补压测、防刷和预聚合 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜接口(租户默认关闭) | 更细复习参数 | | 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 | -| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入,AI 择校推荐已可读取地区/分数线上下文 | 复杂筛选和更细 AI 推荐运营配置 | +| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、动态字段白名单过滤、Taro 学生端复杂筛选第一版、JSON/CSV/Excel 导入,AI 择校推荐已可读取地区/分数线上下文 | 趋势图增强和更细 AI 推荐运营配置 | | 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON/CSV/Excel 导入、会员播放权限、播放次数扣减、签名 URL、播放日志和动态水印上下文 | 深度防盗链、转码级水印、播放统计 | | 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、访问审计、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 可生成可信 `content_assets` 并走签名下载/预览 | CDN 防盗链、真实 AV/内容安全服务联调、转码级水印、资料前端操作体验 | | 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放、资金对账批次/明细/异常查询 API、微信/支付宝官方账单下载 worker、对账差错工单和事件轨迹 | 财务复核报表、异常订单运营台和真实生产账单格式验收 | diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index 43867900..5ac40142 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -22,7 +22,7 @@ | 学生刷题 | 基础完成 | 内容入口、分类树、题目集合、顺序刷题、随机刷题、全真模拟 session 题目快照、答题、错题本、收藏夹、模考交卷评分报告、错题复习计划、排行榜接口(租户默认关闭) | 专项练习策略、题型统计深度分析;排行榜仅作为租户显式开启后的活动能力 | | 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计、每日复习计划、旧模板/新模板 JSON/CSV/Excel 预览导入、内容导航绑定、排行榜接口(租户默认关闭) | 更细复习参数 | | 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护、书籍/章节/小节/知识点嵌套 JSON 预览导入、内容导航绑定 | 富文本资源、版本管理、附件/PDF 关联、Excel/Markdown 批量解析 | -| 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入 | 复杂动态筛选、AI 择校数据上下文 | +| 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入、动态字段白名单筛选和 Taro 学生端复杂筛选第一版 | 趋势图增强、AI 择校更细数据上下文和运营配置 | | 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志、动态水印上下文 | 深度防盗链、转码级水印、播放统计 | | 资料下载/PDF | 可联调 | `content_assets` 资源台账、后台资源管理、OSS/COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、学生端列表、SVIP 下载权限、动态水印上下文、assets worker 复检、内置安全扫描和外部 HTTP scanner 接入层 | CDN 防盗链、真实 AV/内容安全服务联调、资料前端管理页 | | 营销中心 | 可联调 | SVIP 套餐、激活码批次、激活码生成、优惠券启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细、核销报表、Banner/FAQ/公告、勋章管理、手动发放、签到/积分/反馈/活动任务/练习/单词/模考自动发放、积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约和积分风控只读报表 | 营销自动化、活动效果看板和前端活动操作台 | diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 7e105273..9fbd91b3 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -83,12 +83,12 @@ | 订单详情 | `apps/taro/src/pages/student/order-detail/index.tsx` | `commerce/orders/detail`、`commerce/orders/status`、`payments/create` | | 背单词 | `apps/taro/src/pages/student/vocabulary/index.tsx` | `vocabulary-units`、`vocabulary-words`、`vocabulary/review-plan`、`vocabulary/review`、`vocabulary/favorites`、`vocabulary/stats`;已接今日计划、单元学习、收藏练习、学习概览、掌握率、计划拆分、收藏数、卡片翻转、发音、美/英音切换、本地进度恢复和单词跳转 | | 知识手册 | `apps/taro/src/pages/student/handbook/index.tsx` | `handbook-subjects`、`handbook-chapters`、`handbook-entries`;已接 RichContent 阅读渲染、章节内搜索、安全文本摘要高亮和目录定位第一版 | -| 分数线 | `apps/taro/src/pages/student/scoreline/index.tsx` | `scoreline/records` | +| 分数线 | `apps/taro/src/pages/student/scoreline/index.tsx` | `scoreline/fields`、`scoreline/schools`、`scoreline/majors`、`scoreline/years`、`scoreline/records`、`scoreline/trend`;已接目标地区默认筛选、院校/专业/年份 chip、租户动态字段过滤、结果字段 chip 和趋势摘要第一版 | | 资料 | `apps/taro/src/pages/student/assets/index.tsx` | `assets`、`assets/preview`、`assets/download`;已接短期签名、过期信息、可见水印覆盖、traceId 展示和强制水印资源外部预览限制 | | 消息中心 | `apps/taro/src/pages/student/notifications/index.tsx` | `profile/notifications`、`profile/notifications/status`;已接未读/已读/归档/全部筛选、类型筛选、批量已读、单条已读、归档、忽略和学生端站内路径白名单跳转 | | 个人中心 | `apps/taro/src/pages/student/profile/index.tsx` | `profile/me`、`PATCH profile/me.avatarPreset`、`check-in`、`score-events`、`badges`、`exam-countdowns`、`svip-plans`、`orders`、`entitlements`、`activation-codes`、`learning/stats`、`learning/trend`、`practice-sessions/history`、`profile/activity-tasks`、`profile/activity-tasks/claim`、`profile/exchange-items`、`profile/exchange-items/redeem`、`profile/notifications`、`profile/notifications/status`;已接学习报告、练习趋势、最近练习、男女预设头像选择、会员订单、激活码、积分任务/兑换/积分明细和消息中心第一版 | -当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题卡/答题/主观题自评/复合题多小题/收藏/反馈/视频 -> 交卷报告逐题复盘 -> 错题/收藏复习”,背单词已经覆盖“单元 -> 今日计划/全单元/收藏练习 -> 学习概览/掌握率/收藏数 -> 卡片翻转 -> 发音 -> 认识/再记上报 -> 本地恢复”,知识手册已经覆盖“科目 -> 章节 -> 章节内搜索 -> 目录定位 -> RichContent 阅读”,资料页已经覆盖“列表 -> 申请预览/下载短签名 -> 展示水印 traceId -> H5 水印容器预览或确认下载”,个人中心已经覆盖“基础资料 -> 学习报告 -> 14 天趋势 -> 题型表现 -> 最近练习 -> 勋章/签到 -> 会员/订单/激活码 -> 积分任务/兑换/明细 -> 消息摘要”,独立消息中心已经覆盖“筛选 -> 已读/归档/忽略 -> 站内安全跳转”,会员闭环已经覆盖“选套餐 -> 领优惠券 -> 下单 -> 创建支付参数 -> 状态轮询 -> 订单详情/售后入口”。`apps/taro/src/components/RichContent.tsx` 是学生端题干、选项、解析和知识手册的统一安全渲染组件:它只支持受控 Markdown 图片、基础表格、粗体、代码和被 parser 识别出的公式 token;H5 端用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]`,私有题图可用 `asset:`、`content_asset:` 或 `/asset/` 资源引用向后端申请短期预览签名。知识手册搜索摘要使用纯文本片段高亮,不把搜索词拼成 HTML 注入正文。组件不执行导入内容中的任意 HTML/JS,也会拒绝 `javascript:`、`data:` 等危险图片地址。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补小程序公式真机验收、题图资源字段化、小程序分享/支付容器体验。 +当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题卡/答题/主观题自评/复合题多小题/收藏/反馈/视频 -> 交卷报告逐题复盘 -> 错题/收藏复习”,背单词已经覆盖“单元 -> 今日计划/全单元/收藏练习 -> 学习概览/掌握率/收藏数 -> 卡片翻转 -> 发音 -> 认识/再记上报 -> 本地恢复”,知识手册已经覆盖“科目 -> 章节 -> 章节内搜索 -> 目录定位 -> RichContent 阅读”,分数线已经覆盖“目标地区 -> 院校/专业/年份 -> 租户动态字段筛选 -> 结果字段 chip -> 趋势摘要”,资料页已经覆盖“列表 -> 申请预览/下载短签名 -> 展示水印 traceId -> H5 水印容器预览或确认下载”,个人中心已经覆盖“基础资料 -> 学习报告 -> 14 天趋势 -> 题型表现 -> 最近练习 -> 勋章/签到 -> 会员/订单/激活码 -> 积分任务/兑换/明细 -> 消息摘要”,独立消息中心已经覆盖“筛选 -> 已读/归档/忽略 -> 站内安全跳转”,会员闭环已经覆盖“选套餐 -> 领优惠券 -> 下单 -> 创建支付参数 -> 状态轮询 -> 订单详情/售后入口”。`apps/taro/src/components/RichContent.tsx` 是学生端题干、选项、解析和知识手册的统一安全渲染组件:它只支持受控 Markdown 图片、基础表格、粗体、代码和被 parser 识别出的公式 token;H5 端用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]`,私有题图可用 `asset:`、`content_asset:` 或 `/asset/` 资源引用向后端申请短期预览签名。知识手册搜索摘要使用纯文本片段高亮,不把搜索词拼成 HTML 注入正文。组件不执行导入内容中的任意 HTML/JS,也会拒绝 `javascript:`、`data:` 等危险图片地址。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补小程序公式真机验收、题图资源字段化、小程序分享/支付容器体验。 ## 已落地的 Taro 租户后台页面 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index c94f1b54..1f29a3e3 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -30,7 +30,7 @@ | 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询、支付/退款补偿、微信/支付宝官方账单下载、资金对账、差错工单、异常订单运营台和人工调整凭证已实现 | API 集成测试、commerce worker 集成测试 | 商城主链路可联调,Taro 租户财务运营台第一版已接;真实生产账单格式验收、生产账号联调和更完整学生端售后体验待补 | | 背单词 | 已建单词单元、单词、进度、收藏表,并可绑定 `content_entries/content_nodes` | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、每日复习计划、租户后台单词维护 API、旧模板/新模板 JSON 预览导入、排行榜接口已实现且租户默认关闭 | 核心 API 集成测试含导入和排行榜断言,Taro 类型检查覆盖卡片学习、收藏练习和学习概览 | 学生端学习状态、后台维护、批量 JSON 导入、Taro 学习概览、掌握率、计划拆分、卡片翻转、发音、美/英音切换、收藏练习、本地位置恢复和基础排行榜接口已实现且默认关闭;租户级发音源、更细复习参数、后台统计和小程序音频兼容验收待完善 | | 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言,Taro 类型检查含 RichContent 阅读页 | 学生端阅读、RichContent 安全渲染、章节内搜索、安全文本摘要高亮、目录定位、后台维护和批量 JSON 导入基础可用;小程序公式真机验收、题图资源字段化、富文本资源/版本管理待补 | -| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 查询、后台维护和批量 JSON 导入基础闭环已实现,复杂动态筛选和 AI 择校上下文待补 | +| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、动态字段过滤、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入、动态字段筛选和非法字段拒绝断言 | 查询、后台维护、复杂动态筛选第一版和批量 JSON 导入基础闭环已实现,AI 择校更细上下文和趋势图增强待补 | | 题目视频讲解 | 已建 `video_explanations`、`question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、播放签名、视频次数扣减、播放水印上下文、租户后台视频创建绑定 API、JSON 预览导入已实现 | 核心 API 集成测试含播放、水印 traceId 和导入断言 | 播放、权益、后台绑定、动态水印上下文和批量 JSON 导入链路已实现,深度防盗链、转码级水印和播放统计待补 | | 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、资源访问审计、动态水印上下文、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 自动发布可信资源、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载、安全扫描门禁、水印 traceId,assets worker 覆盖内置规则与外部 scanner 通过/失败/不可用 fail-closed,exports worker 测试;Taro 类型检查覆盖学生资料水印预览/下载确认 | 资料资源基础闭环可跑,Taro 学生资料页已接短期签名、可见水印覆盖、追踪码展示和强制水印资源外部打开限制第一版;真实 AV/内容安全服务联调、CDN 防盗链、转码/CDN 级水印待补 | | 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges`、积分任务/兑换表、`user_notifications` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、积分任务、积分兑换、题目反馈、考试倒计时、勋章和站内通知 API 已实现 | API 集成测试、Taro 类型检查 | 学生端个人中心已接学习报告、14 天趋势、题型表现、最近练习、男女预设头像选择、积分任务/兑换/积分明细、消息中心摘要、激活码和订单入口第一版;独立消息中心已接筛选、批量已读、归档/忽略和站内安全跳转第一版;账号合并和更细学习建议待补;不做头像上传或第三方头像同步 | diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 71464b1b..b3202a58 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -29,7 +29,7 @@ | 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志和动态水印上下文已有;缺深度防盗链、转码级水印、播放统计报表 | | 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划、后端复习调度、Taro 学习概览、掌握率、计划拆分、卡片翻转、上一个/下一个、跳转、发音、美/英音切换、收藏练习和本地进度恢复第一版已覆盖;后续补租户级发音源配置、小程序音频兼容验收和更精细的间隔算法参数。排行榜运营看板只作为显式活动需求 | | 知识手册 | `Handbook*.tsx` | 已覆盖 | Taro 已接手册科目/章节/条目、RichContent 安全渲染、章节内搜索、安全文本摘要高亮、目录定位、H5 KaTeX 公式渲染和私有资源 ID 短签名地基第一版;后续补小程序公式真机验收和图片资源字段化 | -| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 | +| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护、JSON 批量导入、学生端院校/专业/年份/动态字段筛选已有;后续补趋势图增强和 AI 择校更细运营配置 | | 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker、全额退款权益撤销、资金对账手工/API 导入比对、微信/支付宝官方账单下载 worker、异常查询、差错工单和事件轨迹、异常订单运营台、人工调整凭证复核报表已有;Taro 学生收银台/订单详情和租户财务运营台第一版已接;仍缺更完整售后体验、真实生产账单抽样验收和生产账号联调 | | 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、男女预设头像、权益、订单统计、练习历史、学习统计、签到积分、积分活动任务、积分兑换、考试倒计时、趋势、勋章展示和站内通知 API 已有;Taro 个人中心已接学习报告、男女预设头像选择、积分任务、积分兑换、积分明细、消息筛选/已读/归档第一版;后端登录和导入链路不会保存微信/QQ/旧系统头像 URL,产品不支持学生头像上传;继续补账号合并和更细学习建议 | | 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、动态水印上下文、Taro 学生端水印 traceId 预览/下载确认、worker 复检、内置安全扫描和外部 HTTP scanner 接入层已有;仍缺深度防盗链、真实 AV/内容安全服务联调、转码/CDN 级水印和生命周期策略 | diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 379a21be..9a8a7246 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -233,8 +233,8 @@ - RichContent 已支持 Markdown 图片、基础表格、代码、加粗、H5 KaTeX 公式渲染、私有资源 ID 短签名和安全过滤;Taro 已接章节内搜索、安全文本摘要高亮和目录定位第一版;后续补小程序公式真机验收和题图资源字段化 6. 分数线 - - 院校、专业、年份、动态字段筛选 - - 趋势图 + - 已接院校、专业、年份、动态字段筛选;后端只允许 `scoreline_fields.is_filter=true` 且字段名安全的动态字段参与查询 + - 后续补趋势图增强、院校/专业搜索体验和 AI 择校更细上下文配置 7. 资料下载 - PDF 列表、权限提示、短签名预览、下载确认和动态水印 traceId 展示已接第一版 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 3eb958fd..7ec8a31c 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -261,7 +261,7 @@ tenant::theme | 单词进度/计划 | `/api/learning/vocabulary/progress`、`/api/learning/vocabulary/stats`、`/api/learning/vocabulary/review-plan`、`POST /api/learning/vocabulary/review` | | 单词收藏 | `/api/learning/vocabulary/favorites` | | 知识手册 | `/api/catalog/handbook-subjects`、`handbook-chapters`、`handbook-entries` | -| 分数线 | `/api/scoreline/fields`、`schools`、`majors`、`records`、`trend`、`years` | +| 分数线 | `/api/scoreline/fields`、`schools`、`majors`、`records`、`trend`、`years`;动态字段筛选使用 `field.`、数值范围使用 `min.` / `max.`,字段必须以后端 `fields.items[].isFilter=true` 为准 | | 资料下载/预览 | `/api/catalog/assets`、`/api/catalog/assets/preview`、`/api/catalog/assets/download` | | 商城/收银台 | `/api/catalog/svip-plans`、`POST /api/commerce/coupons/claim`、`POST /api/commerce/orders`、`POST /api/commerce/payments/create` | | 订单/权益 | `/api/commerce/orders`、`/api/commerce/orders/detail`、`/api/commerce/orders/status`、`/api/commerce/entitlements` | @@ -1594,6 +1594,7 @@ failed:导入结果和目标表不一致,必须提示管理员排查,不 - 后端支持 `fields/schools/majors/records` 分桶,也支持 `items` 列表;Excel 可用 `fields`、`schools`、`majors`、`records` 多 Sheet。 - 页面筛选字段仍以 `/api/scoreline/fields` 为准,不要从导入 JSON 临时生成筛选 UI。 - `record` 至少需要 `schoolId`、`schoolLegacyId` 或 `schoolName`,否则 preview 会返回 issue。 +- 学生端查询记录时,普通动态字段传 `field.=value`;数值下限/上限传 `min.=value`、`max.=value`。后端会按租户和地区校验字段白名单,前端不要自行拼未在 `fields` 返回中的字段。 视频导入前端注意: diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 239a63a3..42407e93 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -3134,6 +3134,29 @@ async function testScoreline() { assert.ok(records.total >= 1, 'scoreline records should have data'); assert.ok(records.items?.some(item => item.fieldValues?.minScore === 188), 'scoreline record should include dynamic field values'); + const filteredRecords = await request('/api/scoreline/records', { + query: { regionId: ids.region, 'field.minScore': 188, pageSize: 5 }, + }); + assert.ok(filteredRecords.total >= 1, 'scoreline dynamic exact field filter should return records'); + assert.ok(filteredRecords.items?.every(item => item.fieldValues?.minScore === 188), 'scoreline exact field filter should only return matching records'); + + const rangeFilteredRecords = await request('/api/scoreline/records', { + query: { regionId: ids.region, 'min.minScore': 180, 'max.minScore': 190, pageSize: 5 }, + }); + assert.ok(rangeFilteredRecords.items?.some(item => item.fieldValues?.minScore === 188), 'scoreline dynamic range filter should return matching records'); + + const unknownField = await request('/api/scoreline/records', { + query: { regionId: ids.region, 'field.unknownScore': 188 }, + expectStatus: 400, + }); + assert.equal(unknownField.code, 'SCORELINE_FIELD_FILTER_NOT_ALLOWED', 'scoreline records should reject unknown dynamic field filters'); + + const unsafeField = await request('/api/scoreline/records', { + query: { regionId: ids.region, 'field.minScore);drop': 188 }, + expectStatus: 400, + }); + assert.equal(unsafeField.code, 'SCORELINE_FIELD_FILTER_KEY_INVALID', 'scoreline records should reject unsafe dynamic field filter keys'); + const years = await request('/api/scoreline/years', { query: { regionId: ids.region } }); assert.ok(years.items?.includes(2026), 'scoreline years should include 2026'); }