From f3f60286335d60eeffd76ac6628b2972f2d7b08a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 1 Jul 2026 05:35:07 +0800 Subject: [PATCH] docs: add taro visual guardrails --- README.md | 1 + apps/taro/README.md | 10 + docs/refactor/frontend-handoff-index.md | 11 +- .../taro-production-integration-checklist.md | 1 + docs/refactor/taro-visual-language.md | 71 ++++++ package.json | 3 +- scripts/taro-visual-guardrails-test.js | 61 +++++ scripts/taro-visual-guardrails.js | 228 ++++++++++++++++++ 8 files changed, 381 insertions(+), 5 deletions(-) create mode 100644 docs/refactor/taro-visual-language.md create mode 100644 scripts/taro-visual-guardrails-test.js create mode 100644 scripts/taro-visual-guardrails.js diff --git a/README.md b/README.md index f2d5b9da..aee3d51e 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ - `docs/refactor/legacy-feature-gap-matrix.md` - `docs/refactor/supabase-frontend-access-strategy.md` - `docs/refactor/taro-frontend-integration.md` +- `docs/refactor/taro-visual-language.md` - `docs/refactor/taro-production-integration-checklist.md` - `docs/refactor/multitenant-auth-security-contract.md` - `docs/refactor/next-development-todo.md` diff --git a/apps/taro/README.md b/apps/taro/README.md index 02033bb2..5c0a1abe 100644 --- a/apps/taro/README.md +++ b/apps/taro/README.md @@ -106,6 +106,16 @@ TARO_APP_TENANT_CODE=<可选,小程序/预览环境使用> 禁止把 service role、数据库连接串、支付私钥、对象存储密钥放进 Taro 构建环境。 +## 视觉规范 + +学生端、租户后台和平台后台的视觉语言见 `docs/refactor/taro-visual-language.md`。旧题库小程序/旧 Web 只作为学习流程、页面状态和运营信息密度参考,不继承旧 PocketBase 直连、旧鉴权或旧字段模型。修改 CSS 后运行: + +```bash +npm run guard:taro:visual +``` + +该守卫会阻断大圆角卡片、视口字体、负字距、装饰性渐变/模糊背景和未登记线性渐变,避免 H5 首发页面视觉漂移。 + ## 接入原则 - H5 可用 Supabase client 管理 Auth session。 diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 625de62d..4663831c 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -20,13 +20,15 @@ - Taro 启动、租户解析、请求封装、页面/API 映射、跨端注意事项。 7. `docs/refactor/taro-h5-deployment.md` - H5 三域名部署、`runtime-config.json`、Nginx history fallback、缓存、CSP 和 CORS 边界。 -8. `docs/refactor/taro-production-integration-checklist.md` +8. `docs/refactor/taro-visual-language.md` + - 学生端、租户后台、平台后台的视觉语言、旧题库参考边界、8px 圆角/工具型页面规范和 CSS 守卫。 +9. `docs/refactor/taro-production-integration-checklist.md` - 正式接 Supabase Auth、三套 H5、真实 provider、runtime-config 和上线门禁时逐项对照。 -9. `docs/refactor/multitenant-auth-security-contract.md` +10. `docs/refactor/multitenant-auth-security-contract.md` - 多租户、鉴权、权限、资源签名和生产安全红线。 -10. `docs/refactor/content-import-contract.md` +11. `docs/refactor/content-import-contract.md` - 后台内容导入、题目 JSON、单词、知识手册、分数线、视频的后端校验契约。 -11. `docs/refactor/production-launch-evidence.template.json` +12. `docs/refactor/production-launch-evidence.template.json` - 上线前证据文件模板;真实生产验收结果填入 `production-launch-evidence.json` 后运行 `npm run launch:gate`,该真实证据文件不入 Git。 ## 当前可进入的前端工作 @@ -46,6 +48,7 @@ - 新增、删除或重命名 Taro 页面时必须同步 `apps/taro/src/app.config.ts`、启动页跳转、H5 静态烟测入口和本文页面清单,并运行 `node scripts/taro-route-contract-test.js`。该脚本会阻断“页面文件存在但未注册”“路由注册但文件缺失”“启动页或烟测跳到不存在页面”的漂移。 - 新增或修改 Taro API service 时必须运行 `node scripts/taro-api-contract-test.js`。该脚本会比对前端 `apiRequest('/api/...')` 与后端 `RouteDefinition[]` 注册表,阻断调用不存在的接口、method 写错或绕过统一 API client;动态路由只能通过脚本 allowlist 明确声明。 - 修改学生端、租户后台或平台后台关键页面时必须运行 `node scripts/taro-persona-contract-test.js`。该脚本按学生刷题/会员订单/错题收藏、租户学生运营/内容导入/营销财务/品牌权限、平台租户/账务/公共题库/员工权限三类角色旅程检查路由和服务调用,避免前端样式重做时误删核心业务入口。 +- 修改 Taro CSS 或新建页面样式时必须运行 `npm run guard:taro:visual`。该脚本会阻断大圆角卡片、视口字体、负字距、装饰性径向渐变/模糊背景和未登记线性渐变;视觉规范见 `docs/refactor/taro-visual-language.md`。 - H5 构建完成后必须运行 `npm run smoke:taro:h5:interaction` 做真实浏览器点击验证。它会覆盖学生首页到题库练习、答题、收藏、会员收银台下单/支付参数/订单状态,租户后台工作台到题库内容/财务运营,以及平台后台工作台到租户管理/账务中心;如果 Chrome/Edge 缺失,可设置 `TARO_H5_SMOKE_BROWSER` 指向 Chromium 浏览器。 - H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session;微信小程序端先验证运行时兼容性,业务数据默认仍走 `apps/api`。 - H5 生产部署优先用每个静态目录自己的 `runtime-config.json` 配置 `apiBaseUrl`、`supabaseUrl`、`supabasePublishableKey`、`tenantCode`;不要为了换域名重打包,也不要把任何 service role、数据库、支付、短信、对象存储密钥放进该文件。 diff --git a/docs/refactor/taro-production-integration-checklist.md b/docs/refactor/taro-production-integration-checklist.md index be6b6a2e..fc36bc65 100644 --- a/docs/refactor/taro-production-integration-checklist.md +++ b/docs/refactor/taro-production-integration-checklist.md @@ -60,6 +60,7 @@ ```bash npm run check:taro npm run test:readiness +npm run guard:taro:visual npm run smoke:taro:h5 npm run smoke:taro:h5:interaction node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config diff --git a/docs/refactor/taro-visual-language.md b/docs/refactor/taro-visual-language.md new file mode 100644 index 00000000..0c5edb02 --- /dev/null +++ b/docs/refactor/taro-visual-language.md @@ -0,0 +1,71 @@ +# Taro H5 视觉语言规范 + +更新时间:2026-07-01 + +这份规范用于约束 `apps/taro` 的学生端、租户后台和平台后台。旧题库前端参考目录为 `F:\project\参考\旧题库小程序前端文件`,只作为页面状态、学习流程、后台信息密度和微信平台交互的参考,不继承旧 PocketBase 直连、旧鉴权、旧字段模型或旧技术栈。 + +## 总原则 + +- Taro 是前端体验层:负责路由、布局、交互、公开运行时配置、Supabase Auth session 和统一 API client;复杂业务判断、权限、价格、权益、导入、支付、CRM 和对象存储签名以后端为准。 +- Web 首发优先做成可反复使用的工具型产品,不做营销落地页式的大图 Hero、装饰卡片堆叠、渐变背景和过度留白。 +- 学生端要接近旧题库小程序的学习节奏:入口清楚、题目阅读专注、答题卡稳定、错题/收藏/报告路径短。 +- 租户后台和平台后台要接近运营工具:信息密度高、层级清楚、操作按钮固定、列表和指标可扫读。 + +## 视觉基线 + +| 项 | 规范 | +| --- | --- | +| 页面背景 | `#f6f8fb`、`#f8fafc`、`#f7f9fc` 这类浅灰蓝工作台背景 | +| 主文字 | `#0f172a`、`#111827`、`#172033` | +| 次级文字 | `#64748b`、`#475569` | +| 主色 | `#2563eb` / `#1d4ed8`,租户发布主题后可由后端安全 token 覆盖 | +| 成功/强调 | `#10b981`、`#16a34a`,只用于进度、完成、兑换等状态 | +| 危险 | `#be123c`、`#dc2626`,只用于退款、驳回、删除、失败 | +| 圆角 | 卡片、按钮、输入框、面板统一不超过 `8px`;小进度条、chip、badge 可用 `999px` | +| 字体 | 固定 px/rpx 风格,不使用 `vw/vh/vmin/vmax` 缩放字体 | +| 字距 | 不使用负 `letter-spacing` | +| 装饰 | 禁止径向渐变、模糊光斑、装饰性大渐变背景;头像预设和水印纹理是当前允许例外 | + +## 页面结构 + +- 学生端页面使用 `student-page`、`student-topbar`、`section-block`、`list-stack`、`quiet-panel` 等现有类名体系。 +- 租户后台使用 `admin-page`、`admin-shell`、`admin-header`、`admin-metric`、`admin-row`、`admin-button`。 +- 平台后台使用 `platform-page`、`platform-shell`、`platform-header`、`platform-metric`、`platform-row`、`platform-button`。 +- 新页面优先复用这些体系,不要为同类按钮、卡片、列表重新发明一套视觉类名。 +- 页面区域不要做卡片套卡片。重复列表项、工具面板、表单组可以是卡片;页面大 section 应保持无外框或全宽分区。 + +## 学生端重点 + +- 首页首屏突出当前租户品牌、地区、SVIP 状态、题库入口、背单词、知识手册、分数线、资料和个人中心,不默认展示排行榜。 +- 刷题页优先保障题干/选项/解析可读,题目图片区、公式、阅读理解/案例分析子题不要挤压答题按钮。 +- 错题本和收藏夹入口要保持短路径,复习入口必须走后端组卷。 +- 资料和视频必须显示后端返回的短签名、过期时间、水印 traceId 或播放授权状态,不拼接私有 URL。 +- 学生头像只提供男女预设,不做上传、裁剪或第三方头像同步。 + +## 后台重点 + +- 租户后台首页以权限驱动模块入口为主,按钮和 tab 可横向滚动,避免小屏换行导致操作错位。 +- 内容导入、财务、分佣、CRM、学生运营这些页面要保留结果/错误/状态区域,不能只做提交表单。 +- 平台后台要明显区分平台全局操作和租户操作,账务、授权、员工权限变更必须有二次确认或状态反馈。 +- 前端菜单隐藏只做体验优化,所有权限以后端 permission keys、RLS 和审计为准。 + +## 自动守卫 + +新增或重做 Taro 样式后运行: + +```bash +npm run test:readiness +node scripts/taro-visual-guardrails.js +npm run smoke:taro:h5 +npm run smoke:taro:h5:interaction +``` + +`taro-visual-guardrails` 会扫描 `apps/taro/src/**/*.css`,阻断: + +- 卡片、按钮、面板、输入框出现超过 `8px` 的圆角。 +- 进度条/chip/badge/pill 之外滥用 `999px` 大圆角。 +- 使用 `vw/vh/vmin/vmax` 做字体大小。 +- 负 `letter-spacing`。 +- 装饰性径向渐变、模糊背景和未登记线性渐变。 + +如确实需要新增例外,必须先说明 UI 目的,再更新 `scripts/taro-visual-guardrails.js` 的白名单和这份文档。 diff --git a/package.json b/package.json index ea3e67ca..9f264b28 100644 --- a/package.json +++ b/package.json @@ -58,13 +58,14 @@ "test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js", "test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js", "test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js", - "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js", + "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node scripts/taro-visual-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js", "test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js", "test:launch-gate": "node scripts/production-launch-gate-test.js", "smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js", "smoke:taro:h5": "node scripts/taro-h5-static-smoke.js", "smoke:taro:h5:interaction": "node scripts/taro-h5-interaction-smoke.js", "manifest:taro:h5": "node scripts/taro-h5-release-manifest.js", + "guard:taro:visual": "node scripts/taro-visual-guardrails.js", "test:pb:dry-run": "node scripts/pb-dry-run-report-test.js", "test:pb:sqlite-export": "node scripts/pb-sqlite-export-test.js", "test:perf:summary": "node scripts/performance-summary-test.js", diff --git a/scripts/taro-visual-guardrails-test.js b/scripts/taro-visual-guardrails-test.js new file mode 100644 index 00000000..a7469c2d --- /dev/null +++ b/scripts/taro-visual-guardrails-test.js @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const repoRoot = process.cwd(); +const scriptPath = path.join(repoRoot, 'scripts', 'taro-visual-guardrails.js'); + +function run(args = []) { + return spawnSync(process.execPath, [scriptPath, '--json', ...args], { + cwd: repoRoot, + encoding: 'utf8', + env: { + PATH: process.env.PATH || '', + Path: process.env.Path || '', + SystemRoot: process.env.SystemRoot || '', + ComSpec: process.env.ComSpec || '', + TEMP: process.env.TEMP || os.tmpdir(), + TMP: process.env.TMP || os.tmpdir(), + }, + }); +} + +function parseJson(result) { + return JSON.parse(result.stdout || '{}'); +} + +const current = run(); +assert.equal(current.status, 0, current.stderr || current.stdout); +const currentPayload = parseJson(current); +assert.equal(currentPayload.summary.fail, 0); +assert.ok(currentPayload.summary.scannedFiles >= 6, 'current Taro CSS files should be scanned'); +assert.ok(currentPayload.checks.some(item => item.id === 'css.border_radius' && item.status === 'pass')); +assert.ok(currentPayload.checks.some(item => item.id === 'css.decorative_effects' && item.status === 'pass')); + +const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taro-visual-guardrails-')); +fs.writeFileSync(path.join(fixtureRoot, 'bad.css'), ` +.hero-card { + border-radius: 24px; + font-size: 6vw; + letter-spacing: -0.04em; + background: radial-gradient(circle, #fff, #dbeafe); + filter: blur(20px); +} +.random-shape { + border-radius: 999px; +} +`, 'utf8'); + +const bad = run(['--root', fixtureRoot]); +fs.rmSync(fixtureRoot, { recursive: true, force: true }); +assert.notEqual(bad.status, 0, 'fixture with visual violations should fail'); +const badPayload = parseJson(bad); +assert.ok(badPayload.checks.some(item => item.id === 'css.border_radius' && item.status === 'fail')); +assert.ok(badPayload.checks.some(item => item.id === 'css.viewport_font_size' && item.status === 'fail')); +assert.ok(badPayload.checks.some(item => item.id === 'css.letter_spacing' && item.status === 'fail')); +assert.ok(badPayload.checks.some(item => item.id === 'css.decorative_effects' && item.status === 'fail')); +assert.ok(badPayload.checks.some(item => item.id === 'css.pill_radius_scope' && item.status === 'fail')); + +console.log('[PASS] Taro visual guardrails'); diff --git a/scripts/taro-visual-guardrails.js b/scripts/taro-visual-guardrails.js new file mode 100644 index 00000000..d907774a --- /dev/null +++ b/scripts/taro-visual-guardrails.js @@ -0,0 +1,228 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const repoRoot = process.cwd(); +const defaultRoot = path.join(repoRoot, 'apps', 'taro', 'src'); + +const allowedPillRadiusSelectors = [ + 'progress', + 'track', + 'fill', + 'chip', + 'badge', + 'tag', + 'pill', + 'watermark', +]; + +const allowedLinearGradientSelectors = [ + 'profile-avatar', + 'avatar-choice-icon', + 'asset-watermark', +]; + +function parseArgs(argv) { + const options = { + root: defaultRoot, + json: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--json') options.json = true; + else if (arg === '--root') { + options.root = path.resolve(repoRoot, argv[index + 1] || ''); + index += 1; + } else if (arg.startsWith('--root=')) { + options.root = path.resolve(repoRoot, arg.slice('--root='.length)); + } + } + return options; +} + +function normalizeSlashes(value) { + return value.replace(/\\/g, '/'); +} + +function relative(filePath) { + return normalizeSlashes(path.relative(repoRoot, filePath)); +} + +function readText(filePath) { + return fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); +} + +function walkCssFiles(dir) { + if (!fs.existsSync(dir)) return []; + const result = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) result.push(...walkCssFiles(entryPath)); + else if (entry.name.endsWith('.css')) result.push(entryPath); + } + return result.sort((a, b) => relative(a).localeCompare(relative(b))); +} + +function createCollector() { + const checks = []; + function push(status, id, message, details = {}) { + checks.push({ status, id, message, details }); + } + return { + checks, + pass: (id, message, details) => push('pass', id, message, details), + fail: (id, message, details) => push('fail', id, message, details), + }; +} + +function summarize(checks, scannedFiles) { + return checks.reduce( + (summary, item) => { + summary[item.status] += 1; + return summary; + }, + { fail: 0, pass: 0, scannedFiles }, + ); +} + +function lineNumber(text, index) { + return text.slice(0, index).split('\n').length; +} + +function selectorBefore(text, index) { + const before = text.slice(0, index); + const open = before.lastIndexOf('{'); + const close = before.lastIndexOf('}'); + if (open <= close) return ''; + const selectorStart = before.lastIndexOf('}', open - 1); + return before.slice(selectorStart + 1, open).trim().replace(/\s+/g, ' '); +} + +function selectorMatches(selector, tokens) { + const normalized = selector.toLowerCase(); + return tokens.some(token => normalized.includes(token)); +} + +function collectBorderRadiusViolations(files) { + const violations = []; + const radiusPattern = /border-radius\s*:\s*([^;]+);/gi; + for (const filePath of files) { + const text = readText(filePath); + for (const match of text.matchAll(radiusPattern)) { + const value = match[1].trim(); + const selector = selectorBefore(text, match.index || 0); + if (/999px/.test(value)) { + if (!selectorMatches(selector, allowedPillRadiusSelectors)) { + violations.push({ + file: relative(filePath), + line: lineNumber(text, match.index || 0), + selector, + value, + reason: '999px radius is reserved for progress bars, chips, badges and similar pill controls.', + }); + } + continue; + } + const pxValues = [...value.matchAll(/([0-9]+(?:\.[0-9]+)?)px/g)].map(item => Number(item[1])); + const tooLarge = pxValues.filter(item => item > 8); + if (tooLarge.length) { + violations.push({ + file: relative(filePath), + line: lineNumber(text, match.index || 0), + selector, + value, + reason: 'Cards, panels, inputs and buttons should stay at 8px radius or less.', + }); + } + } + } + return violations; +} + +function collectPatternViolations(files, pattern, reason, allow = () => false) { + const violations = []; + for (const filePath of files) { + const text = readText(filePath); + for (const match of text.matchAll(pattern)) { + const selector = selectorBefore(text, match.index || 0); + if (allow(selector, match, text, filePath)) continue; + violations.push({ + file: relative(filePath), + line: lineNumber(text, match.index || 0), + selector, + value: match[0].trim(), + reason, + }); + } + } + return violations; +} + +function emitCheck(collector, id, message, violations) { + if (violations.length) collector.fail(id, message, { violations: violations.slice(0, 30), count: violations.length }); + else collector.pass(id, message); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const files = walkCssFiles(options.root); + const collector = createCollector(); + + emitCheck( + collector, + 'css.border_radius', + 'Taro CSS uses 8px-or-less radii for cards, panels, buttons and inputs', + collectBorderRadiusViolations(files), + ); + emitCheck( + collector, + 'css.viewport_font_size', + 'Taro CSS does not scale font-size with viewport units', + collectPatternViolations(files, /font-size\s*:\s*[^;]*(?:vw|vh|vmin|vmax)[^;]*;/gi, 'Use fixed px/rpx-like sizing, not viewport-scaled font sizes.'), + ); + emitCheck( + collector, + 'css.letter_spacing', + 'Taro CSS does not use negative letter spacing', + collectPatternViolations(files, /letter-spacing\s*:\s*-\s*[^;]+;/gi, 'Letter spacing must be 0 or positive.'), + ); + emitCheck( + collector, + 'css.decorative_effects', + 'Taro CSS avoids decorative radial gradients, blur backgrounds and unapproved linear gradients', + [ + ...collectPatternViolations(files, /radial-gradient\s*\(/gi, 'Do not add decorative radial/orb gradients.'), + ...collectPatternViolations(files, /filter\s*:\s*[^;]*blur\s*\(/gi, 'Do not add blurred decorative backgrounds.'), + ...collectPatternViolations( + files, + /linear-gradient\s*\(/gi, + 'Linear gradients require a documented UI purpose and scoped selector.', + selector => selectorMatches(selector, allowedLinearGradientSelectors), + ), + ], + ); + emitCheck( + collector, + 'css.pill_radius_scope', + '999px radius is scoped to progress bars, chips, badges and pill controls', + collectBorderRadiusViolations(files).filter(item => item.value.includes('999px')), + ); + + const payload = { + summary: summarize(collector.checks, files.length), + root: relative(options.root), + checks: collector.checks, + }; + if (options.json) { + console.log(JSON.stringify(payload, null, 2)); + } else { + console.log(`Taro visual guardrails: ${payload.summary.fail} fail(s), ${payload.summary.pass} pass(es), ${payload.summary.scannedFiles} file(s) scanned`); + for (const item of collector.checks) { + const marker = item.status === 'fail' ? 'FAIL' : 'PASS'; + console.log(`[${marker}] ${item.id}: ${item.message}`); + } + } + if (payload.summary.fail > 0) process.exitCode = 1; +} + +main();