diff --git a/apps/miniprogram/package.json b/apps/miniprogram/package.json index 1795976..2406556 100644 --- a/apps/miniprogram/package.json +++ b/apps/miniprogram/package.json @@ -4,6 +4,7 @@ "private": true, "description": "HMS 健康管理平台患者小程序", "scripts": { + "generate-tokens": "tsx scripts/generate-tokens.ts", "build:weapp": "taro build --type weapp", "dev:weapp": "taro build --type weapp --watch", "lint": "eslint src/", diff --git a/apps/miniprogram/scripts/__tests__/generate-tokens.test.ts b/apps/miniprogram/scripts/__tests__/generate-tokens.test.ts new file mode 100644 index 0000000..5e9d698 --- /dev/null +++ b/apps/miniprogram/scripts/__tests__/generate-tokens.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { + parseTokensFromScss, + parseScssVariables, + resolveTokenValues, +} from '../generate-tokens'; + +describe('parseScssVariables', () => { + it('extracts SCSS variable declarations', () => { + const scss = '$pri: #C4623A;\n$bg: #F5F0EB;'; + const vars = parseScssVariables(scss); + expect(vars).toEqual({ pri: '#C4623A', bg: '#F5F0EB' }); + }); + + it('ignores comments and non-variable lines', () => { + const scss = '// comment\n$pri: #C4623A;'; + const vars = parseScssVariables(scss); + expect(vars).toEqual({ pri: '#C4623A' }); + }); + + it('handles values with spaces and units', () => { + const scss = '$shadow-sm: 0 1px 4px rgba(45, 42, 38, 0.06);\n$r: 16px;'; + const vars = parseScssVariables(scss); + expect(vars['shadow-sm']).toBe('0 1px 4px rgba(45, 42, 38, 0.06)'); + expect(vars['r']).toBe('16px'); + }); +}); + +describe('parseTokensFromScss', () => { + it('extracts --tk-* CSS variables from page selector', () => { + const scss = `page { + --tk-font-h1: 28px; + --tk-font-body: 16px; +}`; + const tokens = parseTokensFromScss(scss, 'page'); + expect(tokens).toEqual({ 'font-h1': '28px', 'font-body': '16px' }); + }); + + it('extracts from class selector', () => { + const scss = `.elder-mode { + --tk-font-h1: 32px; +}`; + const tokens = parseTokensFromScss(scss, '.elder-mode'); + expect(tokens).toEqual({ 'font-h1': '32px' }); + }); + + it('captures SCSS variable references as-is for later resolution', () => { + const scss = `page { + --tk-pri: #{$pri}; + --tk-font-body: 16px; +}`; + const tokens = parseTokensFromScss(scss, 'page'); + expect(tokens['pri']).toBe('#{$pri}'); + expect(tokens['font-body']).toBe('16px'); + }); + + it('returns empty object when selector not found', () => { + const scss = `page { --tk-font-h1: 28px; }`; + const tokens = parseTokensFromScss(scss, '.nonexistent'); + expect(tokens).toEqual({}); + }); +}); + +describe('resolveTokenValues', () => { + it('replaces SCSS variable references with actual values', () => { + const tokensScss = `page { + --tk-pri: #{$pri}; +}`; + const varsScss = '$pri: #C4623A;'; + const result = resolveTokenValues(tokensScss, varsScss, 'page'); + expect(result).toEqual({ pri: '#C4623A' }); + }); + + it('handles plain values without SCSS references', () => { + const tokensScss = `page { + --tk-card-radius: 16px; + --tk-font-body: 16px; +}`; + const varsScss = '$r: 16px;'; + const result = resolveTokenValues(tokensScss, varsScss, 'page'); + expect(result['card-radius']).toBe('16px'); + expect(result['font-body']).toBe('16px'); + }); + + it('handles multiple variable references', () => { + const tokensScss = `page { + --tk-pri: #{$pri}; + --tk-pri-l: #{$pri-l}; + --tk-font-body: 16px; +}`; + const varsScss = '$pri: #C4623A;\n$pri-l: #F0DDD4;'; + const result = resolveTokenValues(tokensScss, varsScss, 'page'); + expect(result['pri']).toBe('#C4623A'); + expect(result['pri-l']).toBe('#F0DDD4'); + expect(result['font-body']).toBe('16px'); + }); +}); diff --git a/apps/miniprogram/scripts/generate-tokens.ts b/apps/miniprogram/scripts/generate-tokens.ts new file mode 100644 index 0000000..d48d520 --- /dev/null +++ b/apps/miniprogram/scripts/generate-tokens.ts @@ -0,0 +1,133 @@ +// scripts/generate-tokens.ts +// 从 src/styles/tokens.scss + variables.scss 解析 CSS 变量, +// 输出 src/styles/token-values.ts 供 JS 运行时使用 +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +interface TokenMap { + [key: string]: string; +} + +/** 解析 SCSS 文件中的 CSS 变量声明 */ +function parseTokensFromScss( + scssContent: string, + selector: string = 'page', +): TokenMap { + const tokens: TokenMap = {}; + const blockRegex = new RegExp( + `${selector.replace('.', '\\.')}\\s*\\{([\\s\\S]+?)\\n\\}`, + ); + const blockMatch = scssContent.match(blockRegex); + if (!blockMatch) return tokens; + + const varRegex = /--tk-([\w-]+)\s*:\s*([^;]+);/g; + let match: RegExpExecArray | null; + while ((match = varRegex.exec(blockMatch[1])) !== null) { + tokens[match[1]] = match[2].trim(); + } + return tokens; +} + +/** 解析 SCSS 变量(如 $pri: #C4623A)用于替换 #{...} 引用 */ +function parseScssVariables(scssContent: string): Record { + const vars: Record = {}; + const regex = /\$([\w-]+)\s*:\s*([^;]+);/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(scssContent)) !== null) { + vars[match[1]] = match[2].trim(); + } + return vars; +} + +/** 解析 tokens.scss,替换 SCSS 变量引用 */ +function resolveTokenValues( + tokensContent: string, + variablesContent: string, + selector: string, +): TokenMap { + const scssVars = parseScssVariables(variablesContent); + const rawTokens = parseTokensFromScss(tokensContent, selector); + const resolved: TokenMap = {}; + for (const [key, value] of Object.entries(rawTokens)) { + let resolvedValue = value; + resolvedValue = resolvedValue.replace( + /#\{\$([\w-]+)\}/g, + (_, varName) => scssVars[varName] || '', + ); + resolved[key] = resolvedValue; + } + return resolved; +} + +function generateTokenFile( + tokensPath: string, + variablesPath: string, + outputPath: string, +): void { + const tokensContent = readFileSync(tokensPath, 'utf-8'); + const variablesContent = readFileSync(variablesPath, 'utf-8'); + + const normalTokens = resolveTokenValues( + tokensContent, + variablesContent, + 'page', + ); + const elderTokens = resolveTokenValues( + tokensContent, + variablesContent, + '.elder-mode', + ); + const doctorTokens = resolveTokenValues( + tokensContent, + variablesContent, + '.doctor-mode', + ); + + const output = `// Auto-generated by scripts/generate-tokens.ts — DO NOT EDIT +// Generated at: ${new Date().toISOString()} + +export const TOKEN_VALUES = ${JSON.stringify(normalTokens, null, 2)} as const; + +export const ELDER_TOKEN_OVERRIDES = ${JSON.stringify(elderTokens, null, 2)} as const; + +export const DOCTOR_TOKEN_OVERRIDES = ${JSON.stringify(doctorTokens, null, 2)} as const; + +// Canvas 专用:字号(数字,单位 px) +export const CANVAS_FONT_NORMAL = { + yLabel: 10, + xLabel: 10, + tooltip: 12, + pointNormal: 3, + pointAbnormal: 5, +} as const; + +export const CANVAS_FONT_ELDER = { + yLabel: 14, + xLabel: 14, + tooltip: 16, + pointNormal: 5, + pointAbnormal: 8, +} as const; +`; + + writeFileSync(outputPath, output, 'utf-8'); + console.log(`[generate-tokens] Generated ${outputPath}`); + console.log(` Normal: ${Object.keys(normalTokens).length} tokens`); + console.log(` Elder: ${Object.keys(elderTokens).length} overrides`); + console.log(` Doctor: ${Object.keys(doctorTokens).length} overrides`); +} + +// CLI 入口 +if (process.argv[1]?.endsWith('generate-tokens.ts')) { + const root = resolve(__dirname, '..'); + generateTokenFile( + resolve(root, 'src/styles/tokens.scss'), + resolve(root, 'src/styles/variables.scss'), + resolve(root, 'src/styles/token-values.ts'), + ); +} + +export { parseTokensFromScss, parseScssVariables, resolveTokenValues, generateTokenFile }; diff --git a/apps/miniprogram/src/hooks/useCanvasTokens.ts b/apps/miniprogram/src/hooks/useCanvasTokens.ts new file mode 100644 index 0000000..188b795 --- /dev/null +++ b/apps/miniprogram/src/hooks/useCanvasTokens.ts @@ -0,0 +1,89 @@ +import { useMemo } from 'react'; +import { + TOKEN_VALUES, + ELDER_TOKEN_OVERRIDES, + DOCTOR_TOKEN_OVERRIDES, + CANVAS_FONT_NORMAL, + CANVAS_FONT_ELDER, +} from '@/styles/token-values'; +import { useUIStore } from '@/stores/ui'; +import { useAuthStore } from '@/stores/auth'; + +/** Canvas 绘制用的合并 Token */ +export interface CanvasTokens { + pri: string; + tx: string; + tx2: string; + gridColor: string; + fontH1: number; + fontBody: number; + fontBodySm: number; + fontCap: number; + yLabelFontSize: number; + xLabelFontSize: number; + tooltipFontSize: number; + pointNormalRadius: number; + pointAbnormalRadius: number; + referenceBandColor: string; + areaGradientStart: string; + areaGradientEnd: string; + lineColor: string; + abnormalColor: string; +} + +function pxToInt(val: string | undefined, fallback: number): number { + if (!val) return fallback; + return parseInt(val, 10) || fallback; +} + +/** 为 Canvas 组件提供适老化/医生端的 Token 值 */ +export function useCanvasTokens(): CanvasTokens { + const mode = useUIStore((s) => s.mode); + const isDoctor = useAuthStore((s) => s.isDoctor()); + + return useMemo(() => { + const tokens = TOKEN_VALUES as Record; + const docTokens = DOCTOR_TOKEN_OVERRIDES as Record; + const elderTokens = ELDER_TOKEN_OVERRIDES as Record; + + let pri = tokens['pri'] || '#C4623A'; + const tx = '#2D2A26'; + + if (isDoctor) { + pri = docTokens['pri'] || pri; + } + + const isElder = mode === 'elder'; + const fontSet = isElder ? CANVAS_FONT_ELDER : CANVAS_FONT_NORMAL; + + return { + pri, + tx, + tx2: isElder ? '#5A554F' : (tokens['text-secondary'] || '#78716C'), + gridColor: isElder ? '#E0DBD5' : '#F3F4F6', + fontH1: pxToInt(tokens['font-h1'], 28), + fontBody: pxToInt( + isElder ? elderTokens['font-body'] : tokens['font-body'], + isElder ? 22 : 16, + ), + fontBodySm: pxToInt( + isElder ? elderTokens['font-body-sm'] : tokens['font-body-sm'], + isElder ? 19 : 14, + ), + fontCap: pxToInt( + isElder ? elderTokens['font-cap'] : tokens['font-cap'], + isElder ? 18 : 13, + ), + yLabelFontSize: fontSet.yLabel, + xLabelFontSize: fontSet.xLabel, + tooltipFontSize: fontSet.tooltip, + pointNormalRadius: fontSet.pointNormal, + pointAbnormalRadius: fontSet.pointAbnormal, + referenceBandColor: isElder ? 'rgba(5,150,105,0.15)' : 'rgba(5,150,105,0.08)', + areaGradientStart: isElder ? 'rgba(8,145,178,0.20)' : 'rgba(8,145,178,0.15)', + areaGradientEnd: 'rgba(8,145,178,0.01)', + lineColor: '#0891B2', + abnormalColor: '#DC2626', + }; + }, [mode, isDoctor]); +} diff --git a/apps/miniprogram/src/styles/token-values.ts b/apps/miniprogram/src/styles/token-values.ts new file mode 100644 index 0000000..9352b99 --- /dev/null +++ b/apps/miniprogram/src/styles/token-values.ts @@ -0,0 +1,109 @@ +// Auto-generated by scripts/generate-tokens.ts — DO NOT EDIT +// Generated at: 2026-05-22T00:11:18.815Z + +export const TOKEN_VALUES = { + "pri": "#C4623A", + "pri-l": "#F0DDD4", + "pri-d": "#8B3E1F", + "shadow-btn": "0 4px 16px rgba(196, 98, 58, 0.3)", + "shadow-tab": "0 2px 8px rgba(196, 98, 58, 0.25)", + "font-display": "72px", + "font-hero": "48px", + "font-h1": "28px", + "font-h2": "22px", + "font-body-lg": "18px", + "font-body": "16px", + "font-body-sm": "14px", + "font-num": "30px", + "font-num-lg": "34px", + "font-cap": "13px", + "font-nav": "18px", + "font-micro": "11px", + "line-height": "1.5", + "touch-min": "48px", + "btn-primary-h": "52px", + "text-secondary": "#78716C", + "card-bg": "#FFFFFF", + "card-padding": "20px", + "card-padding-sm": "16px", + "card-padding-lg": "28px", + "card-radius": "16px", + "gap-2xs": "4px", + "gap-xs": "8px", + "gap-sm": "12px", + "gap-md": "16px", + "section-gap": "20px", + "gap-lg": "24px", + "gap-xl": "32px", + "gap-2xl": "48px", + "page-padding": "20px", + "input-height": "56px", + "tabbar-space": "100px", + "touch-feedback-opacity": "0.85", + "tag-font-size": "11px", + "tag-padding-v": "3px", + "tag-padding-h": "8px" +} as const; + +export const ELDER_TOKEN_OVERRIDES = { + "font-display": "80px", + "font-hero": "56px", + "font-h1": "32px", + "font-h2": "25px", + "font-body-lg": "22px", + "font-body": "22px", + "font-body-sm": "19px", + "font-num": "34px", + "font-num-lg": "40px", + "font-cap": "18px", + "font-nav": "22px", + "font-micro": "17px", + "line-height": "1.7", + "touch-min": "56px", + "btn-primary-h": "60px", + "text-secondary": "#5A554F", + "card-padding": "28px", + "card-padding-sm": "20px", + "card-padding-lg": "36px", + "card-radius": "20px", + "gap-2xs": "6px", + "gap-xs": "12px", + "gap-sm": "16px", + "gap-md": "20px", + "section-gap": "28px", + "gap-lg": "32px", + "gap-xl": "40px", + "gap-2xl": "56px", + "page-padding": "28px", + "input-height": "64px", + "tabbar-space": "120px", + "touch-feedback-opacity": "0.8", + "tag-font-size": "13px", + "tag-padding-v": "5px", + "tag-padding-h": "12px" +} as const; + +export const DOCTOR_TOKEN_OVERRIDES = { + "pri": "#3A6B8C", + "pri-l": "#D4E5F0", + "pri-d": "#2A4F6A", + "shadow-btn": "0 4px 16px rgba(58, 107, 140, 0.3)", + "shadow-tab": "0 2px 8px rgba(58, 107, 140, 0.25)" +} as const; + +// Canvas 专用:字号(数字,单位 px) +export const CANVAS_FONT_NORMAL = { + yLabel: 10, + xLabel: 10, + tooltip: 12, + pointNormal: 3, + pointAbnormal: 5, +} as const; + +export const CANVAS_FONT_ELDER = { + yLabel: 14, + xLabel: 14, + tooltip: 16, + pointNormal: 5, + pointAbnormal: 8, +} as const; diff --git a/apps/miniprogram/vitest.config.ts b/apps/miniprogram/vitest.config.ts index 5773965..a63deed 100644 --- a/apps/miniprogram/vitest.config.ts +++ b/apps/miniprogram/vitest.config.ts @@ -4,7 +4,7 @@ import path from 'path'; export default defineConfig({ test: { environment: 'node', - include: ['__tests__/**/*.test.ts'], + include: ['__tests__/**/*.test.ts', 'scripts/__tests__/**/*.test.ts'], globals: true, setupFiles: ['__tests__/setup.ts'], },