feat: v1.3.0 差异化重构——星图点亮体系

P0 修复:
- 详情页正文渲染(content 分段),71 条知识不再空白
- 问答页去登录墙:游客每日 3 次(IP 限流),登录后每日 5 次
- 修正问答配额为真正的每日重置语义

点亮闭环:
- KnowledgeLit 模型 + 服务端判题(POST /:id/quiz,答案不下发)
- /api/knowledge/stats 星域统计 + 点亮进度 + streak + litIds
- AI 批量生成 71 条读后检测题(scripts/generate-quiz.js)

设计系统 2.0:
- 70 个统一线性图标替换全部 emoji 图标
- 主题 token mixin 化,修复 H5 暗色级联 bug,MP 跟随系统(darkmode)
- 首页星域导航(进度环)、我的页学习者档案、星域列表页重写
- 硬编码色清理,误植 map 页移除

服务端加固:
- trust proxy 真实 IP、错误信息脱敏、ask 独立限流、无效 ObjectId 404
- seo.js ESM→CJS(修复容器启动)、AI 导师"维度精灵"人格化
This commit is contained in:
2026-09-12 23:39:05 +08:00
parent cbc1a587f2
commit 2a111d21c0
104 changed files with 2776 additions and 2109 deletions
+212
View File
@@ -0,0 +1,212 @@
/**
* 生成统一线性图标集 → client/src/static/icons/
* 用法: NODE_PATH=<ai-art>/server/node_modules node scripts/gen-icons.mjs
* 规格: 48x48 (显示 22-24px @2x), 2.5px 线宽, 圆角端点
*/
import { createRequire } from 'node:module'
import { mkdirSync, writeFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const CANVAS_PATH = process.env.CANVAS_PATH ||
'/home/dev2-ubuntu/workspace/ai-art/server/node_modules/@napi-rs/canvas/index.js'
const { createCanvas } = createRequire(import.meta.url)(CANVAS_PATH)
const __dirname = dirname(fileURLToPath(import.meta.url))
const OUT = join(__dirname, '../client/src/static/icons')
mkdirSync(OUT, { recursive: true })
const BRAND = '#5b5ff8'
const GRAY = '#8e93a8'
const WHITE = '#ffffff'
const GREEN = '#22c55e'
const S = 96
const LW = 2.6
const SCALE = 2
async function make(draw, color = BRAND) {
const c = createCanvas(S, S)
const ctx = c.getContext('2d')
ctx.scale(SCALE, SCALE)
ctx.strokeStyle = color
ctx.fillStyle = color
ctx.lineWidth = LW
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
draw(ctx)
return await c.encode('png')
}
// —— 图标绘制函数(几何线条,风格统一)——
const shapes = {
// 铃铛
notice: (g) => {
g.beginPath(); g.arc(24, 18, 9, Math.PI, 0); g.stroke()
g.beginPath(); g.moveTo(15, 18); g.lineTo(13, 30); g.lineTo(35, 30); g.lineTo(33, 18); g.stroke()
g.beginPath(); g.arc(24, 34, 3, Math.PI, 0, true); g.stroke()
},
// 用户
user: (g) => {
g.beginPath(); g.arc(24, 17, 7, 0, Math.PI * 2); g.stroke()
g.beginPath(); g.arc(24, 40, 12, Math.PI * 1.15, Math.PI * 1.85); g.stroke()
},
// 搜索
search: (g) => {
g.beginPath(); g.arc(21, 21, 10, 0, Math.PI * 2); g.stroke()
g.beginPath(); g.moveTo(29, 29); g.lineTo(38, 38); g.stroke()
},
// 收藏星(空心/实心两版)
star: (g, fill) => {
const pts = []
for (let i = 0; i < 10; i++) {
const r = i % 2 === 0 ? 14 : 6
const a = -Math.PI / 2 + (i * Math.PI) / 5
pts.push([24 + r * Math.cos(a), 24 + r * Math.sin(a)])
}
g.beginPath(); pts.forEach(([x, y], i) => (i ? g.lineTo(x, y) : g.moveTo(x, y))); g.closePath()
if (fill) { g.save(); g.lineWidth = LW / 2; g.fill(); g.restore() }
g.stroke()
},
// 火焰(streak)
flame: (g) => {
g.beginPath()
g.moveTo(24, 10)
g.bezierCurveTo(30, 17, 34, 22, 34, 29)
g.arc(24, 29, 10, 0, Math.PI, false)
g.bezierCurveTo(14, 22, 18, 17, 24, 10)
g.stroke()
g.beginPath(); g.arc(24, 31, 4, 0, Math.PI * 2); g.stroke()
},
// 星球(品牌)
planet: (g) => {
g.beginPath(); g.arc(22, 22, 11, 0, Math.PI * 2); g.stroke()
g.beginPath(); g.ellipse(24, 24, 17, 6.5, -0.45, 0, Math.PI * 2); g.stroke()
},
// 对话气泡
chat: (g) => {
g.beginPath()
g.moveTo(10, 14); g.lineTo(38, 14); g.quadraticCurveTo(41, 14, 41, 17)
g.lineTo(41, 29); g.quadraticCurveTo(41, 32, 38, 32); g.lineTo(22, 32)
g.lineTo(14, 39); g.lineTo(14, 32); g.quadraticCurveTo(11, 32, 11, 29)
g.lineTo(11, 17); g.quadraticCurveTo(11, 14, 14, 14); g.closePath(); g.stroke()
},
// 分享
share: (g) => {
g.beginPath(); g.arc(34, 12, 5, 0, Math.PI * 2); g.stroke()
g.beginPath(); g.arc(34, 36, 5, 0, Math.PI * 2); g.stroke()
g.beginPath(); g.arc(14, 24, 5, 0, Math.PI * 2); g.stroke()
g.beginPath(); g.moveTo(19.5, 21.5); g.lineTo(28.5, 14.5); g.stroke()
g.beginPath(); g.moveTo(19.5, 26.5); g.lineTo(28.5, 33.5); g.stroke()
},
// 设置齿轮
settings: (g) => {
g.beginPath(); g.arc(24, 24, 6.5, 0, Math.PI * 2); g.stroke()
for (let i = 0; i < 8; i++) {
const a = (i * Math.PI) / 4
g.beginPath()
g.moveTo(24 + 10.5 * Math.cos(a), 24 + 10.5 * Math.sin(a))
g.lineTo(24 + 14.5 * Math.cos(a), 24 + 14.5 * Math.sin(a))
g.stroke()
}
},
// 反馈(对话+点)
feedback: (g) => {
g.beginPath()
g.moveTo(10, 13); g.lineTo(38, 13); g.quadraticCurveTo(41, 13, 41, 16)
g.lineTo(41, 28); g.quadraticCurveTo(41, 31, 38, 31); g.lineTo(20, 31)
g.lineTo(13, 38); g.lineTo(13, 31); g.quadraticCurveTo(10, 31, 10, 28)
g.lineTo(10, 16); g.quadraticCurveTo(10, 13, 13, 13); g.closePath(); g.stroke()
g.beginPath(); g.arc(17, 22, 1.6, 0, Math.PI * 2); g.fill()
g.beginPath(); g.arc(25, 22, 1.6, 0, Math.PI * 2); g.fill()
g.beginPath(); g.arc(33, 22, 1.6, 0, Math.PI * 2); g.fill()
},
// 奖杯
trophy: (g) => {
g.beginPath(); g.moveTo(16, 11); g.lineTo(32, 11); g.lineTo(31, 24); g.quadraticCurveTo(30, 30, 24, 30); g.quadraticCurveTo(18, 30, 17, 24); g.closePath(); g.stroke()
g.beginPath(); g.moveTo(24, 30); g.lineTo(24, 36); g.stroke()
g.beginPath(); g.moveTo(17, 38); g.lineTo(31, 38); g.stroke()
g.beginPath(); g.moveTo(16, 14); g.lineTo(10, 16); g.quadraticCurveTo(10, 25, 16, 26); g.stroke()
g.beginPath(); g.moveTo(32, 14); g.lineTo(38, 16); g.quadraticCurveTo(38, 25, 32, 26); g.stroke()
},
// 火箭(趋势)
rocket: (g) => {
g.beginPath()
g.moveTo(24, 8)
g.bezierCurveTo(31, 13, 33, 20, 33, 27)
g.lineTo(24, 33); g.lineTo(15, 27)
g.bezierCurveTo(15, 20, 17, 13, 24, 8)
g.closePath(); g.stroke()
g.beginPath(); g.arc(24, 20, 3.5, 0, Math.PI * 2); g.stroke()
g.beginPath(); g.moveTo(15, 27); g.lineTo(10, 36); g.lineTo(17, 33); g.stroke()
g.beginPath(); g.moveTo(33, 27); g.lineTo(38, 36); g.lineTo(31, 33); g.stroke()
},
// 书本(知识)
book: (g) => {
g.beginPath(); g.moveTo(24, 13); g.quadraticCurveTo(17, 9, 9, 11); g.lineTo(9, 36); g.quadraticCurveTo(17, 34, 24, 38); g.quadraticCurveTo(31, 34, 39, 36); g.lineTo(39, 11); g.quadraticCurveTo(31, 9, 24, 13); g.closePath(); g.stroke()
g.beginPath(); g.moveTo(24, 13); g.lineTo(24, 38); g.stroke()
},
// 灯泡(提示/AI)
bulb: (g) => {
g.beginPath(); g.arc(24, 19, 9.5, Math.PI * 0.95, Math.PI * 0.05, false); g.stroke()
g.beginPath(); g.moveTo(19, 27); g.lineTo(19, 33); g.lineTo(29, 33); g.lineTo(29, 27); g.stroke()
g.beginPath(); g.moveTo(20, 37); g.lineTo(28, 37); g.stroke()
},
// 勾
check: (g) => {
g.beginPath(); g.moveTo(13, 25); g.lineTo(21, 33); g.lineTo(35, 16); g.stroke()
},
// 锁
lock: (g) => {
g.beginPath(); g.moveTo(14, 23); g.lineTo(14, 37); g.lineTo(34, 37); g.lineTo(34, 23); g.closePath(); g.stroke()
g.beginPath(); g.arc(24, 23, 6.5, Math.PI, 0); g.stroke()
},
// 返回箭头
back: (g) => {
g.beginPath(); g.moveTo(27, 12); g.lineTo(18, 24); g.lineTo(27, 36); g.stroke()
},
// 右箭头
arrow: (g) => {
g.beginPath(); g.moveTo(20, 12); g.lineTo(30, 24); g.lineTo(20, 36); g.stroke()
},
// 发送
send: (g) => {
g.beginPath(); g.moveTo(10, 24); g.lineTo(38, 12); g.lineTo(30, 36); g.lineTo(24, 27); g.closePath(); g.stroke()
g.beginPath(); g.moveTo(24, 27); g.lineTo(38, 12); g.stroke()
},
// 礼物
gift: (g) => {
g.beginPath(); g.moveTo(10, 18); g.lineTo(38, 18); g.lineTo(38, 38); g.lineTo(10, 38); g.closePath(); g.stroke()
g.beginPath(); g.moveTo(24, 18); g.lineTo(24, 38); g.stroke()
g.beginPath(); g.moveTo(10, 24); g.lineTo(38, 24); g.stroke()
g.beginPath(); g.arc(19.5, 13, 4.5, Math.PI * 0.6, Math.PI * 1.7); g.stroke()
g.beginPath(); g.arc(28.5, 13, 4.5, Math.PI * 1.3, Math.PI * 0.4); g.stroke()
},
// 时钟(继续探索)
clock: (g) => {
g.beginPath(); g.arc(24, 24, 12, 0, Math.PI * 2); g.stroke()
g.beginPath(); g.moveTo(24, 16); g.lineTo(24, 24); g.lineTo(30, 28); g.stroke()
},
// 闪烁(点亮/正确反馈)
sparkle: (g) => {
g.beginPath()
g.moveTo(24, 8); g.quadraticCurveTo(26, 20, 38, 24); g.quadraticCurveTo(26, 28, 24, 40); g.quadraticCurveTo(22, 28, 10, 24); g.quadraticCurveTo(22, 20, 24, 8)
g.closePath(); g.stroke()
g.beginPath(); g.arc(37, 11, 1.8, 0, Math.PI * 2); g.fill()
g.beginPath(); g.arc(11, 35, 1.4, 0, Math.PI * 2); g.fill()
},
}
const jobs = []
for (const [name, draw] of Object.entries(shapes)) {
jobs.push([name, await make(draw, BRAND)])
jobs.push([`${name}-gray`, await make(draw, GRAY)])
jobs.push([`${name}-white`, await make(draw, WHITE)])
}
// 特例:实心星(品牌色 + 灰)
jobs.push(['star-fill', await make((g) => shapes.star(g, true), BRAND)])
jobs.push(['star-fill-gray', await make((g) => shapes.star(g, true), GRAY)])
jobs.push(['star-fill-white', await make((g) => shapes.star(g, true), WHITE)])
jobs.push(['check-green', await make(shapes.check, GREEN)])
for (const [name, buf] of jobs) writeFileSync(join(OUT, `${name}.png`), buf)
console.log(`生成 ${jobs.length} 个图标 → ${OUT}`)