feat: render rich formulas and signed question media

This commit is contained in:
Codex
2026-06-30 00:42:52 +08:00
parent 4a13715f29
commit db86fa3705
10 changed files with 243 additions and 29 deletions

View File

@@ -35,7 +35,7 @@
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。 - 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。 - 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版个人中心已接学习报告、14 天趋势、题型表现、最近练习和 7 日答题榜当前排名第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。 - `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$``$$...$$``\(...\)``\[...\]` 公式,私有题图可用 `asset:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版个人中心已接学习报告、14 天趋势、题型表现、最近练习和 7 日答题榜当前排名第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
更完整的进度看这些文档: 更完整的进度看这些文档:
@@ -316,7 +316,7 @@ API 身份上下文:
- 商户密钥、短信密钥、OAuth app secret 等必须进入 `app_private.tenant_secrets`,或后续生产 KMS/Vault。 - 商户密钥、短信密钥、OAuth app secret 等必须进入 `app_private.tenant_secrets`,或后续生产 KMS/Vault。
- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL学生端预览、锁定资料和视频会使用短 TTL并返回带 `traceId``watermark` 上下文供前端渲染可见水印。`members/svip/private` 外部 CDN URL 默认拒绝,除非显式登记 provider-managed 访问;所有上传签名、上传确认、下载/预览 granted/denied 都写入 `content_asset_access_events`。托管对象必须 `uploadStatus=verified``securityScanStatus=passed` 后才能发布、下载、预览或播放;生产环境应定时运行 assets worker 复检对象元数据,执行 `metadata_rules` 和外部 HTTP scanner异常资源会被标记 failed/skipped 并退回 draft。 - 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL学生端预览、锁定资料和视频会使用短 TTL并返回带 `traceId``watermark` 上下文供前端渲染可见水印。`members/svip/private` 外部 CDN URL 默认拒绝,除非显式登记 provider-managed 访问;所有上传签名、上传确认、下载/预览 granted/denied 都写入 `content_asset_access_events`。托管对象必须 `uploadStatus=verified``securityScanStatus=passed` 后才能发布、下载、预览或播放;生产环境应定时运行 assets worker 复检对象元数据,执行 `metadata_rules` 和外部 HTTP scanner异常资源会被标记 failed/skipped 并退回 draft。
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。 - 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。学生端题干/解析/手册内容统一走 `apps/taro/src/components/RichContent.tsx` 做受控渲染,不执行导入内容中的任意 HTML/JS。 - 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。学生端题干/解析/手册内容统一走 `apps/taro/src/components/RichContent.tsx` 做受控渲染,不执行导入内容中的任意 HTML/JS;公式只渲染解析出的 LaTeX token私有题图只接受资源 ID 引用并走后端短签名
- 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时顶层题目和复合题子题都必须脱敏PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。 - 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时顶层题目和复合题子题都必须脱敏PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。
- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款,并定时运行 provider-bills worker 下载官方账单核对本地订单。官方账单下载任务只保存下载域名、hash 和对账批次 ID不向前端暴露下载 URL 或商户密钥。优惠券状态、最低金额、封顶、单用户限次、首单、适用套餐/地区和订单抵扣都由后端重新校验,前端只能展示后端返回金额。对账差错工单和人工调整凭证只允许记录财务处理结论、附件引用和审计事件,不允许前端、工单接口或凭证审批接口直接篡改订单、支付、退款或权益状态。 - 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款,并定时运行 provider-bills worker 下载官方账单核对本地订单。官方账单下载任务只保存下载域名、hash 和对账批次 ID不向前端暴露下载 URL 或商户密钥。优惠券状态、最低金额、封顶、单用户限次、首单、适用套餐/地区和订单抵扣都由后端重新校验,前端只能展示后端返回金额。对账差错工单和人工调整凭证只允许记录财务处理结论、附件引用和审计事件,不允许前端、工单接口或凭证审批接口直接篡改订单、支付、退款或权益状态。
@@ -346,7 +346,7 @@ git diff --check
优先继续补: 优先继续补:
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。 1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
2. 继续补 Taro 前端:学生端真正 KaTeX/小程序公式方案、私有题图签名资源映射、个人中心任务系统、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台租户详情/审计/自动计费增强,小程序兼容验证。 2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、个人中心任务系统、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台租户详情/审计/自动计费增强,小程序兼容验证。
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。 3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。 4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。 5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。

View File

@@ -11,7 +11,9 @@
"dev:h5": "taro build --type h5 --watch", "dev:h5": "taro build --type h5 --watch",
"check": "tsc -p tsconfig.json --noEmit" "check": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": {}, "dependencies": {
"katex": "^0.17.0"
},
"devDependencies": { "devDependencies": {
"@babel/core": "^7.28.3", "@babel/core": "^7.28.3",
"@babel/preset-react": "^7.29.7", "@babel/preset-react": "^7.29.7",

View File

@@ -1,4 +1,5 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import 'katex/dist/katex.min.css';
import './app.css'; import './app.css';
export default function App({ children }: PropsWithChildren) { export default function App({ children }: PropsWithChildren) {

View File

@@ -1,5 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { Image, Text, View } from '@tarojs/components'; import { Image, RichText, Text, View } from '@tarojs/components';
import { signAssetPreview, type AssetWatermarkContext, type SignedAssetLink } from '@/services/catalog';
import './rich-content.css'; import './rich-content.css';
type InlineToken = type InlineToken =
@@ -13,7 +15,7 @@ type RichBlock =
| { type: 'heading'; level: number; tokens: InlineToken[] } | { type: 'heading'; level: number; tokens: InlineToken[] }
| { type: 'formula'; text: string } | { type: 'formula'; text: string }
| { type: 'code'; language?: string; text: string } | { type: 'code'; language?: string; text: string }
| { type: 'image'; url: string; alt: string } | { type: 'image'; url?: string; assetId?: string; alt: string }
| { type: 'blocked-image'; alt: string } | { type: 'blocked-image'; alt: string }
| { type: 'table'; header: string[]; align: string[]; rows: string[][] }; | { type: 'table'; header: string[]; align: string[]; rows: string[][] };
@@ -27,6 +29,18 @@ export interface RichContentProps {
} }
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
const ASSET_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
interface SignedMediaState {
link?: SignedAssetLink;
watermark?: AssetWatermarkContext;
title?: string | null;
error?: string;
loading?: boolean;
}
type KatexModule = typeof import('katex');
let katexModulePromise: Promise<KatexModule> | null = null;
function classNames(...values: Array<string | false | null | undefined>) { function classNames(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(' '); return values.filter(Boolean).join(' ');
@@ -95,6 +109,27 @@ export function normalizeSafeMediaUrl(value: unknown) {
return null; return null;
} }
function normalizeAssetId(value: unknown) {
const raw = typeof value === 'string' ? value.trim() : '';
if (!raw) return null;
if (ASSET_ID_RE.test(raw)) return raw;
const byScheme = raw.match(/^(?:asset|content_asset):([0-9a-f-]{36})$/i);
if (byScheme && ASSET_ID_RE.test(byScheme[1])) return byScheme[1];
const byPath = raw.match(/^\/(?:asset|content-assets|content_assets)\/([0-9a-f-]{36})$/i);
if (byPath && ASSET_ID_RE.test(byPath[1])) return byPath[1];
return null;
}
function normalizeImageSource(value: unknown): { url?: string; assetId?: string } | null {
const assetId = normalizeAssetId(value);
if (assetId) return { assetId };
const url = normalizeSafeMediaUrl(value);
return url ? { url } : null;
}
function splitTableCells(line: string) { function splitTableCells(line: string) {
const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, ''); const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
return trimmed.split('|').map(cell => cell.trim()); return trimmed.split('|').map(cell => cell.trim());
@@ -150,9 +185,9 @@ function appendParagraphWithImages(blocks: RichBlock[], rawText: string) {
const index = match.index || 0; const index = match.index || 0;
const before = text.slice(cursor, index).trim(); const before = text.slice(cursor, index).trim();
if (before) blocks.push({ type: 'paragraph', tokens: parseInline(before) }); if (before) blocks.push({ type: 'paragraph', tokens: parseInline(before) });
const url = normalizeSafeMediaUrl(match[2]); const source = normalizeImageSource(match[2]);
if (url) { if (source) {
blocks.push({ type: 'image', url, alt: match[1] || '图片' }); blocks.push({ type: 'image', ...source, alt: match[1] || '图片' });
} else { } else {
blocks.push({ type: 'blocked-image', alt: match[1] || '图片' }); blocks.push({ type: 'blocked-image', alt: match[1] || '图片' });
} }
@@ -250,13 +285,58 @@ function parseBlocks(input: unknown, mediaUrls: RichContentProps['mediaUrls'] =
flushParagraph(); flushParagraph();
for (const mediaUrl of mediaUrls || []) { for (const mediaUrl of mediaUrls || []) {
const url = normalizeSafeMediaUrl(mediaUrl); const source = normalizeImageSource(mediaUrl);
if (url) blocks.push({ type: 'image', url, alt: '题目图片' }); if (source) blocks.push({ type: 'image', ...source, alt: '题目图片' });
} }
return blocks; return blocks;
} }
function loadKatex() {
if (!katexModulePromise) {
katexModulePromise = import('katex');
}
return katexModulePromise;
}
function renderFormulaHtml(katex: KatexModule, text: string, displayMode = false) {
return katex.default.renderToString(text, {
displayMode,
throwOnError: false,
trust: false,
strict: 'warn',
output: 'html',
});
}
function FormulaView({ text, display = false, block = false }: { text: string; display?: boolean; block?: boolean }) {
const [html, setHtml] = useState('');
useEffect(() => {
let active = true;
loadKatex()
.then(katex => {
if (!active) return;
try {
setHtml(renderFormulaHtml(katex, text, display || block));
} catch (_error) {
setHtml('');
}
})
.catch(() => {
if (active) setHtml('');
});
return () => {
active = false;
};
}, [text, display, block]);
if (html && block) return <View className='rich-formula-block rendered'><RichText nodes={html} /></View>;
if (html) return <RichText className={display ? 'rich-formula-rendered display' : 'rich-formula-rendered'} nodes={html} />;
if (block) return <View className='rich-formula-block'><Text>{text}</Text></View>;
return <Text className={display ? 'rich-formula-inline display' : 'rich-formula-inline'}>{text}</Text>;
}
function renderInline(token: InlineToken, index: number) { function renderInline(token: InlineToken, index: number) {
if (token.type === 'bold') { if (token.type === 'bold') {
return <Text key={index} className='rich-bold'>{token.text}</Text>; return <Text key={index} className='rich-bold'>{token.text}</Text>;
@@ -265,7 +345,7 @@ function renderInline(token: InlineToken, index: number) {
return <Text key={index} className='rich-code-inline'>{token.text}</Text>; return <Text key={index} className='rich-code-inline'>{token.text}</Text>;
} }
if (token.type === 'formula') { if (token.type === 'formula') {
return <Text key={index} className={token.display ? 'rich-formula-inline display' : 'rich-formula-inline'}>{token.text}</Text>; return <FormulaView key={index} text={token.text} display={token.display} />;
} }
return <Text key={index}>{token.text}</Text>; return <Text key={index}>{token.text}</Text>;
} }
@@ -274,8 +354,51 @@ function preview(url: string) {
Taro.previewImage({ current: url, urls: [url] }).catch(() => undefined); Taro.previewImage({ current: url, urls: [url] }).catch(() => undefined);
} }
function assetIdsFromBlocks(blocks: RichBlock[]) {
return Array.from(new Set(blocks.flatMap(block => (block.type === 'image' && block.assetId ? [block.assetId] : []))));
}
export default function RichContent({ content, className, compact = false, muted = false, mediaUrls, emptyText }: RichContentProps) { export default function RichContent({ content, className, compact = false, muted = false, mediaUrls, emptyText }: RichContentProps) {
const blocks = parseBlocks(content, mediaUrls); const blocks = useMemo(() => parseBlocks(content, mediaUrls), [content, mediaUrls]);
const assetIds = useMemo(() => assetIdsFromBlocks(blocks), [blocks]);
const [signedMedia, setSignedMedia] = useState<Record<string, SignedMediaState>>({});
const requestedAssetIds = useRef(new Set<string>());
useEffect(() => {
if (!assetIds.length) return;
let active = true;
for (const assetId of assetIds) {
if (requestedAssetIds.current.has(assetId)) continue;
requestedAssetIds.current.add(assetId);
setSignedMedia(current => ({ ...current, [assetId]: { ...current[assetId], loading: true } }));
signAssetPreview(assetId)
.then(payload => {
if (!active) return;
setSignedMedia(current => ({
...current,
[assetId]: {
link: payload.preview,
watermark: payload.watermark,
title: payload.item?.title || payload.item?.fileName || null,
loading: false,
},
}));
})
.catch(error => {
if (!active) return;
setSignedMedia(current => ({
...current,
[assetId]: {
error: error instanceof Error ? error.message : '图片签名失败',
loading: false,
},
}));
});
}
return () => {
active = false;
};
}, [assetIds.join('|')]);
if (!blocks.length) { if (!blocks.length) {
return emptyText ? <Text className='rich-empty'>{emptyText}</Text> : null; return emptyText ? <Text className='rich-empty'>{emptyText}</Text> : null;
@@ -299,7 +422,7 @@ export default function RichContent({ content, className, compact = false, muted
); );
} }
if (block.type === 'formula') { if (block.type === 'formula') {
return <View key={index} className='rich-formula-block'><Text>{block.text}</Text></View>; return <FormulaView key={index} text={block.text} block />;
} }
if (block.type === 'code') { if (block.type === 'code') {
return ( return (
@@ -310,10 +433,25 @@ export default function RichContent({ content, className, compact = false, muted
); );
} }
if (block.type === 'image') { if (block.type === 'image') {
const signed = block.assetId ? signedMedia[block.assetId] : undefined;
const imageUrl = block.url || signed?.link?.url;
if (block.assetId && signed?.error) {
return <View key={index} className='rich-blocked-image'>{signed.error}</View>;
}
if (block.assetId && !imageUrl) {
return <View key={index} className='rich-image-pending'>...</View>;
}
if (!imageUrl) {
return <View key={index} className='rich-blocked-image'>{block.alt}</View>;
}
return ( return (
<View key={index} className='rich-image-wrap' onClick={() => preview(block.url)}> <View key={index} className='rich-image-wrap' onClick={() => preview(imageUrl)}>
<Image className='rich-image' src={block.url} mode='widthFix' /> <Image className='rich-image' src={imageUrl} mode='widthFix' />
{block.alt ? <Text className='rich-image-caption'>{block.alt}</Text> : null} {block.alt || signed?.watermark?.traceId ? (
<Text className='rich-image-caption'>
{block.alt || signed?.title || '题目图片'}{signed?.watermark?.traceId ? ` · ${signed.watermark.traceId}` : ''}
</Text>
) : null}
</View> </View>
); );
} }

View File

@@ -85,10 +85,23 @@
word-break: break-all; word-break: break-all;
} }
.rich-formula-rendered {
display: inline-flex;
max-width: 100%;
margin: 0 4px;
overflow-x: auto;
vertical-align: middle;
}
.rich-formula-inline.display { .rich-formula-inline.display {
display: flex; display: flex;
} }
.rich-formula-rendered.display {
display: flex;
margin: 10px 0;
}
.rich-formula-block, .rich-formula-block,
.rich-code-block { .rich-code-block {
min-width: 0; min-width: 0;
@@ -105,6 +118,23 @@
word-break: break-word; word-break: break-word;
} }
.rich-formula-block.rendered {
font-family: inherit;
white-space: normal;
}
.rich-formula-block .katex-display {
margin: 0;
overflow-x: auto;
overflow-y: hidden;
}
.rich-formula-block .katex,
.rich-formula-rendered .katex {
max-width: 100%;
font-size: 1.08em;
}
.rich-code-language { .rich-code-language {
display: block; display: block;
margin-bottom: 10px; margin-bottom: 10px;
@@ -156,6 +186,16 @@
word-break: break-word; word-break: break-word;
} }
.rich-image-pending {
padding: 18px;
border: 1px dashed #bfdbfe;
border-radius: 8px;
background: #eff6ff;
color: #1d4ed8;
font-size: 22px;
line-height: 1.45;
}
.rich-table { .rich-table {
display: block; display: block;
width: 100%; width: 100%;

View File

@@ -78,7 +78,7 @@
| 资料 | `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/profile/index.tsx` | `profile/me``check-in``badges``exam-countdowns``svip-plans``orders``entitlements``activation-codes``leaderboard``learning/stats``learning/trend``practice-sessions/history` | | 个人中心 | `apps/taro/src/pages/student/profile/index.tsx` | `profile/me``check-in``badges``exam-countdowns``svip-plans``orders``entitlements``activation-codes``leaderboard``learning/stats``learning/trend``practice-sessions/history` |
当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题卡/答题/主观题自评/复合题多小题/收藏/反馈/视频 -> 交卷报告逐题复盘 -> 错题/收藏复习”,背单词已经覆盖“单元 -> 今日计划/全单元/收藏练习 -> 卡片翻转 -> 发音 -> 认识/再记上报 -> 本地恢复”,资料页已经覆盖“列表 -> 申请预览/下载短签名 -> 展示水印 traceId -> H5 水印容器预览或确认下载”,个人中心已经覆盖“基础资料 -> 学习报告 -> 14 天趋势 -> 题型表现 -> 最近练习 -> 排名/勋章/签到 -> 会员/订单/激活码”,会员闭环已经覆盖“选套餐 -> 领优惠券 -> 下单 -> 创建支付参数 -> 状态轮询 -> 订单详情/售后入口”。`apps/taro/src/components/RichContent.tsx` 是学生端题干、选项、解析和知识手册的统一安全渲染组件:它只支持受控 Markdown 图片、基础表格、粗体、代码和公式占位,不执行任意 HTML/JS也会拒绝 `javascript:``data:` 等危险图片地址。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补真正 KaTeX/小程序公式方案、私有题图签名资源映射、小程序分享/支付容器体验。 当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题卡/答题/主观题自评/复合题多小题/收藏/反馈/视频 -> 交卷报告逐题复盘 -> 错题/收藏复习”,背单词已经覆盖“单元 -> 今日计划/全单元/收藏练习 -> 卡片翻转 -> 发音 -> 认识/再记上报 -> 本地恢复”,资料页已经覆盖“列表 -> 申请预览/下载短签名 -> 展示水印 traceId -> H5 水印容器预览或确认下载”,个人中心已经覆盖“基础资料 -> 学习报告 -> 14 天趋势 -> 题型表现 -> 最近练习 -> 排名/勋章/签到 -> 会员/订单/激活码”,会员闭环已经覆盖“选套餐 -> 领优惠券 -> 下单 -> 创建支付参数 -> 状态轮询 -> 订单详情/售后入口”。`apps/taro/src/components/RichContent.tsx` 是学生端题干、选项、解析和知识手册的统一安全渲染组件:它只支持受控 Markdown 图片、基础表格、粗体、代码和被 parser 识别出的公式 tokenH5 端用 KaTeX 渲染 `$...$``$$...$$``\(...\)``\[...\]`,私有题图可用 `asset:<uuid>``content_asset:<uuid>``/asset/<uuid>` 资源引用向后端申请短期预览签名。组件不执行导入内容中的任意 HTML/JS也会拒绝 `javascript:``data:` 等危险图片地址。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补小程序公式真机验收、题图资源字段化、小程序分享/支付容器体验。
## 已落地的 Taro 租户后台页面 ## 已落地的 Taro 租户后台页面

View File

@@ -21,14 +21,14 @@
| 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、FAQ、时间线、考试倒计时、入口、个人统计有基础缺完整运营动态和学习任务聚合 | | 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、FAQ、时间线、考试倒计时、入口、个人统计有基础缺完整运营动态和学习任务聚合 |
| 题库入口 | `pages/SubjectSelector.tsx``RegionArchitectureEditor.tsx` | 已覆盖 | 前端应改接 `content_entries/content_nodes` | | 题库入口 | `pages/SubjectSelector.tsx``RegionArchitectureEditor.tsx` | 已覆盖 | 前端应改接 `content_entries/content_nodes` |
| 多级分类树 | 旧 module/subject/category 树 | 已覆盖 | 新后端支持任意深度和 `marker_type`;前端不要写死层级 | | 多级分类树 | 旧 module/subject/category 树 | 已覆盖 | 新后端支持任意深度和 `marker_type`;前端不要写死层级 |
| 顺序刷题 | `pages/Quiz.tsx` | 部分覆盖 | 免费额度/SVIP 校验、session 快照、练习历史、趋势统计和题目反馈已由后端强制Taro 已有答题卡、后端 session detail 续练、本地进度恢复、客观题自动提交、主观题后端自评、阅读理解/案例分析多小题作答、题干/选项/解析 RichContent 安全渲染和报告明细第一版;继续补真正 KaTeX/小程序公式方案、图片私有签名资源映射和更完整复盘体验 | | 顺序刷题 | `pages/Quiz.tsx` | 部分覆盖 | 免费额度/SVIP 校验、session 快照、练习历史、趋势统计和题目反馈已由后端强制Taro 已有答题卡、后端 session detail 续练、本地进度恢复、客观题自动提交、主观题后端自评、阅读理解/案例分析多小题作答、题干/选项/解析 RichContent 安全渲染、H5 KaTeX 公式渲染、`content_assets` 私有题图短签名渲染地基和报告明细第一版;继续补小程序公式真机验收、题图资源字段化和更完整复盘体验 |
| 随机刷题 | `pages/Quiz.tsx` | 部分覆盖 | 已有 blueprint/session 快照、访问控制和历史统计Taro 已按 session 题目快照渲染第一版,并复用 RichContent 渲染长题干/图片/公式占位;继续补随机刷题状态管理和更完整复盘 | | 随机刷题 | `pages/Quiz.tsx` | 部分覆盖 | 已有 blueprint/session 快照、访问控制和历史统计Taro 已按 session 题目快照渲染第一版,并复用 RichContent 渲染长题干/图片/公式占位;继续补随机刷题状态管理和更完整复盘 |
| 全真模拟 | `components/AdminMockexam``MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、倒计时、交卷评分、分段统计、错题解析汇总、逐题复盘第一版和复合题子题部分得分已覆盖;后续补排行榜/排名、完整复盘体验 | | 全真模拟 | `components/AdminMockexam``MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、倒计时、交卷评分、分段统计、错题解析汇总、逐题复盘第一版和复合题子题部分得分已覆盖;后续补排行榜/排名、完整复盘体验 |
| 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 错题列表、移出错题、复习计划和 `wrong_review` 后端组卷已覆盖;后续补更细的间隔复习算法 | | 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 错题列表、移出错题、复习计划和 `wrong_review` 后端组卷已覆盖;后续补更细的间隔复习算法 |
| 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 | | 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 |
| 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志和动态水印上下文已有;缺深度防盗链、转码级水印、播放统计报表 | | 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志和动态水印上下文已有;缺深度防盗链、转码级水印、播放统计报表 |
| 背单词 | `VocabularyPage.tsx``VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划、后端复习调度、Taro 卡片翻转、上一个/下一个、跳转、发音、美/英音切换、收藏练习和本地进度恢复第一版已覆盖;后续补租户级发音源配置、收藏专项统计、排行榜运营看板和更精细的间隔算法参数 | | 背单词 | `VocabularyPage.tsx``VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划、后端复习调度、Taro 卡片翻转、上一个/下一个、跳转、发音、美/英音切换、收藏练习和本地进度恢复第一版已覆盖;后续补租户级发音源配置、收藏专项统计、排行榜运营看板和更精细的间隔算法参数 |
| 知识手册 | `Handbook*.tsx` | 已覆盖 | Taro 已接手册科目/章节/条目RichContent 安全渲染第一版;后续补搜索、高亮、目录锚点、真正公式渲染和图片私有资源签名映射 | | 知识手册 | `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 已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、激活码和订单入口第一版;继续补任务系统、头像上传、账号合并和更细学习建议 | | 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时、趋势、勋章展示 API 已有Taro 已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、激活码和订单入口第一版;继续补任务系统、头像上传、账号合并和更细学习建议 |
@@ -95,7 +95,7 @@
这些是旧项目中已经出现过、但新后端还没有完整业务闭环的功能: 这些是旧项目中已经出现过、但新后端还没有完整业务闭环的功能:
1. 阅读理解/案例分析多小题:后端 `subAnswers` 判分、断点恢复、报告 `subResults`、Taro 作答 UI、RichContent 长题干/图片/公式占位渲染和逐题复盘第一版已完成;仍需补真正 KaTeX/小程序公式方案、私有题图签名映射和更细复盘交互。 1. 阅读理解/案例分析多小题:后端 `subAnswers` 判分、断点恢复、报告 `subResults`、Taro 作答 UI、RichContent 长题干/图片/H5 KaTeX 公式渲染、私有资源 ID 短签名题图和逐题复盘第一版已完成;仍需补小程序公式真机验收、题图资源字段化和更细复盘交互。
2. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。 2. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。
3. 账号设置完整流:绑定/更换手机号基础 API 已完成;仍缺头像上传、微信/QQ 账号合并、密码/邮箱能力。 3. 账号设置完整流:绑定/更换手机号基础 API 已完成;仍缺头像上传、微信/QQ 账号合并、密码/邮箱能力。
4. 题库导出:服务端 JSON/试卷 payload、PDF/Word 二进制生成、水印、资料发布路径、权限审计、答案脱敏、每日一练基础导出和图片 ZIP 素材包已补;仍缺更精细试卷模板和后台导出操作台体验。 4. 题库导出:服务端 JSON/试卷 payload、PDF/Word 二进制生成、水印、资料发布路径、权限审计、答案脱敏、每日一练基础导出和图片 ZIP 素材包已补;仍缺更精细试卷模板和后台导出操作台体验。

View File

@@ -178,7 +178,7 @@
- H5 和小程序共用同一套业务 API client。 - H5 和小程序共用同一套业务 API client。
- 租户通过域名、小程序配置或启动参数解析。 - 租户通过域名、小程序配置或启动参数解析。
- 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。 - 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、题干/选项/解析/知识手册 RichContent 安全渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定和 CRM/分佣操作台第一版;下一步补真正 KaTeX/小程序公式方案、私有题图签名资源映射、个人中心任务系统、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。 - 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定和 CRM/分佣操作台第一版;下一步补小程序公式真机验收、题图资源字段化、个人中心任务系统、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
### 第一批页面 ### 第一批页面
@@ -205,7 +205,7 @@
5. 知识手册 5. 知识手册
- 手册入口、章节、小节、知识点阅读 - 手册入口、章节、小节、知识点阅读
- RichContent 已支持 Markdown 图片、基础表格、代码、加粗、公式占位和安全过滤;后续补搜索、高亮、目录锚点、真正公式渲染和私有资源签名映射 - RichContent 已支持 Markdown 图片、基础表格、代码、加粗、H5 KaTeX 公式渲染、私有资源 ID 短签名和安全过滤;后续补搜索、高亮、目录锚点、小程序公式真机验收和题图资源字段化
6. 分数线 6. 分数线
- 院校、专业、年份、动态字段筛选 - 院校、专业、年份、动态字段筛选
@@ -230,7 +230,7 @@
## 推荐下一步顺序 ## 推荐下一步顺序
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。 1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补真正 KaTeX/小程序公式方案、私有题图签名资源映射、个人中心任务系统、背单词更细统计、小程序支付容器、分享场景和状态管理。 2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、个人中心任务系统、背单词更细统计、小程序支付容器、分享场景和状态管理。
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。 3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。 4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。 5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。

View File

@@ -404,6 +404,11 @@ apps/taro/src/components/RichContent.tsx
apps/taro/src/components/rich-content.css apps/taro/src/components/rich-content.css
``` ```
官方参考:
- KaTeX options`https://katex.org/docs/options`
- Taro RichText`https://docs.taro.zone/en/docs/components/base/rich-text`
当前接入页面: 当前接入页面:
```text ```text
@@ -415,18 +420,18 @@ pages/student/handbook/index 知识点摘要和正文
第一版支持: 第一版支持:
- 纯文本和换行。 - 纯文本和换行。
- Markdown 图片 `![alt](https://...)`站内 `/...` 路径。 - Markdown 图片 `![alt](https://...)`站内 `/...` 路径,或私有资源引用 `asset:<uuid>``content_asset:<uuid>``/asset/<uuid>`
- 基础表格。 - 基础表格。
- `**加粗**`、行内代码和代码块。 - `**加粗**`、行内代码和代码块。
- `$...$``$$...$$``\(...\)``\[...\]` 公式占位样式 - H5 端用 KaTeX 渲染 `$...$``$$...$$``\(...\)``\[...\]`;渲染失败时降级显示公式原文
- 长题干、长单词、长公式自动换行或横向滚动。 - 长题干、长单词、长公式自动换行或横向滚动。
安全边界: 安全边界:
- 组件会剥离 `<script>``<style>` 和普通 HTML 标签,不执行后端或导入内容中的 HTML/JS。 - 组件会剥离 `<script>``<style>` 和普通 HTML 标签,不执行后端或导入内容中的 HTML/JS。
- 图片只允许 HTTPS、站内相对路径、本地开发 localhost HTTP拒绝 `javascript:``data:`、协议相对 URL 等危险来源。 - 图片只允许 HTTPS、站内相对路径、本地开发 localhost HTTP,或明确的 `content_assets` 资源 ID 引用;拒绝 `javascript:``data:`、协议相对 URL 等危险来源。
- 私有题图、资料图、PDF 和视频仍必须走后端资源台账和签名接口;不要把私有 OSS/COS/Supabase Storage URL 直接塞进题干长期渲染 - 私有题图通过 `GET /api/catalog/assets/preview?assetId=...` 申请短期签名后展示,不把私有 OSS/COS/Supabase Storage URL 长期写进题干或本地缓存
- 当前公式只是安全可读的占位样式,不等于完整 KaTeX/MathJax。后续小程序端要在同一组件内替换为跨端公式渲染或服务端公式图片方案 - 公式 HTML 只来自 KaTeX `renderToString`,不要把题库导入的原始 HTML 直接交给 `RichText`。小程序端还要真机验收 KaTeX 生成 HTML 的兼容性;如兼容性不足,保持同一 parser替换为服务端公式图片或小程序专用公式组件
### 断点续练 ### 断点续练

28
package-lock.json generated
View File

@@ -54,6 +54,9 @@
"apps/taro": { "apps/taro": {
"name": "@tiku-saas/taro", "name": "@tiku-saas/taro",
"version": "0.1.0", "version": "0.1.0",
"dependencies": {
"katex": "^0.17.0"
},
"devDependencies": { "devDependencies": {
"@babel/core": "^7.28.3", "@babel/core": "^7.28.3",
"@babel/preset-react": "^7.29.7", "@babel/preset-react": "^7.29.7",
@@ -12256,6 +12259,31 @@
"setimmediate": "^1.0.5" "setimmediate": "^1.0.5"
} }
}, },
"node_modules/katex": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz",
"integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/katex/node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",