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

@@ -35,7 +35,7 @@
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。 - 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。
- 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩系统触发勋章已可联调积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。学生激励默认以后台配置勋章自动发放为主,排行榜默认不开启也不在学生端默认请求。后续还要补更细活动效果看板、外部微信订阅消息/短信推送、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。 - 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩系统触发勋章已可联调积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。学生激励默认以后台配置勋章自动发放为主,排行榜默认不开启也不在学生端默认请求。后续还要补更细活动效果看板、外部微信订阅消息/短信推送、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料、独立消息中心和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘H5 端已用 KaTeX 渲染 `$...$``$$...$$``\(...\)``\[...\]` 公式,私有题图可用 `asset:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、学习概览、掌握率、收藏数、计划拆分、卡片翻转、发音、美/英音切换和本地位置恢复第一版,知识手册已接章节内搜索、安全文本摘要高亮和目录定位第一版,资料页已补齐预览/下载的短签名、水印 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:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、学习概览、掌握率、收藏数、计划拆分、卡片翻转、发音、美/英音切换和本地位置恢复第一版,知识手册已接章节内搜索、安全文本摘要高亮和目录定位第一版,分数线已接目标地区默认筛选、院校/专业/年份 chip、租户动态字段筛选、结果字段 chip 和趋势摘要第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版个人中心已接学习报告、14 天趋势、题型表现、最近练习、男女预设头像选择、积分任务/兑换/积分明细和消息中心摘要第一版,独立消息中心已接状态/类型筛选、批量已读、归档/忽略和站内安全跳转第一版;学生端不默认请求排行榜,仅在租户显式开启 `enableLeaderboard` 并完成压测后进入独立排行榜页或活动页;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,学生运营页已接跟进看板、学习督导自动化、督导规则保存和批量 CRM 推送第一版,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权、平台员工管理,以及创建租户、租户详情、状态变更、账务资料维护、平台员工创建/编辑/禁用恢复、权限点勾选、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、催缴外部通知渠道/事件摘要、用量和题库授权第一版写操作。
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
更完整的进度看这些文档: 更完整的进度看这些文档:

View File

@@ -1,23 +1,172 @@
import type { RequestContext } from '../../core/http.js'; import type { RequestContext } from '../../core/http.js';
import { HttpError } from '../../core/http.js';
import { intParam, stringParam, tenantIdFrom } from '../../core/request.js'; import { intParam, stringParam, tenantIdFrom } from '../../core/request.js';
import { query, queryOne } from '../../core/db.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) { export async function scorelineFieldsRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx); const tenantId = await tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId'); const regionId = stringParam(ctx, 'regionId');
const items = await query( const items = await query(
` `
select id, legacy_id as "legacyId", region_id as "regionId", select *
field_key as "fieldKey", field_name as "fieldName", from (
field_type as "fieldType", unit, is_filter as "isFilter", select distinct on (field_key)
is_required as "isRequired", is_visible as "isVisible", id, legacy_id as "legacyId", region_id as "regionId",
is_trend as "isTrend", options, placeholder, description, field_key as "fieldKey", field_name as "fieldName",
sort_order as "sortOrder", created_at as "createdAt", field_type as "fieldType", unit, is_filter as "isFilter",
updated_at as "updatedAt" is_required as "isRequired", is_visible as "isVisible",
from public.scoreline_fields is_trend as "isTrend", options, placeholder, description,
where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid) sort_order as "sortOrder", created_at as "createdAt",
order by sort_order asc, field_name asc 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], [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 pageSize = intParam(ctx, 'pageSize', intParam(ctx, 'perPage', 20, 100), 100);
const offset = (page - 1) * pageSize; 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 = ` const where = `
tenant_id = $1 tenant_id = $1
and ($2::uuid is null or region_id = $2::uuid) and ($2::uuid is null or region_id = $2::uuid)
and ($3::uuid is null or school_id = $3::uuid) and ($3::uuid is null or school_id = $3::uuid)
and ($4::uuid is null or major_id = $4::uuid) and ($4::uuid is null or major_id = $4::uuid)
and ($5::integer = 0 or year = $5::integer) 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([ const [countRow, items] = await Promise.all([
queryOne<{ total: string }>(`select count(*)::text as total from public.scoreline_records where ${where}`, params), 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 from public.scoreline_records
where ${where} where ${where}
order by year desc, school_name asc nulls last, major_name asc nulls last 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], [...params, pageSize, offset],
), ),

View File

@@ -1,41 +1,341 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Text, View } from '@tarojs/components'; import Taro from '@tarojs/taro';
import { loadScorelineRecords, type ScorelineRecord } from '@/services/catalog'; 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'; import '../student.css';
function fieldText(values: Record<string, unknown> | undefined) { function valueText(value: unknown) {
if (!values) return '暂无字段'; if (value === null || value === undefined || value === '') return '暂无';
return Object.entries(values).slice(0, 4).map(([key, value]) => `${key}: ${String(value)}`).join(' · '); 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() { 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 [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 [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(() => { useEffect(() => {
loadScorelineRecords({ pageSize: 50 }) loadProfile()
.then(payload => setRecords(payload.items || [])) .then(payload => {
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '分数线加载失败')); 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 ( return (
<View className='student-page'> <View className='student-page scoreline-page'>
<View className='student-topbar'> <View className='student-topbar'>
<View className='student-title-block'> <View className='student-title-block'>
<Text className='student-kicker'>Scoreline</Text> <Text className='student-kicker'>Scoreline</Text>
<Text className='student-title'>线</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> </View>
<View className='list-stack'>
{records.map(item => ( {trend.length ? (
<View className='list-row' key={item.id}> <View className='section-block'>
<Text className='row-main'>{item.year} · {item.schoolName || '院校'}{item.majorName ? ` · ${item.majorName}` : ''}</Text> <Text className='section-heading'></Text>
<Text className='row-meta'>{fieldText(item.fieldValues)}</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>
))} </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> </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} {error ? <Text className='error-text'>{error}</Text> : null}
</View> </View>
); );

View File

@@ -1000,3 +1000,178 @@
border-radius: 8px; border-radius: 8px;
background: #f8fafc; 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 { export interface ScorelineRecord {
id: string; id: string;
regionId?: string | null;
schoolId?: string | null;
majorId?: string | null;
year: number; year: number;
schoolName?: string | null; schoolName?: string | null;
majorName?: string | null; majorName?: string | null;
fieldValues?: Record<string, unknown>; 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 { export interface ContentAsset {
id: string; id: string;
title?: string | null; 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' }); 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', { 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', authMode: 'none',
}); });
} }

View File

@@ -82,7 +82,7 @@
| 单词复习算法/每日计划 | 可联调 | `GET /api/learning/vocabulary/review-plan``POST /api/learning/vocabulary/review`;后端计算 `nextReviewDate`、连续正确、掌握状态和待复习计划 | | 单词复习算法/每日计划 | 可联调 | `GET /api/learning/vocabulary/review-plan``POST /api/learning/vocabulary/review`;后端计算 `nextReviewDate`、连续正确、掌握状态和待复习计划 |
| 知识手册目录/内容 | 可联调 | `/api/catalog/handbook-*` | | 知识手册目录/内容 | 可联调 | `/api/catalog/handbook-*` |
| 知识手册 JSON 导入 | 可联调 | `/api/tenant-content/imports/*/handbook` | | 知识手册 JSON 导入 | 可联调 | `/api/tenant-content/imports/*/handbook` |
| 分数线字段/院校/专业/记录/趋势 | 可联调 | `/api/scoreline/*` | | 分数线字段/院校/专业/记录/趋势 | 可联调 | `/api/scoreline/*``records` 支持 `field.<key>``min.<key>``max.<key>` 动态字段筛选,只允许 `scoreline_fields.is_filter=true` 且字段名安全的字段参与查询 |
| 分数线 JSON 导入 | 可联调 | `/api/tenant-content/imports/preview/scoreline``/api/tenant-content/imports/scoreline`;支持字段、院校、专业、记录、动态字段值、逐行 issue、幂等和审计 | | 分数线 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 报告渲染待补 | | AI 择校推荐 | 可联调 | `ai_recommendation_reports` + `/api/ai/school-recommendations*`;默认要求当前学生有有效 SVIP后端读取学生目标地区和分数线上下文使用 `local_rules` 生成稳定 JSON 报告并写入报告台账和审计;真实 AI provider、人工 prompt 编排和 PDF 报告渲染待补 |
| 题目视频/批量预加载/搜索 | 可联调 | `/api/questions/*/videos``/api/videos/search`;付费视频列表不返回可播放 URL | | 题目视频/批量预加载/搜索 | 可联调 | `/api/questions/*/videos``/api/videos/search`;付费视频列表不返回可播放 URL |

View File

@@ -26,7 +26,7 @@
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜接口(租户默认关闭)、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、starter 单地区/专业版全国公共题库访问边界、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台;排行榜仅在租户显式开启活动后再补压测、防刷和预聚合 | | 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜接口(租户默认关闭)、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、starter 单地区/专业版全国公共题库访问边界、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台;排行榜仅在租户显式开启活动后再补压测、防刷和预聚合 |
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜接口(租户默认关闭) | 更细复习参数 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜接口(租户默认关闭) | 更细复习参数 |
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 | | 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入AI 择校推荐已可读取地区/分数线上下文 | 复杂筛选和更细 AI 推荐运营配置 | | 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、动态字段白名单过滤、Taro 学生端复杂筛选第一版、JSON/CSV/Excel 导入AI 择校推荐已可读取地区/分数线上下文 | 趋势图增强和更细 AI 推荐运营配置 |
| 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON/CSV/Excel 导入、会员播放权限、播放次数扣减、签名 URL、播放日志和动态水印上下文 | 深度防盗链、转码级水印、播放统计 | | 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON/CSV/Excel 导入、会员播放权限、播放次数扣减、签名 URL、播放日志和动态水印上下文 | 深度防盗链、转码级水印、播放统计 |
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、访问审计、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 可生成可信 `content_assets` 并走签名下载/预览 | CDN 防盗链、真实 AV/内容安全服务联调、转码级水印、资料前端操作体验 | | 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、访问审计、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 可生成可信 `content_assets` 并走签名下载/预览 | CDN 防盗链、真实 AV/内容安全服务联调、转码级水印、资料前端操作体验 |
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放、资金对账批次/明细/异常查询 API、微信/支付宝官方账单下载 worker、对账差错工单和事件轨迹 | 财务复核报表、异常订单运营台和真实生产账单格式验收 | | 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放、资金对账批次/明细/异常查询 API、微信/支付宝官方账单下载 worker、对账差错工单和事件轨迹 | 财务复核报表、异常订单运营台和真实生产账单格式验收 |

View File

@@ -22,7 +22,7 @@
| 学生刷题 | 基础完成 | 内容入口、分类树、题目集合、顺序刷题、随机刷题、全真模拟 session 题目快照、答题、错题本、收藏夹、模考交卷评分报告、错题复习计划、排行榜接口(租户默认关闭) | 专项练习策略、题型统计深度分析;排行榜仅作为租户显式开启后的活动能力 | | 学生刷题 | 基础完成 | 内容入口、分类树、题目集合、顺序刷题、随机刷题、全真模拟 session 题目快照、答题、错题本、收藏夹、模考交卷评分报告、错题复习计划、排行榜接口(租户默认关闭) | 专项练习策略、题型统计深度分析;排行榜仅作为租户显式开启后的活动能力 |
| 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计、每日复习计划、旧模板/新模板 JSON/CSV/Excel 预览导入、内容导航绑定、排行榜接口(租户默认关闭) | 更细复习参数 | | 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计、每日复习计划、旧模板/新模板 JSON/CSV/Excel 预览导入、内容导航绑定、排行榜接口(租户默认关闭) | 更细复习参数 |
| 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护、书籍/章节/小节/知识点嵌套 JSON 预览导入、内容导航绑定 | 富文本资源、版本管理、附件/PDF 关联、Excel/Markdown 批量解析 | | 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护、书籍/章节/小节/知识点嵌套 JSON 预览导入、内容导航绑定 | 富文本资源、版本管理、附件/PDF 关联、Excel/Markdown 批量解析 |
| 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入 | 复杂动态筛选、AI 择校数据上下文 | | 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入、动态字段白名单筛选和 Taro 学生端复杂筛选第一版 | 趋势图增强、AI 择校更细数据上下文和运营配置 |
| 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志、动态水印上下文 | 深度防盗链、转码级水印、播放统计 | | 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志、动态水印上下文 | 深度防盗链、转码级水印、播放统计 |
| 资料下载/PDF | 可联调 | `content_assets` 资源台账、后台资源管理、OSS/COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、学生端列表、SVIP 下载权限、动态水印上下文、assets worker 复检、内置安全扫描和外部 HTTP scanner 接入层 | CDN 防盗链、真实 AV/内容安全服务联调、资料前端管理页 | | 资料下载/PDF | 可联调 | `content_assets` 资源台账、后台资源管理、OSS/COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、学生端列表、SVIP 下载权限、动态水印上下文、assets worker 复检、内置安全扫描和外部 HTTP scanner 接入层 | CDN 防盗链、真实 AV/内容安全服务联调、资料前端管理页 |
| 营销中心 | 可联调 | SVIP 套餐、激活码批次、激活码生成、优惠券启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细、核销报表、Banner/FAQ/公告、勋章管理、手动发放、签到/积分/反馈/活动任务/练习/单词/模考自动发放、积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约和积分风控只读报表 | 营销自动化、活动效果看板和前端活动操作台 | | 营销中心 | 可联调 | SVIP 套餐、激活码批次、激活码生成、优惠券启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细、核销报表、Banner/FAQ/公告、勋章管理、手动发放、签到/积分/反馈/活动任务/练习/单词/模考自动发放、积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约和积分风控只读报表 | 营销自动化、活动效果看板和前端活动操作台 |

View File

@@ -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/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/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/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/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/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`;已接学习报告、练习趋势、最近练习、男女预设头像选择、会员订单、激活码、积分任务/兑换/积分明细和消息中心第一版 | | 个人中心 | `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 识别出的公式 tokenH5 端用 KaTeX 渲染 `$...$``$$...$$``\(...\)``\[...\]`,私有题图可用 `asset:<uuid>``content_asset:<uuid>``/asset/<uuid>` 资源引用向后端申请短期预览签名。知识手册搜索摘要使用纯文本片段高亮,不把搜索词拼成 HTML 注入正文。组件不执行导入内容中的任意 HTML/JS也会拒绝 `javascript:``data:` 等危险图片地址。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补小程序公式真机验收、题图资源字段化、小程序分享/支付容器体验。 当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题卡/答题/主观题自评/复合题多小题/收藏/反馈/视频 -> 交卷报告逐题复盘 -> 错题/收藏复习”,背单词已经覆盖“单元 -> 今日计划/全单元/收藏练习 -> 学习概览/掌握率/收藏数 -> 卡片翻转 -> 发音 -> 认识/再记上报 -> 本地恢复”,知识手册已经覆盖“科目 -> 章节 -> 章节内搜索 -> 目录定位 -> RichContent 阅读”,分数线已经覆盖“目标地区 -> 院校/专业/年份 -> 租户动态字段筛选 -> 结果字段 chip -> 趋势摘要”,资料页已经覆盖“列表 -> 申请预览/下载短签名 -> 展示水印 traceId -> H5 水印容器预览或确认下载”,个人中心已经覆盖“基础资料 -> 学习报告 -> 14 天趋势 -> 题型表现 -> 最近练习 -> 勋章/签到 -> 会员/订单/激活码 -> 积分任务/兑换/明细 -> 消息摘要”,独立消息中心已经覆盖“筛选 -> 已读/归档/忽略 -> 站内安全跳转”,会员闭环已经覆盖“选套餐 -> 领优惠券 -> 下单 -> 创建支付参数 -> 状态轮询 -> 订单详情/售后入口”。`apps/taro/src/components/RichContent.tsx` 是学生端题干、选项、解析和知识手册的统一安全渲染组件:它只支持受控 Markdown 图片、基础表格、粗体、代码和被 parser 识别出的公式 tokenH5 端用 KaTeX 渲染 `$...$``$$...$$``\(...\)``\[...\]`,私有题图可用 `asset:<uuid>``content_asset:<uuid>``/asset/<uuid>` 资源引用向后端申请短期预览签名。知识手册搜索摘要使用纯文本片段高亮,不把搜索词拼成 HTML 注入正文。组件不执行导入内容中的任意 HTML/JS也会拒绝 `javascript:``data:` 等危险图片地址。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补小程序公式真机验收、题图资源字段化、小程序分享/支付容器体验。
## 已落地的 Taro 租户后台页面 ## 已落地的 Taro 租户后台页面

View File

@@ -30,7 +30,7 @@
| 用户订阅/题库会员/SVIP | 已建 `orders``payments``entitlements``svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询、支付/退款补偿、微信/支付宝官方账单下载、资金对账、差错工单、异常订单运营台和人工调整凭证已实现 | API 集成测试、commerce worker 集成测试 | 商城主链路可联调Taro 租户财务运营台第一版已接;真实生产账单格式验收、生产账号联调和更完整学生端售后体验待补 | | 用户订阅/题库会员/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、旧模板/新模板 JSON 预览导入、排行榜接口已实现且租户默认关闭 | 核心 API 集成测试含导入和排行榜断言Taro 类型检查覆盖卡片学习、收藏练习和学习概览 | 学生端学习状态、后台维护、批量 JSON 导入、Taro 学习概览、掌握率、计划拆分、卡片翻转、发音、美/英音切换、收藏练习、本地位置恢复和基础排行榜接口已实现且默认关闭;租户级发音源、更细复习参数、后台统计和小程序音频兼容验收待完善 |
| 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言Taro 类型检查含 RichContent 阅读页 | 学生端阅读、RichContent 安全渲染、章节内搜索、安全文本摘要高亮、目录定位、后台维护和批量 JSON 导入基础可用;小程序公式真机验收、题图资源字段化、富文本资源/版本管理待补 | | 知识手册 | 已建手册科目、章节、条目,并可绑定 `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 导入链路已实现,深度防盗链、转码级水印和播放统计待补 | | 题目视频讲解 | 已建 `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 资料下载、安全扫描门禁、水印 traceIdassets worker 覆盖内置规则与外部 scanner 通过/失败/不可用 fail-closedexports worker 测试Taro 类型检查覆盖学生资料水印预览/下载确认 | 资料资源基础闭环可跑Taro 学生资料页已接短期签名、可见水印覆盖、追踪码展示和强制水印资源外部打开限制第一版;真实 AV/内容安全服务联调、CDN 防盗链、转码/CDN 级水印待补 | | 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、资源访问审计、动态水印上下文、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 自动发布可信资源、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载、安全扫描门禁、水印 traceIdassets worker 覆盖内置规则与外部 scanner 通过/失败/不可用 fail-closedexports worker 测试Taro 类型检查覆盖学生资料水印预览/下载确认 | 资料资源基础闭环可跑Taro 学生资料页已接短期签名、可见水印覆盖、追踪码展示和强制水印资源外部打开限制第一版;真实 AV/内容安全服务联调、CDN 防盗链、转码/CDN 级水印待补 |
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges`、积分任务/兑换表、`user_notifications` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、积分任务、积分兑换、题目反馈、考试倒计时、勋章和站内通知 API 已实现 | API 集成测试、Taro 类型检查 | 学生端个人中心已接学习报告、14 天趋势、题型表现、最近练习、男女预设头像选择、积分任务/兑换/积分明细、消息中心摘要、激活码和订单入口第一版;独立消息中心已接筛选、批量已读、归档/忽略和站内安全跳转第一版;账号合并和更细学习建议待补;不做头像上传或第三方头像同步 | | 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges`、积分任务/兑换表、`user_notifications` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、积分任务、积分兑换、题目反馈、考试倒计时、勋章和站内通知 API 已实现 | API 集成测试、Taro 类型检查 | 学生端个人中心已接学习报告、14 天趋势、题型表现、最近练习、男女预设头像选择、积分任务/兑换/积分明细、消息中心摘要、激活码和订单入口第一版;独立消息中心已接筛选、批量已读、归档/忽略和站内安全跳转第一版;账号合并和更细学习建议待补;不做头像上传或第三方头像同步 |

View File

@@ -29,7 +29,7 @@
| 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志和动态水印上下文已有;缺深度防盗链、转码级水印、播放统计报表 | | 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志和动态水印上下文已有;缺深度防盗链、转码级水印、播放统计报表 |
| 背单词 | `VocabularyPage.tsx``VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划、后端复习调度、Taro 学习概览、掌握率、计划拆分、卡片翻转、上一个/下一个、跳转、发音、美/英音切换、收藏练习和本地进度恢复第一版已覆盖;后续补租户级发音源配置、小程序音频兼容验收和更精细的间隔算法参数。排行榜运营看板只作为显式活动需求 | | 背单词 | `VocabularyPage.tsx``VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划、后端复习调度、Taro 学习概览、掌握率、计划拆分、卡片翻转、上一个/下一个、跳转、发音、美/英音切换、收藏练习和本地进度恢复第一版已覆盖;后续补租户级发音源配置、小程序音频兼容验收和更精细的间隔算法参数。排行榜运营看板只作为显式活动需求 |
| 知识手册 | `Handbook*.tsx` | 已覆盖 | Taro 已接手册科目/章节/条目、RichContent 安全渲染、章节内搜索、安全文本摘要高亮、目录定位、H5 KaTeX 公式渲染和私有资源 ID 短签名地基第一版;后续补小程序公式真机验收和图片资源字段化 | | 知识手册 | `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 学生收银台/订单详情和租户财务运营台第一版已接;仍缺更完整售后体验、真实生产账单抽样验收和生产账号联调 | | 商城/SVIP | `Store.tsx``SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker、全额退款权益撤销、资金对账手工/API 导入比对、微信/支付宝官方账单下载 worker、异常查询、差错工单和事件轨迹、异常订单运营台、人工调整凭证复核报表已有Taro 学生收银台/订单详情和租户财务运营台第一版已接;仍缺更完整售后体验、真实生产账单抽样验收和生产账号联调 |
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、男女预设头像、权益、订单统计、练习历史、学习统计、签到积分、积分活动任务、积分兑换、考试倒计时、趋势、勋章展示和站内通知 API 已有Taro 个人中心已接学习报告、男女预设头像选择、积分任务、积分兑换、积分明细、消息筛选/已读/归档第一版;后端登录和导入链路不会保存微信/QQ/旧系统头像 URL产品不支持学生头像上传继续补账号合并和更细学习建议 | | 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、男女预设头像、权益、订单统计、练习历史、学习统计、签到积分、积分活动任务、积分兑换、考试倒计时、趋势、勋章展示和站内通知 API 已有Taro 个人中心已接学习报告、男女预设头像选择、积分任务、积分兑换、积分明细、消息筛选/已读/归档第一版;后端登录和导入链路不会保存微信/QQ/旧系统头像 URL产品不支持学生头像上传继续补账号合并和更细学习建议 |
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、动态水印上下文、Taro 学生端水印 traceId 预览/下载确认、worker 复检、内置安全扫描和外部 HTTP scanner 接入层已有;仍缺深度防盗链、真实 AV/内容安全服务联调、转码/CDN 级水印和生命周期策略 | | 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、动态水印上下文、Taro 学生端水印 traceId 预览/下载确认、worker 复检、内置安全扫描和外部 HTTP scanner 接入层已有;仍缺深度防盗链、真实 AV/内容安全服务联调、转码/CDN 级水印和生命周期策略 |

View File

@@ -233,8 +233,8 @@
- RichContent 已支持 Markdown 图片、基础表格、代码、加粗、H5 KaTeX 公式渲染、私有资源 ID 短签名和安全过滤Taro 已接章节内搜索、安全文本摘要高亮和目录定位第一版;后续补小程序公式真机验收和题图资源字段化 - RichContent 已支持 Markdown 图片、基础表格、代码、加粗、H5 KaTeX 公式渲染、私有资源 ID 短签名和安全过滤Taro 已接章节内搜索、安全文本摘要高亮和目录定位第一版;后续补小程序公式真机验收和题图资源字段化
6. 分数线 6. 分数线
- 院校、专业、年份、动态字段筛选 - 已接院校、专业、年份、动态字段筛选;后端只允许 `scoreline_fields.is_filter=true` 且字段名安全的动态字段参与查询
- 趋势图 - 后续补趋势图增强、院校/专业搜索体验和 AI 择校更细上下文配置
7. 资料下载 7. 资料下载
- PDF 列表、权限提示、短签名预览、下载确认和动态水印 traceId 展示已接第一版 - PDF 列表、权限提示、短签名预览、下载确认和动态水印 traceId 展示已接第一版

View File

@@ -261,7 +261,7 @@ tenant:<tenantId>:theme
| 单词进度/计划 | `/api/learning/vocabulary/progress``/api/learning/vocabulary/stats``/api/learning/vocabulary/review-plan``POST /api/learning/vocabulary/review` | | 单词进度/计划 | `/api/learning/vocabulary/progress``/api/learning/vocabulary/stats``/api/learning/vocabulary/review-plan``POST /api/learning/vocabulary/review` |
| 单词收藏 | `/api/learning/vocabulary/favorites` | | 单词收藏 | `/api/learning/vocabulary/favorites` |
| 知识手册 | `/api/catalog/handbook-subjects``handbook-chapters``handbook-entries` | | 知识手册 | `/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.<fieldKey>`、数值范围使用 `min.<fieldKey>` / `max.<fieldKey>`,字段必须以后端 `fields.items[].isFilter=true` 为准 |
| 资料下载/预览 | `/api/catalog/assets``/api/catalog/assets/preview``/api/catalog/assets/download` | | 资料下载/预览 | `/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/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` | | 订单/权益 | `/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。 - 后端支持 `fields/schools/majors/records` 分桶,也支持 `items` 列表Excel 可用 `fields``schools``majors``records` 多 Sheet。
- 页面筛选字段仍以 `/api/scoreline/fields` 为准,不要从导入 JSON 临时生成筛选 UI。 - 页面筛选字段仍以 `/api/scoreline/fields` 为准,不要从导入 JSON 临时生成筛选 UI。
- `record` 至少需要 `schoolId``schoolLegacyId``schoolName`,否则 preview 会返回 issue。 - `record` 至少需要 `schoolId``schoolLegacyId``schoolName`,否则 preview 会返回 issue。
- 学生端查询记录时,普通动态字段传 `field.<fieldKey>=value`;数值下限/上限传 `min.<fieldKey>=value``max.<fieldKey>=value`。后端会按租户和地区校验字段白名单,前端不要自行拼未在 `fields` 返回中的字段。
视频导入前端注意: 视频导入前端注意:

View File

@@ -3134,6 +3134,29 @@ async function testScoreline() {
assert.ok(records.total >= 1, 'scoreline records should have data'); 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'); 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 } }); const years = await request('/api/scoreline/years', { query: { regionId: ids.region } });
assert.ok(years.items?.includes(2026), 'scoreline years should include 2026'); assert.ok(years.items?.includes(2026), 'scoreline years should include 2026');
} }