Files
dudu/design/ui_kits/mobile/AppScreens.jsx
T
wangjia 208d0cda12 feat(design): design/ 真相源 + 防漂移静态闸(#25)
真相源整固:
- design/index.html 登记簿:token 色板(var() 实时渲染)/ 组件登记卡 /
  图标与跨端映射 / guidelines / ui_kits 单一入口,check-ds 强制登记
- design/icons.js 图标单源(Lucide 线条)+ icon-map.json 跨端映射表
  (web/iOS/Android 三列,iOS 12 个 SF Symbol 全部收编登记)
- components/core/Icon.jsx 单源图标渲染器;MicBar/RecognitionOverlay 接入
- tokens 新增 --brand-wechat(-press)(微信官方绿)与 --overlay-wave
- fonts.css 弃 Google Fonts CDN,Outfit 可变字体自托管
  (assets/outfit-latin.woff2,40KB;中国网络下 CDN 不可用会卡字体)
- design/serve.mjs 零依赖预览服务器(登记簿与原型评审入口)

codegen 强化(export-tokens.mjs):
- 产物直写消费位置:ios/dudu/Shared/DuduTheme.swift、
  android/.../design/DuduTheme.kt——删除 design-pipeline/generated/
  中间拷贝层(此前 app 内是手工拷贝,--check 守不到真实消费文件已漂移)
- 新增 web/tokens.css 产物(custom props 透传 + Outfit data URI 内嵌)
- tokens 文件遍历排序,产物确定性

存量收编:
- desktop:三个窗口内联 SVG → Icon 组件;tray 裸 rgba → token+opacity
- Android:LoginScreen 微信绿常量 → DuduPalette.brandWechat(Press),
  Color.White → textOnAccent
- 官网 web/index.html:接入 tokens.css,34 处硬编码 hex 全部 var() 化;
  深色板块(metrics/cta/footer)改挂 data-theme="dark" 复用 dark 令牌;
  移除 Google Fonts CDN
- design 包内 8 处裸色:微信绿 token 化、装饰色 ds-ignore 白名单、
  同值色改 var() 引用

防漂移闸(零依赖 Node):
- design-pipeline/check-ds.mjs:真相源自检 7 道(dark⊆light / var 链 /
  裸色∈tokens 值集 / 组件登记 / icons 唯一 / icon-map 双向同集 / 禁 emoji)
- design-pipeline/check-code.mjs:代码侧单源(desktop 禁字面色+内联 path /
  iOS 禁字面色+SF Symbol 同集 / Android 禁 Color(0x / web 禁字面色),
  ds-ignore 行级豁免制
- .githooks/pre-commit(启用:git config core.hooksPath .githooks)
- CI design job 扩展为三道闸全绿
- 反向验证通过:注入裸色/孤儿 token/未登记组件/未登记 symbol 均被拦截

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:05:22 +08:00

188 lines
11 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// dudu 移动端 · 主 App 四屏:启用引导 / 登录 / 购买时长 / 我的
function MAppHeader({ title }) {
return (
<div style={{ textAlign: 'center', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)', padding: '4px 0 10px' }}>{title}</div>
);
}
/* ---------- ① 启用引导 ---------- */
function OnboardingScreen() {
const { Button } = window.DuduDesignSystem_2e0172;
const [step, setStep] = React.useState(0);
const steps = [
{ title: '添加键盘', desc: '设置 → 通用 → 键盘 → 添加新键盘,选择 dudu', icon: <DuMIcons.Keyboard size={26} /> },
{ title: '开启完全访问', desc: '语音识别需要联网,请允许完全访问', icon: <DuMIcons.Shield size={26} /> },
{ title: '试一试', desc: '在下面的输入框按住麦克风说一句话', icon: <DuMIcons.Mic size={26} /> },
];
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '4px 18px 18px' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, padding: '18px 0 6px' }}>
<span style={{ color: 'var(--accent)' }}><DuduMLogo size={44} /></span>
<div style={{ fontSize: 'var(--text-xl)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)', whiteSpace: 'nowrap' }}>三步开启 dudu</div>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)' }}>按住说话松开上屏</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 14 }}>
{steps.map((s, i) => {
const state = i < step ? 'done' : i === step ? 'active' : 'todo';
return (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '13px 14px',
borderRadius: 'var(--radius-lg)', background: 'var(--surface-card)',
border: state === 'active' ? '1.5px solid var(--accent)' : '1px solid var(--border-1)',
opacity: state === 'todo' ? 0.62 : 1,
transition: 'border-color var(--dur-fast) var(--ease-out), opacity var(--dur-fast) var(--ease-out)',
}}>
<span style={{
width: 40, height: 40, borderRadius: 'var(--radius-md)', flex: 'none',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: state === 'done' ? 'var(--positive-soft)' : 'var(--accent-soft)',
color: state === 'done' ? 'var(--positive)' : 'var(--accent-text)',
}}>{state === 'done' ? <DuMIcons.Check size={20} /> : s.icon}</span>
<span style={{ minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 'var(--text-base)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)' }}>{i + 1}. {s.title}</span>
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginTop: 2, lineHeight: 'var(--leading-normal)' }}>{s.desc}</span>
</span>
</div>
);
})}
</div>
<div style={{ marginTop: 'auto' }}>
<Button variant="primary" size="lg" block onClick={() => setStep((s) => (s + 1) % 4)}>
{step >= 3 ? '完成' : step === 2 ? '我说完了' : '去设置'}
</Button>
</div>
</div>
);
}
/* ---------- ② 登录 ---------- */
function LoginScreen() {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '4px 22px 22px' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<span style={{ color: 'var(--accent)' }}><DuduMLogo size={64} sw={3.2} /></span>
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)', letterSpacing: 'var(--tracking-wide)' }}>dudu</div>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)' }}>大模型语音输入 · 说话即上屏</div>
</div>
<button type="button" style={{
height: 'var(--control-xl)', borderRadius: 'var(--radius-full)', border: 'none', cursor: 'pointer',
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} /> 微信一键登录
</button>
<div style={{ textAlign: 'center', fontSize: 'var(--text-xs)', color: 'var(--text-3)', marginTop: 12, lineHeight: 'var(--leading-normal)' }}>
登录即同意用户协议隐私政策
</div>
</div>
);
}
/* ---------- ③ 购买时长 ---------- */
function PaywallScreen() {
const { Button } = window.DuduDesignSystem_2e0172;
const [pack, setPack] = React.useState('m');
const PACKS = [
['s', '100 分钟', 9, '约 ¥0.09 / 分钟', ''],
['m', '500 分钟', 39, '约 ¥0.078 / 分钟', '省 13%'],
['l', '2000 分钟', 129, '约 ¥0.065 / 分钟', '省 28%'],
];
const price = PACKS.find((p) => p[0] === pack)[2];
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '4px 16px 16px', minHeight: 0, overflow: 'auto' }}>
<MAppHeader title="购买时长" />
{/* 余额卡 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', background: 'var(--surface-card)', border: '1px solid var(--border-1)', borderRadius: 'var(--radius-lg)', flex: 'none' }}>
<span>
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-2)' }}>时长余额</span>
<span style={{ display: 'block', marginTop: 2, fontSize: 'var(--text-2xl)', fontWeight: 'var(--weight-bold)', color: 'var(--text-1)', fontFamily: 'var(--font-sans)' }}>472 <span style={{ fontSize: 'var(--text-xs)', fontWeight: 'var(--weight-regular)', color: 'var(--text-3)' }}>分钟</span></span>
</span>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-3)', textAlign: 'right', lineHeight: 'var(--leading-normal)' }}>时长不过期<br />每天另有 3 分钟免费试用</span>
</div>
{/* 时长包 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 12 }}>
{PACKS.map(([key, lab, p, desc, tag]) => {
const active = pack === key;
return (
<button key={key} type="button" onClick={() => setPack(key)} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center', textAlign: 'left',
padding: '12px 14px', cursor: 'pointer', borderRadius: 'var(--radius-lg)', fontFamily: 'var(--font-sans)',
border: active ? '1.5px solid var(--accent)' : '1px solid var(--border-1)',
background: active ? 'var(--accent-soft)' : 'var(--surface-card)',
transition: 'border-color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out)',
}}>
<span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
<span style={{ fontSize: 'var(--text-base)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)' }}>{lab}</span>
{tag && <span style={{ fontSize: 10, padding: '1px 7px', borderRadius: 'var(--radius-full)', background: 'var(--warning-soft)', color: 'var(--warning)', fontWeight: 'var(--weight-medium)', whiteSpace: 'nowrap' }}>{tag}</span>}
</span>
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginTop: 2 }}>{desc}</span>
</span>
<span style={{ whiteSpace: 'nowrap', fontSize: 'var(--text-xl)', fontWeight: 'var(--weight-bold)', color: active ? 'var(--accent-text)' : 'var(--text-1)' }}>¥{p}</span>
</button>
);
})}
</div>
<div style={{ marginTop: 'auto', paddingTop: 14 }}>
<Button variant="primary" size="lg" block>微信支付 ¥{price}</Button>
<div style={{ textAlign: 'center', fontSize: 'var(--text-xs)', color: 'var(--text-3)', marginTop: 8 }}>支付后立即到账 · 时长不过期</div>
</div>
</div>
);
}
/* ---------- ④ 我的 ---------- */
function ProfileScreen() {
const { Badge, ProgressBar } = window.DuduDesignSystem_2e0172;
const perms = [
{ label: '麦克风权限', ok: true },
{ label: '键盘已添加', ok: true },
{ label: '完全访问', ok: false, hint: '去开启' },
];
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, padding: '4px 16px 16px', minHeight: 0, overflow: 'auto' }}>
<MAppHeader title="我的" />
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 14px', background: 'var(--surface-card)', border: '1px solid var(--border-1)', borderRadius: 'var(--radius-lg)' }}>
<span style={{ width: 46, height: 46, borderRadius: '50%', background: 'var(--accent-soft)', color: 'var(--accent-text)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flex: 'none' }}>
<DuMIcons.User size={22} />
</span>
<span style={{ minWidth: 0, flex: 1 }}>
<span style={{ display: 'block', fontSize: 'var(--text-md)', fontWeight: 'var(--weight-medium)', color: 'var(--text-1)' }}>wang***</span>
<span style={{ display: 'block', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginTop: 2 }}>时长余额 472 分钟 · 不过期</span>
</span>
<Badge tone="green">余额充足</Badge>
</div>
<div style={{ padding: '14px 14px 16px', background: 'var(--surface-card)', border: '1px solid var(--border-1)', borderRadius: 'var(--radius-lg)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--text-xs)', color: 'var(--text-2)', marginBottom: 8 }}>
<span>今日免费试用</span><span style={{ fontFamily: 'var(--font-mono)' }}>1 / 3 分钟</span>
</div>
<ProgressBar value={1} max={3} />
</div>
<div style={{ background: 'var(--surface-card)', border: '1px solid var(--border-1)', borderRadius: 'var(--radius-lg)', overflow: 'hidden' }}>
{perms.map((p, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 14px', borderBottom: i < perms.length - 1 ? '1px solid var(--border-2)' : 'none', fontSize: 'var(--text-base)', color: 'var(--text-1)' }}>
<span>{p.label}</span>
{p.ok ? (
<span style={{ color: 'var(--positive)', display: 'inline-flex' }}><DuMIcons.Check size={16} /></span>
) : (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 'var(--text-sm)', color: 'var(--danger)' }}>
{p.hint} <DuMIcons.ChevronRight size={14} />
</span>
)}
</div>
))}
</div>
</div>
);
}
Object.assign(window, { OnboardingScreen, LoginScreen, PaywallScreen, ProfileScreen });