Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a8089a96d | |||
| 208d0cda12 | |||
| 4aec0ef6e9 | |||
| 2979bdd2a3 | |||
| d22e02fa2e | |||
| 7b693b0f63 |
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
# dudu pre-commit 闸:设计系统单源强制(启用:git config core.hooksPath .githooks)
|
||||
# 动 design/ → 真相源自检 + codegen 零 diff;动各端 UI 代码/官网 → 代码侧单源。
|
||||
set -e
|
||||
|
||||
CHANGED=$(git diff --cached --name-only)
|
||||
|
||||
if echo "$CHANGED" | grep -q "^design/"; then
|
||||
echo "[pre-commit] design/ 变更 → check-ds + export-tokens --check"
|
||||
node design-pipeline/check-ds.mjs
|
||||
node design-pipeline/export-tokens.mjs --check
|
||||
fi
|
||||
|
||||
if echo "$CHANGED" | grep -qE "^(desktop/src/|ios/.*\.swift|android/.*\.kt|web/)"; then
|
||||
echo "[pre-commit] 端代码变更 → check-code"
|
||||
node design-pipeline/check-code.mjs
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -19,12 +19,16 @@ jobs:
|
||||
go vet ./...
|
||||
go test ./...
|
||||
|
||||
design-tokens:
|
||||
design-system:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Check generated tokens up to date
|
||||
- name: 真相源自检(check-ds)
|
||||
run: node design-pipeline/check-ds.mjs
|
||||
- name: 令牌产物零 diff(export-tokens --check)
|
||||
run: node design-pipeline/export-tokens.mjs --check
|
||||
- name: 代码侧颜色/图标单源(check-code)
|
||||
run: node design-pipeline/check-code.mjs
|
||||
|
||||
@@ -10,8 +10,8 @@ server/ Go 后端(gin + gorilla/websocket + PostgreSQL + Redis)
|
||||
desktop/ 桌面端 Tauri 2(Mac + Windows)
|
||||
android/ Kotlin:主 App + IME Service
|
||||
ios/ Swift:主 App + Keyboard Extension
|
||||
design/ 官方设计系统(tokens / React 组件 / UI kits)—— 界面唯一真相源
|
||||
design-pipeline/ 令牌导出管线:tokens.css → DuduTheme.swift / DuduTheme.kt
|
||||
design/ 官方设计系统(tokens / 组件 / 图标 / UI kits,登记簿 index.html)—— 界面唯一真相源
|
||||
design-pipeline/ 令牌导出管线 + 防漂移闸(export-tokens / check-ds / check-code)
|
||||
doc/ 设计文档(HTML)
|
||||
todo/ 项目 TODO(todo.html 看板,经 /todo skill 管理)
|
||||
```
|
||||
@@ -35,13 +35,24 @@ go run ./cmd/server # 默认 :8080,ASR_PROVIDER=mock
|
||||
go test ./...
|
||||
```
|
||||
|
||||
令牌导出(design/tokens 变更后执行并提交产物):
|
||||
令牌导出(design/tokens 变更后执行并提交产物;直写 ios/android/web 消费位置):
|
||||
|
||||
```bash
|
||||
node design-pipeline/export-tokens.mjs # 生成
|
||||
node design-pipeline/export-tokens.mjs --check # CI 校验
|
||||
```
|
||||
|
||||
设计系统防漂移闸(首次 clone 后启用 pre-commit):
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .githooks # 启用提交前闸(一次性)
|
||||
node design-pipeline/check-ds.mjs # 真相源自检(design/ 自身 7 道)
|
||||
node design-pipeline/check-code.mjs # 代码侧颜色/图标单源(四端)
|
||||
node design/serve.mjs # 登记簿与原型评审(http://localhost:5180)
|
||||
```
|
||||
|
||||
全景说明见 `doc/frontend-overview.html`;改 UI 前先读 `design/index.html`(登记簿)。
|
||||
|
||||
## 关键约定
|
||||
|
||||
- 协议改动只改 `server/pkg/protocol`,各端对照实现
|
||||
|
||||
@@ -23,7 +23,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -42,8 +41,6 @@ import app.dudu.wechat.wechatAuth
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** 微信绿——全局唯一例外色(仅登录按钮,见设计核查清单)。 */
|
||||
private val WechatGreen = Color(0xFF07C160)
|
||||
private val WechatGreenPress = Color(0xFF06AD56)
|
||||
|
||||
/**
|
||||
* 登录页(13B):居中字标 + 底部微信绿胶囊按钮。
|
||||
@@ -121,11 +118,11 @@ fun LoginScreen(onLoggedIn: () -> Unit, onBack: () -> Unit) {
|
||||
.height(56.dp)
|
||||
.alpha(if (loading) 0.6f else 1f)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (pressed) WechatGreenPress else WechatGreen)
|
||||
.background(if (pressed) p.brandWechatPress else p.brandWechat)
|
||||
.clickable(interactionSource = src, indication = null, enabled = !loading) { doLogin() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
DText(if (loading) "登录中" else "微信一键登录", 16.sp, Color.White, weight = FontWeight.Medium)
|
||||
DText(if (loading) "登录中" else "微信一键登录", 16.sp, p.textOnAccent, weight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
@@ -38,6 +38,8 @@ data class DuduPalette(
|
||||
val overlayText: Color,
|
||||
val overlayText2: Color,
|
||||
val overlayText3: Color,
|
||||
val brandWechat: Color,
|
||||
val brandWechatPress: Color,
|
||||
)
|
||||
|
||||
val DuduLightPalette = DuduPalette(
|
||||
@@ -66,6 +68,8 @@ val DuduLightPalette = DuduPalette(
|
||||
overlayText = DuduTheme.Light.overlayText,
|
||||
overlayText2 = DuduTheme.Light.overlayText2,
|
||||
overlayText3 = DuduTheme.Light.overlayText3,
|
||||
brandWechat = DuduTheme.Light.brandWechat,
|
||||
brandWechatPress = DuduTheme.Light.brandWechatPress,
|
||||
)
|
||||
|
||||
val DuduDarkPalette = DuduPalette(
|
||||
@@ -94,6 +98,8 @@ val DuduDarkPalette = DuduPalette(
|
||||
overlayText = DuduTheme.Dark.overlayText,
|
||||
overlayText2 = DuduTheme.Dark.overlayText2,
|
||||
overlayText3 = DuduTheme.Dark.overlayText3,
|
||||
brandWechat = DuduTheme.Dark.brandWechat,
|
||||
brandWechatPress = DuduTheme.Dark.brandWechatPress,
|
||||
)
|
||||
|
||||
val LocalDuduPalette = staticCompositionLocalOf { DuduLightPalette }
|
||||
|
||||
@@ -29,6 +29,8 @@ object DuduTheme {
|
||||
val blue900 = Color(0xFF1E2B6B)
|
||||
val border1 = Color(0xFFE4E6EB)
|
||||
val border2 = Color(0xFFD2D5DD)
|
||||
val brandWechat = Color(0xFF07C160)
|
||||
val brandWechatPress = Color(0xFF06AD56)
|
||||
val danger = Color(0xFFDC2626)
|
||||
val dangerSoft = Color(0xFFFEE2E2)
|
||||
val gray0 = Color(0xFFFFFFFF)
|
||||
@@ -50,6 +52,7 @@ object DuduTheme {
|
||||
val overlayText = Color(0xFFF2F4FA)
|
||||
val overlayText2 = Color(0xFF9AA3BD)
|
||||
val overlayText3 = Color(0xFF646E8C)
|
||||
val overlayWave = Color(0xFF7C9BFF)
|
||||
val positive = Color(0xFF16A34A)
|
||||
val positiveSoft = Color(0xFFDCFCE7)
|
||||
val red100 = Color(0xFFFEE2E2)
|
||||
@@ -87,6 +90,8 @@ object DuduTheme {
|
||||
val blue900 = Color(0xFF1E2B6B)
|
||||
val border1 = Color(0xFF272C38)
|
||||
val border2 = Color(0xFF343A49)
|
||||
val brandWechat = Color(0xFF07C160)
|
||||
val brandWechatPress = Color(0xFF06AD56)
|
||||
val danger = Color(0xFFF05B5B)
|
||||
val dangerSoft = Color(0x29F05B5B)
|
||||
val gray0 = Color(0xFFFFFFFF)
|
||||
@@ -108,6 +113,7 @@ object DuduTheme {
|
||||
val overlayText = Color(0xFFF2F4FA)
|
||||
val overlayText2 = Color(0xFF9AA3BD)
|
||||
val overlayText3 = Color(0xFF646E8C)
|
||||
val overlayWave = Color(0xFF7C9BFF)
|
||||
val positive = Color(0xFF34C46A)
|
||||
val positiveSoft = Color(0x2934C46A)
|
||||
val red100 = Color(0xFFFEE2E2)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-code.mjs — 代码侧颜色/图标单源闸(分端扫描,零依赖,Node >= 18)
|
||||
*
|
||||
* 各端规则(违规 exit 1;行内 `ds-ignore: 理由` 豁免):
|
||||
* desktop/src(jsx/css/js):禁字面色(hex/rgb/hsl),须 var(--*);
|
||||
* 禁内联 <path>(图标须经 @dudu/design 的 Icon/icons.js)
|
||||
* ios(swift) :禁 Color(red:/UIColor(/Color(0x/#colorLiteral,须 DuduTheme;
|
||||
* systemName 用的 SF Symbol 须 ∈ design/icon-map.json 的 ios 列
|
||||
* android(kt) :禁 Color(0x,须 DuduTheme(生成文件本身豁免)
|
||||
* web(html/css) :禁字面色(令牌由 GENERATED tokens.css 提供,页面只允许 var(--*))
|
||||
*
|
||||
* 用法:node design-pipeline/check-code.mjs
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname, join, relative, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dir, '..');
|
||||
const errors = [];
|
||||
const err = (file, line, msg) => errors.push(`${relative(ROOT, file)}:${line} ${msg}`);
|
||||
|
||||
function walk(dir, exts, out = []) {
|
||||
if (!existsSync(dir)) return out;
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === 'node_modules' || name === 'dist' || name === 'target' || name.startsWith('.')) continue;
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) walk(p, exts, out);
|
||||
else if (exts.some((e) => name.endsWith(e))) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const COLOR_RE = /#[0-9a-fA-F]{3,8}\b|(?:rgba?|hsla?)\(/g;
|
||||
|
||||
function scanColors(files, label) {
|
||||
for (const f of files) {
|
||||
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
|
||||
if (line.includes('ds-ignore:')) return;
|
||||
const stripped = line.replace(/var\(--[\w-]+\)/g, ''); // var() 里不含字面色,先剥掉便于报错定位
|
||||
const m = stripped.match(COLOR_RE);
|
||||
if (m) err(f, i + 1, `${label} 字面色 ${m[0]}(改用设计令牌,或加 ds-ignore: 理由)`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── desktop:字面色 + 内联 <path> ───────────────────────────────────────────
|
||||
|
||||
const desktopFiles = walk(join(ROOT, 'desktop/src'), ['.jsx', '.js', '.css', '.html']);
|
||||
scanColors(desktopFiles, 'desktop');
|
||||
for (const f of desktopFiles) {
|
||||
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
|
||||
if (line.includes('ds-ignore:')) return;
|
||||
if (/<path[\s>]/.test(line))
|
||||
err(f, i + 1, 'desktop 内联 <path>(图标须登记 design/icons.js 并经 Icon 组件渲染)');
|
||||
});
|
||||
}
|
||||
|
||||
// ─── iOS:字面色 + SF Symbol 同集 ────────────────────────────────────────────
|
||||
|
||||
const iconMap = JSON.parse(readFileSync(join(ROOT, 'design/icon-map.json'), 'utf8'));
|
||||
const iosSymbols = new Set(
|
||||
Object.entries(iconMap).filter(([k]) => k !== '$schema').map(([, v]) => v.ios).filter(Boolean)
|
||||
);
|
||||
|
||||
for (const f of walk(join(ROOT, 'ios'), ['.swift'])) {
|
||||
const isGenerated = basename(f) === 'DuduTheme.swift';
|
||||
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
|
||||
if (line.includes('ds-ignore:')) return;
|
||||
if (!isGenerated && /Color\(red:|UIColor\(red:|Color\(\s*0x|#colorLiteral/.test(line))
|
||||
err(f, i + 1, 'iOS 字面色(改用 DuduTheme.*,或加 ds-ignore: 理由)');
|
||||
for (const m of line.matchAll(/systemName:\s*"([^"]+)"/g)) {
|
||||
if (!iosSymbols.has(m[1]))
|
||||
err(f, i + 1, `iOS SF Symbol "${m[1]}" 未登记 design/icon-map.json(ios 列)`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Android:字面色 ─────────────────────────────────────────────────────────
|
||||
|
||||
for (const f of walk(join(ROOT, 'android/app/src'), ['.kt'])) {
|
||||
if (basename(f) === 'DuduTheme.kt') continue; // codegen 产物
|
||||
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
|
||||
if (line.includes('ds-ignore:')) return;
|
||||
if (/Color\(\s*0x/.test(line))
|
||||
err(f, i + 1, 'Android 字面色(改用 DuduTheme / DuduPalette,或加 ds-ignore: 理由)');
|
||||
});
|
||||
}
|
||||
|
||||
// ─── web:字面色(tokens.css 为 GENERATED 豁免)──────────────────────────────
|
||||
|
||||
scanColors(
|
||||
walk(join(ROOT, 'web'), ['.html', '.css']).filter((f) => basename(f) !== 'tokens.css'),
|
||||
'web'
|
||||
);
|
||||
|
||||
// ─── 汇总 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
if (errors.length) {
|
||||
console.error(`❌ check-code:${errors.length} 处违规\n` + errors.map((e) => ' ' + e).join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✅ check-code:desktop / iOS / Android / web 单源全绿');
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-ds.mjs — 真相源自检闸(扫 design/ 自身,零依赖,Node >= 18)
|
||||
*
|
||||
* 七道检查(违规 exit 1,逐条打印文件:行):
|
||||
* ① tokens dark 键 ⊆ light 键(防孤儿 dark token)
|
||||
* ② token 值内 var(--x) 引用链可解析;styles.css @import 的文件存在
|
||||
* ③ design/ 内(guidelines/components/ui_kits/index.html/styles.css)裸色
|
||||
* 必须 ∈ tokens 值集,或行内带 `ds-ignore:` 豁免注释
|
||||
* ④ components/**\/*.jsx 导出的组件必须在登记簿 design/index.html 出现
|
||||
* ⑤ icons.js:key 唯一、path 为描边风格(禁 fill="#...")
|
||||
* ⑥ icon-map.json:web 列 ↔ icons.js key 双向同集;值为 string|null
|
||||
* ⑦ 组件/ui_kits 界面文案禁 emoji(键帽符号 ⌘⇧⌫↵⌥ 不在 emoji 区,天然放行)
|
||||
*
|
||||
* 用法:node design-pipeline/check-ds.mjs
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
||||
import { resolve, dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const DESIGN = resolve(__dir, '../design');
|
||||
const errors = [];
|
||||
const err = (file, line, msg) =>
|
||||
errors.push(`${relative(resolve(__dir, '..'), file)}:${line} ${msg}`);
|
||||
|
||||
// ─── 工具 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function walk(dir, exts, out = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) walk(p, exts, out);
|
||||
else if (exts.some((e) => name.endsWith(e))) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseBlock(css, selector) {
|
||||
const out = {};
|
||||
const re = new RegExp(`${selector.replace(/[[\]"=]/g, (m) => '\\' + m)}\\s*\\{([\\s\\S]*?)\\}`, 'g');
|
||||
let m;
|
||||
while ((m = re.exec(css))) {
|
||||
const body = m[1].replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
for (const line of body.split(';')) {
|
||||
const mm = line.match(/--([\w-]+)\s*:\s*(.+)/);
|
||||
if (mm) out[mm[1].trim()] = mm[2].trim();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── 解析 tokens ─────────────────────────────────────────────────────────────
|
||||
|
||||
const tokenFiles = readdirSync(join(DESIGN, 'tokens')).filter((f) => f.endsWith('.css')).sort();
|
||||
const light = {}, dark = {};
|
||||
for (const f of tokenFiles) {
|
||||
const css = readFileSync(join(DESIGN, 'tokens', f), 'utf8');
|
||||
Object.assign(light, parseBlock(css, ':root'));
|
||||
Object.assign(dark, parseBlock(css, '[data-theme="dark"]'));
|
||||
}
|
||||
|
||||
// tokens 合法值集(hex 大写 + rgba 去空格),供 ③ 比对
|
||||
const tokenValues = new Set();
|
||||
for (const v of [...Object.values(light), ...Object.values(dark)]) {
|
||||
for (const hex of v.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) tokenValues.add(hex[0].toUpperCase());
|
||||
for (const fn of v.matchAll(/(?:rgba?|hsla?)\([^)]*\)/g)) tokenValues.add(fn[0].replace(/\s+/g, ''));
|
||||
}
|
||||
|
||||
// ① dark ⊆ light
|
||||
for (const k of Object.keys(dark)) {
|
||||
if (!(k in light)) err(join(DESIGN, 'tokens'), 0, `① 孤儿 dark token:--${k} 未在 :root 定义`);
|
||||
}
|
||||
|
||||
// ② var() 链可解析 + @import 存在
|
||||
for (const [scopeName, scope] of [['light', light], ['dark', dark]]) {
|
||||
for (const [k, v] of Object.entries(scope)) {
|
||||
for (const ref of v.matchAll(/var\(--([\w-]+)\)/g)) {
|
||||
if (!(ref[1] in light) && !(ref[1] in dark))
|
||||
err(join(DESIGN, 'tokens'), 0, `② --${k}(${scopeName})引用了不存在的 var(--${ref[1]})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
const styles = readFileSync(join(DESIGN, 'styles.css'), 'utf8');
|
||||
for (const im of styles.matchAll(/@import\s+(?:url\()?['"]([^'"]+)['"]/g)) {
|
||||
if (!existsSync(join(DESIGN, im[1]))) err(join(DESIGN, 'styles.css'), 0, `② @import 目标不存在:${im[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ③ design/ 裸色须 ∈ tokens 值集(ds-ignore 豁免;tokens/ 与资产 svg 不扫)
|
||||
const scanFiles = [
|
||||
...walk(join(DESIGN, 'guidelines'), ['.html', '.css']),
|
||||
...walk(join(DESIGN, 'components'), ['.jsx', '.css', '.html']),
|
||||
...walk(join(DESIGN, 'ui_kits'), ['.jsx', '.css', '.html']),
|
||||
join(DESIGN, 'index.html'),
|
||||
join(DESIGN, 'styles.css'),
|
||||
];
|
||||
for (const f of scanFiles) {
|
||||
const lines = readFileSync(f, 'utf8').split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
if (line.includes('ds-ignore:')) return;
|
||||
for (const m of line.matchAll(/#[0-9a-fA-F]{3,8}\b|(?:rgba?|hsla?)\([^)]*\)/g)) {
|
||||
const val = m[0].startsWith('#') ? m[0].toUpperCase() : m[0].replace(/\s+/g, '');
|
||||
// #fff/#FFF 简写归一到 6 位再比对
|
||||
const norm = /^#[0-9A-F]{3}$/.test(val)
|
||||
? '#' + [...val.slice(1)].map((c) => c + c).join('')
|
||||
: val;
|
||||
if (!tokenValues.has(norm))
|
||||
err(f, i + 1, `③ 裸色 ${m[0]} 不在 tokens 值集(改用 var(--*) 或加 ds-ignore: 理由)`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ④ 组件登记:components/**/*.jsx 导出名必须现于 index.html
|
||||
const registry = readFileSync(join(DESIGN, 'index.html'), 'utf8');
|
||||
for (const f of walk(join(DESIGN, 'components'), ['.jsx'])) {
|
||||
const src = readFileSync(f, 'utf8');
|
||||
for (const m of src.matchAll(/export\s+(?:function|const)\s+([A-Z]\w+)/g)) {
|
||||
if (!registry.includes(`<b>${m[1]}</b>`) && !registry.includes(`<b>${m[1]} `) && !new RegExp(`<b>[^<]*\\b${m[1]}\\b`).test(registry))
|
||||
err(f, 0, `④ 组件 ${m[1]} 未在登记簿 design/index.html 登记`);
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ icons.js:key 唯一 + 描边风格
|
||||
{
|
||||
const f = join(DESIGN, 'icons.js');
|
||||
const src = readFileSync(f, 'utf8');
|
||||
const keys = [...src.matchAll(/^\s{2}(\w+):/gm)].map((m) => m[1]);
|
||||
const seen = new Set();
|
||||
for (const k of keys) {
|
||||
if (seen.has(k)) err(f, 0, `⑤ icons.js 重复 key:${k}`);
|
||||
seen.add(k);
|
||||
}
|
||||
if (/fill="#/.test(src)) err(f, 0, '⑤ icons.js 含填充色(应为 stroke/currentColor 线条风格)');
|
||||
globalThis.__iconKeys = seen;
|
||||
}
|
||||
|
||||
// ⑥ icon-map.json ↔ icons.js 双向同集
|
||||
{
|
||||
const f = join(DESIGN, 'icon-map.json');
|
||||
const map = JSON.parse(readFileSync(f, 'utf8'));
|
||||
const webUsed = new Set();
|
||||
for (const [name, v] of Object.entries(map)) {
|
||||
if (name === '$schema') continue;
|
||||
for (const col of ['web', 'ios', 'android']) {
|
||||
if (!(col in v)) err(f, 0, `⑥ ${name} 缺 ${col} 列(未使用请置 null)`);
|
||||
else if (v[col] !== null && typeof v[col] !== 'string') err(f, 0, `⑥ ${name}.${col} 应为 string|null`);
|
||||
}
|
||||
if (v.web) {
|
||||
if (!globalThis.__iconKeys.has(v.web)) err(f, 0, `⑥ ${name}.web=${v.web} 不存在于 icons.js`);
|
||||
webUsed.add(v.web);
|
||||
}
|
||||
}
|
||||
for (const k of globalThis.__iconKeys) {
|
||||
if (!webUsed.has(k)) err(f, 0, `⑥ icons.js 的 ${k} 未登记进 icon-map.json(web 列)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ⑦ 界面文案禁 emoji(components + ui_kits 的 jsx/card.html)
|
||||
const EMOJI = /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}]/u;
|
||||
for (const f of [...walk(join(DESIGN, 'components'), ['.jsx', '.html']), ...walk(join(DESIGN, 'ui_kits'), ['.jsx', '.html'])]) {
|
||||
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
|
||||
if (line.includes('ds-ignore:')) return;
|
||||
if (EMOJI.test(line)) err(f, i + 1, '⑦ 界面文案/原型含 emoji(品牌规范禁用;确属注释示例加 ds-ignore: 理由)');
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 汇总 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
if (errors.length) {
|
||||
console.error(`❌ check-ds:${errors.length} 处违规\n` + errors.map((e) => ' ' + e).join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✅ check-ds:真相源自检 7 道全绿');
|
||||
@@ -3,9 +3,11 @@
|
||||
* export-tokens.mjs — 设计令牌导出管线(零依赖,Node >= 18)
|
||||
*
|
||||
* 解析 design/tokens/*.css 的 CSS 自定义属性(:root = light,[data-theme="dark"] = dark),
|
||||
* 生成移动端原生主题文件,保证三端令牌单一来源:
|
||||
* generated/ios/DuduTheme.swift — Color(light/dark 动态) + 尺寸/字号常量
|
||||
* generated/android/DuduTheme.kt — Compose Color/Dp/Sp 常量(Light/Dark 两组)
|
||||
* 生成各端主题产物并直写消费位置(无中间拷贝层,--check 守的即真实消费文件):
|
||||
* ios/dudu/Shared/DuduTheme.swift — Color(light/dark 动态) + 尺寸常量
|
||||
* android/app/src/main/java/app/dudu/design/DuduTheme.kt — Compose Color/Dp 常量(Light/Dark)
|
||||
* web/tokens.css — 官网令牌层(custom props 原样透传 +
|
||||
* Outfit 字体 data URI 内嵌,单文件自包含)
|
||||
*
|
||||
* 用法:node design-pipeline/export-tokens.mjs [--check]
|
||||
* --check CI 校验模式:重新生成并与已提交产物比对,不一致退出 1
|
||||
@@ -17,8 +19,10 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const TOKENS_DIR = resolve(__dir, '../design/tokens');
|
||||
const OUT_IOS = resolve(__dir, 'generated/ios/DuduTheme.swift');
|
||||
const OUT_ANDROID = resolve(__dir, 'generated/android/DuduTheme.kt');
|
||||
const OUT_IOS = resolve(__dir, '../ios/dudu/Shared/DuduTheme.swift');
|
||||
const OUT_ANDROID = resolve(__dir, '../android/app/src/main/java/app/dudu/design/DuduTheme.kt');
|
||||
const OUT_WEB = resolve(__dir, '../web/tokens.css');
|
||||
const FONT_WOFF2 = resolve(__dir, '../design/assets/outfit-latin.woff2');
|
||||
|
||||
// ─── 解析 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -37,7 +41,7 @@ function parseBlock(css, selector) {
|
||||
return out;
|
||||
}
|
||||
|
||||
const files = readdirSync(TOKENS_DIR).filter((f) => f.endsWith('.css'));
|
||||
const files = readdirSync(TOKENS_DIR).filter((f) => f.endsWith('.css')).sort();
|
||||
let light = {}, dark = {};
|
||||
for (const f of files) {
|
||||
const css = readFileSync(resolve(TOKENS_DIR, f), 'utf8');
|
||||
@@ -166,11 +170,38 @@ ${dims}
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── 生成 Web(官网令牌层)───────────────────────────────────────────────────
|
||||
|
||||
function web() {
|
||||
const props = (scope) =>
|
||||
Object.entries(scope).map(([k, v]) => ` --${k}: ${v};`).join('\n');
|
||||
const fontB64 = readFileSync(FONT_WOFF2).toString('base64');
|
||||
return `/* 自动生成 — 请勿手改。来源 design/tokens/*.css + assets/outfit-latin.woff2
|
||||
重新生成:node design-pipeline/export-tokens.mjs */
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
font-style: normal;
|
||||
font-weight: 400 700;
|
||||
font-display: swap;
|
||||
src: url(data:font/woff2;base64,${fontB64}) format('woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
${props(light)}
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
${props(dark)}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── 输出 / 校验 ─────────────────────────────────────────────────────────────
|
||||
|
||||
const outputs = [
|
||||
[OUT_IOS, swift()],
|
||||
[OUT_ANDROID, kotlin()],
|
||||
[OUT_WEB, web()],
|
||||
];
|
||||
|
||||
const check = process.argv.includes('--check');
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
// 自动生成 — 请勿手改。来源 design/tokens/*.css
|
||||
// 重新生成:node design-pipeline/export-tokens.mjs
|
||||
|
||||
package app.dudu.design
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
object DuduTheme {
|
||||
object Light {
|
||||
val accent = Color(0xFF4F6EF7)
|
||||
val accentHover = Color(0xFF3D58DB)
|
||||
val accentPress = Color(0xFF2F44B3)
|
||||
val accentSoft = Color(0xFFEEF1FE)
|
||||
val accentSoft2 = Color(0xFFDFE5FD)
|
||||
val accentText = Color(0xFF3D58DB)
|
||||
val amber100 = Color(0xFFFCEFD7)
|
||||
val amber500 = Color(0xFFE8890C)
|
||||
val bgApp = Color(0xFFF6F7FA)
|
||||
val blue100 = Color(0xFFDFE5FD)
|
||||
val blue200 = Color(0xFFC4CFFB)
|
||||
val blue300 = Color(0xFF9FB1F9)
|
||||
val blue400 = Color(0xFF7590F8)
|
||||
val blue50 = Color(0xFFEEF1FE)
|
||||
val blue500 = Color(0xFF4F6EF7)
|
||||
val blue600 = Color(0xFF3D58DB)
|
||||
val blue700 = Color(0xFF2F44B3)
|
||||
val blue800 = Color(0xFF25368C)
|
||||
val blue900 = Color(0xFF1E2B6B)
|
||||
val border1 = Color(0xFFE4E6EB)
|
||||
val border2 = Color(0xFFD2D5DD)
|
||||
val danger = Color(0xFFDC2626)
|
||||
val dangerSoft = Color(0xFFFEE2E2)
|
||||
val gray0 = Color(0xFFFFFFFF)
|
||||
val gray100 = Color(0xFFF0F1F4)
|
||||
val gray200 = Color(0xFFE4E6EB)
|
||||
val gray25 = Color(0xFFFCFCFD)
|
||||
val gray300 = Color(0xFFD2D5DD)
|
||||
val gray400 = Color(0xFFA6ABB8)
|
||||
val gray50 = Color(0xFFF6F7FA)
|
||||
val gray500 = Color(0xFF7A8090)
|
||||
val gray600 = Color(0xFF5A6072)
|
||||
val gray700 = Color(0xFF434957)
|
||||
val gray800 = Color(0xFF2B2F3A)
|
||||
val gray900 = Color(0xFF1B1E26)
|
||||
val gray950 = Color(0xFF11131A)
|
||||
val green100 = Color(0xFFDCFCE7)
|
||||
val green500 = Color(0xFF16A34A)
|
||||
val overlayBg = Color(0xF2181A22)
|
||||
val overlayText = Color(0xFFF2F4FA)
|
||||
val overlayText2 = Color(0xFF9AA3BD)
|
||||
val overlayText3 = Color(0xFF646E8C)
|
||||
val positive = Color(0xFF16A34A)
|
||||
val positiveSoft = Color(0xFFDCFCE7)
|
||||
val red100 = Color(0xFFFEE2E2)
|
||||
val red500 = Color(0xFFDC2626)
|
||||
val surface2 = Color(0xFFF0F1F4)
|
||||
val surface3 = Color(0xFFE4E6EB)
|
||||
val surfaceCard = Color(0xFFFFFFFF)
|
||||
val text1 = Color(0xFF1B1E26)
|
||||
val text2 = Color(0xFF5A6072)
|
||||
val text3 = Color(0xFFA6ABB8)
|
||||
val textOnAccent = Color(0xFFFFFFFF)
|
||||
val warning = Color(0xFFE8890C)
|
||||
val warningSoft = Color(0xFFFCEFD7)
|
||||
}
|
||||
|
||||
object Dark {
|
||||
val accent = Color(0xFF5C77E8)
|
||||
val accentHover = Color(0xFF6E89F9)
|
||||
val accentPress = Color(0xFF4A63D6)
|
||||
val accentSoft = Color(0x295C77E8)
|
||||
val accentSoft2 = Color(0x425C77E8)
|
||||
val accentText = Color(0xFF7D95F7)
|
||||
val amber100 = Color(0xFFFCEFD7)
|
||||
val amber500 = Color(0xFFE8890C)
|
||||
val bgApp = Color(0xFF0F1116)
|
||||
val blue100 = Color(0xFFDFE5FD)
|
||||
val blue200 = Color(0xFFC4CFFB)
|
||||
val blue300 = Color(0xFF9FB1F9)
|
||||
val blue400 = Color(0xFF7590F8)
|
||||
val blue50 = Color(0xFFEEF1FE)
|
||||
val blue500 = Color(0xFF4F6EF7)
|
||||
val blue600 = Color(0xFF3D58DB)
|
||||
val blue700 = Color(0xFF2F44B3)
|
||||
val blue800 = Color(0xFF25368C)
|
||||
val blue900 = Color(0xFF1E2B6B)
|
||||
val border1 = Color(0xFF272C38)
|
||||
val border2 = Color(0xFF343A49)
|
||||
val danger = Color(0xFFF05B5B)
|
||||
val dangerSoft = Color(0x29F05B5B)
|
||||
val gray0 = Color(0xFFFFFFFF)
|
||||
val gray100 = Color(0xFFF0F1F4)
|
||||
val gray200 = Color(0xFFE4E6EB)
|
||||
val gray25 = Color(0xFFFCFCFD)
|
||||
val gray300 = Color(0xFFD2D5DD)
|
||||
val gray400 = Color(0xFFA6ABB8)
|
||||
val gray50 = Color(0xFFF6F7FA)
|
||||
val gray500 = Color(0xFF7A8090)
|
||||
val gray600 = Color(0xFF5A6072)
|
||||
val gray700 = Color(0xFF434957)
|
||||
val gray800 = Color(0xFF2B2F3A)
|
||||
val gray900 = Color(0xFF1B1E26)
|
||||
val gray950 = Color(0xFF11131A)
|
||||
val green100 = Color(0xFFDCFCE7)
|
||||
val green500 = Color(0xFF16A34A)
|
||||
val overlayBg = Color(0xF51E212B)
|
||||
val overlayText = Color(0xFFF2F4FA)
|
||||
val overlayText2 = Color(0xFF9AA3BD)
|
||||
val overlayText3 = Color(0xFF646E8C)
|
||||
val positive = Color(0xFF34C46A)
|
||||
val positiveSoft = Color(0x2934C46A)
|
||||
val red100 = Color(0xFFFEE2E2)
|
||||
val red500 = Color(0xFFDC2626)
|
||||
val surface2 = Color(0xFF1E222C)
|
||||
val surface3 = Color(0xFF262B37)
|
||||
val surfaceCard = Color(0xFF171A22)
|
||||
val text1 = Color(0xFFECEEF4)
|
||||
val text2 = Color(0xFF9AA1B2)
|
||||
val text3 = Color(0xFF5F6678)
|
||||
val textOnAccent = Color(0xFFFFFFFF)
|
||||
val warning = Color(0xFFF2A33C)
|
||||
val warningSoft = Color(0x29F2A33C)
|
||||
}
|
||||
|
||||
// Dimensions (dp)
|
||||
val controlLg = 44.dp
|
||||
val controlMd = 36.dp
|
||||
val controlSm = 28.dp
|
||||
val controlXl = 56.dp
|
||||
val radiusFull = 999.dp
|
||||
val radiusLg = 14.dp
|
||||
val radiusMd = 10.dp
|
||||
val radiusSm = 6.dp
|
||||
val radiusXl = 20.dp
|
||||
val radiusXs = 4.dp
|
||||
val space1 = 4.dp
|
||||
val space10 = 40.dp
|
||||
val space12 = 48.dp
|
||||
val space16 = 64.dp
|
||||
val space2 = 8.dp
|
||||
val space3 = 12.dp
|
||||
val space4 = 16.dp
|
||||
val space5 = 20.dp
|
||||
val space6 = 24.dp
|
||||
val space8 = 32.dp
|
||||
val text2xl = 24.dp
|
||||
val text3xl = 32.dp
|
||||
val text4xl = 44.dp
|
||||
val textBase = 14.dp
|
||||
val textLg = 17.dp
|
||||
val textMd = 15.dp
|
||||
val textSm = 13.dp
|
||||
val textXl = 20.dp
|
||||
val textXs = 12.dp
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
// 自动生成 — 请勿手改。来源 design/tokens/*.css
|
||||
// 重新生成:node design-pipeline/export-tokens.mjs
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public enum DuduTheme {
|
||||
/// light/dark 动态色(跟随系统外观)
|
||||
static func dynamic(light: Color, dark: Color) -> Color {
|
||||
Color(UIColor { trait in
|
||||
trait.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light)
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Colors
|
||||
public static let accent = dynamic(
|
||||
light: Color(.sRGB, red: 0.3098, green: 0.4314, blue: 0.9686, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.3608, green: 0.4667, blue: 0.9098, opacity: 1.00))
|
||||
public static let accentHover = dynamic(
|
||||
light: Color(.sRGB, red: 0.2392, green: 0.3451, blue: 0.8588, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.4314, green: 0.5373, blue: 0.9765, opacity: 1.00))
|
||||
public static let accentPress = dynamic(
|
||||
light: Color(.sRGB, red: 0.1843, green: 0.2667, blue: 0.7020, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.2902, green: 0.3882, blue: 0.8392, opacity: 1.00))
|
||||
public static let accentSoft = dynamic(
|
||||
light: Color(.sRGB, red: 0.9333, green: 0.9451, blue: 0.9961, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.3608, green: 0.4667, blue: 0.9098, opacity: 0.16))
|
||||
public static let accentSoft2 = dynamic(
|
||||
light: Color(.sRGB, red: 0.8745, green: 0.8980, blue: 0.9922, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.3608, green: 0.4667, blue: 0.9098, opacity: 0.26))
|
||||
public static let accentText = dynamic(
|
||||
light: Color(.sRGB, red: 0.2392, green: 0.3451, blue: 0.8588, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.4902, green: 0.5843, blue: 0.9686, opacity: 1.00))
|
||||
public static let amber100 = dynamic(
|
||||
light: Color(.sRGB, red: 0.9882, green: 0.9373, blue: 0.8431, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.9882, green: 0.9373, blue: 0.8431, opacity: 1.00))
|
||||
public static let amber500 = dynamic(
|
||||
light: Color(.sRGB, red: 0.9098, green: 0.5373, blue: 0.0471, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.9098, green: 0.5373, blue: 0.0471, opacity: 1.00))
|
||||
public static let bgApp = dynamic(
|
||||
light: Color(.sRGB, red: 0.9647, green: 0.9686, blue: 0.9804, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.0588, green: 0.0667, blue: 0.0863, opacity: 1.00))
|
||||
public static let blue100 = dynamic(
|
||||
light: Color(.sRGB, red: 0.8745, green: 0.8980, blue: 0.9922, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.8745, green: 0.8980, blue: 0.9922, opacity: 1.00))
|
||||
public static let blue200 = dynamic(
|
||||
light: Color(.sRGB, red: 0.7686, green: 0.8118, blue: 0.9843, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.7686, green: 0.8118, blue: 0.9843, opacity: 1.00))
|
||||
public static let blue300 = dynamic(
|
||||
light: Color(.sRGB, red: 0.6235, green: 0.6941, blue: 0.9765, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.6235, green: 0.6941, blue: 0.9765, opacity: 1.00))
|
||||
public static let blue400 = dynamic(
|
||||
light: Color(.sRGB, red: 0.4588, green: 0.5647, blue: 0.9725, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.4588, green: 0.5647, blue: 0.9725, opacity: 1.00))
|
||||
public static let blue50 = dynamic(
|
||||
light: Color(.sRGB, red: 0.9333, green: 0.9451, blue: 0.9961, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.9333, green: 0.9451, blue: 0.9961, opacity: 1.00))
|
||||
public static let blue500 = dynamic(
|
||||
light: Color(.sRGB, red: 0.3098, green: 0.4314, blue: 0.9686, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.3098, green: 0.4314, blue: 0.9686, opacity: 1.00))
|
||||
public static let blue600 = dynamic(
|
||||
light: Color(.sRGB, red: 0.2392, green: 0.3451, blue: 0.8588, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.2392, green: 0.3451, blue: 0.8588, opacity: 1.00))
|
||||
public static let blue700 = dynamic(
|
||||
light: Color(.sRGB, red: 0.1843, green: 0.2667, blue: 0.7020, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.1843, green: 0.2667, blue: 0.7020, opacity: 1.00))
|
||||
public static let blue800 = dynamic(
|
||||
light: Color(.sRGB, red: 0.1451, green: 0.2118, blue: 0.5490, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.1451, green: 0.2118, blue: 0.5490, opacity: 1.00))
|
||||
public static let blue900 = dynamic(
|
||||
light: Color(.sRGB, red: 0.1176, green: 0.1686, blue: 0.4196, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.1176, green: 0.1686, blue: 0.4196, opacity: 1.00))
|
||||
public static let border1 = dynamic(
|
||||
light: Color(.sRGB, red: 0.8941, green: 0.9020, blue: 0.9216, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.1529, green: 0.1725, blue: 0.2196, opacity: 1.00))
|
||||
public static let border2 = dynamic(
|
||||
light: Color(.sRGB, red: 0.8235, green: 0.8353, blue: 0.8667, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.2039, green: 0.2275, blue: 0.2863, opacity: 1.00))
|
||||
public static let danger = dynamic(
|
||||
light: Color(.sRGB, red: 0.8627, green: 0.1490, blue: 0.1490, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.9412, green: 0.3569, blue: 0.3569, opacity: 1.00))
|
||||
public static let dangerSoft = dynamic(
|
||||
light: Color(.sRGB, red: 0.9961, green: 0.8863, blue: 0.8863, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.9412, green: 0.3569, blue: 0.3569, opacity: 0.16))
|
||||
public static let gray0 = dynamic(
|
||||
light: Color(.sRGB, red: 1.0000, green: 1.0000, blue: 1.0000, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 1.0000, green: 1.0000, blue: 1.0000, opacity: 1.00))
|
||||
public static let gray100 = dynamic(
|
||||
light: Color(.sRGB, red: 0.9412, green: 0.9451, blue: 0.9569, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.9412, green: 0.9451, blue: 0.9569, opacity: 1.00))
|
||||
public static let gray200 = dynamic(
|
||||
light: Color(.sRGB, red: 0.8941, green: 0.9020, blue: 0.9216, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.8941, green: 0.9020, blue: 0.9216, opacity: 1.00))
|
||||
public static let gray25 = dynamic(
|
||||
light: Color(.sRGB, red: 0.9882, green: 0.9882, blue: 0.9922, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.9882, green: 0.9882, blue: 0.9922, opacity: 1.00))
|
||||
public static let gray300 = dynamic(
|
||||
light: Color(.sRGB, red: 0.8235, green: 0.8353, blue: 0.8667, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.8235, green: 0.8353, blue: 0.8667, opacity: 1.00))
|
||||
public static let gray400 = dynamic(
|
||||
light: Color(.sRGB, red: 0.6510, green: 0.6706, blue: 0.7216, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.6510, green: 0.6706, blue: 0.7216, opacity: 1.00))
|
||||
public static let gray50 = dynamic(
|
||||
light: Color(.sRGB, red: 0.9647, green: 0.9686, blue: 0.9804, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.9647, green: 0.9686, blue: 0.9804, opacity: 1.00))
|
||||
public static let gray500 = dynamic(
|
||||
light: Color(.sRGB, red: 0.4784, green: 0.5020, blue: 0.5647, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.4784, green: 0.5020, blue: 0.5647, opacity: 1.00))
|
||||
public static let gray600 = dynamic(
|
||||
light: Color(.sRGB, red: 0.3529, green: 0.3765, blue: 0.4471, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.3529, green: 0.3765, blue: 0.4471, opacity: 1.00))
|
||||
public static let gray700 = dynamic(
|
||||
light: Color(.sRGB, red: 0.2627, green: 0.2863, blue: 0.3412, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.2627, green: 0.2863, blue: 0.3412, opacity: 1.00))
|
||||
public static let gray800 = dynamic(
|
||||
light: Color(.sRGB, red: 0.1686, green: 0.1843, blue: 0.2275, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.1686, green: 0.1843, blue: 0.2275, opacity: 1.00))
|
||||
public static let gray900 = dynamic(
|
||||
light: Color(.sRGB, red: 0.1059, green: 0.1176, blue: 0.1490, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.1059, green: 0.1176, blue: 0.1490, opacity: 1.00))
|
||||
public static let gray950 = dynamic(
|
||||
light: Color(.sRGB, red: 0.0667, green: 0.0745, blue: 0.1020, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.0667, green: 0.0745, blue: 0.1020, opacity: 1.00))
|
||||
public static let green100 = dynamic(
|
||||
light: Color(.sRGB, red: 0.8627, green: 0.9882, blue: 0.9059, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.8627, green: 0.9882, blue: 0.9059, opacity: 1.00))
|
||||
public static let green500 = dynamic(
|
||||
light: Color(.sRGB, red: 0.0863, green: 0.6392, blue: 0.2902, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.0863, green: 0.6392, blue: 0.2902, opacity: 1.00))
|
||||
public static let overlayBg = dynamic(
|
||||
light: Color(.sRGB, red: 0.0941, green: 0.1020, blue: 0.1333, opacity: 0.95),
|
||||
dark: Color(.sRGB, red: 0.1176, green: 0.1294, blue: 0.1686, opacity: 0.96))
|
||||
public static let overlayText = dynamic(
|
||||
light: Color(.sRGB, red: 0.9490, green: 0.9569, blue: 0.9804, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.9490, green: 0.9569, blue: 0.9804, opacity: 1.00))
|
||||
public static let overlayText2 = dynamic(
|
||||
light: Color(.sRGB, red: 0.6039, green: 0.6392, blue: 0.7412, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.6039, green: 0.6392, blue: 0.7412, opacity: 1.00))
|
||||
public static let overlayText3 = dynamic(
|
||||
light: Color(.sRGB, red: 0.3922, green: 0.4314, blue: 0.5490, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.3922, green: 0.4314, blue: 0.5490, opacity: 1.00))
|
||||
public static let positive = dynamic(
|
||||
light: Color(.sRGB, red: 0.0863, green: 0.6392, blue: 0.2902, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.2039, green: 0.7686, blue: 0.4157, opacity: 1.00))
|
||||
public static let positiveSoft = dynamic(
|
||||
light: Color(.sRGB, red: 0.8627, green: 0.9882, blue: 0.9059, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.2039, green: 0.7686, blue: 0.4157, opacity: 0.16))
|
||||
public static let red100 = dynamic(
|
||||
light: Color(.sRGB, red: 0.9961, green: 0.8863, blue: 0.8863, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.9961, green: 0.8863, blue: 0.8863, opacity: 1.00))
|
||||
public static let red500 = dynamic(
|
||||
light: Color(.sRGB, red: 0.8627, green: 0.1490, blue: 0.1490, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.8627, green: 0.1490, blue: 0.1490, opacity: 1.00))
|
||||
public static let surface2 = dynamic(
|
||||
light: Color(.sRGB, red: 0.9412, green: 0.9451, blue: 0.9569, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.1176, green: 0.1333, blue: 0.1725, opacity: 1.00))
|
||||
public static let surface3 = dynamic(
|
||||
light: Color(.sRGB, red: 0.8941, green: 0.9020, blue: 0.9216, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.1490, green: 0.1686, blue: 0.2157, opacity: 1.00))
|
||||
public static let surfaceCard = dynamic(
|
||||
light: Color(.sRGB, red: 1.0000, green: 1.0000, blue: 1.0000, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.0902, green: 0.1020, blue: 0.1333, opacity: 1.00))
|
||||
public static let text1 = dynamic(
|
||||
light: Color(.sRGB, red: 0.1059, green: 0.1176, blue: 0.1490, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.9255, green: 0.9333, blue: 0.9569, opacity: 1.00))
|
||||
public static let text2 = dynamic(
|
||||
light: Color(.sRGB, red: 0.3529, green: 0.3765, blue: 0.4471, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.6039, green: 0.6314, blue: 0.6980, opacity: 1.00))
|
||||
public static let text3 = dynamic(
|
||||
light: Color(.sRGB, red: 0.6510, green: 0.6706, blue: 0.7216, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.3725, green: 0.4000, blue: 0.4706, opacity: 1.00))
|
||||
public static let textOnAccent = dynamic(
|
||||
light: Color(.sRGB, red: 1.0000, green: 1.0000, blue: 1.0000, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 1.0000, green: 1.0000, blue: 1.0000, opacity: 1.00))
|
||||
public static let warning = dynamic(
|
||||
light: Color(.sRGB, red: 0.9098, green: 0.5373, blue: 0.0471, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.9490, green: 0.6392, blue: 0.2353, opacity: 1.00))
|
||||
public static let warningSoft = dynamic(
|
||||
light: Color(.sRGB, red: 0.9882, green: 0.9373, blue: 0.8431, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.9490, green: 0.6392, blue: 0.2353, opacity: 0.16))
|
||||
|
||||
// MARK: - Dimensions (pt)
|
||||
public static let controlLg: CGFloat = 44
|
||||
public static let controlMd: CGFloat = 36
|
||||
public static let controlSm: CGFloat = 28
|
||||
public static let controlXl: CGFloat = 56
|
||||
public static let radiusFull: CGFloat = 999
|
||||
public static let radiusLg: CGFloat = 14
|
||||
public static let radiusMd: CGFloat = 10
|
||||
public static let radiusSm: CGFloat = 6
|
||||
public static let radiusXl: CGFloat = 20
|
||||
public static let radiusXs: CGFloat = 4
|
||||
public static let space1: CGFloat = 4
|
||||
public static let space10: CGFloat = 40
|
||||
public static let space12: CGFloat = 48
|
||||
public static let space16: CGFloat = 64
|
||||
public static let space2: CGFloat = 8
|
||||
public static let space3: CGFloat = 12
|
||||
public static let space4: CGFloat = 16
|
||||
public static let space5: CGFloat = 20
|
||||
public static let space6: CGFloat = 24
|
||||
public static let space8: CGFloat = 32
|
||||
public static let text2xl: CGFloat = 24
|
||||
public static let text3xl: CGFloat = 32
|
||||
public static let text4xl: CGFloat = 44
|
||||
public static let textBase: CGFloat = 14
|
||||
public static let textLg: CGFloat = 17
|
||||
public static let textMd: CGFloat = 15
|
||||
public static let textSm: CGFloat = 13
|
||||
public static let textXl: CGFloat = 20
|
||||
public static let textXs: CGFloat = 12
|
||||
}
|
||||
@@ -13,3 +13,11 @@ If creating visual artifacts (slides, mocks, throwaway prototypes, etc), copy as
|
||||
If the user invokes this skill without any other guidance, ask them what they want to build or design, ask some questions, and act as an expert designer who outputs HTML artifacts _or_ production code, depending on the need.
|
||||
|
||||
Hard rules: 一屏一个蓝色主按钮;无渐变;无 emoji;浮层永远深色玻璃;文案短、用"你"、无句号。
|
||||
|
||||
## 单源与闸(改 UI 前必读)
|
||||
|
||||
- **登记先行(L1 无例外)**:新颜色/字号/间距 → `tokens/*.css`;新组件 → `components/` + 登记簿 `index.html` 加卡;新图标 → `icons.js` + `icon-map.json`。登记后跑 `node design-pipeline/export-tokens.mjs` 再写端代码。
|
||||
- **业务代码禁止**:字面色(hex/rgb/hsl,一律 var(--*) / DuduTheme)、内联 `<path>`(走 `Icon` 组件)、未登记的 SF Symbol。确属例外加行内 `ds-ignore: 理由`。
|
||||
- **闸**:`node design-pipeline/check-ds.mjs`(真相源自检 7 道)· `check-code.mjs`(四端代码侧)· `export-tokens.mjs --check`(产物零 diff)。pre-commit(`git config core.hooksPath .githooks` 启用)与 CI 双挂。
|
||||
- **预览评审**:`node design/serve.mjs` → http://localhost:5180(登记簿 index.html 与 ui_kits)。
|
||||
- 全景说明:`doc/frontend-overview.html`。
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import type { icons } from '../../icons.js';
|
||||
|
||||
export interface IconProps extends React.SVGProps<SVGSVGElement> {
|
||||
/** design/icons.js 登记的图标名 */
|
||||
name: keyof typeof icons;
|
||||
/** 边长 px(24 网格等比缩放),默认 16 */
|
||||
size?: number;
|
||||
/** 描边宽度,默认 2 */
|
||||
sw?: number;
|
||||
}
|
||||
|
||||
export declare function Icon(props: IconProps): React.JSX.Element;
|
||||
@@ -0,0 +1,25 @@
|
||||
// Icon:渲染 design/icons.js 单源图标(Lucide 线条、24 网格、currentColor)。
|
||||
// 业务代码禁止内联 <path>(check-code 闸拦截)——新图标先登记 icons.js + icon-map.json。
|
||||
import { icons } from '../../icons.js';
|
||||
|
||||
export function Icon({ name, size = 16, sw = 2, ...rest }) {
|
||||
// 安全前提:body 只能来自 icons.js 的静态常量(源码入库、无用户输入),
|
||||
// 故 dangerouslySetInnerHTML 无 XSS 面。禁止改为接受外部传入的 SVG 字符串。
|
||||
const body = icons[name];
|
||||
if (!body) throw new Error(`unknown icon: ${name}(先在 design/icons.js 登记)`);
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={sw}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
dangerouslySetInnerHTML={{ __html: body }}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ const ddSwitchCss = `
|
||||
.dd-switch::after {
|
||||
content: ''; position: absolute; top: 3px; left: 3px;
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
background: #fff; box-shadow: 0 1px 3px rgba(17,19,26,0.25);
|
||||
background: #fff; box-shadow: 0 1px 3px rgba(17,19,26,0.25); /* ds-ignore: 拇指投影,gray-950 的 25% 派生 */
|
||||
transition: transform var(--dur-base) var(--ease-out);
|
||||
}
|
||||
.dd-switch[aria-checked="true"] { background: var(--accent); }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Waveform } from './Waveform';
|
||||
import { Icon } from '../core/Icon';
|
||||
|
||||
const ddMicBarCss = `
|
||||
.dd-micbar {
|
||||
@@ -24,14 +25,7 @@ if (typeof document !== 'undefined' && !document.getElementById('dd-micbar-css')
|
||||
}
|
||||
|
||||
function DdMicIcon({ size = 20 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
||||
<line x1="12" x2="12" y1="19" y2="22"></line>
|
||||
</svg>
|
||||
);
|
||||
return <Icon name="mic" size={size} />;
|
||||
}
|
||||
|
||||
const DD_MICBAR_LABELS = {
|
||||
@@ -46,7 +40,7 @@ export function MicBar({ state = 'idle', label, ...rest }) {
|
||||
return (
|
||||
<button type="button" className={`dd-micbar dd-micbar--${state}`} {...rest}>
|
||||
{state === 'recording'
|
||||
? <Waveform active bars={9} height={18} color="rgba(255,255,255,0.95)" />
|
||||
? <Waveform active bars={9} height={18} color="rgba(255,255,255,0.95)" /> /* ds-ignore: accent 底上白波形 95% 派生 */
|
||||
: <DdMicIcon />}
|
||||
<span>{text}</span>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Waveform } from './Waveform';
|
||||
import { Icon } from '../core/Icon';
|
||||
|
||||
const ddOverlayCss = `
|
||||
.dd-overlay {
|
||||
@@ -35,15 +36,10 @@ export function RecognitionOverlay({
|
||||
}) {
|
||||
return (
|
||||
<div className="dd-overlay" style={{ width, ...style }} role="status">
|
||||
<Waveform active bars={12} height={24} color="#7C9BFF" />
|
||||
<Waveform active bars={12} height={24} color="var(--overlay-wave)" />
|
||||
{state === 'listening' ? (
|
||||
<div className="dd-overlay__status">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
||||
<line x1="12" x2="12" y1="19" y2="22"></line>
|
||||
</svg>
|
||||
<Icon name="mic" size={16} />
|
||||
<span>聆听中…</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
<div class="cell light"><img src="../assets/logo-mark-ink.svg" width="72" height="72" alt="dudu logo"></div>
|
||||
<div class="cell tint"><img src="../assets/logo-mark-blue.svg" width="72" height="72" alt="dudu logo blue"></div>
|
||||
<div class="cell dark"><img src="../assets/logo-mark-white.svg" width="72" height="72" alt="dudu logo white"></div>
|
||||
<div class="cell blue"><img src="../assets/app-icon.svg" width="76" height="76" alt="dudu app icon" style="border-radius: 17px; box-shadow: 0 4px 14px rgba(0,0,0,.25);"></div>
|
||||
<div class="cell blue"><img src="../assets/app-icon.svg" width="76" height="76" alt="dudu app icon" style="border-radius: 17px; box-shadow: 0 4px 14px rgba(0,0,0,.25);"></div><!-- ds-ignore: 色卡演示投影,非 UI 规范 -->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
.sw { display: flex; flex-direction: column; justify-content: flex-end; padding: 7px 6px; box-sizing: border-box; }
|
||||
.sw span { font-size: 9.5px; font-family: var(--font-mono); }
|
||||
.lt span { color: var(--gray-600); }
|
||||
.dk span { color: rgba(255,255,255,0.75); }
|
||||
.dk span { color: rgba(255,255,255,0.75); } /* ds-ignore: 深色色块上的标注文字,色卡专用 */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "语义图标 → 各端实现的映射表(真相源)。web = design/icons.js 的 key;ios = SF Symbol 名;android = Compose/资源名。null = 该端暂未使用。业务代码使用的图标必须能在对应列找到,check-ds/check-code 闸强制。",
|
||||
"mic": { "web": "mic", "ios": "mic", "android": null },
|
||||
"micFill": { "web": null, "ios": "mic.fill", "android": null },
|
||||
"micSlash": { "web": null, "ios": "mic.slash", "android": null },
|
||||
"check": { "web": "check", "ios": "checkmark", "android": null },
|
||||
"close": { "web": "x", "ios": "xmark", "android": null },
|
||||
"chevronRight": { "web": null, "ios": "chevron.right", "android": null },
|
||||
"settings": { "web": null, "ios": "gearshape", "android": null },
|
||||
"person": { "web": null, "ios": "person.fill", "android": null },
|
||||
"globe": { "web": null, "ios": "globe", "android": null },
|
||||
"logout": { "web": null, "ios": "rectangle.portrait.and.arrow.right", "android": null },
|
||||
"imagePlus": { "web": "imagePlus", "ios": "photo.badge.plus", "android": null },
|
||||
"feedback": { "web": null, "ios": "bubble.left.and.bubble.right.fill", "android": null },
|
||||
"accessibilityKey": { "web": "key", "ios": null, "android": null }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* ============================================================
|
||||
dudu · UI 图标单源(web 侧)
|
||||
Lucide 线条图标(ISC):24 网格、2px 圆头描边、currentColor。
|
||||
新增图标:从 lucide.dev 复制 path → 在此登记 → icon-map.json
|
||||
补跨端映射 → 登记簿(design/index.html)自动展示。
|
||||
禁止在业务代码里内联 <path>——check-code 闸会拦。
|
||||
============================================================ */
|
||||
|
||||
/** 24 网格图标 path 片段(不含 <svg> 外壳) */
|
||||
export const icons = {
|
||||
/** 麦克风(识别/录音) */
|
||||
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
|
||||
/** 对勾(完成/选中) */
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
/** 关闭 */
|
||||
x: '<path d="M18 6 6 18M6 6l12 12"/>',
|
||||
/** 添加图片(反馈附图) */
|
||||
imagePlus:
|
||||
'<path d="M16 5h6"/><path d="M19 2v6"/><path d="M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
|
||||
/** 钥匙(权限引导) */
|
||||
key: '<path d="m15.5 7.5 3 3L22 7l-3-3"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
|
||||
};
|
||||
|
||||
/** 品牌 mark(48 网格、3.5 圆头——圆嘴 + 三条声波,与 assets/logo-mark.svg 同构) */
|
||||
export const logoMark =
|
||||
'<circle cx="24" cy="24" r="18"/><path d="M17 20.5v7"/><path d="M24 16.5v15"/><path d="M31 20.5v7"/>';
|
||||
|
||||
/** 生成完整 <svg> 字符串(框架无关,官网/innerHTML 场景用) */
|
||||
export function svgIcon(name, { size = 16, sw = 2 } = {}) {
|
||||
const body = icons[name];
|
||||
if (!body) throw new Error(`unknown icon: ${name}`);
|
||||
return (
|
||||
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +
|
||||
`stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>dudu 设计系统 · 登记簿</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
<style>
|
||||
/* 登记簿版式(色值一律 var(--*),本页也受 check-ds 闸约束) */
|
||||
body { margin: 0; background: var(--bg-app); color: var(--text-1); font-family: var(--font-sans); line-height: var(--leading-normal); }
|
||||
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 24px 80px; }
|
||||
header.hero { padding: 40px 0 8px; display: flex; align-items: center; justify-content: space-between; }
|
||||
header.hero h1 { font-size: var(--text-2xl); margin: 0; }
|
||||
header.hero p { color: var(--text-2); margin: 4px 0 0; font-size: var(--text-sm); }
|
||||
.theme-btn { height: var(--control-sm); padding: 0 14px; border-radius: var(--radius-sm); border: 1px solid var(--border-2); background: var(--surface-card); color: var(--text-1); cursor: pointer; font-size: var(--text-sm); }
|
||||
section { background: var(--surface-card); border: 1px solid var(--border-1); border-radius: var(--radius-lg); padding: 22px 26px; margin-top: 20px; box-shadow: var(--shadow-card); }
|
||||
h2 { font-size: var(--text-lg); margin: 0 0 12px; }
|
||||
h3 { font-size: var(--text-base); margin: 18px 0 8px; color: var(--accent-text); }
|
||||
.rule { border-left: 3px solid var(--accent); background: var(--accent-soft); padding: 8px 14px; border-radius: 0 var(--radius-sm) var(--radius-sm) 0; font-size: var(--text-sm); margin: 8px 0; }
|
||||
.sw-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.sw { width: 88px; font-size: var(--text-xs); color: var(--text-2); }
|
||||
.sw i { display: block; height: 40px; border-radius: var(--radius-sm); border: 1px solid var(--border-1); margin-bottom: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); margin-top: 8px; }
|
||||
th, td { border: 1px solid var(--border-1); padding: 6px 10px; text-align: left; }
|
||||
th { background: var(--surface-2); }
|
||||
a { color: var(--accent-text); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code { background: var(--surface-2); padding: 1px 6px; border-radius: var(--radius-xs); font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 10px; }
|
||||
.entry { border: 1px solid var(--border-1); border-radius: var(--radius-md); padding: 12px 14px; }
|
||||
.entry b { font-size: var(--text-base); }
|
||||
.entry .sub { color: var(--text-2); font-size: var(--text-xs); margin: 3px 0 6px; }
|
||||
.entry .meta { font-size: var(--text-xs); color: var(--text-3); }
|
||||
#icon-grid { display: flex; flex-wrap: wrap; gap: 14px; }
|
||||
#icon-grid .ic { width: 96px; text-align: center; font-size: var(--text-xs); color: var(--text-2); }
|
||||
#icon-grid .ic .box { height: 48px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--border-1); border-radius: var(--radius-sm); margin-bottom: 4px; color: var(--text-1); }
|
||||
.note { color: var(--text-3); font-size: var(--text-xs); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header class="hero">
|
||||
<div>
|
||||
<h1>dudu 设计系统 · 登记簿</h1>
|
||||
<p>单一真相源活文档 —— 所有 token / 组件 / 图标必须在本页登记(check-ds 闸强制)。评审预览:<code>node design/serve.mjs</code></p>
|
||||
</div>
|
||||
<button class="theme-btn" onclick="const r=document.documentElement; r.dataset.theme = r.dataset.theme==='dark'?'':'dark'">切换 Light / Dark</button>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>规则速览(L1 · 设计元素单源)</h2>
|
||||
<div class="rule">新增<b>颜色/字号/间距</b>:先登记 <code>tokens/*.css</code> → <code>node design-pipeline/export-tokens.mjs</code> 再生成各端 → 业务代码只引 token。</div>
|
||||
<div class="rule">新增<b>组件</b>:先建 <code>components/<组>/X.jsx</code>(+ .d.ts)并在本页「组件登记」加卡,再到各端实现。</div>
|
||||
<div class="rule">新增<b>图标</b>:先登记 <code>icons.js</code>(Lucide 24 网格 2px 圆头)+ <code>icon-map.json</code> 跨端映射,业务代码禁止内联 <path>。</div>
|
||||
<div class="rule">违规谁拦:<code>design-pipeline/check-ds.mjs</code>(真相源自检)/ <code>check-code.mjs</code>(代码侧单源)/ <code>export-tokens.mjs --check</code>(产物零 diff),pre-commit 与 CI 双挂。</div>
|
||||
|
||||
<h3>各端消费方式</h3>
|
||||
<table>
|
||||
<tr><th>端</th><th>消费方式</th><th>令牌文件</th></tr>
|
||||
<tr><td>桌面(Tauri web 层)</td><td>vite alias <code>@dudu/design</code> 直引 CSS 变量与组件</td><td><code>design/tokens/*.css</code>(源)</td></tr>
|
||||
<tr><td>iOS(SwiftUI)</td><td><code>DuduTheme.*</code> 常量(codegen 直写)</td><td><code>ios/dudu/Shared/DuduTheme.swift</code></td></tr>
|
||||
<tr><td>Android(Compose)</td><td><code>DuduTheme.Light/Dark</code> → <code>DuduPalette</code> 注入</td><td><code>android/.../design/DuduTheme.kt</code></td></tr>
|
||||
<tr><td>官网(web/)</td><td><code><link href="tokens.css"></code>,页面只允许 var(--*)</td><td><code>web/tokens.css</code>(codegen,含 Outfit 内嵌)</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Tokens 色板</h2>
|
||||
<p class="note">色块直接渲染 var(--token),与 tokens/colors.css 永远同步;右上角切主题查看 dark 值。完整定义见 <a href="./tokens/colors.css">colors.css</a> · <a href="./tokens/typography.css">typography.css</a> · <a href="./tokens/spacing.css">spacing.css</a> · <a href="./tokens/effects.css">effects.css</a> · <a href="./tokens/fonts.css">fonts.css</a></p>
|
||||
<h3>品牌蓝阶梯</h3>
|
||||
<div class="sw-row">
|
||||
<div class="sw"><i style="background:var(--blue-50)"></i>blue-50</div>
|
||||
<div class="sw"><i style="background:var(--blue-100)"></i>blue-100</div>
|
||||
<div class="sw"><i style="background:var(--blue-200)"></i>blue-200</div>
|
||||
<div class="sw"><i style="background:var(--blue-300)"></i>blue-300</div>
|
||||
<div class="sw"><i style="background:var(--blue-400)"></i>blue-400</div>
|
||||
<div class="sw"><i style="background:var(--blue-500)"></i>blue-500 ★</div>
|
||||
<div class="sw"><i style="background:var(--blue-600)"></i>blue-600</div>
|
||||
<div class="sw"><i style="background:var(--blue-700)"></i>blue-700</div>
|
||||
<div class="sw"><i style="background:var(--blue-800)"></i>blue-800</div>
|
||||
<div class="sw"><i style="background:var(--blue-900)"></i>blue-900</div>
|
||||
</div>
|
||||
<h3>中性阶梯(冷灰)</h3>
|
||||
<div class="sw-row">
|
||||
<div class="sw"><i style="background:var(--gray-0)"></i>gray-0</div>
|
||||
<div class="sw"><i style="background:var(--gray-25)"></i>gray-25</div>
|
||||
<div class="sw"><i style="background:var(--gray-50)"></i>gray-50</div>
|
||||
<div class="sw"><i style="background:var(--gray-100)"></i>gray-100</div>
|
||||
<div class="sw"><i style="background:var(--gray-200)"></i>gray-200</div>
|
||||
<div class="sw"><i style="background:var(--gray-300)"></i>gray-300</div>
|
||||
<div class="sw"><i style="background:var(--gray-400)"></i>gray-400</div>
|
||||
<div class="sw"><i style="background:var(--gray-500)"></i>gray-500</div>
|
||||
<div class="sw"><i style="background:var(--gray-600)"></i>gray-600</div>
|
||||
<div class="sw"><i style="background:var(--gray-700)"></i>gray-700</div>
|
||||
<div class="sw"><i style="background:var(--gray-800)"></i>gray-800</div>
|
||||
<div class="sw"><i style="background:var(--gray-900)"></i>gray-900</div>
|
||||
<div class="sw"><i style="background:var(--gray-950)"></i>gray-950</div>
|
||||
</div>
|
||||
<h3>语义与功能</h3>
|
||||
<div class="sw-row">
|
||||
<div class="sw"><i style="background:var(--accent)"></i>accent</div>
|
||||
<div class="sw"><i style="background:var(--accent-hover)"></i>accent-hover</div>
|
||||
<div class="sw"><i style="background:var(--accent-press)"></i>accent-press</div>
|
||||
<div class="sw"><i style="background:var(--accent-soft)"></i>accent-soft</div>
|
||||
<div class="sw"><i style="background:var(--positive)"></i>positive</div>
|
||||
<div class="sw"><i style="background:var(--warning)"></i>warning</div>
|
||||
<div class="sw"><i style="background:var(--danger)"></i>danger</div>
|
||||
<div class="sw"><i style="background:var(--bg-app)"></i>bg-app</div>
|
||||
<div class="sw"><i style="background:var(--surface-card)"></i>surface-card</div>
|
||||
<div class="sw"><i style="background:var(--surface-2)"></i>surface-2</div>
|
||||
<div class="sw"><i style="background:var(--surface-3)"></i>surface-3</div>
|
||||
<div class="sw"><i style="background:var(--border-1)"></i>border-1</div>
|
||||
</div>
|
||||
<h3>浮层与第三方品牌</h3>
|
||||
<div class="sw-row">
|
||||
<div class="sw"><i style="background:var(--overlay-bg)"></i>overlay-bg</div>
|
||||
<div class="sw"><i style="background:var(--overlay-text)"></i>overlay-text</div>
|
||||
<div class="sw"><i style="background:var(--overlay-text-2)"></i>overlay-text-2</div>
|
||||
<div class="sw"><i style="background:var(--overlay-text-3)"></i>overlay-text-3</div>
|
||||
<div class="sw"><i style="background:var(--overlay-wave)"></i>overlay-wave</div>
|
||||
<div class="sw"><i style="background:var(--brand-wechat)"></i>brand-wechat</div>
|
||||
<div class="sw"><i style="background:var(--brand-wechat-press)"></i>brand-wechat-press</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>组件登记</h2>
|
||||
<p class="note">每个 <code>components/**/*.jsx</code> 组件必须在此有登记卡(check-ds 第④道)。演示卡:<a href="./components/core/core.card.html">core</a> · <a href="./components/forms/forms.card.html">forms</a> · <a href="./components/voice/voice.card.html">voice</a></p>
|
||||
<h3>core</h3>
|
||||
<div class="grid">
|
||||
<div class="entry"><b>Button</b><div class="sub">主/次/幽灵/危险按钮,三种尺寸</div><div class="meta">variant · size · block — components/core/Button.jsx</div></div>
|
||||
<div class="entry"><b>IconButton</b><div class="sub">方形图标按钮</div><div class="meta">size · label(无障碍必填)— components/core/IconButton.jsx</div></div>
|
||||
<div class="entry"><b>Badge</b><div class="sub">状态徽标</div><div class="meta">tone: green/blue/orange/red/neutral — components/core/Badge.jsx</div></div>
|
||||
<div class="entry"><b>Kbd</b> / <b>HotkeyCombo</b><div class="sub">键帽与快捷键组合(⌘ ⇧ Space)</div><div class="meta">keys=['⌘','⇧','Space'] — components/core/Kbd.jsx</div></div>
|
||||
<div class="entry"><b>Card</b><div class="sub">标准卡片容器</div><div class="meta">padding — components/core/Card.jsx</div></div>
|
||||
<div class="entry"><b>ProgressBar</b><div class="sub">用量进度条,超阈自动警告色</div><div class="meta">value/max · warnAt=0.9 — components/core/ProgressBar.jsx</div></div>
|
||||
<div class="entry"><b>Icon</b><div class="sub">icons.js 单源图标渲染器</div><div class="meta">name · size · sw — components/core/Icon.jsx</div></div>
|
||||
</div>
|
||||
<h3>forms</h3>
|
||||
<div class="grid">
|
||||
<div class="entry"><b>Input</b><div class="sub">文本输入框</div><div class="meta">size: md/lg — components/forms/Input.jsx</div></div>
|
||||
<div class="entry"><b>Switch</b><div class="sub">开关</div><div class="meta">checked · onChange — components/forms/Switch.jsx</div></div>
|
||||
<div class="entry"><b>SettingRow</b><div class="sub">设置行(标签 + 描述 + 右侧控件)</div><div class="meta">label · description — components/forms/SettingRow.jsx</div></div>
|
||||
</div>
|
||||
<h3>voice</h3>
|
||||
<div class="grid">
|
||||
<div class="entry"><b>RecognitionOverlay</b><div class="sub">桌面识别浮层 · 聆听/流式两态</div><div class="meta">state · text — components/voice/RecognitionOverlay.jsx</div></div>
|
||||
<div class="entry"><b>MicBar</b><div class="sub">移动端按住说话 · 四种状态</div><div class="meta">state — components/voice/MicBar.jsx</div></div>
|
||||
<div class="entry"><b>Waveform</b><div class="sub">波形条(唯一循环动画,仅录音中)</div><div class="meta">active · bars · height · color — components/voice/Waveform.jsx</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>图标登记(icons.js + icon-map.json)</h2>
|
||||
<p class="note">Lucide 线条 · 24 网格 · 2px 圆头 · currentColor。下方由 <code>icons.js</code> 实时渲染(需经 serve.mjs 访问;file:// 打开时仅显示映射表)。</p>
|
||||
<div id="icon-grid"></div>
|
||||
<div id="icon-map-table"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Guidelines 规范卡</h2>
|
||||
<div class="grid">
|
||||
<div class="entry"><b><a href="./guidelines/brand-logo.card.html">Logo</a></b><div class="sub">圆形嘴巴 + 声波线条 · 线性风格</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/brand-wordmark.card.html">Wordmark</a></b><div class="sub">dudu 几何线条字标</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/colors-brand.card.html">品牌蓝阶梯</a></b><div class="sub">blue-50 → 900</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/colors-neutrals.card.html">中性阶梯</a></b><div class="sub">gray-0 → 950 冷灰</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/colors-surfaces.card.html">表面与文字</a></b><div class="sub">bg → surface → text,Light/Dark</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/colors-semantic.card.html">功能色</a></b><div class="sub">绿=录音/成功 · 琥珀=警告 · 红=错误</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/colors-overlay.card.html">识别浮层色</a></b><div class="sub">双模式深色玻璃</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/type-families.card.html">字体家族</a></b><div class="sub">Outfit(自托管)+ 系统中文栈</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/type-scale.card.html">字号阶梯</a></b><div class="sub">12 → 44</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/type-recognition.card.html">识别文本规范</a></b><div class="sub">final 实色 · partial 弱色</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/spacing-grid.card.html">间距网格</a></b><div class="sub">4px 基准</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/spacing-radius.card.html">圆角阶梯</a></b><div class="sub">xs 4 → full 999</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/spacing-controls.card.html">控件高度</a></b><div class="sub">桌面 28/36 · 移动 44/56</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/spacing-elevation.card.html">层级与阴影</a></b><div class="sub">card → menu → window → overlay</div></div>
|
||||
<div class="entry"><b><a href="./guidelines/motion.card.html">动效</a></b><div class="sub">快入慢出 · 120/200/320ms · 无弹跳</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>UI Kits(屏级原型)</h2>
|
||||
<div class="grid">
|
||||
<div class="entry"><b><a href="./ui_kits/desktop/index.html">桌面端 UI Kit</a></b><div class="sub">识别浮层 · 托盘菜单 · 设置 · 登录购买(可交互,Light/Dark)</div></div>
|
||||
<div class="entry"><b><a href="./ui_kits/mobile/index.html">移动端 UI Kit</a></b><div class="sub">语音键盘(按住说话)+ 主 App 四屏(可交互,Light/Dark)</div></div>
|
||||
</div>
|
||||
<p class="note">已知滞后:ui_kits 预览页仍经 unpkg CDN 加载 React/Babel,离线/无代理网络下不可用(治理项见 doc/frontend-overview.html)。</p>
|
||||
</section>
|
||||
|
||||
<script type="module">
|
||||
// 图标区:从单源渲染(serve.mjs 下可用;file:// 因模块 CORS 限制自动降级为仅映射表)
|
||||
try {
|
||||
const { icons } = await import('./icons.js');
|
||||
const map = await (await fetch('./icon-map.json')).json();
|
||||
const grid = document.getElementById('icon-grid');
|
||||
grid.innerHTML = Object.entries(icons).map(([k, body]) =>
|
||||
`<div class="ic"><div class="box"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg></div>${k}</div>`
|
||||
).join('');
|
||||
const rows = Object.entries(map).filter(([k]) => k !== '$schema').map(([k, v]) =>
|
||||
`<tr><td><code>${k}</code></td><td>${v.web ?? '—'}</td><td>${v.ios ?? '—'}</td><td>${v.android ?? '—'}</td></tr>`
|
||||
).join('');
|
||||
document.getElementById('icon-map-table').innerHTML =
|
||||
`<table><tr><th>语义名</th><th>web(icons.js)</th><th>iOS(SF Symbol)</th><th>Android</th></tr>${rows}</table>`;
|
||||
} catch {
|
||||
document.getElementById('icon-map-table').innerHTML =
|
||||
'<p class="note">file:// 模式无法加载 icons.js / icon-map.json,请 <code>node design/serve.mjs</code> 后访问。</p>';
|
||||
}
|
||||
</script>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
// 零依赖本地热重载静态服务器
|
||||
// 用法: node serve.mjs [port] 默认 5180
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = Number(process.argv[2]) || 5180;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
const clients = new Set();
|
||||
const RELOAD_SNIPPET = `\n<script>
|
||||
(function(){ try{
|
||||
var es=new EventSource('/__reload');
|
||||
es.onmessage=function(){ location.reload(); };
|
||||
}catch(e){} })();
|
||||
</script>\n`;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/__reload') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write('retry: 500\n\n');
|
||||
clients.add(res);
|
||||
req.on('close', () => clients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
let urlPath = decodeURIComponent(req.url.split('?')[0]);
|
||||
if (urlPath === '/') urlPath = '/index.html';
|
||||
const filePath = path.join(ROOT, urlPath);
|
||||
if (!filePath.startsWith(ROOT)) { res.writeHead(403); res.end('forbidden'); return; }
|
||||
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }); res.end('<h1>404</h1>'); return; }
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mime = MIME[ext] || 'application/octet-stream';
|
||||
const noCache = { 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0' };
|
||||
if (ext === '.html') {
|
||||
const html = data.toString('utf8').replace('</body>', RELOAD_SNIPPET + '</body>');
|
||||
res.writeHead(200, { 'Content-Type': mime, ...noCache });
|
||||
res.end(html);
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': mime, ...noCache });
|
||||
res.end(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 设计系统守门:启动 + 每次改动自动跑 check-ds.mjs,控制台报 PASS/FAIL
|
||||
import { spawn } from 'node:child_process';
|
||||
function runDsCheck() {
|
||||
const p = spawn(process.execPath, [path.join(ROOT, 'tools', 'check-ds.mjs')], { cwd: ROOT });
|
||||
let out = '';
|
||||
p.stdout.on('data', d => out += d);
|
||||
p.stderr.on('data', d => out += d);
|
||||
p.on('close', code => {
|
||||
const t = new Date().toLocaleTimeString();
|
||||
if (code === 0) console.log(`\x1b[32m[设计系统 ✓ ${t}] screens 颜色全部走 token、变量均已定义\x1b[0m`);
|
||||
else {
|
||||
const m = out.match(/❶[^\n]*共 (\d+)[\s\S]*?❷[^\n]*共 (\d+)/);
|
||||
const sum = m ? `硬编码颜色 ${m[1]} · 未定义 token ${m[2]}` : '存在违规';
|
||||
console.log(`\x1b[31m[设计系统 ✗ ${t}] ${sum} —— 运行 node tools/check-ds.mjs 看详情\x1b[0m`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let debounce;
|
||||
fs.watch(ROOT, { recursive: true }, (_ev, file) => {
|
||||
if (file && file.endsWith('serve.mjs')) return;
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(() => {
|
||||
for (const c of clients) c.write('data: reload\n\n');
|
||||
if (file && /\.(html|css|js)$/.test(file)) runDsCheck();
|
||||
}, 80);
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`prototype live at http://localhost:${PORT}/ (watching ${ROOT})`);
|
||||
runDsCheck();
|
||||
});
|
||||
@@ -73,8 +73,13 @@
|
||||
--overlay-text: #F2F4FA;
|
||||
--overlay-text-2: #9AA3BD;
|
||||
--overlay-text-3: #646E8C;
|
||||
--overlay-wave: #7C9BFF;
|
||||
|
||||
--focus-ring: 0 0 0 3px var(--blue-200);
|
||||
|
||||
/* 第三方品牌色(微信登录/支付按钮,官方规范色,双主题同值)*/
|
||||
--brand-wechat: #07C160;
|
||||
--brand-wechat-press: #06AD56;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
@@ -108,6 +113,10 @@
|
||||
--overlay-text: #F2F4FA;
|
||||
--overlay-text-2: #9AA3BD;
|
||||
--overlay-text-3: #646E8C;
|
||||
--overlay-wave: #7C9BFF;
|
||||
|
||||
--focus-ring: 0 0 0 3px rgba(92, 119, 232, 0.4);
|
||||
|
||||
--brand-wechat: #07C160;
|
||||
--brand-wechat-press: #06AD56;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
/* dudu · 品牌字体
|
||||
Outfit(Google Fonts CDN)— 圆润几何无衬线,用于品牌拉丁字
|
||||
"dudu"、数字与价格;中文 UI 文本走系统字体栈(PingFang SC /
|
||||
Outfit(圆润几何无衬线,可变字重 400-700,latin 子集)——用于品牌
|
||||
拉丁字 "dudu"、数字与价格;中文 UI 文本走系统字体栈(PingFang SC /
|
||||
MiSans / HarmonyOS Sans / 雅黑)。
|
||||
注:暂以 CDN 引入;如有自托管字体文件请替换为 @font-face。 */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap");
|
||||
自托管于 ../assets/outfit-latin.woff2(40KB),禁外部 CDN——
|
||||
中国网络下 fonts.googleapis.com 不可用会卡字体加载。
|
||||
官网产物由 design-pipeline/export-tokens.mjs 生成 data URI 内嵌版。 */
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
font-style: normal;
|
||||
font-weight: 400 700;
|
||||
font-display: swap;
|
||||
src: url('../assets/outfit-latin.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@@ -83,9 +83,9 @@ function MacWindow({ title, width = 460, children, style }) {
|
||||
display: 'flex', alignItems: 'center', gap: 7, padding: '10px 14px',
|
||||
background: 'var(--surface-2)', borderBottom: '1px solid var(--border-1)',
|
||||
}}>
|
||||
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#FF5F57' }}></span>
|
||||
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#FEBC2E' }}></span>
|
||||
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#28C840' }}></span>
|
||||
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#FF5F57' /* ds-ignore: mac 红绿灯窗饰,系统固定色 */ }}></span>
|
||||
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#FEBC2E' /* ds-ignore: mac 红绿灯窗饰,系统固定色 */ }}></span>
|
||||
<span style={{ width: 11, height: 11, borderRadius: '50%', background: '#28C840' /* ds-ignore: mac 红绿灯窗饰,系统固定色 */ }}></span>
|
||||
<span style={{ marginLeft: 10, fontSize: 'var(--text-xs)', color: 'var(--text-2)', whiteSpace: 'nowrap' }}>{title}</span>
|
||||
</div>
|
||||
{children}
|
||||
@@ -109,7 +109,7 @@ function FakeQr({ size = 148, seed = 7 }) {
|
||||
} else {
|
||||
on = rnd() > 0.52;
|
||||
}
|
||||
if (on) cells.push(<span key={x + '-' + y} style={{ gridColumn: x + 1, gridRow: y + 1, background: '#1B1E26', borderRadius: 1 }}></span>);
|
||||
if (on) cells.push(<span key={x + '-' + y} style={{ gridColumn: x + 1, gridRow: y + 1, background: 'var(--gray-900)', borderRadius: 1 }}></span>);
|
||||
}
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -15,7 +15,7 @@ function TrayMenu() {
|
||||
transition: 'background var(--dur-fast) var(--ease-out)',
|
||||
}}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, whiteSpace: 'nowrap' }}>{children}</span>
|
||||
{right && <span style={{ fontSize: 'var(--text-xs)', color: hover ? 'rgba(255,255,255,0.8)' : 'var(--text-3)', whiteSpace: 'nowrap' }}>{right}</span>}
|
||||
{right && <span style={{ fontSize: 'var(--text-xs)', color: hover ? 'var(--text-on-accent)' : 'var(--text-3)', opacity: hover ? 0.8 : 1, whiteSpace: 'nowrap' }}>{right}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ function LoginScreen() {
|
||||
</div>
|
||||
<button type="button" style={{
|
||||
height: 'var(--control-xl)', borderRadius: 'var(--radius-full)', border: 'none', cursor: 'pointer',
|
||||
background: '#07C160', color: '#fff', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-semibold)',
|
||||
background: 'var(--brand-wechat)', color: 'var(--text-on-accent)', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-semibold)',
|
||||
fontFamily: 'var(--font-sans)', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, width: '100%',
|
||||
}}>
|
||||
<DuMIcons.Wechat size={19} /> 微信一键登录
|
||||
|
||||
@@ -83,7 +83,7 @@ function DuduMLogo({ size = 20, sw = 3.5 }) {
|
||||
function PhoneFrame({ width = 312, height = 596, children }) {
|
||||
return (
|
||||
<div style={{
|
||||
width, height, boxSizing: 'border-box', background: '#0B0C10', borderRadius: 38,
|
||||
width, height, boxSizing: 'border-box', background: '#0B0C10' /* ds-ignore: 手机外壳装饰,非 UI 色 */, borderRadius: 38,
|
||||
padding: 9, boxShadow: 'var(--shadow-window)', flex: 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
@@ -92,7 +92,7 @@ function PhoneFrame({ width = 312, height = 596, children }) {
|
||||
}}>
|
||||
{/* 状态栏 */}
|
||||
<div style={{ height: 30, flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ width: 84, height: 18, background: '#0B0C10', borderRadius: 10 }}></div>
|
||||
<div style={{ width: 84, height: 18, background: '#0B0C10' /* ds-ignore: 手机刘海装饰,非 UI 色 */, borderRadius: 10 }}></div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 32 KiB |
@@ -127,11 +127,13 @@ pub fn stop(app: &AppHandle, canceled: bool) {
|
||||
let release_at = Instant::now();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if !canceled {
|
||||
// 事件驱动:收到 stop 后的尾部 final(或 usage 结算帧)即触发注入;
|
||||
// 350ms 仅作超时兜底,避免 flush 慢于固定 sleep 截丢尾 final(18C)。
|
||||
// 事件驱动:收到 stop 后的尾部 final(或 usage 结算帧)即触发注入(18C)。
|
||||
// 服务端已保证 stop 后不再下发周期 usage 帧(结算帧必在全部尾部 final
|
||||
// 之后,#23),二者均可安全作为提交信号。800ms 仅作超时兜底:
|
||||
// 12B 实测 8s 会话的 final flush 需 350~540ms,350ms 兜底会截丢尾 final。
|
||||
tokio::select! {
|
||||
_ = finalized.notified() => {}
|
||||
_ = tokio::time::sleep(Duration::from_millis(350)) => {}
|
||||
_ = tokio::time::sleep(Duration::from_millis(800)) => {}
|
||||
}
|
||||
|
||||
let buf = app.state::<CommitBuffer>();
|
||||
|
||||
@@ -85,6 +85,12 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": ["icons/icon.png"]
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@dudu/design/styles.css';
|
||||
import { Button } from '@dudu/design/components/core/Button';
|
||||
import { Icon } from '@dudu/design/components/core/Icon';
|
||||
import { Switch } from '@dudu/design/components/forms/Switch';
|
||||
import { SettingRow } from '@dudu/design/components/forms/SettingRow';
|
||||
import { invoke } from '../shared/tauri';
|
||||
@@ -45,7 +46,7 @@ function Thumb({ img, onRemove }) {
|
||||
background: 'var(--text-1)', color: 'var(--bg-app)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
|
||||
}}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
<Icon name="x" size={10} sw={3} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -65,11 +66,7 @@ function AddBox({ onClick, dragging }) {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'border-color var(--dur-fast) var(--ease-out)',
|
||||
}}>
|
||||
{/* Lucide image-plus */}
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 5h6"/><path d="M19 2v6"/><path d="M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5"/>
|
||||
<circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
|
||||
</svg>
|
||||
<Icon name="imagePlus" size={20} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -81,7 +78,7 @@ function SuccessView({ onAgain }) {
|
||||
width: 56, height: 56, borderRadius: '50%', background: 'var(--positive-soft)', color: 'var(--positive)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
<Icon name="check" size={28} sw={2.5} />
|
||||
</span>
|
||||
<div style={{ fontSize: 17, fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>已收到 · 谢谢你</div>
|
||||
<Button variant="ghost" size="sm" onClick={onAgain}>继续反馈</Button>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import '@dudu/design/styles.css';
|
||||
import { Button } from '@dudu/design/components/core/Button';
|
||||
import { Badge } from '@dudu/design/components/core/Badge';
|
||||
import { Icon } from '@dudu/design/components/core/Icon';
|
||||
import { invoke } from '../shared/tauri';
|
||||
import { setThemePref } from '../shared/theme';
|
||||
import { usePoll } from '../shared/usePoll';
|
||||
@@ -172,7 +173,7 @@ function LoginApp() {
|
||||
width: 56, height: 56, borderRadius: '50%', background: 'var(--positive-soft)', color: 'var(--positive)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28,
|
||||
}}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
<Icon name="check" size={28} sw={2.5} />
|
||||
</span>
|
||||
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>已到账 {pack.minutes} 分钟</div>
|
||||
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import '@dudu/design/styles.css';
|
||||
import { Button } from '@dudu/design/components/core/Button';
|
||||
import { Badge } from '@dudu/design/components/core/Badge';
|
||||
import { HotkeyCombo } from '@dudu/design/components/core/Kbd';
|
||||
import { Icon } from '@dudu/design/components/core/Icon';
|
||||
import { invoke } from '../shared/tauri';
|
||||
import { setThemePref } from '../shared/theme';
|
||||
import { usePoll } from '../shared/usePoll';
|
||||
@@ -56,17 +57,9 @@ function PermCard({ icon, title, desc, granted, onGrant }) {
|
||||
);
|
||||
}
|
||||
|
||||
const MicIcon = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>
|
||||
</svg>
|
||||
);
|
||||
const MicIcon = <Icon name="mic" size={18} />;
|
||||
|
||||
const KeyIcon = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m15.5 7.5 3 3L22 7l-3-3"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>
|
||||
</svg>
|
||||
);
|
||||
const KeyIcon = <Icon name="key" size={18} />;
|
||||
|
||||
function OnboardingApp() {
|
||||
const [step, setStep] = useState(0);
|
||||
@@ -105,9 +98,7 @@ function OnboardingApp() {
|
||||
width: 64, height: 64, borderRadius: 18, background: 'var(--accent)', color: 'var(--text-on-accent)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto',
|
||||
}}>
|
||||
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>
|
||||
</svg>
|
||||
<Icon name="mic" size={30} />
|
||||
</span>
|
||||
<Title>dudu 语音输入</Title>
|
||||
<Desc>按住快捷键说话,松开文字就出现在光标处<br />全程比打字快三倍</Desc>
|
||||
|
||||
@@ -18,7 +18,7 @@ function Item({ children, right, onClick }) {
|
||||
transition: 'background var(--dur-fast) var(--ease-out)',
|
||||
}}>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{children}</span>
|
||||
{right && <span style={{ fontSize: 'var(--text-xs)', color: hover ? 'rgba(255,255,255,.8)' : 'var(--text-3)' }}>{right}</span>}
|
||||
{right && <span style={{ fontSize: 'var(--text-xs)', color: hover ? 'var(--text-on-accent)' : 'var(--text-3)', opacity: hover ? 0.8 : 1 }}>{right}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>dudu 前端全景:设计真相源与防漂移体系</title>
|
||||
<style>
|
||||
:root {
|
||||
--accent: #4F6EF7;
|
||||
--accent-text: #3D58DB;
|
||||
--accent-soft: #EEF1FE;
|
||||
--bg: #F6F7FA;
|
||||
--card: #FFFFFF;
|
||||
--text-1: #1B1E26;
|
||||
--text-2: #5A6072;
|
||||
--text-3: #A6ABB8;
|
||||
--border-1: #E4E6EB;
|
||||
--positive: #16A34A;
|
||||
--warning: #E8890C;
|
||||
--danger: #DC2626;
|
||||
--code-bg: #1B1E26;
|
||||
--code-text: #D6DCEA;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||
background: var(--bg); color: var(--text-1); line-height: 1.75;
|
||||
}
|
||||
.container { max-width: 980px; margin: 0 auto; padding: 0 24px 80px; }
|
||||
header.hero { background: var(--accent); color: #fff; padding: 48px 24px 40px; text-align: center; }
|
||||
header.hero h1 { margin: 0 0 6px; font-size: 30px; }
|
||||
header.hero p { margin: 0; opacity: .85; font-size: 15px; }
|
||||
.chips { margin-top: 16px; }
|
||||
.chip { display: inline-block; padding: 3px 14px; margin: 3px; background: rgba(255,255,255,.16); border-radius: 999px; font-size: 13px; }
|
||||
section.card {
|
||||
background: var(--card); border: 1px solid var(--border-1); border-radius: 14px;
|
||||
padding: 28px 32px; margin-top: 24px; box-shadow: 0 1px 3px rgba(17,19,26,.05);
|
||||
}
|
||||
h2 { font-size: 21px; margin: 0 0 14px; padding-bottom: 8px; border-bottom: 2px solid var(--accent); display: inline-block; }
|
||||
h3 { font-size: 16px; margin: 22px 0 8px; color: var(--accent-text); }
|
||||
table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 13.5px; }
|
||||
th, td { border: 1px solid var(--border-1); padding: 7px 11px; text-align: left; vertical-align: top; }
|
||||
th { background: #F0F1F4; font-weight: 600; }
|
||||
tr:nth-child(even) td { background: #FCFCFD; }
|
||||
pre.code, pre.diagram {
|
||||
background: var(--code-bg); color: var(--code-text); padding: 16px 18px; border-radius: 10px;
|
||||
overflow-x: auto; font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12.5px; line-height: 1.55;
|
||||
}
|
||||
code.inline { background: var(--accent-soft); color: var(--accent-text); padding: 1px 6px; border-radius: 4px; font-family: "SF Mono", Menlo, monospace; font-size: 12.5px; }
|
||||
.callout { border-left: 4px solid var(--accent); background: var(--accent-soft); padding: 11px 16px; border-radius: 0 8px 8px 0; margin: 12px 0; font-size: 13.5px; }
|
||||
.callout.warn { border-color: var(--warning); background: #FCEFD7; }
|
||||
.tag { display: inline-block; font-size: 12px; padding: 1px 10px; border-radius: 999px; margin-right: 6px; font-weight: 600; }
|
||||
.tag.green { background: #DCFCE7; color: var(--positive); }
|
||||
.tag.orange { background: #FCEFD7; color: var(--warning); }
|
||||
.tag.blue { background: var(--accent-soft); color: var(--accent-text); }
|
||||
ul, ol { padding-left: 22px; }
|
||||
li { margin: 3px 0; }
|
||||
footer { text-align: center; color: var(--text-3); font-size: 13px; margin-top: 44px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hero">
|
||||
<h1>dudu 前端全景</h1>
|
||||
<p>设计真相源(design/)· 五端消费 · 防漂移闸体系 —— 参考 jiu ds-flow 思想的非 Flutter 落地</p>
|
||||
<div class="chips">
|
||||
<span class="chip">2026-07-11</span>
|
||||
<span class="chip">登记簿:design/index.html</span>
|
||||
<span class="chip">闸:design-pipeline/check-ds · check-code · export-tokens --check</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<section class="card">
|
||||
<h2>一、一次 UI 改动的标准路径</h2>
|
||||
<pre class="diagram">改颜色/字号/间距 design/tokens/*.css 登记
|
||||
│
|
||||
▼
|
||||
node design-pipeline/export-tokens.mjs
|
||||
│(直写四端消费位置)
|
||||
┌──────────┬──────┴──────┬──────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
desktop iOS Swift Android Kotlin 官网 web/
|
||||
CSS 变量 DuduTheme DuduTheme tokens.css
|
||||
(直引源) (生成) (生成) (生成,含字体内嵌)
|
||||
|
||||
改组件 / 图标 先在 design/ 登记(components/ + index.html 卡;
|
||||
icons.js + icon-map.json)→ 再到各端实现
|
||||
提交 pre-commit 自动跑对应闸;CI 三道闸复验</pre>
|
||||
<div class="callout"><b>核心原则(ds-flow):</b>设计决策只有一个出生地(design/),代码永远是镜像;绕过单源的行为由闸在提交/CI 时拦截。<b>规则写了没上闸 = 没有规则。</b></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>二、真相源结构(design/)</h2>
|
||||
<table>
|
||||
<tr><th>路径</th><th>角色</th></tr>
|
||||
<tr><td><code class="inline">index.html</code></td><td><b>登记簿</b>(活文档主页):token 色板 / 组件卡 / 图标映射 / guidelines / ui_kits 单一入口。新元素必须在此登记(check-ds ④ 强制)。评审:<code class="inline">node design/serve.mjs</code></td></tr>
|
||||
<tr><td><code class="inline">tokens/*.css</code></td><td>令牌单源:colors(含 --brand-wechat / --overlay-wave)/ typography / spacing / effects / fonts(Outfit 自托管,禁 CDN)</td></tr>
|
||||
<tr><td><code class="inline">icons.js + icon-map.json</code></td><td>图标单源(Lucide 线条 24 网格)+ 语义名→三端实现映射表;业务代码禁内联 <path></td></tr>
|
||||
<tr><td><code class="inline">components/</code>(core/forms/voice)</td><td>参考组件(React),桌面端直接运行时消费;Icon.jsx 渲染 icons.js</td></tr>
|
||||
<tr><td><code class="inline">ui_kits/</code>(desktop/mobile)</td><td>屏级交互原型(新屏 design-first 的出生地)</td></tr>
|
||||
<tr><td><code class="inline">guidelines/*.card.html</code></td><td>规范卡(品牌/色板/字体/间距/动效)</td></tr>
|
||||
<tr><td><code class="inline">readme.md / SKILL.md</code></td><td>设计基调、文案基调、视觉硬规则;agent 使用入口</td></tr>
|
||||
<tr><td><code class="inline">_ds_manifest.json</code></td><td>Claude 设计导出的机读清单——<b>非权威、可能滞后</b>,真相以 tokens/ 与登记簿为准</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>三、五端消费方式对照</h2>
|
||||
<table>
|
||||
<tr><th>端</th><th>技术栈</th><th>令牌</th><th>组件/图标</th></tr>
|
||||
<tr><td>桌面 Mac/Win</td><td>Tauri 2 + React</td><td>vite alias <code class="inline">@dudu/design</code> 直引 tokens CSS(无生成层)</td><td>直接 import design/components;Icon 组件渲染 icons.js</td></tr>
|
||||
<tr><td>iOS</td><td>SwiftUI</td><td><code class="inline">ios/dudu/Shared/DuduTheme.swift</code>(codegen 直写)</td><td>SF Symbols,须 ∈ icon-map ios 列(check-code 同集闸)</td></tr>
|
||||
<tr><td>Android</td><td>Compose</td><td><code class="inline">DuduTheme.kt</code>(codegen 直写)→ <code class="inline">DuduPalette</code> CompositionLocal</td><td>暂无图标使用;启用后同集闸生效</td></tr>
|
||||
<tr><td>官网</td><td>静态单页</td><td><code class="inline">web/tokens.css</code>(codegen:custom props + Outfit data URI)</td><td>页面只允许 var(--*);深色板块挂 <code class="inline">data-theme="dark"</code> 复用 dark 令牌</td></tr>
|
||||
<tr><td>原型自身</td><td>React(浏览器内 Babel)</td><td>styles.css @import tokens</td><td>与桌面同一套组件源码</td></tr>
|
||||
</table>
|
||||
<div class="callout"><b>codegen 直写消费位置</b>(2026-07-11 改):此前生成到 design-pipeline/generated/ 再手工拷贝进 app,<code class="inline">--check</code> 守不到真实文件、拷贝已漂移。现直写 + CI 校验真实消费文件,漂移类型整个消除。</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>四、各道闸全景</h2>
|
||||
<table>
|
||||
<tr><th>闸</th><th>拦什么</th><th>何时跑</th></tr>
|
||||
<tr><td><code class="inline">check-ds.mjs</code><br>真相源自检(7 道)</td><td>① dark 孤儿 token ② var() 断链/@import 缺失 ③ design/ 内裸色 ∉ tokens 值集 ④ 组件未登记登记簿 ⑤ icons.js 重复 key/填充色 ⑥ icon-map ↔ icons.js 双向不同集 ⑦ 界面文案 emoji</td><td>pre-commit(动 design/)+ CI</td></tr>
|
||||
<tr><td><code class="inline">export-tokens.mjs --check</code></td><td>四端令牌产物与源不一致(改了 tokens 忘 regen / 手改生成文件)</td><td>pre-commit(动 design/)+ CI</td></tr>
|
||||
<tr><td><code class="inline">check-code.mjs</code><br>代码侧单源(四端)</td><td>desktop 字面色/内联 <path>;iOS 字面色/未登记 SF Symbol;Android Color(0x;web 字面色</td><td>pre-commit(动端代码)+ CI</td></tr>
|
||||
</table>
|
||||
<h3>豁免机制</h3>
|
||||
<ul>
|
||||
<li>行级 <code class="inline">ds-ignore: 理由</code> 注释——必须写理由(mac 红绿灯窗饰、手机外壳装饰、alpha 派生色等)</li>
|
||||
<li>codegen 产物(DuduTheme.*、web/tokens.css)自动豁免</li>
|
||||
</ul>
|
||||
<h3>启用</h3>
|
||||
<pre class="code"># 一次性(每个 clone)
|
||||
git config core.hooksPath .githooks
|
||||
|
||||
# 手动全量
|
||||
node design-pipeline/check-ds.mjs && node design-pipeline/export-tokens.mjs --check && node design-pipeline/check-code.mjs</pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>五、三层分治规则(L1 / L2 / L3)</h2>
|
||||
<table>
|
||||
<tr><th>层</th><th>规则</th></tr>
|
||||
<tr><td><b>L1 设计系统元素</b><br>颜色/字号/间距/组件/图标</td><td>先登记 design/(tokens / components+登记簿 / icons+map)再写端代码,<b>无例外</b>。闸强制。</td></tr>
|
||||
<tr><td><b>L2 屏级</b></td><td>屏以 ui_kits 原型为基准;小迭代直改代码但不得引入 L1 违规(闸兜底);整屏改版原型先行。</td></tr>
|
||||
<tr><td><b>L3 新屏</b></td><td>design-first:ui_kits 加屏(serve.mjs 起服务给 URL 评审,不截图)→ 各端实现 → 登记。</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>六、已知滞后与后续项</h2>
|
||||
<ul>
|
||||
<li><span class="tag orange">todo #26</span>桌面 fidelity 像素闸(Playwright 截 ui_kits 原型 vs Tauri 页面 pixelmatch)——静态闸先行,像素级验收后续补</li>
|
||||
<li><span class="tag orange">待治理</span>ui_kits 预览页经 unpkg CDN 加载 React/Babel,离线/无代理网络不可用(评审时需网络;可选改为 vendor 本地化)</li>
|
||||
<li><span class="tag blue">说明</span>_ds_manifest.json 为 Claude 设计导出产物,未纳入闸;以 tokens/ 与登记簿为权威</li>
|
||||
<li><span class="tag blue">说明</span>移动端不做跨渲染器像素比对(字体度量漂移大,jiu 教训);iOS/Android 一致性由 token codegen + 同集闸保障</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<footer>dudu · 前端全景 · 2026-07-11 · 变更走 feat/design-truth-source</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>dudu 文档索引</title>
|
||||
<style>
|
||||
:root {
|
||||
--accent: #4F6EF7;
|
||||
--accent-text: #3D58DB;
|
||||
--accent-soft: #EEF1FE;
|
||||
--bg: #F6F7FA;
|
||||
--card: #FFFFFF;
|
||||
--text-1: #1B1E26;
|
||||
--text-2: #5A6072;
|
||||
--text-3: #A6ABB8;
|
||||
--border-1: #E4E6EB;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||
background: var(--bg); color: var(--text-1); line-height: 1.75;
|
||||
}
|
||||
.container { max-width: 980px; margin: 0 auto; padding: 0 24px 80px; }
|
||||
header.hero { background: var(--accent); color: #fff; padding: 48px 24px 40px; text-align: center; }
|
||||
header.hero h1 { margin: 0 0 6px; font-size: 30px; }
|
||||
header.hero p { margin: 0; opacity: .85; font-size: 15px; }
|
||||
section.card {
|
||||
background: var(--card); border: 1px solid var(--border-1); border-radius: 14px;
|
||||
padding: 28px 32px; margin-top: 24px; box-shadow: 0 1px 3px rgba(17,19,26,.05);
|
||||
}
|
||||
h2 { font-size: 21px; margin: 0 0 14px; padding-bottom: 8px; border-bottom: 2px solid var(--accent); display: inline-block; }
|
||||
ul { padding-left: 22px; }
|
||||
li { margin: 8px 0; }
|
||||
a { color: var(--accent-text); text-decoration: none; font-weight: 600; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.desc { color: var(--text-2); font-size: 13.5px; font-weight: 400; }
|
||||
footer { text-align: center; color: var(--text-3); font-size: 13px; margin-top: 44px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hero">
|
||||
<h1>dudu 文档索引</h1>
|
||||
<p>五端付费语音输入法 · 全部设计 / 计划 / 调研 / 排障文档单一入口</p>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<section class="card">
|
||||
<h2>设计方案</h2>
|
||||
<ul>
|
||||
<li><a href="frontend-design.html">前端交互与界面设计</a> <span class="desc">— 五端交互流、界面规格与设计系统对接(v1.1)</span></li>
|
||||
<li><a href="backend-architecture.html">后端与数据架构</a> <span class="desc">— API、WS 流式协议、时长账本计费、Redis/PG 设计(v1.1)</span></li>
|
||||
<li><a href="frontend-overview.html">前端全景:设计真相源与防漂移体系</a> <span class="desc">— 2026-07-11 · design/ 单源、五端消费对照、三道闸、L1/L2/L3 分治规则</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>实现计划</h2>
|
||||
<ul>
|
||||
<li><a href="plan/design.html">MVP 实现计划(阅读版)</a> <span class="desc">— 全端任务拆解与依赖关系</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>知识库调研 / 验收报告</h2>
|
||||
<ul>
|
||||
<li><a href="latency-acceptance-12b.html">12B 延迟验收结论(暂定)</a> <span class="desc">— 2026-07-10 · 全链路延迟分解、#23/#24 修复、暂定新目标与 paraformer 待决策</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<footer>dudu · 文档索引 · 新增文档须同时登记到本页</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,181 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>dudu 12B 延迟验收结论(暂定)</title>
|
||||
<style>
|
||||
:root {
|
||||
--accent: #4F6EF7;
|
||||
--accent-text: #3D58DB;
|
||||
--accent-soft: #EEF1FE;
|
||||
--bg: #F6F7FA;
|
||||
--card: #FFFFFF;
|
||||
--text-1: #1B1E26;
|
||||
--text-2: #5A6072;
|
||||
--text-3: #A6ABB8;
|
||||
--border-1: #E4E6EB;
|
||||
--positive: #16A34A;
|
||||
--warning: #E8890C;
|
||||
--danger: #DC2626;
|
||||
--code-bg: #1B1E26;
|
||||
--code-text: #D6DCEA;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||
background: var(--bg); color: var(--text-1); line-height: 1.75;
|
||||
}
|
||||
.container { max-width: 980px; margin: 0 auto; padding: 0 24px 80px; }
|
||||
header.hero { background: var(--accent); color: #fff; padding: 48px 24px 40px; text-align: center; }
|
||||
header.hero h1 { margin: 0 0 6px; font-size: 30px; }
|
||||
header.hero p { margin: 0; opacity: .85; font-size: 15px; }
|
||||
.chips { margin-top: 16px; }
|
||||
.chip { display: inline-block; padding: 3px 14px; margin: 3px; background: rgba(255,255,255,.16); border-radius: 999px; font-size: 13px; }
|
||||
section.card {
|
||||
background: var(--card); border: 1px solid var(--border-1); border-radius: 14px;
|
||||
padding: 28px 32px; margin-top: 24px; box-shadow: 0 1px 3px rgba(17,19,26,.05);
|
||||
}
|
||||
h2 { font-size: 21px; margin: 0 0 14px; padding-bottom: 8px; border-bottom: 2px solid var(--accent); display: inline-block; }
|
||||
h3 { font-size: 16px; margin: 22px 0 8px; color: var(--accent-text); }
|
||||
table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 13.5px; }
|
||||
th, td { border: 1px solid var(--border-1); padding: 7px 11px; text-align: left; vertical-align: top; }
|
||||
th { background: #F0F1F4; font-weight: 600; }
|
||||
tr:nth-child(even) td { background: #FCFCFD; }
|
||||
pre.code, pre.diagram {
|
||||
background: var(--code-bg); color: var(--code-text); padding: 16px 18px; border-radius: 10px;
|
||||
overflow-x: auto; font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12.5px; line-height: 1.55;
|
||||
}
|
||||
code.inline { background: var(--accent-soft); color: var(--accent-text); padding: 1px 6px; border-radius: 4px; font-family: "SF Mono", Menlo, monospace; font-size: 12.5px; }
|
||||
.callout { border-left: 4px solid var(--accent); background: var(--accent-soft); padding: 11px 16px; border-radius: 0 8px 8px 0; margin: 12px 0; font-size: 13.5px; }
|
||||
.callout.warn { border-color: var(--warning); background: #FCEFD7; }
|
||||
.tag { display: inline-block; font-size: 12px; padding: 1px 10px; border-radius: 999px; margin-right: 6px; font-weight: 600; }
|
||||
.tag.green { background: #DCFCE7; color: var(--positive); }
|
||||
.tag.orange { background: #FCEFD7; color: var(--warning); }
|
||||
.tag.red { background: #FEE2E2; color: var(--danger); }
|
||||
.tag.blue { background: var(--accent-soft); color: var(--accent-text); }
|
||||
ul, ol { padding-left: 22px; }
|
||||
li { margin: 3px 0; }
|
||||
footer { text-align: center; color: var(--text-3); font-size: 13px; margin-top: 44px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hero">
|
||||
<h1>12B 延迟验收结论(暂定)</h1>
|
||||
<p>网关全链路实测 · 延迟分解 · 修复与调优 · 暂定新目标</p>
|
||||
<div class="chips">
|
||||
<span class="chip">2026-07-10</span>
|
||||
<span class="chip">工具:server/cmd/latencyprobe</span>
|
||||
<span class="chip">分支:feat/12b-latency-probe</span>
|
||||
<span class="chip">provider:gummy-realtime-v1(真实)</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<section class="card">
|
||||
<h2>一、暂定结论(TL;DR)</h2>
|
||||
<table>
|
||||
<tr><th>指标</th><th>原目标 P95</th><th>实测(预连接后)</th><th>暂定新目标</th></tr>
|
||||
<tr>
|
||||
<td><code class="inline">first_partial</code>(按键→首字)</td>
|
||||
<td><500ms</td>
|
||||
<td><span class="tag green">P50 942ms · P95 950ms</span>(极稳定)</td>
|
||||
<td><strong>P95 < 1.2s</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code class="inline">release_to_commit</code>(松键→上屏)</td>
|
||||
<td><300ms</td>
|
||||
<td>短句 ~170ms · 8s 会话 350~500ms</td>
|
||||
<td><strong>≤3s 会话 <300ms;长会话 <600ms</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="callout">
|
||||
<strong>为什么 500ms 结构性不可达:</strong>first_partial = max(握手, DashScope 音频块阈值 ~850ms) + 推理 ~90ms。
|
||||
地板是 DashScope 的<strong>音频块阈值</strong>,与网关代码无关(直连同样 ~1s)。要破地板只能换模型(见第四节)。
|
||||
</div>
|
||||
<div class="callout">
|
||||
<strong>体感修正:</strong>前导静音实验证明阈值按「音频量」而非「语音量」计——400ms 静音只使 first_partial 推迟 ~100ms。
|
||||
用户按键后自然停顿 ~300ms 再开口时,<strong>从开口算的首字延迟仅 ~640ms</strong>,体感优于指标数字。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>二、延迟分解(实测)</h2>
|
||||
<table>
|
||||
<tr><th>环节</th><th>耗时</th><th>处置</th></tr>
|
||||
<tr><td>网关预检(限流 / 配额 / 设备槽)</td><td>~10ms</td><td>无需优化</td></tr>
|
||||
<tr><td>DashScope dial + task-started 握手</td><td>暖 ~130ms · <span class="tag red">抖动 60ms~3.7s</span></td><td><span class="tag green">已消除</span> spare 预连接(#24)</td></tr>
|
||||
<tr><td>DashScope 音频块阈值</td><td><strong>~850ms(地板)</strong></td><td><span class="tag orange">换模型才能降</span>(paraformer ~520ms)</td></tr>
|
||||
<tr><td>推理 + 回传</td><td>~90ms</td><td>无手段</td></tr>
|
||||
<tr><td>final flush(松键后)</td><td>短句 ~170ms · 8s 会话 350~540ms</td><td>随内容量增长,属 provider 物理时间</td></tr>
|
||||
</table>
|
||||
<h3>测量口径(对齐桌面端 dictation.rs)</h3>
|
||||
<ul>
|
||||
<li><code class="inline">first_partial</code>:start 帧发出 → 首个 partial(不含麦克风启动,桌面另有 <code class="inline">audio.start_ms</code>)</li>
|
||||
<li><code class="inline">release_to_commit</code>:stop 帧发出 → 尾部 final(不含注入耗时 <10ms)</li>
|
||||
<li>工具:<code class="inline">go run ./cmd/latencyprobe -wav xx.wav -n 12</code>(单持久连接 + 100ms/帧实时推流,复刻桌面行为)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>三、本轮落地的修复与优化</h2>
|
||||
<h3>#23 stop 后周期 usage 帧抢跑尾部 final(已修,服务端根治)</h3>
|
||||
<ul>
|
||||
<li><strong>症状:</strong>8s 会话 4/4 复现 usage 先于尾部 final 到达,客户端以 partial 文本提前上屏、丢失 final 修正(iOS 21A 修过客户端侧,桌面端仍暴露)</li>
|
||||
<li><strong>根因:</strong>finish 等尾部 final 期间(最长 3s)usageLoop 仍在 tick</li>
|
||||
<li><strong>修复:</strong>finish 先冻结会话并停 usageLoop 再 flush——stop 后唯一的 usage 是结算帧且必在全部 final 之后,<strong>五端客户端「final 或 usage 即提交」逻辑自动变安全</strong>,无协议改动。修复后 6/6 归零</li>
|
||||
<li>桌面端收尾兜底 350ms → 800ms(final flush 实测 350~540ms,350ms 会截丢尾 final)</li>
|
||||
</ul>
|
||||
<h3>#24 gummy spare 预连接(已实现)</h3>
|
||||
<ul>
|
||||
<li>常备一条已完成 run-task 握手的 DashScope 会话,start 直取、异步补位、40s 定期换新</li>
|
||||
<li>依据:DashScope 空闲 <strong>60s 存活、120s 报 Idle timeout</strong>(实测),留余量 45s</li>
|
||||
<li>效果:网关 start 处理 <strong>120~670ms → 3~4ms</strong>;dial 抖动(60ms~3.7s)移出关键路径,否则 P95 可达 1.5~4.5s</li>
|
||||
</ul>
|
||||
<h3>顺手修复</h3>
|
||||
<ul>
|
||||
<li>滑动窗口 Lua「只查不记」与实现不符:val=0 的检查不再写入 0 值成员污染窗口 ZSET</li>
|
||||
<li>gummycheck 增加 <code class="inline">-model / -pace / -idle</code> 参数,支持模型对比与闲置存活实验</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>四、待决策:要不要换 paraformer</h2>
|
||||
<table>
|
||||
<tr><th></th><th>gummy-realtime-v1(现用)</th><th>paraformer-realtime-v2</th></tr>
|
||||
<tr><td>音频块阈值(首字地板)</td><td>~850ms</td><td><span class="tag green">~520ms</span></td></tr>
|
||||
<tr><td>partial 刷新节奏</td><td>~1s / 次</td><td><span class="tag green">~500ms / 次(更跟手)</span></td></tr>
|
||||
<tr><td>识别质量(单样本 TTS 实测)</td><td><span class="tag green">全对</span></td><td><span class="tag red">「各自模块的进度汇报」→「各字文案者进入汇报」</span></td></tr>
|
||||
<tr><td>partial 时间戳(计量交叉校验用)</td><td>每条带 end_time</td><td>仅 final 带</td></tr>
|
||||
</table>
|
||||
<div class="callout warn">
|
||||
<strong>决策前置条件:</strong>用 eval 框架(<code class="inline">eval/</code>)跑真实数据集对比两模型 WER,再决定是否为 ~300ms 首字收益换质量。
|
||||
切换成本低:<code class="inline">GummyProvider.Model</code> 一行配置。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>五、复测方法</h2>
|
||||
<pre class="code"># 起服务端(pg/redis 容器已在跑)
|
||||
cd server && ./run-dev.sh
|
||||
|
||||
# 全链路延迟实测(另开终端)
|
||||
go run ./cmd/latencyprobe -wav /path/to/16k-mono.wav -n 12
|
||||
|
||||
# 直连模型对比 / 闲置存活实验
|
||||
rbw get dashscope-api-key | go run ./cmd/gummycheck -wav xx.wav -pace 100 -model paraformer-realtime-v2
|
||||
rbw get dashscope-api-key | go run ./cmd/gummycheck -wav xx.wav -pace 100 -idle 60</pre>
|
||||
<ul>
|
||||
<li>注意设备 30 分钟滑动窗口上限 30 次会话,probe 换 <code class="inline">-device</code> 规避</li>
|
||||
<li>观测日志:<code class="inline">gummy: session ready</code>(dial/task-started 耗时)、<code class="inline">gummy: session from spare</code>、<code class="inline">gateway: start handled</code></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<footer>dudu · 12B 延迟验收(暂定结论)· 2026-07-10 · 数据与工具见 feat/12b-latency-probe</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -75,6 +75,12 @@ public enum DuduTheme {
|
||||
public static let border2 = dynamic(
|
||||
light: Color(.sRGB, red: 0.8235, green: 0.8353, blue: 0.8667, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.2039, green: 0.2275, blue: 0.2863, opacity: 1.00))
|
||||
public static let brandWechat = dynamic(
|
||||
light: Color(.sRGB, red: 0.0275, green: 0.7569, blue: 0.3765, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.0275, green: 0.7569, blue: 0.3765, opacity: 1.00))
|
||||
public static let brandWechatPress = dynamic(
|
||||
light: Color(.sRGB, red: 0.0235, green: 0.6784, blue: 0.3373, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.0235, green: 0.6784, blue: 0.3373, opacity: 1.00))
|
||||
public static let danger = dynamic(
|
||||
light: Color(.sRGB, red: 0.8627, green: 0.1490, blue: 0.1490, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.9412, green: 0.3569, blue: 0.3569, opacity: 1.00))
|
||||
@@ -138,6 +144,9 @@ public enum DuduTheme {
|
||||
public static let overlayText3 = dynamic(
|
||||
light: Color(.sRGB, red: 0.3922, green: 0.4314, blue: 0.5490, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.3922, green: 0.4314, blue: 0.5490, opacity: 1.00))
|
||||
public static let overlayWave = dynamic(
|
||||
light: Color(.sRGB, red: 0.4863, green: 0.6078, blue: 1.0000, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.4863, green: 0.6078, blue: 1.0000, opacity: 1.00))
|
||||
public static let positive = dynamic(
|
||||
light: Color(.sRGB, red: 0.0863, green: 0.6392, blue: 0.2902, opacity: 1.00),
|
||||
dark: Color(.sRGB, red: 0.2039, green: 0.7686, blue: 0.4157, opacity: 1.00))
|
||||
|
||||
@@ -20,6 +20,9 @@ import (
|
||||
|
||||
func main() {
|
||||
wavPath := flag.String("wav", "", "16kHz/16bit/mono wav 文件路径")
|
||||
model := flag.String("model", "gummy-realtime-v1", "DashScope 实时识别模型")
|
||||
paceMs := flag.Int("pace", 50, "每 100ms 帧的推流间隔 ms(50=2x 加速,100=实时)")
|
||||
idleSec := flag.Int("idle", 0, "会话建立后先闲置 N 秒再推流(测预连接会话的存活时间)")
|
||||
flag.Parse()
|
||||
if *wavPath == "" {
|
||||
fmt.Fprintln(os.Stderr, "用法: rbw get dashscope-api-key | gummycheck -wav test.wav")
|
||||
@@ -41,6 +44,8 @@ func main() {
|
||||
fmt.Printf("音频 %d 字节 ≈ %.1fs\n", len(pcm), float64(len(pcm))/32000)
|
||||
|
||||
p := asr.NewGummy(key)
|
||||
p.Model = *model
|
||||
fmt.Printf("模型 %s · 推流间隔 %dms/帧\n", *model, *paceMs)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -51,6 +56,11 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("会话建立 %v\n", time.Since(start).Round(time.Millisecond))
|
||||
if *idleSec > 0 {
|
||||
fmt.Printf("闲置 %ds...\n", *idleSec)
|
||||
time.Sleep(time.Duration(*idleSec) * time.Second)
|
||||
start = time.Now() // 重新计时,测闲置后的识别延迟
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
var firstPartial time.Duration
|
||||
@@ -79,11 +89,11 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "SendAudio 失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond) // 2x 实时推流
|
||||
time.Sleep(time.Duration(*paceMs) * time.Millisecond)
|
||||
}
|
||||
_ = sess.Close()
|
||||
<-done
|
||||
fmt.Printf("首个结果延迟 %v(含 2x 推流速度因素)\n", firstPartial.Round(time.Millisecond))
|
||||
fmt.Printf("首个结果延迟 %v(推流 %dms/帧)\n", firstPartial.Round(time.Millisecond), *paceMs)
|
||||
}
|
||||
|
||||
// readWavData 提取 wav 的 data chunk(仅支持 PCM)。
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// latencyprobe:12B 延迟验收工具——走本地网关全链路(WS 网关 → gummy)实测延迟分布。
|
||||
//
|
||||
// 复刻桌面客户端行为:单条持久 WS 连接,串行多次会话,音频实时推流(100ms/帧)。
|
||||
//
|
||||
// 测量口径对齐桌面端 dictation.rs:
|
||||
//
|
||||
// first_partial_ms = start 帧发出 → 本会话首个 partial 收到
|
||||
// (桌面端另含麦克风启动耗时 audio.start_ms,此处不含)
|
||||
// release_to_commit_ms = stop 帧发出 → 本会话尾部 final 收到(文本完整落定)
|
||||
// (桌面端另含文字注入耗时,通常 <10ms,此处不含;
|
||||
// 若 stop 后先到 usage 帧,桌面端会提前以 partial 文本注入,
|
||||
// 该情形单独计数为 usage_first)
|
||||
//
|
||||
// 用法(服务端需已以 gummy provider 启动):
|
||||
//
|
||||
// go run ./cmd/latencyprobe -wav /path/to/16k-mono-16bit.wav -n 12
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"dudu/server/internal/auth"
|
||||
"dudu/server/internal/store"
|
||||
"dudu/server/pkg/protocol"
|
||||
)
|
||||
|
||||
type event struct {
|
||||
msg protocol.ServerMsg
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "localhost:8080", "服务端地址")
|
||||
wavPath := flag.String("wav", "", "16kHz/16bit/mono wav 文件路径")
|
||||
n := flag.Int("n", 12, "会话次数(注意 30 分钟滑动窗口上限 30 次/设备)")
|
||||
dsn := flag.String("dsn", "host=localhost user=dudu password=dudu dbname=dudu port=5432 sslmode=disable TimeZone=Asia/Shanghai", "postgres dsn(用于确保探针用户存在且余额充足)")
|
||||
secret := flag.String("secret", "dev-secret-change-me", "JWT secret(须与服务端一致)")
|
||||
userID := flag.String("user", "latency-probe", "探针用户 ID")
|
||||
deviceID := flag.String("device", "probe-dev", "探针设备 ID")
|
||||
flag.Parse()
|
||||
if *wavPath == "" {
|
||||
fmt.Fprintln(os.Stderr, "用法: latencyprobe -wav test.wav [-n 12]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
pcm, err := readWavData(*wavPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "读 wav 失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
audioSec := float64(len(pcm)) / float64(protocol.SampleRate*2)
|
||||
fmt.Printf("音频 %.1fs · %d 次会话 · 单连接串行 · 实时推流(100ms/帧)\n", audioSec, *n)
|
||||
|
||||
// 确保探针用户存在且余额充足(试用 180s/天 不够跑长测)。
|
||||
db, err := store.Open(*dsn)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "postgres 连接失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var u store.User
|
||||
db.Where(store.User{ID: *userID}).FirstOrCreate(&u)
|
||||
if err := db.Model(&store.User{}).Where("id = ?", *userID).Update("balance_seconds", 360000).Error; err != nil {
|
||||
fmt.Fprintln(os.Stderr, "更新探针用户余额失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
token, err := auth.NewJWT(*secret, time.Hour, nil).Sign(*userID)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "签发 JWT 失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 单条持久连接(桌面端同款):会话串行复用,读循环常驻。
|
||||
url := fmt.Sprintf("ws://%s/v1/asr/stream?device_id=%s", *addr, *deviceID)
|
||||
hdr := http.Header{"Authorization": {"Bearer " + token}}
|
||||
conn, _, err := websocket.DefaultDialer.Dial(url, hdr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "dial 失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer conn.Close()
|
||||
events := make(chan event, 256)
|
||||
go func() {
|
||||
defer close(events)
|
||||
for {
|
||||
var msg protocol.ServerMsg
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
events <- event{msg, time.Now()}
|
||||
}
|
||||
}()
|
||||
|
||||
var firstPartials, releaseToCommits []time.Duration
|
||||
usageFirst := 0
|
||||
for i := 0; i < *n; i++ {
|
||||
sid := fmt.Sprintf("probe-%d-%d", time.Now().UnixNano(), i)
|
||||
r, err := runSession(conn, events, sid, pcm)
|
||||
if err != nil {
|
||||
fmt.Printf(" #%02d 失败: %v\n", i+1, err)
|
||||
} else {
|
||||
firstPartials = append(firstPartials, r.firstPartial)
|
||||
releaseToCommits = append(releaseToCommits, r.releaseToCommit)
|
||||
note := ""
|
||||
if r.usageBeforeFinal {
|
||||
usageFirst++
|
||||
note = " ⚠️usage先到"
|
||||
}
|
||||
fmt.Printf(" #%02d first_partial=%5dms release_to_commit=%4dms %q%s\n",
|
||||
i+1, r.firstPartial.Milliseconds(), r.releaseToCommit.Milliseconds(), truncate(r.text, 24), note)
|
||||
}
|
||||
time.Sleep(800 * time.Millisecond) // 会话间隔(无论成败),模拟自然停顿
|
||||
}
|
||||
|
||||
if len(firstPartials) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "无有效样本")
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println()
|
||||
report("first_partial ", firstPartials, 500*time.Millisecond)
|
||||
report("release_to_commit ", releaseToCommits, 300*time.Millisecond)
|
||||
if usageFirst > 0 {
|
||||
fmt.Printf("⚠️ %d/%d 次会话 stop 后 usage 帧先于尾部 final 到达(桌面端将以 partial 文本提前注入,丢失 final 修正)\n",
|
||||
usageFirst, len(firstPartials))
|
||||
}
|
||||
}
|
||||
|
||||
type sessionResult struct {
|
||||
firstPartial time.Duration
|
||||
releaseToCommit time.Duration
|
||||
text string
|
||||
usageBeforeFinal bool
|
||||
}
|
||||
|
||||
// runSession 在共享连接上执行一次会话:start → 实时推流 → stop → 等尾部 final。
|
||||
// 事件按 session_id 过滤,前一会话的迟到帧不串扰。
|
||||
func runSession(conn *websocket.Conn, events <-chan event, sid string, pcm []byte) (r sessionResult, err error) {
|
||||
start, _ := json.Marshal(protocol.ClientMsg{Type: protocol.MsgStart, SessionID: sid, SampleRate: protocol.SampleRate, Format: "pcm16"})
|
||||
t0 := time.Now()
|
||||
if err := conn.WriteMessage(websocket.TextMessage, start); err != nil {
|
||||
return r, fmt.Errorf("start: %w", err)
|
||||
}
|
||||
|
||||
handle := func(ev event, tStop *time.Time) error {
|
||||
if ev.msg.SessionID != sid {
|
||||
return nil // 旧会话迟到帧
|
||||
}
|
||||
switch ev.msg.Type {
|
||||
case protocol.MsgPartial:
|
||||
if r.firstPartial == 0 {
|
||||
r.firstPartial = ev.at.Sub(t0)
|
||||
}
|
||||
case protocol.MsgFinal:
|
||||
r.text += ev.msg.Text
|
||||
if tStop != nil && r.releaseToCommit == 0 {
|
||||
r.releaseToCommit = ev.at.Sub(*tStop)
|
||||
}
|
||||
case protocol.MsgUsage:
|
||||
if tStop != nil && r.releaseToCommit == 0 {
|
||||
r.usageBeforeFinal = true // 桌面端此刻已提前注入 partial 文本
|
||||
}
|
||||
case protocol.MsgError:
|
||||
return fmt.Errorf("server error: %s %s", ev.msg.Code, ev.msg.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 实时推流 + 并行收包。
|
||||
ticker := time.NewTicker(protocol.FrameMillis * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
off := 0
|
||||
for off < len(pcm) {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
end := min(off+protocol.FrameBytes, len(pcm))
|
||||
if err := conn.WriteMessage(websocket.BinaryMessage, pcm[off:end]); err != nil {
|
||||
return r, fmt.Errorf("audio: %w", err)
|
||||
}
|
||||
off = end
|
||||
case ev, ok := <-events:
|
||||
if !ok {
|
||||
return r, fmt.Errorf("连接已断开")
|
||||
}
|
||||
if err := handle(ev, nil); err != nil {
|
||||
return r, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop, _ := json.Marshal(protocol.ClientMsg{Type: protocol.MsgStop, SessionID: sid})
|
||||
tStop := time.Now()
|
||||
if err := conn.WriteMessage(websocket.TextMessage, stop); err != nil {
|
||||
return r, fmt.Errorf("stop: %w", err)
|
||||
}
|
||||
|
||||
// 收尾:等尾部 final(文本完整落定),10s 兜底。
|
||||
deadline := time.After(10 * time.Second)
|
||||
for r.releaseToCommit == 0 {
|
||||
select {
|
||||
case ev, ok := <-events:
|
||||
if !ok {
|
||||
return r, fmt.Errorf("连接已断开")
|
||||
}
|
||||
if err := handle(ev, &tStop); err != nil {
|
||||
return r, err
|
||||
}
|
||||
case <-deadline:
|
||||
return r, fmt.Errorf("收尾超时:stop 后 10s 未收到尾部 final")
|
||||
}
|
||||
}
|
||||
if r.firstPartial == 0 {
|
||||
return r, fmt.Errorf("全程未收到 partial")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func report(name string, ds []time.Duration, target time.Duration) {
|
||||
sorted := append([]time.Duration(nil), ds...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
|
||||
p50 := sorted[len(sorted)/2]
|
||||
p95 := sorted[(len(sorted)*95+99)/100-1]
|
||||
verdict := "✅ 达标"
|
||||
if p95 > target {
|
||||
verdict = "❌ 超标"
|
||||
}
|
||||
fmt.Printf("%s n=%d P50=%dms P95=%dms min=%dms max=%dms 目标P95<%dms %s\n",
|
||||
name, len(sorted), p50.Milliseconds(), p95.Milliseconds(),
|
||||
sorted[0].Milliseconds(), sorted[len(sorted)-1].Milliseconds(), target.Milliseconds(), verdict)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
// readWavData 提取 wav 的 data chunk(仅支持 PCM,同 gummycheck)。
|
||||
func readWavData(path string) ([]byte, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) < 44 || string(b[0:4]) != "RIFF" || string(b[8:12]) != "WAVE" {
|
||||
return nil, fmt.Errorf("不是 wav 文件")
|
||||
}
|
||||
off := 12
|
||||
for off+8 <= len(b) {
|
||||
id := string(b[off : off+4])
|
||||
size := int(binary.LittleEndian.Uint32(b[off+4 : off+8]))
|
||||
if id == "data" {
|
||||
return b[off+8 : min(off+8+size, len(b))], nil
|
||||
}
|
||||
off += 8 + size
|
||||
}
|
||||
return nil, fmt.Errorf("未找到 data chunk")
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -21,8 +22,25 @@ const dashscopeWS = "wss://dashscope.aliyuncs.com/api-ws/v1/inference"
|
||||
type GummyProvider struct {
|
||||
APIKey string
|
||||
Model string // 默认 gummy-realtime-v1
|
||||
|
||||
// spare 预连接(12B/#24):后台常备一条已完成 run-task 握手的 DashScope
|
||||
// 会话,start 直接取用,把 dial+task-started(实测暖路径 ~130ms、抖动可达
|
||||
// 2.5~3.7s)从 first_partial 关键路径上移除。协议固定 16kHz,spare 可互换。
|
||||
mu sync.Mutex
|
||||
spare *spareEntry
|
||||
dialing bool
|
||||
maintainOne sync.Once
|
||||
}
|
||||
|
||||
type spareEntry struct {
|
||||
sess *gummySession
|
||||
born time.Time
|
||||
}
|
||||
|
||||
// spareMaxAge spare 的可用年龄上限:DashScope 空闲 60s 断连(实测 60s 存活、
|
||||
// 120s 报 Idle timeout),留余量在 45s 内取用、40s 定期换新。
|
||||
const spareMaxAge = 45 * time.Second
|
||||
|
||||
func NewGummy(apiKey string) *GummyProvider {
|
||||
return &GummyProvider{APIKey: apiKey, Model: "gummy-realtime-v1"}
|
||||
}
|
||||
@@ -30,14 +48,97 @@ func NewGummy(apiKey string) *GummyProvider {
|
||||
func (g *GummyProvider) Name() string { return "gummy" }
|
||||
|
||||
func (g *GummyProvider) StartSession(ctx context.Context, cfg SessionConfig) (Session, error) {
|
||||
g.maintainOne.Do(func() { go g.maintainSpare() })
|
||||
if cfg.SampleRate == 16000 {
|
||||
if s := g.takeSpare(); s != nil {
|
||||
go g.replenish()
|
||||
return s, nil
|
||||
}
|
||||
defer func() { go g.replenish() }()
|
||||
}
|
||||
return g.dialSession(ctx, cfg)
|
||||
}
|
||||
|
||||
// takeSpare 取走当前 spare(若仍新鲜且存活);过期/已死的就地关闭丢弃。
|
||||
func (g *GummyProvider) takeSpare() *gummySession {
|
||||
g.mu.Lock()
|
||||
sp := g.spare
|
||||
g.spare = nil
|
||||
g.mu.Unlock()
|
||||
if sp == nil {
|
||||
return nil
|
||||
}
|
||||
if time.Since(sp.born) < spareMaxAge && sp.sess.alive() {
|
||||
slog.Info("gummy: session from spare", "task", sp.sess.taskID,
|
||||
"age_ms", time.Since(sp.born).Milliseconds())
|
||||
return sp.sess
|
||||
}
|
||||
_ = sp.sess.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// replenish 异步补位一条 spare;已有 spare 或正在补位时空转返回。
|
||||
func (g *GummyProvider) replenish() {
|
||||
g.mu.Lock()
|
||||
if g.dialing || g.spare != nil {
|
||||
g.mu.Unlock()
|
||||
return
|
||||
}
|
||||
g.dialing = true
|
||||
g.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
sess, err := g.dialSession(ctx, SessionConfig{SampleRate: 16000, SessionID: "spare"})
|
||||
cancel()
|
||||
|
||||
g.mu.Lock()
|
||||
g.dialing = false
|
||||
if err == nil {
|
||||
if g.spare == nil {
|
||||
g.spare = &spareEntry{sess: sess.(*gummySession), born: time.Now()}
|
||||
g.mu.Unlock()
|
||||
return
|
||||
}
|
||||
g.mu.Unlock()
|
||||
_ = sess.Close() // 竞态下已有他人补位:丢弃多余会话
|
||||
return
|
||||
}
|
||||
g.mu.Unlock()
|
||||
slog.Warn("gummy: spare replenish failed", "err", err)
|
||||
}
|
||||
|
||||
// maintainSpare 每 40s 换新 spare,避免超过 DashScope 60s 空闲上限后
|
||||
// 取到已死会话、退化为冷启 dial。进程常驻,随 provider 生命周期运行。
|
||||
func (g *GummyProvider) maintainSpare() {
|
||||
t := time.NewTicker(40 * time.Second)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
g.mu.Lock()
|
||||
sp := g.spare
|
||||
stale := sp != nil && time.Since(sp.born) >= spareMaxAge-5*time.Second
|
||||
if stale {
|
||||
g.spare = nil
|
||||
}
|
||||
g.mu.Unlock()
|
||||
if stale {
|
||||
_ = sp.sess.Close()
|
||||
}
|
||||
g.replenish()
|
||||
}
|
||||
}
|
||||
|
||||
// dialSession 建立一条全新的 DashScope 会话(原 StartSession 主体)。
|
||||
func (g *GummyProvider) dialSession(ctx context.Context, cfg SessionConfig) (Session, error) {
|
||||
header := map[string][]string{
|
||||
"Authorization": {"bearer " + g.APIKey},
|
||||
"X-DashScope-DataInspection": {"enable"},
|
||||
}
|
||||
tDial := time.Now()
|
||||
conn, _, err := websocket.DefaultDialer.DialContext(ctx, dashscopeWS, header)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dashscope dial: %w", err)
|
||||
}
|
||||
dialMs := time.Since(tDial).Milliseconds()
|
||||
|
||||
taskID := uuid.NewString()
|
||||
runTask := map[string]any{
|
||||
@@ -75,8 +176,13 @@ func (g *GummyProvider) StartSession(ctx context.Context, cfg SessionConfig) (Se
|
||||
go s.readLoop()
|
||||
|
||||
// 协议要求:必须等 task-started 后才能推音频,否则服务端静默丢弃
|
||||
tWait := time.Now()
|
||||
select {
|
||||
case <-s.started:
|
||||
// 会话建立耗时是 first_partial 延迟的组成部分(12B):dial 冷启(DNS+TLS)
|
||||
// 可达 ~600ms、暖路径 ~60ms,task-started 通常 ~60ms。持续观测供延迟排查。
|
||||
slog.Info("gummy: session ready", "task", taskID,
|
||||
"dial_ms", dialMs, "task_started_wait_ms", time.Since(tWait).Milliseconds())
|
||||
return s, nil
|
||||
case <-s.done:
|
||||
return nil, fmt.Errorf("dashscope: closed before task-started")
|
||||
@@ -101,6 +207,16 @@ type gummySession struct {
|
||||
lastText string // 当前句已下发文本,用于切分 partial/final
|
||||
}
|
||||
|
||||
// alive 会话底层连接是否仍然存活(readLoop 退出即 done 关闭)。
|
||||
func (s *gummySession) alive() bool {
|
||||
select {
|
||||
case <-s.done:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *gummySession) SendAudio(pcm []byte) error {
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
|
||||
@@ -137,6 +137,12 @@ func (h *Handler) HandleWS(c *gin.Context) {
|
||||
// startSession 执行 start 预检并建立 Provider 会话;失败时返回 nil(已下发 error 帧)。
|
||||
func (h *Handler) startSession(ctx context.Context, ws *wsConn, uid, deviceID string, msg protocol.ClientMsg) *session {
|
||||
now := time.Now()
|
||||
// start 处理耗时(预检 + provider 握手)串行阻塞在读循环里,直接叠加进客户端
|
||||
// 可感知的 first_partial 延迟(12B),持续观测供延迟排查。
|
||||
defer func() {
|
||||
slog.Info("gateway: start handled", "session", msg.SessionID,
|
||||
"total_ms", time.Since(now).Milliseconds())
|
||||
}()
|
||||
|
||||
// ① 设备 30min 窗口:次数 + 累计时长
|
||||
if ok, err := store.AllowSession(ctx, h.RDB, deviceID, now); err != nil || !ok {
|
||||
@@ -358,24 +364,26 @@ const finishWaitTimeout = 3 * time.Second
|
||||
// 注意 done/consumeMu 顺序(16D):先停 usageLoop 并 drain 在途 consumeDelta,
|
||||
// 再读 trialPart/balancePart 快照,避免漏记在途扣费。
|
||||
func (s *session) finish(ctx context.Context, canceled bool) {
|
||||
// 先冻结会话并停 usageLoop,再 flush provider(12B/#23):若 usageLoop 在
|
||||
// waitResults 期间(最长 3s)继续 tick,周期 usage 帧会抢在尾部 final 之前
|
||||
// 下发——客户端把"stop 后首个 final/usage"当提交信号,会以 partial 文本
|
||||
// 提前上屏、丢失 final 修正。冻结后 stop 之后唯一的 usage 是本函数末尾的
|
||||
// 结算帧,且必然在全部尾部 final 之后,客户端提交信号因此安全。
|
||||
s.mu.Lock()
|
||||
if s.finished {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.finished = true
|
||||
close(s.done)
|
||||
s.mu.Unlock()
|
||||
|
||||
_ = s.provider.Close()
|
||||
s.waitResults() // 尾部 final 全部下发后再收尾(带超时,16B)
|
||||
|
||||
// 先标记 finished 并停 usageLoop,再 drain 在途 consumeDelta(16D):
|
||||
// 取 consumeMu 会等待任何已过 finished 检查、正等 Redis 返回的 consumeDelta
|
||||
// 完成并把 trialPart/balancePart 计入,从而快照不漏账。
|
||||
s.mu.Lock()
|
||||
s.finished = true
|
||||
close(s.done)
|
||||
s.mu.Unlock()
|
||||
|
||||
// drain 在途 consumeDelta(16D):finished 已置位,取 consumeMu 会等待任何
|
||||
// 已过 finished 检查、正等 Redis 返回的 consumeDelta 完成并把
|
||||
// trialPart/balancePart 计入,从而快照不漏账。
|
||||
s.consumeMu.Lock() // drain:等当前在途 consume(若有)完成
|
||||
s.consumeMu.Unlock()
|
||||
|
||||
|
||||
@@ -46,10 +46,14 @@ else
|
||||
end
|
||||
end
|
||||
if sum + val > limit then return 0 end
|
||||
local seq = redis.call('INCR', key .. ':seq')
|
||||
redis.call('EXPIRE', key .. ':seq', window + 60) -- 17C:seq 计数器与 ZSET 同寿命,避免按设备永久泄漏
|
||||
redis.call('ZADD', key, now, now .. '-' .. seq .. ':' .. val)
|
||||
redis.call('EXPIRE', key, window + 60)
|
||||
-- val=0 为"只查不记"(AudioWindowExhausted 的 start 预检):不落 0 值成员,
|
||||
-- 避免污染窗口 ZSET 并空耗 seq;count 模式 val 恒为 1,不受影响。
|
||||
if val > 0 then
|
||||
local seq = redis.call('INCR', key .. ':seq')
|
||||
redis.call('EXPIRE', key .. ':seq', window + 60) -- 17C:seq 计数器与 ZSET 同寿命,避免按设备永久泄漏
|
||||
redis.call('ZADD', key, now, now .. '-' .. seq .. ':' .. val)
|
||||
redis.call('EXPIRE', key, window + 60)
|
||||
end
|
||||
return 1
|
||||
`)
|
||||
|
||||
|
||||
@@ -5,49 +5,21 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>dudu — 按住说话,文字上屏</title>
|
||||
<meta name="description" content="dudu 语音输入法:大模型识别,流式实时上屏。Mac / Windows / iPhone / iPad / Android。每天免费 3 分钟。">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="tokens.css">
|
||||
<style>
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
令牌来源 design/tokens/(宣传页独立部署,按值内联;改动需同步)
|
||||
令牌层由 tokens.css 提供(design/tokens/ 经 export-tokens.mjs 生成,
|
||||
含 Outfit 字体内嵌,禁外部 CDN)。本页只允许 var(--*),禁字面色值
|
||||
(check-code 闸拦截)。深色板块用 data-theme="dark" 复用 dark 令牌。
|
||||
品牌硬规则:单一品牌蓝 · 无渐变 · 无 emoji · 波形动画仅录音中
|
||||
──────────────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #F6F7FA;
|
||||
--card: #FFFFFF;
|
||||
--surface-2: #F0F1F4;
|
||||
--surface-3: #E4E6EB;
|
||||
--border-1: #E4E6EB;
|
||||
--border-2: #D2D5DD;
|
||||
--text-1: #1B1E26;
|
||||
--text-2: #5A6072;
|
||||
--text-3: #A6ABB8;
|
||||
--accent: #4F6EF7;
|
||||
--accent-hover: #3D58DB;
|
||||
--accent-press: #2F44B3;
|
||||
--accent-soft: #EEF1FE;
|
||||
--accent-text: #3D58DB;
|
||||
--positive: #16A34A;
|
||||
--positive-soft: #DCFCE7;
|
||||
--warning: #E8890C;
|
||||
--warning-soft: #FCEFD7;
|
||||
--ink: #11131A;
|
||||
--ink-card: #171A22;
|
||||
--ink-border: #272C38;
|
||||
--ink-text-2: #9AA1B2;
|
||||
--overlay-bg: rgba(24, 26, 34, 0.95);
|
||||
--overlay-text: #F2F4FA;
|
||||
--overlay-text-2: #9AA3BD;
|
||||
--overlay-text-3: #646E8C;
|
||||
--shadow-overlay: 0 12px 36px rgba(10, 12, 18, 0.38);
|
||||
--shadow-card: 0 1px 3px rgba(17, 19, 26, 0.05);
|
||||
--shadow-window: 0 8px 28px rgba(17, 19, 26, 0.14);
|
||||
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
--sans: -apple-system, BlinkMacSystemFont, "PingFang SC", "MiSans",
|
||||
"HarmonyOS Sans SC", "Microsoft YaHei", sans-serif;
|
||||
--latin: "Outfit", -apple-system, sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
/* 页面级别名 → 设计系统 token(仅别名,禁止写字面色值) */
|
||||
--bg: var(--bg-app);
|
||||
--card: var(--surface-card);
|
||||
--sans: var(--font-sans);
|
||||
--latin: var(--font-sans);
|
||||
--mono: var(--font-mono);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
html { scroll-behavior: smooth; }
|
||||
@@ -90,7 +62,7 @@ nav {
|
||||
}
|
||||
.btn-sm { height: 32px; padding: 0 14px; font-size: 13px; }
|
||||
.btn-lg { height: 48px; padding: 0 26px; font-size: 16px; border-radius: 999px; }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary { background: var(--accent); color: var(--text-on-accent); }
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-primary:active { background: var(--accent-press); }
|
||||
.btn-secondary { background: var(--card); border-color: var(--border-2); color: var(--text-1); }
|
||||
@@ -154,7 +126,7 @@ nav {
|
||||
pointer-events: none;
|
||||
}
|
||||
.overlay.show { opacity: 1; translate: 0 0; }
|
||||
.wave { display: inline-flex; align-items: center; gap: 3px; height: 24px; color: #7C9BFF; }
|
||||
.wave { display: inline-flex; align-items: center; gap: 3px; height: 24px; color: var(--overlay-wave); }
|
||||
.wave i { display: block; width: 3px; border-radius: 2px; background: currentColor; }
|
||||
.recording .wave i { animation: wavebounce 0.9s cubic-bezier(0.45, 0, 0.2, 1) infinite alternate; }
|
||||
@keyframes wavebounce { from { transform: scaleY(0.35); } to { transform: scaleY(1); } }
|
||||
@@ -168,7 +140,7 @@ nav {
|
||||
|
||||
.micbar {
|
||||
margin-top: 64px; width: 100%; height: 56px; border: none; cursor: pointer;
|
||||
border-radius: 999px; background: var(--accent); color: #fff;
|
||||
border-radius: 999px; background: var(--accent); color: var(--text-on-accent);
|
||||
font-family: var(--sans); font-size: 16px; font-weight: 600;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 9px;
|
||||
user-select: none; -webkit-user-select: none; touch-action: none;
|
||||
@@ -184,22 +156,22 @@ kbd {
|
||||
}
|
||||
|
||||
/* ── 指标带(墨色) ── */
|
||||
.metrics { background: var(--ink); color: #ECEEF4; border-bottom: 1px solid var(--ink-border); }
|
||||
.metrics { background: var(--gray-950); color: var(--text-1); border-bottom: 1px solid var(--border-1); }
|
||||
.metrics-grid { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.metric {
|
||||
padding: 44px 32px; text-align: center;
|
||||
border-left: 1px solid var(--ink-border);
|
||||
border-left: 1px solid var(--border-1);
|
||||
}
|
||||
.metric:first-child { border-left: none; }
|
||||
.metric .num {
|
||||
font-family: var(--latin); font-weight: 700;
|
||||
font-size: clamp(34px, 4.4vw, 52px); line-height: 1.1;
|
||||
}
|
||||
.metric .num small { font-size: 0.45em; font-weight: 600; color: var(--ink-text-2); margin-left: 2px; }
|
||||
.metric .label { margin-top: 8px; font-size: 14px; color: var(--ink-text-2); }
|
||||
.metric .num small { font-size: 0.45em; font-weight: 600; color: var(--text-2); margin-left: 2px; }
|
||||
.metric .label { margin-top: 8px; font-size: 14px; color: var(--text-2); }
|
||||
@media (max-width: 720px) {
|
||||
.metrics-grid { grid-template-columns: 1fr; }
|
||||
.metric { border-left: none; border-top: 1px solid var(--ink-border); padding: 28px 20px; }
|
||||
.metric { border-left: none; border-top: 1px solid var(--border-1); padding: 28px 20px; }
|
||||
.metric:first-child { border-top: none; }
|
||||
}
|
||||
|
||||
@@ -282,25 +254,23 @@ section { border-bottom: 1px solid var(--border-1); }
|
||||
@media (max-width: 820px) { .pricing { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ── 尾部 CTA(墨色) ── */
|
||||
.cta { background: var(--ink); color: #ECEEF4; border-bottom: 1px solid var(--ink-border); }
|
||||
.cta { background: var(--gray-950); color: var(--text-1); border-bottom: 1px solid var(--border-1); }
|
||||
.cta-inner { padding: 110px 0; text-align: center; }
|
||||
.cta .logo-big { color: #5C77E8; }
|
||||
.cta .logo-big { color: var(--accent); }
|
||||
.cta h2 { margin-top: 26px; font-size: clamp(34px, 5vw, 56px); font-weight: 700; letter-spacing: -0.01em; }
|
||||
.cta p { margin-top: 14px; color: var(--ink-text-2); font-size: 16px; }
|
||||
.cta p { margin-top: 14px; color: var(--text-2); font-size: 16px; }
|
||||
.cta .btn { margin-top: 36px; }
|
||||
.cta .btn-primary { background: #5C77E8; }
|
||||
.cta .btn-primary:hover { background: #6E89F9; }
|
||||
|
||||
/* ── 页脚 ── */
|
||||
footer { background: var(--ink); color: var(--ink-text-2); }
|
||||
footer { background: var(--gray-950); color: var(--text-2); }
|
||||
.footer-inner {
|
||||
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;
|
||||
padding: 28px 0; font-size: 13px;
|
||||
}
|
||||
.footer-inner .brand { color: #ECEEF4; }
|
||||
.footer-inner .brand svg { color: #5C77E8; }
|
||||
.footer-inner .brand { color: var(--text-1); }
|
||||
.footer-inner .brand svg { color: var(--accent); }
|
||||
.footer-links { display: flex; gap: 22px; }
|
||||
.footer-links a:hover { color: #ECEEF4; }
|
||||
.footer-links a:hover { color: var(--text-1); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -367,7 +337,7 @@ footer { background: var(--ink); color: var(--ink-text-2); }
|
||||
</header>
|
||||
|
||||
<!-- ════════ 指标带 ════════ -->
|
||||
<div class="metrics">
|
||||
<div class="metrics" data-theme="dark">
|
||||
<div class="frame metrics-grid">
|
||||
<div class="metric reveal">
|
||||
<div class="num"><500<small>ms</small></div>
|
||||
@@ -489,7 +459,7 @@ footer { background: var(--ink); color: var(--ink-text-2); }
|
||||
</section>
|
||||
|
||||
<!-- ════════ 尾部 CTA ════════ -->
|
||||
<div class="cta">
|
||||
<div class="cta" data-theme="dark">
|
||||
<div class="frame cta-inner reveal">
|
||||
<svg class="logo-big" width="64" height="64" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round"><circle cx="24" cy="24" r="18"/><path d="M17 20.5v7"/><path d="M24 16.5v15"/><path d="M31 20.5v7"/></svg>
|
||||
<h2>从下一句话开始<br>用说的</h2>
|
||||
@@ -499,7 +469,7 @@ footer { background: var(--ink); color: var(--ink-text-2); }
|
||||
</div>
|
||||
|
||||
<!-- ════════ 页脚 ════════ -->
|
||||
<footer>
|
||||
<footer data-theme="dark">
|
||||
<div class="frame footer-inner">
|
||||
<a class="brand" href="#">
|
||||
<svg width="20" height="20" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round"><circle cx="24" cy="24" r="18"/><path d="M17 20.5v7"/><path d="M24 16.5v15"/><path d="M31 20.5v7"/></svg>
|
||||
|
||||