feat(design+desktop): Select 真相源组件 + 设置窗口无边框(12A 实测反馈)
Select 组件(用户拍板:下拉框全端共用一个真相源): - design/components/forms/Select.jsx:自绘下拉——触发器同 Input (control-sm/radius-sm/border-1 + chevronDown),弹层同菜单 (radius-md/shadow-menu、选中项前置对勾、hover surface-2), 外点/Esc 收起;登记簿加卡;icons.js 新增 chevronDown 并登记 icon-map - 原型 SettingsTray 与桌面 SettingsApp 同步换用(替换原生 select—— 其系统弹层样式/位置不可控,即 12A 截图问题) 原型组件运行时可再生(修复单源缺口): - design-pipeline/bundle-components.mjs:用 desktop 现成 esbuild 重建 _ds_bundle.js(此前为 Claude 导出产物、仓库内无法再生,组件一改即 过期);import 'react' 经全局 shim,命名空间不变;已实测原型页正常 - _ds_bundle.js 重建(15 个组件,含 Select/Icon) 设置窗口无边框: - tauri.conf settings 窗口 titleBarStyle=Overlay + hiddenTitle, 系统红绿灯悬浮左上;SettingsApp 自绘顶条(surface-2 + 下边框 + data-tauri-drag-region 可拖拽),对齐原型 MacWindow 规格 合并 feat/design-truth-source(真相源与闸体系)进本分支 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* bundle-components.mjs — 重建 design/_ds_bundle.js(原型页组件运行时)
|
||||
*
|
||||
* ui_kits 预览页经 <script src="../../_ds_bundle.js"> 消费组件;该文件原为
|
||||
* Claude 设计导出产物、仓库内无法再生,组件源码一改即过期。本脚本用
|
||||
* desktop 现成的 esbuild(不新增依赖)从 design/components/ 重新打包:
|
||||
* - JSX → React.createElement(运行时用页面全局 React,unpkg UMD)
|
||||
* - import 'react' → 全局 React shim(组件源码保持 ESM 规范,vite 侧不受影响)
|
||||
* - 导出统一挂到 window.DuduDesignSystem_2e0172(与原命名空间一致)
|
||||
*
|
||||
* 用法:node design-pipeline/bundle-components.mjs
|
||||
* 组件源码(components/ icons.js)变更后须重跑并提交产物。
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { resolve, dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const DESIGN = resolve(__dir, '../design');
|
||||
const require = createRequire(resolve(__dir, '../desktop/package.json'));
|
||||
const esbuild = require('esbuild');
|
||||
|
||||
// 收集全部组件导出(export function/const 大写开头)
|
||||
function walk(dir, out = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) walk(p, out);
|
||||
else if (name.endsWith('.jsx')) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const imports = [];
|
||||
const names = [];
|
||||
for (const f of walk(join(DESIGN, 'components'))) {
|
||||
const src = readFileSync(f, 'utf8');
|
||||
const exported = [...src.matchAll(/export\s+(?:function|const)\s+([A-Z]\w+)/g)].map((m) => m[1]);
|
||||
if (!exported.length) continue;
|
||||
imports.push(`import { ${exported.join(', ')} } from './${relative(DESIGN, f)}';`);
|
||||
names.push(...exported);
|
||||
}
|
||||
|
||||
const entry = `${imports.join('\n')}
|
||||
const ns = (globalThis.DuduDesignSystem_2e0172 = globalThis.DuduDesignSystem_2e0172 || {});
|
||||
Object.assign(ns, { ${names.join(', ')} });
|
||||
`;
|
||||
|
||||
// import 'react' → 页面全局 React(unpkg UMD)
|
||||
const reactShim = {
|
||||
name: 'react-global-shim',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^react$/ }, () => ({ path: 'react', namespace: 'react-shim' }));
|
||||
build.onLoad({ filter: /.*/, namespace: 'react-shim' }, () => ({
|
||||
contents: `const R = globalThis.React;
|
||||
export default R;
|
||||
export const useState = R.useState, useEffect = R.useEffect, useRef = R.useRef,
|
||||
useMemo = R.useMemo, useCallback = R.useCallback, useContext = R.useContext,
|
||||
createElement = R.createElement, Fragment = R.Fragment;`,
|
||||
loader: 'js',
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const result = await esbuild.build({
|
||||
stdin: { contents: entry, resolveDir: DESIGN, loader: 'js', sourcefile: '_ds_entry.js' },
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
jsx: 'transform',
|
||||
jsxFactory: 'React.createElement',
|
||||
jsxFragment: 'React.Fragment',
|
||||
plugins: [reactShim],
|
||||
write: false,
|
||||
banner: {
|
||||
js: `/* 自动生成 — 请勿手改。来源 design/components/ + icons.js
|
||||
重新生成:node design-pipeline/bundle-components.mjs
|
||||
运行时依赖:页面全局 React(ui_kits 页先加载 unpkg UMD React)。
|
||||
导出命名空间:window.DuduDesignSystem_2e0172(组件 ${names.length} 个)*/`,
|
||||
},
|
||||
});
|
||||
|
||||
writeFileSync(join(DESIGN, '_ds_bundle.js'), result.outputFiles[0].text, 'utf8');
|
||||
console.log(`✅ design/_ds_bundle.js(${names.length} 个组件:${names.join(', ')})`);
|
||||
@@ -20,4 +20,5 @@ Hard rules: 一屏一个蓝色主按钮;无渐变;无 emoji;浮层永远
|
||||
- **业务代码禁止**:字面色(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)。
|
||||
- **组件源码(components/ 或 icons.js)变更后**:`node design-pipeline/bundle-components.mjs` 重建原型运行时 `_ds_bundle.js` 并随改动提交(ui_kits 页消费的是该 bundle)。
|
||||
- 全景说明:`doc/frontend-overview.html`。
|
||||
|
||||
+347
-2371
File diff suppressed because it is too large
Load Diff
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'onChange'> {
|
||||
/** 当前值(受控) */
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
/** 字符串数组或 { value, label } 数组 */
|
||||
options?: Array<string | SelectOption>;
|
||||
/** value 无匹配项时的显示文案 */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export declare function Select(props: SelectProps): React.JSX.Element;
|
||||
@@ -0,0 +1,110 @@
|
||||
// Select:自绘下拉选择器(真相源组件,全端外观基准)。
|
||||
// 原生 <select> 的弹层是系统样式且位置不可控——设计语言要求:触发器同 Input
|
||||
// (control-sm / radius-sm / border-1),弹层同菜单(radius-md / shadow-menu),
|
||||
// 选中项前置对勾。iOS/Android 以本组件登记规格 + 各自 DuduTheme 镜像实现。
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Icon } from '../core/Icon';
|
||||
|
||||
const ddSelectCss = `
|
||||
.dd-select { position: relative; display: inline-block; font-family: var(--font-sans); }
|
||||
.dd-select__trigger {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: var(--control-sm); padding: 0 26px 0 10px;
|
||||
border-radius: var(--radius-sm); border: 1px solid var(--border-1);
|
||||
background: var(--surface-card); color: var(--text-1);
|
||||
font-size: var(--text-sm); font-family: inherit; cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dd-select__trigger:hover { background: var(--surface-2); }
|
||||
.dd-select__trigger:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
.dd-select__chev {
|
||||
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
|
||||
pointer-events: none; color: var(--text-3); display: inline-flex;
|
||||
}
|
||||
.dd-select__menu {
|
||||
position: absolute; top: calc(100% + 4px); right: 0; z-index: 60;
|
||||
min-width: 100%; max-width: 300px; padding: 4px; margin: 0;
|
||||
background: var(--surface-card); border: 1px solid var(--border-1);
|
||||
border-radius: var(--radius-md); box-shadow: var(--shadow-menu);
|
||||
list-style: none;
|
||||
}
|
||||
.dd-select__opt {
|
||||
display: flex; align-items: center; gap: 7px; width: 100%;
|
||||
height: 28px; padding: 0 10px 0 8px; border: none; border-radius: var(--radius-sm);
|
||||
background: transparent; color: var(--text-1);
|
||||
font-size: var(--text-sm); font-family: inherit; cursor: pointer;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: left;
|
||||
}
|
||||
.dd-select__opt:hover { background: var(--surface-2); }
|
||||
.dd-select__opt .dd-select__tick { width: 14px; flex: none; display: inline-flex; color: var(--accent-text); visibility: hidden; }
|
||||
.dd-select__opt[aria-selected="true"] .dd-select__tick { visibility: visible; }
|
||||
`;
|
||||
|
||||
if (typeof document !== 'undefined' && !document.getElementById('dd-select-css')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'dd-select-css';
|
||||
s.textContent = ddSelectCss;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* options: Array<string | { value, label }>;value/onChange 受控。
|
||||
* placeholder 在 value 无匹配项时显示。
|
||||
*/
|
||||
export function Select({ value, onChange, options = [], placeholder = '请选择', ...rest }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef(null);
|
||||
|
||||
const items = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
||||
const current = items.find((o) => o.value === value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<span className="dd-select" ref={rootRef} {...rest}>
|
||||
<button
|
||||
type="button"
|
||||
className="dd-select__trigger"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{current ? current.label : placeholder}
|
||||
</button>
|
||||
<span className="dd-select__chev"><Icon name="chevronDown" size={13} /></span>
|
||||
{open && (
|
||||
<ul className="dd-select__menu" role="listbox">
|
||||
{items.map((o) => (
|
||||
<li key={o.value}>
|
||||
<button
|
||||
type="button"
|
||||
className="dd-select__opt"
|
||||
role="option"
|
||||
aria-selected={o.value === value}
|
||||
onClick={() => { onChange?.(o.value); setOpen(false); }}
|
||||
>
|
||||
<span className="dd-select__tick"><Icon name="check" size={12} sw={2.5} /></span>
|
||||
{o.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
"check": { "web": "check", "ios": "checkmark", "android": null },
|
||||
"close": { "web": "x", "ios": "xmark", "android": null },
|
||||
"chevronRight": { "web": null, "ios": "chevron.right", "android": null },
|
||||
"chevronDown": { "web": "chevronDown", "ios": null, "android": null },
|
||||
"settings": { "web": null, "ios": "gearshape", "android": null },
|
||||
"person": { "web": null, "ios": "person.fill", "android": null },
|
||||
"globe": { "web": null, "ios": "globe", "android": null },
|
||||
|
||||
@@ -19,6 +19,8 @@ export const icons = {
|
||||
'<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"/>',
|
||||
/** 下拉箭头(Select 等展开指示) */
|
||||
chevronDown: '<path d="m6 9 6 6 6-6"/>',
|
||||
};
|
||||
|
||||
/** 品牌 mark(48 网格、3.5 圆头——圆嘴 + 三条声波,与 assets/logo-mark.svg 同构) */
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
<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 class="entry"><b>Select</b><div class="sub">自绘下拉选择器:触发器同 Input,弹层同菜单(radius-md + shadow-menu),选中项前置对勾。全端下拉外观基准,禁用原生 select</div><div class="meta">value · onChange · options · placeholder — components/forms/Select.jsx</div></div>
|
||||
</div>
|
||||
<h3>voice</h3>
|
||||
<div class="grid">
|
||||
|
||||
@@ -48,7 +48,7 @@ function TrayMenu() {
|
||||
}
|
||||
|
||||
function SettingsWindow({ theme, onThemeChange }) {
|
||||
const { Switch, SettingRow, HotkeyCombo, Button, Badge, ProgressBar } = window.DuduDesignSystem_2e0172;
|
||||
const { Switch, SettingRow, HotkeyCombo, Button, Badge, ProgressBar, Select } = window.DuduDesignSystem_2e0172;
|
||||
const [autostart, setAutostart] = React.useState(true);
|
||||
const [sound, setSound] = React.useState(false);
|
||||
const [mic, setMic] = React.useState('MacBook Pro 麦克风');
|
||||
@@ -72,18 +72,11 @@ function SettingsWindow({ theme, onThemeChange }) {
|
||||
</span>
|
||||
</SettingRow>
|
||||
<SettingRow label="麦克风">
|
||||
<span style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<select value={mic} onChange={(e) => setMic(e.target.value)} style={{
|
||||
appearance: 'none', WebkitAppearance: 'none', height: 'var(--control-sm)',
|
||||
padding: '0 30px 0 10px', borderRadius: 'var(--radius-sm)', border: '1px solid var(--border-1)',
|
||||
background: 'var(--surface-card)', color: 'var(--text-1)', fontSize: 'var(--text-sm)', fontFamily: 'var(--font-sans)', cursor: 'pointer',
|
||||
}}>
|
||||
<option>MacBook Pro 麦克风</option>
|
||||
<option>AirPods Pro</option>
|
||||
<option>外接 USB 麦克风</option>
|
||||
</select>
|
||||
<span style={{ position: 'absolute', right: 8, pointerEvents: 'none', color: 'var(--text-3)', display: 'inline-flex' }}><DuIcons.ChevronDown size={13} /></span>
|
||||
</span>
|
||||
<Select
|
||||
value={mic}
|
||||
onChange={setMic}
|
||||
options={['MacBook Pro 麦克风', 'AirPods Pro', '外接 USB 麦克风']}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="提示音" description="开始 / 完成时播放轻提示音">
|
||||
<Switch checked={sound} onChange={setSound} />
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
"width": 440,
|
||||
"height": 560,
|
||||
"resizable": false,
|
||||
"visible": false
|
||||
"visible": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true
|
||||
},
|
||||
{
|
||||
"label": "overlay",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { HotkeyCombo } from '@dudu/design/components/core/Kbd';
|
||||
import { ProgressBar } from '@dudu/design/components/core/ProgressBar';
|
||||
import { Switch } from '@dudu/design/components/forms/Switch';
|
||||
import { SettingRow } from '@dudu/design/components/forms/SettingRow';
|
||||
import { Select } from '@dudu/design/components/forms/Select';
|
||||
import { invoke } from '../shared/tauri';
|
||||
import { setThemePref } from '../shared/theme';
|
||||
|
||||
@@ -61,6 +62,19 @@ export function SettingsApp() {
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'var(--font-sans)', background: 'var(--bg-app)', minHeight: '100vh' }}>
|
||||
{/* 无边框窗口自绘顶条(titleBarStyle Overlay:系统红绿灯悬浮于左上,留 76px);
|
||||
样式对齐原型 MacWindow:surface-2 + 下边框,整条可拖拽 */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
style={{
|
||||
height: 36, display: 'flex', alignItems: 'center', padding: '0 14px 0 76px',
|
||||
background: 'var(--surface-2)', borderBottom: '1px solid var(--border-1)',
|
||||
fontSize: 'var(--text-sm)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)',
|
||||
userSelect: 'none', WebkitUserSelect: 'none', cursor: 'default',
|
||||
}}
|
||||
>
|
||||
dudu 设置
|
||||
</div>
|
||||
<Section title="输入">
|
||||
<SettingRow label="说话快捷键" description="按住说话,松开上屏">
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
@@ -69,17 +83,11 @@ export function SettingsApp() {
|
||||
</span>
|
||||
</SettingRow>
|
||||
<SettingRow label="麦克风">
|
||||
<select
|
||||
<Select
|
||||
value={settings.mic}
|
||||
onChange={(e) => update({ mic: e.target.value })}
|
||||
style={{
|
||||
height: 'var(--control-sm)', padding: '0 10px', borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border-1)', background: 'var(--surface-card)',
|
||||
color: 'var(--text-1)', fontSize: 'var(--text-sm)', fontFamily: 'var(--font-sans)',
|
||||
}}>
|
||||
<option value="">系统默认</option>
|
||||
{mics.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
onChange={(mic) => update({ mic })}
|
||||
options={[{ value: '', label: '系统默认' }, ...mics.map((m) => ({ value: m, label: m }))]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="提示音" description="开始 / 完成时播放轻提示音">
|
||||
<Switch checked={settings.sound} onChange={(v) => update({ sound: v })} />
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 B |
Binary file not shown.
|
After Width: | Height: | Size: 12 B |
Reference in New Issue
Block a user