@linhey/rhine-design 0.11.0 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/CONTRIBUTING.md +2 -0
- package/README.md +9 -4
- package/components/Actions.tsx +1 -0
- package/components/AdvancedForms.tsx +13 -5
- package/components/AppShell.tsx +3 -2
- package/components/COMMON_COMPONENTS.md +5 -1
- package/components/Chat.tsx +160 -0
- package/components/Forms.tsx +66 -6
- package/components/Navigation.tsx +15 -4
- package/components/Overlays.tsx +37 -7
- package/components/README.md +11 -1
- package/components/chat.css +32 -0
- package/components/common.css +2 -0
- package/components/components.tsx +19 -9
- package/components/dialog-settings.css +54 -1
- package/components/extensions.css +4 -0
- package/components/index.ts +8 -3
- package/components/patterns.css +4 -0
- package/components/styles.css +11 -2
- package/components/theme-arknights.css +4 -2
- package/components/useFloatingScale.ts +7 -3
- package/docs/AGENT_CHAT.md +76 -0
- package/docs/AGENT_CHAT_VERIFICATION.md +42 -0
- package/docs/APPLICATION_COMPONENTS.md +4 -2
- package/docs/BUTTON_ACTIONS.md +32 -0
- package/docs/BUTTON_ACTIONS_VERIFICATION.md +14 -0
- package/docs/COMPACT_ACCOUNT_PATTERNS.md +16 -0
- package/docs/COMPACT_SELECTION_VERIFICATION.md +15 -0
- package/docs/FLOATING_OVERLAYS.md +20 -0
- package/docs/INTERACTION_PATTERNS.md +4 -0
- package/docs/SETTINGS_COMPOSITION_VERIFICATION.md +12 -0
- package/docs/SETTINGS_DIALOG.md +42 -4
- package/docs/SIDE_NAV_ACTIONS.md +9 -0
- package/docs/SINGLE_SEARCH.md +19 -0
- package/docs/SINGLE_SEARCH_VERIFICATION.md +15 -0
- package/docs/SUMMARY_TABS.md +7 -1
- package/docs/TRAY_USAGE.md +34 -0
- package/docs/TRAY_USAGE_VERIFICATION.md +13 -0
- package/docs/assets/chat/after.png +0 -0
- package/docs/assets/chat/arknights.png +0 -0
- package/docs/assets/chat/before.png +0 -0
- package/docs/assets/chat/neo-brutalism.png +0 -0
- package/package.json +1 -1
- package/preview/ButtonActionsGallery.tsx +25 -0
- package/preview/ChatGallery.tsx +98 -0
- package/preview/CompactSelectionGallery.tsx +76 -0
- package/preview/DenseFilterControlsGallery.tsx +5 -2
- package/preview/GalleryShell.tsx +2 -2
- package/preview/NavActionsGallery.tsx +24 -0
- package/preview/SettingsCompositionGallery.tsx +38 -0
- package/preview/SettingsDialogGallery.tsx +54 -14
- package/preview/SingleSearchGallery.tsx +50 -0
- package/preview/SummaryTabsGallery.tsx +7 -3
- package/preview/TrayUsageGallery.tsx +82 -0
- package/preview/chat-gallery.css +1 -0
- package/preview/compact-selection.css +9 -0
- package/preview/main.tsx +15 -1
- package/preview/packageInfo.ts +1 -1
- package/preview/settings-dialog-gallery.css +5 -0
- package/preview/tray-usage.css +4 -0
- package/scripts/check-button-actions.js +34 -0
- package/scripts/check-combobox-modal-focus.js +22 -0
- package/scripts/check-combobox-zoom.js +109 -0
- package/scripts/check-compact-selection.js +40 -0
- package/scripts/check-dialog-scroll-boundaries.js +41 -0
- package/scripts/check-multiselect-completion.js +46 -0
- package/scripts/check-nav-actions.js +41 -0
- package/scripts/check-settings-bounded.js +49 -0
- package/scripts/check-settings-composition.js +50 -0
- package/scripts/check-settings-interactions.js +93 -0
- package/scripts/check-settings-theme.js +1 -5
- package/scripts/check-single-search.js +43 -0
- package/scripts/check-tray-usage.js +68 -0
- package/scripts/prepare-settings-consumer.mjs +21 -0
- package/skills/rhine-design/SKILL.md +2 -0
- package/skills/rhine-design/references/API_MAP.md +11 -0
- package/skills/rhine-design/references/TASK_MAP.md +1 -0
- package/skills/rhine-design/references/integration.md +1 -1
- package/tests/Chat.test.tsx +84 -0
- package/tests/CompactSelection.test.tsx +47 -0
- package/tests/FormParity.test.tsx +104 -2
- package/tests/NavActions.test.tsx +29 -0
- package/tests/SettingsDialog.test.tsx +102 -9
- package/tests/SingleSearch.test.tsx +42 -0
- package/tests/SkillInstallation.test.tsx +77 -0
- package/tests/TrayUsage.test.tsx +70 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Badge, Button, Dialog, Icon, Inline, Menu, Select, Stack, Surface, Tabs, Text, Toolbar } from '../components';
|
|
3
|
+
import './compact-selection.css';
|
|
4
|
+
|
|
5
|
+
export function CompactSelectionGallery() {
|
|
6
|
+
const [width, setWidth] = useState('880'), [scenario, setScenario] = useState('normal');
|
|
7
|
+
const [selected, setSelected] = useState('a'), [pending, setPending] = useState<string | null>(null), [error, setError] = useState('');
|
|
8
|
+
const [confirmed, setConfirmed] = useState('alpha'), [draft, setDraft] = useState('alpha');
|
|
9
|
+
const [dialogOwner, setDialogOwner] = useState('a');
|
|
10
|
+
const [open, setOpen] = useState(false), [saving, setSaving] = useState(false), [saveError, setSaveError] = useState('');
|
|
11
|
+
const [page, setPage] = useState('two'), [tab, setTab] = useState('one'), [layout, setLayout] = useState<'content' | 'fill'>('content');
|
|
12
|
+
const request = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
13
|
+
const saveRequest = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
14
|
+
const locked = useRef(false), saveLock = useRef(false);
|
|
15
|
+
useEffect(() => () => { clearTimeout(request.current); clearTimeout(saveRequest.current); }, []);
|
|
16
|
+
const choose = (id: string) => {
|
|
17
|
+
if (locked.current || id === selected) return;
|
|
18
|
+
locked.current = true; setPending(id); setError('');
|
|
19
|
+
request.current = setTimeout(() => {
|
|
20
|
+
if (scenario === 'error') setError('切换失败,保留最后确认项。'); else setSelected(id);
|
|
21
|
+
locked.current = false; setPending(null);
|
|
22
|
+
}, 700);
|
|
23
|
+
};
|
|
24
|
+
const changeOpen = (next: boolean) => {
|
|
25
|
+
if (saveLock.current) return;
|
|
26
|
+
setOpen(next); setDraft(confirmed); setSaveError(''); setPage('two');
|
|
27
|
+
};
|
|
28
|
+
const save = () => {
|
|
29
|
+
if (saveLock.current || draft === confirmed) return;
|
|
30
|
+
saveLock.current = true; setSaving(true); setSaveError('');
|
|
31
|
+
saveRequest.current = setTimeout(() => {
|
|
32
|
+
if (scenario === 'error') setSaveError('保存失败,请重试;确认值未改变。'); else setConfirmed(draft);
|
|
33
|
+
saveLock.current = false; setSaving(false);
|
|
34
|
+
}, 700);
|
|
35
|
+
};
|
|
36
|
+
const groupName = (value: string) => ({alpha:'示例组',beta:'联合研究组',none:'未绑定',unknown:'未知组'})[value] || value || '未选择';
|
|
37
|
+
useEffect(() => { if (page !== 'loading') return; const timer = setTimeout(() => setPage('two'), 600); return () => clearTimeout(timer); }, [page]);
|
|
38
|
+
const count = page === 'four' ? 4 : page === 'two' ? 2 : page === 'last' ? 1 : 0;
|
|
39
|
+
const items = [
|
|
40
|
+
{ id:'a', name: scenario === 'long' ? '跨区域联合研究与长期设计验证的合成对象名称' : '合成对象 A', menu:false },
|
|
41
|
+
{ id:'b', name: scenario === 'long' ? '跨区域联合研究与长期设计验证的合成备用对象' : '合成对象 B', menu:true },
|
|
42
|
+
];
|
|
43
|
+
return <section id="compact-selection"><Stack gap={6}>
|
|
44
|
+
<Toolbar title="紧凑选择与预览保存" description="正文配对、稳定状态位和动作列;选择先预览,保存后才确认。">
|
|
45
|
+
<Select label="组合宽度" value={width} onChange={e=>setWidth(e.target.value)}>{['400','520','880'].map(v=><option key={v}>{v}</option>)}</Select>
|
|
46
|
+
<Select label="组合场景" value={scenario} onChange={e=>setScenario(e.target.value)}><option value="normal">正常</option><option value="long">长名称与多行标签</option><option value="error">失败</option><option value="unbound">未绑定</option><option value="unknown">未知</option><option value="disabled">只读</option><option value="loading">加载</option></Select>
|
|
47
|
+
<Select label="页签布局" value={layout} onChange={e=>setLayout(e.target.value as 'content'|'fill')}><option value="content">内容宽度左对齐</option><option value="fill">均分铺满</option></Select>
|
|
48
|
+
</Toolbar>
|
|
49
|
+
<div className="ds-compact-selection" style={{width:`${width}px`,maxWidth:'100%'}}>
|
|
50
|
+
<Tabs label="选择摘要" layout={layout} value={tab} onValueChange={setTab} items={[
|
|
51
|
+
{value:'one',label:'工作区',status:<Badge>{pending?'处理中':'可用'}</Badge>,content:<Text>合成身份完整展示在这里,不在页签重复第二行。</Text>},
|
|
52
|
+
{value:'two',label:'备用区',status:<Badge>已停止</Badge>,content:<Text>备用区身份与说明。</Text>},
|
|
53
|
+
]}/>
|
|
54
|
+
<div className="ds-compact-selection__cards">{items.map(item=><Surface key={item.id} padding="none" className="ds-choice-card" data-card={item.id}>
|
|
55
|
+
<div className="ds-choice-card__info"><Inline gap={2}><Text as="h3" variant="heading">{item.name}</Text><span className="ds-choice-card__status">{selected===item.id?<Badge tone="success">当前项</Badge>:null}</span></Inline>
|
|
56
|
+
<Text variant="caption" tone="muted">研究类型 · 合成额度 120</Text>
|
|
57
|
+
<div className="ds-inline-choice"><Text variant="body">{scenario==='long'?'授权分组与跨区域研究范围':'授权分组'}</Text>
|
|
58
|
+
<Dialog trigger={<Button variant="secondary" contentAlign="between" disabled={scenario==='disabled'} busy={scenario==='loading'} aria-label={`选择 ${item.name} 的授权分组`} style={{width:180,maxWidth:'100%'}} onClick={()=>{}}>{scenario==='unbound'?'未绑定':scenario==='unknown'?'未知组':groupName(confirmed)}<Icon name="chevronDown" /></Button>}
|
|
59
|
+
title="分组选择" description="选择仅更新预览;保存后生效。" height={560} open={open && item.id===dialogOwner} onOpenChange={next=>{if(next) setDialogOwner(item.id);changeOpen(next);}}
|
|
60
|
+
footer={<Inline style={{justifyContent:'flex-end'}}><Button busy={saving} disabled={draft===confirmed || !draft} onClick={save}>保存</Button></Inline>}
|
|
61
|
+
sidePanel={<Stack gap={3}><Text as="h3" variant="heading">预览:{groupName(draft)}</Text><Text>已确认:{groupName(confirmed)}</Text><Select label="预览内容" value={page} onChange={e=>setPage(e.target.value)}><option value="two">两条</option><option value="four">四条</option><option value="last">末页一条</option><option value="empty">空组</option><option value="loading">异步加载</option><option value="error">读取错误</option></Select>
|
|
62
|
+
{count>0?Array.from({length:count},(_,i)=><Text key={i}>合成记录 {i+1}:用于比较候选内容。{'长详情用于验证内部滚动。'.repeat(5)}</Text>):<Text role="status">{page==='loading'?'正在读取预览…':page==='error'?'预览读取失败,请重试。':'暂无记录'}</Text>}</Stack>}>
|
|
63
|
+
<Stack gap={4}><Select label="候选分组" value={draft} disabled={saving} onChange={e=>setDraft(e.target.value)}><option value="">未选择</option><option value="alpha">示例组</option><option value="beta">联合研究组</option><option value="none">未绑定</option><option value="unknown">未知组</option></Select><Text role="status">{saving?'正在保存…':saveError}</Text></Stack>
|
|
64
|
+
</Dialog>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
<div className="ds-choice-card__actions"><Dialog trigger={<Button variant="secondary" style={{width:64}}>详情</Button>} title={`${item.name}详情`} description="只读详情在其他写入处理中仍可访问。"><Text>纯合成记录。</Text></Dialog>
|
|
68
|
+
{item.menu?<Menu label={`${item.name}操作`} trigger={<Button variant="secondary" style={{width:112}} disabled={!!pending && pending!==item.id} busy={pending===item.id}>切换操作</Button>} items={[{id:'choose',label:'设为当前项',disabled:!!pending||selected===item.id,onSelect:()=>choose(item.id)}]}/>:<Button style={{width:112}} busy={pending===item.id} disabled={selected===item.id||(!!pending&&pending!==item.id)} onClick={()=>choose(item.id)}>设为当前</Button>}
|
|
69
|
+
</div>
|
|
70
|
+
</Surface>)}</div>
|
|
71
|
+
<Text role="status">{error}</Text>
|
|
72
|
+
<Surface padding="none" className="ds-choice-card"><div><Text as="h3" variant="heading">永久不可用对象</Text><Text>已停用 · 无法切换,详情仍可访问。</Text></div><Dialog trigger={<Button variant="secondary" style={{width:64}}>详情</Button>} title="停用详情" description="原因保留可见。"><Text>合成停用原因。</Text></Dialog></Surface>
|
|
73
|
+
<Surface><Stack gap={2}><Text as="h3" variant="heading">密度反例(不推荐)</Text><Inline><Text variant="caption">授权分组</Text><Button variant="secondary">示例组<Icon name="chevronDown" /></Button><Button variant="secondary">详情</Button></Inline><Text variant="caption">caption 12/18 辅助标签搭配标准按钮,文字份量失配。上方推荐行采用14/22标签和值,标题16/24保留层级,命中均不小于44px。</Text></Stack></Surface>
|
|
74
|
+
</div>
|
|
75
|
+
</Stack></section>;
|
|
76
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
|
-
import { Button, DateRangePicker, Inline, RemoteMultiSelect, Stack, Surface, Text, type Choice, type DateRange } from '../components';
|
|
2
|
+
import { Button, DateRangePicker, Dialog, Inline, RemoteMultiSelect, Stack, Surface, Text, type Choice, type DateRange } from '../components';
|
|
3
3
|
|
|
4
4
|
const departments: Choice[] = Array.from({ length: 100 }, (_, i) => ({ value: `synthetic-${i}`, label: `合成部门 ${i + 1}${i % 8 === 0 ? ' · 跨团队协作与长期计划' : ''}` }));
|
|
5
5
|
|
|
@@ -12,9 +12,12 @@ export function DenseFilterControlsGallery() {
|
|
|
12
12
|
const options = departments.filter(item => item.label.includes(query));
|
|
13
13
|
return <Surface><Stack gap={4}>
|
|
14
14
|
<Text as="h2" variant="heading">密集筛选控件</Text>
|
|
15
|
-
<Text tone="muted">100 个合成部门、90
|
|
15
|
+
<Text tone="muted">100 个合成部门、90 个已选项;可搜索、再次点选取消、完成选择收起、展开移除或清空。日期保留原生键盘和日历。</Text>
|
|
16
16
|
<Inline gap={2}><Button variant="secondary" onClick={() => setSelected(departments)}>全选部门</Button><Button variant="quiet" onClick={() => setSelected([])}>清空部门</Button></Inline>
|
|
17
17
|
<RemoteMultiSelect label="部门" query={query} onQueryChange={setQuery} value={selected} onValueChange={setSelected} options={options} maxVisibleTags={2} placeholder="搜索部门" />
|
|
18
|
+
<Dialog trigger={<Button variant="secondary">在弹窗内选择</Button>} title="搜索多选" description="再次点击已选项可取消;完成选择只收起列表,不提交表单。">
|
|
19
|
+
<RemoteMultiSelect label="弹窗部门" query={query} onQueryChange={setQuery} value={selected} onValueChange={setSelected} options={options} maxVisibleTags={2}/>
|
|
20
|
+
</Dialog>
|
|
18
21
|
<DateRangePicker compact label="日期区间" value={date} onValueChange={setDate} min="2026-01-01" max="2026-12-31" clearable={false} required onValidationChange={setError} />
|
|
19
22
|
<Text variant="caption" tone="muted">{error || `${date.start} 至 ${date.end}`}</Text>
|
|
20
23
|
</Stack></Surface>;
|
package/preview/GalleryShell.tsx
CHANGED
|
@@ -10,8 +10,8 @@ export const galleryGroups = [
|
|
|
10
10
|
{ label: '开始', items: [['top', '设计总览'], ['subscriptions', '订阅管理示例'], ['compact-filters', '紧凑与大量筛选'], ['checkbox-rows', '侧栏双行多选'], ['component-typography', '组件文字配对']] },
|
|
11
11
|
{ label: '应用能力', items: [['app-shell', '背景与多侧栏'], ['advanced-forms', '复杂表单'], ['advanced-data', '受控数据表'], ['media', '媒体卡片与查看器'], ['monitoring', '健康详情与图表']] },
|
|
12
12
|
{ label: '设计语言', items: [['foundation', '视觉基础'], ['backgrounds', '场景背景'], ['spacing', '间距与组合'], ['typography', '字体与文字'], ['brand', '品牌标志']] },
|
|
13
|
-
{ label: '通用组件', items: [['actions', '按钮与输入'], ['selection', '选择与切换'], ['summary-tabs', '带摘要的页签'], ['feedback', '状态与加载'], ['disclosure', '展开与组合'], ['data', '数据与分页'], ['forms', '表单与选择'], ['utilities', '操作与布局'], ['overlays', '浮层与反馈'], ['overlay-bounds', '浮层缩放与边界'], ['settings-dialog', '设置导航弹窗'], ['theme-picker', '主题预览与选择'], ['navigation', '导航与步骤'], ['form-workflow', '表单流程']] },
|
|
14
|
-
{ label: '交互与动效', items: [['patterns', '场景与交互指南'], ['compact-account', '紧凑账号与凭证'], ['list-filter-bar', '列表筛选栏'], ['date-range-filter', '日期筛选组合'], ['motion', '动效实验室'], ['startup', '启动动画'], ['orbit', '轨道星空'], ['neutron-star', '中子星']] },
|
|
13
|
+
{ label: '通用组件', items: [['button-actions', '动作语义与尺寸'], ['actions', '按钮与输入'], ['selection', '选择与切换'], ['summary-tabs', '带摘要的页签'], ['feedback', '状态与加载'], ['disclosure', '展开与组合'], ['data', '数据与分页'], ['forms', '表单与选择'], ['utilities', '操作与布局'], ['overlays', '浮层与反馈'], ['overlay-bounds', '浮层缩放与边界'], ['tray-usage', '托盘用量组合'], ['settings-composition', '设置页组合'], ['settings-dialog', '设置导航弹窗'], ['theme-picker', '主题预览与选择'], ['nav-actions', '导航行操作'], ['navigation', '导航与步骤'], ['form-workflow', '表单流程']] },
|
|
14
|
+
{ label: '交互与动效', items: [['patterns', '场景与交互指南'], ['agent-chat', '聊天消息与记录'], ['compact-selection', '紧凑选择与保存'], ['compact-account', '紧凑账号与凭证'], ['single-search', '单输入搜索'], ['list-filter-bar', '列表筛选栏'], ['date-range-filter', '日期筛选组合'], ['motion', '动效实验室'], ['startup', '启动动画'], ['orbit', '轨道星空'], ['neutron-star', '中子星']] },
|
|
15
15
|
] as const;
|
|
16
16
|
export type GallerySection = typeof galleryGroups[number]['items'][number][0];
|
|
17
17
|
const entries = galleryGroups.flatMap(group => [...group.items]);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { Dialog, Select, SideNav, Stack, Text, Toolbar } from '../components';
|
|
3
|
+
export function NavActionsGallery() {
|
|
4
|
+
const [width, setWidth] = useState('520');
|
|
5
|
+
const [long, setLong] = useState(false);
|
|
6
|
+
const [current, setCurrent] = useState('a');
|
|
7
|
+
const [open, setOpen] = useState(false);
|
|
8
|
+
const returnFocus = useRef<HTMLButtonElement | null>(null);
|
|
9
|
+
const label = long ? '跨区域联合研究项目与长期设计验证的合成工作区' : '合成工作区';
|
|
10
|
+
return <section id="nav-actions"><Stack gap={6}>
|
|
11
|
+
<Toolbar title="导航行操作" description="导航链接与更多操作互为兄弟,空动作不占位。">
|
|
12
|
+
<Select label="导航容器宽度" value={width} onChange={e => setWidth(e.target.value)}>{['400', '520', '880'].map(value => <option key={value}>{value}</option>)}</Select>
|
|
13
|
+
<Select label="导航标题" value={long ? 'long' : 'normal'} onChange={e => setLong(e.target.value === 'long')}><option value="normal">普通</option><option value="long">长标题</option></Select>
|
|
14
|
+
</Toolbar>
|
|
15
|
+
<div style={{ width: `${width}px`, maxWidth: '100%' }}><SideNav aria-label="行操作示例" currentId={current} onNavigate={(item, event) => { event.preventDefault(); setCurrent(item.id); }} groups={[{ label: '合成项目', items: [
|
|
16
|
+
{ id: 'a', label, href: '#nav-actions', icon: 'chart', actions: [{ id: 'details', label: '查看详情', onSelect: trigger => { returnFocus.current = trigger; setOpen(true); } }, { id: 'disabled', label: '暂不可用动作', disabled: true, onSelect: () => {} }] },
|
|
17
|
+
{ id: 'b', label: '没有动作的工作区', href: '#nav-actions', actions: [] },
|
|
18
|
+
] }]} /></div>
|
|
19
|
+
<Text role="status">当前导航:{current === 'a' ? label : '没有动作的工作区'}</Text>
|
|
20
|
+
<Dialog trigger={<button hidden type="button">受控详情入口</button>} title="工作区详情" description="由菜单打开的受控 Dialog。关闭后回到原行更多按钮。" open={open} onOpenChange={setOpen} onCloseAutoFocus={event => { event.preventDefault(); returnFocus.current?.focus(); }}>
|
|
21
|
+
<Text>纯合成内容;动作由调用方提供,不含权限判断。</Text>
|
|
22
|
+
</Dialog>
|
|
23
|
+
</Stack></section>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { AppShell, AppShellToggle, Button, Checkbox, Inline, Select, SideNav, Stack, Surface, Text } from '../components';
|
|
3
|
+
import { ThemeProvider, type ThemeName } from '../components/theme';
|
|
4
|
+
|
|
5
|
+
export function SettingsCompositionGallery() {
|
|
6
|
+
const [open, setOpen] = useState(true), [state, setState] = useState('current');
|
|
7
|
+
const [long, setLong] = useState(false), [action, setAction] = useState(false);
|
|
8
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
9
|
+
useEffect(() => () => clearTimeout(timer.current), []);
|
|
10
|
+
const reset = () => { clearTimeout(timer.current); setOpen(true); setState('current'); setLong(false); setAction(false); };
|
|
11
|
+
const check = () => { clearTimeout(timer.current); setState('checking'); timer.current = setTimeout(() => setState('available'), 1200); };
|
|
12
|
+
return <Stack>
|
|
13
|
+
<Text as="h2" variant="section">设置页组合</Text><Text>未发布示例 · 合成更新数据,宿主负责真实检查与安装。</Text>
|
|
14
|
+
<Select label="更新状态" value={state} onChange={e => { clearTimeout(timer.current); setState(e.target.value); }}>
|
|
15
|
+
<option value="current">已是最新</option><option value="checking">检查中</option><option value="available">可安装</option><option value="error">检查失败</option>
|
|
16
|
+
</Select>
|
|
17
|
+
<Inline><Checkbox label="长产品名与状态" checked={long} onChange={e => setLong(e.target.checked)} /><Checkbox label="显示顶部动作" checked={action} onChange={e => setAction(e.target.checked)} /><Button variant="secondary" onClick={reset}>重置设置示例</Button></Inline>
|
|
18
|
+
<div style={{ height: 520 }}>
|
|
19
|
+
<AppShell label="设置组合工作区" layout="single" controls="inline" collapseEmptyContentHeader navigation={{ label: '设置导航', open, onOpenChange: setOpen, content: <Stack><Inline><Text>演示工作区</Text><AppShellToggle placement="panel" iconOnly /></Inline><SideNav currentId="settings" groups={[{ label: '偏好', items: [{ id: 'settings', href: '#settings-composition', label: '设置', icon: 'settings' }] }]} onNavigate={(_, event) => event.preventDefault()} /></Stack> }}
|
|
20
|
+
contentHeader={<><AppShellToggle placement="content" iconOnly />{action && <Button variant="quiet" onClick={() => setAction(false)}>完成顶部操作</Button>}</>}>
|
|
21
|
+
<Stack><Text as="h1" variant="heading">设置</Text><Surface>
|
|
22
|
+
<Inline style={{ alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
|
23
|
+
<Stack gap={2} style={{ flex: '1 1 180px', minWidth: 0, overflowWrap: 'anywhere' }}><Text as="h2" variant="heading">{long ? 'Rhine Studio · 用于跨地区协作的超长产品身份与版本名称' : 'Rhine Studio'}</Text><Text tone="muted">演示版本 · 关于</Text></Stack>
|
|
24
|
+
<Stack gap={2} data-testid="update-area" style={{ flex: '1 1 200px', minWidth: 0, alignItems: 'flex-end', textAlign: 'end', overflowWrap: 'anywhere' }}>
|
|
25
|
+
<Text role="status">{state === 'checking' ? '正在检查更新,请稍候…' : state === 'error' ? '检查失败,请重试。' : state === 'available' ? '发现可安装的演示更新。' : '当前已是最新版本。'}{long && '此说明包含完整的更新渠道和网络检查信息,会随可用空间自然换行。'}</Text>
|
|
26
|
+
<Button variant="secondary" busy={state === 'checking'} busyLabel="正在检查更新" onClick={() => state === 'available' ? setState('current') : check()}>{state === 'error' ? '重试检查' : state === 'available' ? '安装演示更新' : '检查更新'}</Button>
|
|
27
|
+
</Stack>
|
|
28
|
+
</Inline>
|
|
29
|
+
</Surface></Stack>
|
|
30
|
+
</AppShell>
|
|
31
|
+
</div>
|
|
32
|
+
</Stack>;
|
|
33
|
+
}
|
|
34
|
+
export function SettingsCompositionFixture() {
|
|
35
|
+
const params = new URLSearchParams(location.search), zoom = Number(params.get('zoom')) || 1;
|
|
36
|
+
useEffect(() => { document.documentElement.style.zoom = String(zoom); return () => { document.documentElement.style.zoom = ''; }; }, [zoom]);
|
|
37
|
+
return <ThemeProvider theme={(params.get('theme') || 'rhine') as ThemeName} data-rhine-motion={params.get('motion') || 'system'}><div style={{ padding: 16 }}><SettingsCompositionGallery /></div></ThemeProvider>;
|
|
38
|
+
}
|
|
@@ -1,27 +1,67 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { Button, Dialog, EmptyState, Notice, Select, SideNav, Skeleton, Stack, Text, TextField,
|
|
3
|
-
import { ThemeProvider, type ThemeName } from '../components/theme';
|
|
2
|
+
import { Button, Checkbox, Dialog, Drawer, EmptyState, Notice, RemoteMultiSelect, Select, SideNav, Skeleton, Stack, Text, TextField, type Choice } from '../components';
|
|
3
|
+
import { ThemeProvider, themes, useTheme, type ThemeName } from '../components/theme';
|
|
4
|
+
import './settings-dialog-gallery.css';
|
|
4
5
|
|
|
6
|
+
const members: Choice[] = Array.from({ length: 30 }, (_, i) => ({ value: `member-${i}`, label: `合成成员 ${i + 1}` }));
|
|
5
7
|
export function SettingsDialogGallery() {
|
|
8
|
+
const { theme, setTheme } = useTheme();
|
|
6
9
|
const [section, setSection] = useState('account'), [state, setState] = useState('normal');
|
|
7
10
|
const [draft, setDraft] = useState('研究工作区'), [saved, setSaved] = useState('研究工作区');
|
|
8
11
|
const [busy, setBusy] = useState(false), [message, setMessage] = useState('');
|
|
9
|
-
const [open, setOpen] = useState(false);
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
12
|
+
const [open, setOpen] = useState(false), [longNav, setLongNav] = useState(false), [longActions, setLongActions] = useState(false);
|
|
13
|
+
const [outcome, setOutcome] = useState('success'), [loadingSection, setLoadingSection] = useState(false);
|
|
14
|
+
const [query, setQuery] = useState(''), [selected, setSelected] = useState<Choice[]>([]);
|
|
15
|
+
const draftEdited = useRef(false), pending = useRef(false);
|
|
16
|
+
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
17
|
+
const sectionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
18
|
+
useEffect(() => () => { clearTimeout(saveTimer.current); clearTimeout(sectionTimer.current); }, []);
|
|
19
|
+
const save = () => {
|
|
20
|
+
if (pending.current) return;
|
|
21
|
+
pending.current = true;
|
|
22
|
+
const submitted = draft; draftEdited.current = false; setBusy(true); setMessage('');
|
|
23
|
+
saveTimer.current = setTimeout(() => {
|
|
24
|
+
if (outcome === 'success') { setSaved(submitted); if (!draftEdited.current) setDraft(submitted); setMessage('已保存演示草稿'); }
|
|
25
|
+
else setMessage(outcome === 'conflict' ? '内容已更新,请核对后重新保存' : '保存失败,草稿已保留');
|
|
26
|
+
pending.current = false; setBusy(false);
|
|
27
|
+
}, 500);
|
|
28
|
+
};
|
|
29
|
+
const navigate = (id: string) => {
|
|
30
|
+
clearTimeout(sectionTimer.current); setSection(id); setLoadingSection(id === 'async');
|
|
31
|
+
if (id === 'async') sectionTimer.current = setTimeout(() => setLoadingSection(false), 500);
|
|
32
|
+
};
|
|
33
|
+
const items = [{ id: 'account', label: '账号', href: '#account' }, { id: 'users', label: '用户管理与权限', href: '#users' }, { id: 'public', label: '公开项目', href: '#public' }, { id: 'async', label: '异步栏目', href: '#async' }, ...(longNav ? Array.from({ length: 20 }, (_, i) => ({ id: `extra-${i}`, label: `扩展设置 ${i + 1} · 长栏目名称`, href: `#extra-${i}` })) : [])];
|
|
34
|
+
return <Stack gap={4}><Text as="h2" variant="section">设置导航对话框</Text><Text>稳定窗口、独立正文滚动;使用合成数据。</Text>
|
|
13
35
|
<TextField label="宿主查询" defaultValue="保留这条查询" />
|
|
14
|
-
<
|
|
15
|
-
|
|
36
|
+
<Checkbox label="长导航" checked={longNav} onChange={event => setLongNav(event.target.checked)} />
|
|
37
|
+
<Checkbox label="长操作文案" checked={longActions} onChange={event => setLongActions(event.target.checked)} />
|
|
38
|
+
<Select label="保存结果" value={outcome} onChange={event => setOutcome(event.target.value)}><option value="success">成功</option><option value="error">失败</option><option value="conflict">冲突</option></Select>
|
|
39
|
+
<Dialog title="设置" height={640} trigger={<Button>打开设置</Button>} open={open} onOpenChange={next => { setOpen(next); if (next) { draftEdited.current = false; setDraft(saved); setMessage(''); } }}
|
|
40
|
+
footer={<>
|
|
41
|
+
<Button variant="secondary" aria-label="撤销全部未保存的修改" disabled={busy} onClick={() => { draftEdited.current = false; setDraft(saved); setMessage('已撤销未保存修改'); }}><span className={longActions ? 'rhine-settings-action-full' : undefined}>{longActions ? '撤销全部未保存的修改' : '撤销'}</span>{longActions && <span className="rhine-settings-action-short">撤销</span>}</Button>
|
|
42
|
+
<Button variant="secondary" aria-label="关闭设置" onClick={() => setOpen(false)}>关闭</Button>
|
|
43
|
+
<Button busy={busy} aria-label="保存当前工作区设置" onClick={save}><span className={longActions ? 'rhine-settings-action-full' : undefined}>{longActions ? '保存当前工作区设置' : '保存'}</span>{longActions && <span className="rhine-settings-action-short">保存</span>}</Button>
|
|
44
|
+
</>}
|
|
45
|
+
navigation={<SideNav aria-label="设置导航" currentId={section} groups={[{ label: '偏好与管理', items }]} onNavigate={(item, event) => { event.preventDefault(); navigate(item.id); }} />}>
|
|
16
46
|
<Stack gap={4}>
|
|
17
|
-
<
|
|
18
|
-
{section === 'account' && <><Text as="h3" variant="heading">账号</Text><TextField label="工作区名称" value={draft} onChange={event => { draftEdited.current = true; setDraft(event.target.value);
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
47
|
+
{busy ? <Text role="status">正在保存</Text> : message ? <Notice tone={outcome !== 'success' && !message.startsWith('已撤销') ? 'error' : 'neutral'} title={message} /> : draft !== saved ? <Text role="status">有未保存的修改</Text> : null}
|
|
48
|
+
{section === 'account' && <><Text as="h3" variant="heading">账号</Text><TextField label="工作区名称" value={draft} onChange={event => { draftEdited.current = true; setDraft(event.target.value); setMessage(''); }} />
|
|
49
|
+
<Select label="界面主题" value={theme} onChange={event => setTheme(event.target.value as ThemeName)}>{themes.map(item => <option key={item.name} value={item.name}>{item.label}</option>)}</Select>
|
|
50
|
+
<RemoteMultiSelect label="授权成员" query={query} onQueryChange={setQuery} value={selected} onValueChange={setSelected} options={members.filter(item => item.label.includes(query))} maxVisibleTags={2} />
|
|
51
|
+
</>}
|
|
52
|
+
{section === 'users' && <><Text as="h3" variant="heading">用户管理</Text>{Array.from({length: 30}, (_, i) => <Stack key={i} gap={2}><Text>演示成员 {i + 1} · 长名称研究工作组</Text><Dialog trigger={<Button variant="secondary">编辑成员 {i + 1}</Button>} title={`编辑成员 ${i + 1}`}><TextField label="成员备注" defaultValue="合成备注" /></Dialog></Stack>)}</>}
|
|
53
|
+
{section === 'public' && <><Text as="h3" variant="heading">公开项目</Text><Select label="项目状态" value={state} onChange={event => setState(event.target.value)}><option value="normal">正常</option><option value="loading">加载</option><option value="error">失败</option><option value="empty">空态</option></Select>{state === 'loading' ? <Skeleton label="正在读取项目" /> : state === 'error' ? <Notice tone="error" title="项目读取失败" action={<Button onClick={() => setState('normal')}>重试</Button>} /> : state === 'empty' ? <EmptyState title="暂无公开项目" description="" /> : <TextField label="项目标题" defaultValue="公开研究计划" />}</>}
|
|
54
|
+
{section === 'async' && (loadingSection ? <Skeleton label="正在读取栏目" /> : <Text>异步栏目已就绪</Text>)}
|
|
55
|
+
{section.startsWith('extra-') && <Text>扩展栏目 {Number(section.slice(6)) + 1}</Text>}
|
|
22
56
|
</Stack>
|
|
23
57
|
</Dialog>
|
|
24
58
|
<Text>已保存名称:{saved}</Text>
|
|
59
|
+
<Drawer title="普通抽屉" trigger={<Button variant="secondary">普通抽屉示例</Button>}><TextField label="抽屉输入" /></Drawer>
|
|
60
|
+
<Dialog title="辅助分栏" trigger={<Button variant="secondary">辅助分栏示例</Button>} sidePanel={<Text>辅助内容</Text>}><TextField label="分栏输入" /></Dialog>
|
|
61
|
+
<Dialog title="有界普通表单" height={640} trigger={<Button variant="secondary">有界普通表单示例</Button>} footer={<Button>确认</Button>}><Stack>{Array.from({length:30}, (_,i)=><TextField key={i} label={`表单字段 ${i+1}`} />)}</Stack></Dialog>
|
|
62
|
+
<Dialog title="自动高度操作区" trigger={<Button variant="secondary">自动高度操作区示例</Button>} footer={<>{Array.from({length:20}, (_,i)=><Button key={i}>长操作 {i+1}</Button>)}</>}><Text>短正文仍然可读。</Text></Dialog>
|
|
63
|
+
<Dialog title="纯文本长正文" height={400} trigger={<Button variant="secondary">纯文本长正文示例</Button>} footer={<Button>阅读完成</Button>}><Stack>{Array.from({length:30}, (_,i)=><Text key={i}>合成说明第 {i+1} 段,仅用于键盘滚动验证。</Text>)}</Stack></Dialog>
|
|
64
|
+
<Dialog title="短对话框" trigger={<Button variant="secondary">普通短弹窗</Button>}><TextField label="简短输入" /></Dialog>
|
|
25
65
|
</Stack>;
|
|
26
66
|
}
|
|
27
67
|
|
|
@@ -29,5 +69,5 @@ export function SettingsDialogFixture() {
|
|
|
29
69
|
const params = new URLSearchParams(location.search);
|
|
30
70
|
const zoom = Number(params.get('zoom')) || 1;
|
|
31
71
|
useEffect(() => { document.documentElement.style.zoom = String(zoom); return () => { document.documentElement.style.zoom = ''; }; }, [zoom]);
|
|
32
|
-
return <ThemeProvider
|
|
72
|
+
return <ThemeProvider defaultTheme={(params.get('theme') || 'rhine') as ThemeName} data-rhine-motion={params.get('motion') || 'reduced'}><div style={{padding:16}}><SettingsDialogGallery /></div></ThemeProvider>;
|
|
33
73
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useEffect, useId, useState } from 'react';
|
|
2
|
+
import { Select, Stack, Text, TextField, Toolbar } from '../components';
|
|
3
|
+
|
|
4
|
+
// Synthetic host: debounce, composition, cancellation and results belong here, not in TextField.
|
|
5
|
+
export function SingleSearchGallery() {
|
|
6
|
+
const [width, setWidth] = useState('520');
|
|
7
|
+
const [scenario, setScenario] = useState('normal');
|
|
8
|
+
const [query, setQuery] = useState('');
|
|
9
|
+
const [composing, setComposing] = useState(false);
|
|
10
|
+
const [feedback, setFeedback] = useState('');
|
|
11
|
+
const [failed, setFailed] = useState(false);
|
|
12
|
+
const feedbackId = useId();
|
|
13
|
+
const disabled = scenario === 'disabled';
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
setFeedback(''); setFailed(false);
|
|
16
|
+
if (!query.trim() || composing || disabled) return;
|
|
17
|
+
let active = true;
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
let response: ReturnType<typeof setTimeout>;
|
|
20
|
+
const debounce = setTimeout(() => {
|
|
21
|
+
if (!active) return;
|
|
22
|
+
setFeedback('正在搜索…');
|
|
23
|
+
if (scenario === 'loading') return;
|
|
24
|
+
response = setTimeout(() => {
|
|
25
|
+
if (!active || controller.signal.aborted) return;
|
|
26
|
+
const error = scenario === 'error';
|
|
27
|
+
setFailed(error);
|
|
28
|
+
setFeedback(error ? '搜索失败,请修改查询后重试。' : scenario === 'empty' ? '没有匹配结果。' : `找到与“${query}”相关的 2 条合成结果。`);
|
|
29
|
+
}, 500);
|
|
30
|
+
}, 250);
|
|
31
|
+
return () => { active = false; controller.abort(); clearTimeout(debounce); clearTimeout(response); };
|
|
32
|
+
}, [query, composing, disabled, scenario]);
|
|
33
|
+
return <section id="single-search"><Stack gap={6}>
|
|
34
|
+
<Toolbar title="单输入搜索" description="只有一个搜索条件时,直接使用正文级输入;反馈按需放在结果区。">
|
|
35
|
+
<Select label="搜索容器宽度" value={width} onChange={e => setWidth(e.target.value)}>{['400', '520', '880'].map(value => <option key={value}>{value}</option>)}</Select>
|
|
36
|
+
<Select label="搜索场景" value={scenario} onChange={e => setScenario(e.target.value)}>
|
|
37
|
+
<option value="normal">正常</option><option value="loading">加载</option><option value="empty">无结果</option><option value="error">错误</option><option value="disabled">禁用</option><option value="long">长占位</option>
|
|
38
|
+
</Select>
|
|
39
|
+
</Toolbar>
|
|
40
|
+
<div style={{ width: `${width}px`, maxWidth: '100%' }} data-search-example>
|
|
41
|
+
<TextField label="搜索合成条目" labelVisibility="hidden" type="search" value={query} disabled={disabled}
|
|
42
|
+
placeholder={scenario === 'long' ? '搜索名称、描述或跨区域联合研究项目中的合成关键词' : '搜索名称或关键词'}
|
|
43
|
+
aria-describedby={feedback ? feedbackId : undefined} aria-invalid={failed || undefined}
|
|
44
|
+
onChange={e => setQuery(e.target.value)} onCompositionStart={() => setComposing(true)} onCompositionEnd={e => { setQuery(e.currentTarget.value); setComposing(false); }}
|
|
45
|
+
onKeyDown={e => { if (e.key === 'Escape' && !composing && !e.nativeEvent.isComposing) { e.preventDefault(); setQuery(''); } }} />
|
|
46
|
+
<div id={feedbackId} role="status" aria-live="polite" aria-atomic="true">{feedback && <Text tone={failed ? 'error' : 'muted'}>{feedback}</Text>}</div>
|
|
47
|
+
</div>
|
|
48
|
+
<Text variant="caption" tone="muted">输入后等待 250ms,再模拟 500ms 请求;组合输入期间不查询。Escape 清空并保持焦点,离开章节取消等待。</Text>
|
|
49
|
+
</Stack></section>;
|
|
50
|
+
}
|
|
@@ -2,12 +2,16 @@ import { useState } from 'react';
|
|
|
2
2
|
import { Badge, KeyValue, Select, Stack, Surface, Tabs, Text, TextField, Toolbar } from '../components';
|
|
3
3
|
|
|
4
4
|
export function SummaryTabsGallery() {
|
|
5
|
+
const [layout, setLayout] = useState<'content' | 'fill'>('content');
|
|
6
|
+
const [single, setSingle] = useState(false);
|
|
5
7
|
const [width, setWidth] = useState('520');
|
|
6
8
|
const [scenario, setScenario] = useState('normal');
|
|
7
9
|
const [selected, setSelected] = useState('primary');
|
|
8
10
|
const pending = scenario === 'loading', failed = scenario === 'error';
|
|
9
11
|
return <section id="summary-tabs"><Stack gap={6}>
|
|
10
12
|
<Toolbar title="带摘要的页签" description="名称与状态在同一组,身份摘要紧随;每个对象的状态始终可见。">
|
|
13
|
+
<Select label="摘要布局" value={layout} onChange={e=>setLayout(e.target.value as 'content'|'fill')}><option value="content">内容宽度左对齐</option><option value="fill">均分铺满</option></Select>
|
|
14
|
+
<Select label="摘要行数" value={single?'single':'double'} onChange={e=>setSingle(e.target.value==='single')}><option value="single">名称与短状态</option><option value="double">双行摘要</option></Select>
|
|
11
15
|
<Select label="局部容器宽度" value={width} onChange={event => setWidth(event.target.value)}>
|
|
12
16
|
<option value="320">320px · 窄容器</option><option value="520">520px · 标准</option><option value="100%">自适应</option>
|
|
13
17
|
</Select>
|
|
@@ -17,11 +21,11 @@ export function SummaryTabsGallery() {
|
|
|
17
21
|
</Toolbar>
|
|
18
22
|
<Surface><Stack gap={6}>
|
|
19
23
|
<div style={{width: width === '100%' ? width : `${width}px`, maxWidth:'100%'}}>
|
|
20
|
-
<Tabs label="摘要页签规范示例" value={selected} onValueChange={setSelected} items={[
|
|
21
|
-
{value:'primary',label:scenario === 'long' ? '跨部门联合研究工作区' : '主工作区',description:pending ? '尚未读取当前身份' : scenario === 'long' ? '研究项目 · 跨区域联合研发与长期设计验证团队' : '研发 · 研究项目',
|
|
24
|
+
<Tabs layout={layout} label="摘要页签规范示例" value={selected} onValueChange={setSelected} items={[
|
|
25
|
+
{value:'primary',label:scenario === 'long' ? '跨部门联合研究工作区' : '主工作区',description:single ? undefined : pending ? '尚未读取当前身份' : scenario === 'long' ? '研究项目 · 跨区域联合研发与长期设计验证团队' : '研发 · 研究项目',
|
|
22
26
|
status:<Badge tone={failed ? 'error' : pending ? 'neutral' : 'success'}>{failed ? '读取失败' : pending ? '读取中' : '同步中'}</Badge>,
|
|
23
27
|
content:<Stack gap={4}><Text>主工作区内容</Text><TextField label="主工作区草稿" placeholder="切换后保留已输入内容"/></Stack>},
|
|
24
|
-
{value:'team',label:'协作工作区',description:'协作 · 设计项目',disabled:scenario === 'disabled',status:<Badge>{scenario === 'disabled' ? '暂不可用' : '已停止'}</Badge>,
|
|
28
|
+
{value:'team',label:'协作工作区',description:single ? undefined : '协作 · 设计项目',disabled:scenario === 'disabled',status:<Badge>{scenario === 'disabled' ? '暂不可用' : '已停止'}</Badge>,
|
|
25
29
|
content:<Stack gap={4}><Text>协作工作区内容</Text><TextField label="协作工作区草稿" placeholder="与另一页签独立"/></Stack>},
|
|
26
30
|
]}/>
|
|
27
31
|
</div>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Button, Checkbox, Divider, IconButton, Inline, Metric, Notice, Popover, Progress, Select, Skeleton, Stack, Text } from '../components';
|
|
3
|
+
import { ThemeProvider, type ThemeName } from '../components/theme';
|
|
4
|
+
import './tray-usage.css';
|
|
5
|
+
|
|
6
|
+
type Scenario = 'normal' | 'loading' | 'low' | 'unknown' | 'stale' | 'connecting' | 'unmatched' | 'signed-out' | 'shared';
|
|
7
|
+
type Feedback = 'idle' | 'busy' | 'error' | 'success';
|
|
8
|
+
const accounts = [{ id: 'a', name: '研究账号 A', remaining: 78 }, { id: 'b', name: '协作账号 B', remaining: 42 }];
|
|
9
|
+
|
|
10
|
+
// A synthetic host, not a product account service or a public tray component.
|
|
11
|
+
export function TrayUsageGallery() {
|
|
12
|
+
const [open, setOpen] = useState(true), [scenario, setScenario] = useState<Scenario>('normal');
|
|
13
|
+
const [long, setLong] = useState(false), [failOnce, setFailOnce] = useState(false);
|
|
14
|
+
const [current, setCurrent] = useState('a'), [feedback, setFeedback] = useState<Feedback>('idle');
|
|
15
|
+
const [target, setTarget] = useState(''), [requests, setRequests] = useState(0);
|
|
16
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
17
|
+
const pending = useRef(false), attempts = useRef(0), epoch = useRef(0);
|
|
18
|
+
useEffect(() => () => { clearTimeout(timer.current); epoch.current++; }, []);
|
|
19
|
+
const cancel = () => { clearTimeout(timer.current); epoch.current++; pending.current = false; setFeedback('idle'); setTarget(''); };
|
|
20
|
+
const reset = () => { cancel(); attempts.current = 0; setCurrent('a'); setRequests(0); setScenario('normal'); setLong(false); setFailOnce(false); setOpen(true); };
|
|
21
|
+
const unavailable = ['loading','stale','connecting','unmatched','signed-out'].includes(scenario);
|
|
22
|
+
const switchAccount = (next: string) => {
|
|
23
|
+
if (pending.current || unavailable || next === current || !accounts.some(a => a.id === next)) return;
|
|
24
|
+
pending.current = true; setTarget(next); setFeedback('busy'); setRequests(n => n + 1);
|
|
25
|
+
const request = ++epoch.current, shouldFail = failOnce && attempts.current++ === 0;
|
|
26
|
+
timer.current = setTimeout(() => {
|
|
27
|
+
if (request !== epoch.current) return;
|
|
28
|
+
pending.current = false;
|
|
29
|
+
if (shouldFail) setFeedback('error');
|
|
30
|
+
else { setCurrent(next); setFeedback('success'); }
|
|
31
|
+
}, 1200);
|
|
32
|
+
};
|
|
33
|
+
const account = accounts.find(a => a.id === current)!;
|
|
34
|
+
const name = (value: string) => long ? `${value} · 跨地区协作研究工作空间与长期项目的完整账号名称` : value;
|
|
35
|
+
const remaining = scenario === 'unknown' ? null : scenario === 'low' ? 4 : account.remaining;
|
|
36
|
+
return <Stack>
|
|
37
|
+
<Text as="h2" variant="section">托盘用量组合</Text><Text>浏览器浮层 · 合成数据。默认展开;宿主拥有账号、请求和数据时效。</Text>
|
|
38
|
+
<Select label="用量场景" value={scenario} onChange={e => { cancel(); setScenario(e.target.value as Scenario); setOpen(true); }}>
|
|
39
|
+
<option value="normal">正常</option><option value="loading">加载</option><option value="low">低额度</option><option value="unknown">未知额度</option><option value="stale">目录过期 / 刷新失败</option><option value="connecting">连接处理中</option><option value="unmatched">连接未匹配</option><option value="signed-out">未登录</option><option value="shared">共享多账号</option>
|
|
40
|
+
</Select>
|
|
41
|
+
<Inline><Checkbox label="长账号名" checked={long} onChange={e => { setLong(e.target.checked); setOpen(true); }} /><Checkbox label="首次切换失败" checked={failOnce} onChange={e => { cancel(); attempts.current = 0; setFailOnce(e.target.checked); setOpen(true); }} /><Button variant="secondary" onClick={reset}>重置托盘示例</Button></Inline>
|
|
42
|
+
<Text variant="caption" data-testid="request-count">合成切换请求:{requests}</Text>
|
|
43
|
+
<div className="preview-tray-anchor"><Popover title="账号与用量概览" showHeader={false} open={open} onOpenChange={setOpen} side="bottom" trigger={<IconButton icon="chart" label="切换用量面板" />}>
|
|
44
|
+
<Stack gap={3} data-testid="tray-panel">
|
|
45
|
+
{scenario === 'signed-out' ? <Notice title="尚未登录">登录后显示当前账号和用量。</Notice> : <>
|
|
46
|
+
<section aria-label="当前 Agent 账号" aria-busy={feedback === 'busy'}>
|
|
47
|
+
<Stack gap={2}>
|
|
48
|
+
<Select label="Agent A 当前账号" value={scenario === 'unmatched' ? '' : current} disabled={unavailable || feedback === 'busy'} onChange={e => switchAccount(e.target.value)}>{scenario === 'unmatched' && <option value="">未匹配账号</option>}{accounts.map(a => <option key={a.id} value={a.id}>{name(a.name)}</option>)}</Select>
|
|
49
|
+
{scenario === 'loading' ? <Skeleton label="正在读取当前账号额度" lines={2} /> : scenario === 'unmatched' ? <Notice title="连接未匹配">当前连接尚未关联账号,额度不可用。</Notice> : <>
|
|
50
|
+
<Text data-testid="current-account">{name(account.name)}</Text>
|
|
51
|
+
<Metric label="本周期剩余额度" value={remaining === null ? '未知' : `${remaining}%`} description="2026-10-01 至 2026-10-31 · UTC+8" />
|
|
52
|
+
{remaining !== null && <Progress label="周期剩余" value={remaining} />}
|
|
53
|
+
</>}
|
|
54
|
+
<div className="preview-tray-feedback" data-testid="switch-feedback">
|
|
55
|
+
{(['idle','busy','error','success'] as const).map(status => <div key={status} style={{ visibility: feedback === status ? 'visible' : 'hidden' }} aria-hidden={feedback !== status}>
|
|
56
|
+
<Text role={feedback === status ? (status === 'error' ? 'alert' : 'status') : undefined}>{status === 'busy' ? '正在切换目标账号,确认前保留原账号及额度。' : status === 'error' ? '切换失败,原账号及额度保持不变。' : status === 'success' ? '切换已确认。其他 Agent 和统计保持不变。' : '选择账号即可切换,确认后更新额度。'}</Text>
|
|
57
|
+
{status === 'error' && <Button variant="secondary" disabled={feedback !== 'error' || unavailable} onClick={() => switchAccount(target)}>重试切换</Button>}
|
|
58
|
+
</div>)}
|
|
59
|
+
</div>
|
|
60
|
+
</Stack>
|
|
61
|
+
</section>
|
|
62
|
+
{scenario === 'low' && <Notice tone="warning" title="当前周期剩余额度较低" />}
|
|
63
|
+
{scenario === 'unknown' && <Notice title="额度暂不可用">未返回额度;未知不等于零。</Notice>}
|
|
64
|
+
{scenario === 'stale' && <Notice tone="warning" title="上次数据 · 目录已过期">刷新失败,暂时无法切换。以下为上次可信缓存。</Notice>}
|
|
65
|
+
{scenario === 'connecting' && <Notice title="连接正在处理">完成后才能切换账号。</Notice>}
|
|
66
|
+
{scenario !== 'loading' && <section aria-label="其他 Agent" data-testid="other-agent"><Text>Agent B · 独立演示账号</Text><Text>本周期剩余 61% · 2026-10</Text></section>}
|
|
67
|
+
{scenario === 'shared' && <section aria-label="共享成员额度"><Stack gap={2}><Text as="h3" variant="heading">共享成员</Text><Text tone="muted">同一上游的多个连接共用额度,不累加百分比。</Text>{Array.from({length:12},(_,i)=><div key={i}><Text>{name(`成员账号 ${i+1}`)}</Text><Text>2026-10 周期剩余 {i === 11 ? '未知' : `${78-i*4}%`}</Text></div>)}</Stack></section>}
|
|
68
|
+
<Divider />
|
|
69
|
+
<section aria-label="今日用量" data-testid="usage-summary"><Stack gap={2}>
|
|
70
|
+
<Metric label="今日预估消费" value="1.28" unit="USD" loading={scenario === 'loading'} />
|
|
71
|
+
{scenario === 'loading' ? <Skeleton label="正在读取今日统计" lines={2} /> : <><Text>请求数 128 · 待计价 3</Text><Text variant="caption" tone="muted">{scenario === 'stale' ? '上次更新' : '更新时间'}:2026-10-27 20:30 UTC+8</Text></>}
|
|
72
|
+
</Stack></section>
|
|
73
|
+
</>}
|
|
74
|
+
</Stack>
|
|
75
|
+
</Popover></div>
|
|
76
|
+
</Stack>;
|
|
77
|
+
}
|
|
78
|
+
export function TrayUsageFixture() {
|
|
79
|
+
const params = new URLSearchParams(location.search), zoom = Number(params.get('zoom')) || 1;
|
|
80
|
+
useEffect(() => { document.documentElement.style.zoom = String(zoom); return () => { document.documentElement.style.zoom = ''; }; }, [zoom]);
|
|
81
|
+
return <ThemeProvider theme={(params.get('theme') || 'rhine') as ThemeName} data-rhine-motion={params.get('motion') || 'system'}><div style={{ padding: 16 }}><TrayUsageGallery /></div></ThemeProvider>;
|
|
82
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.chat-gallery-canvas { max-width:100%; min-width:0; }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
.ds-compact-selection { display:grid; gap:16px; }
|
|
2
|
+
.ds-compact-selection__cards { display:grid; gap:12px; }
|
|
3
|
+
.ds-choice-card { padding:12px; display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; }
|
|
4
|
+
.ds-choice-card__info { display:grid; gap:4px; flex:1 1 240px; min-width:0; }
|
|
5
|
+
.ds-choice-card__info h3 { margin:0; }
|
|
6
|
+
.ds-choice-card__status { display:inline-flex; min-width:64px; min-height:32px; }
|
|
7
|
+
.ds-inline-choice { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
8
|
+
.ds-inline-choice>.rhine-text { font-size:var(--rhine-text-body); line-height:var(--rhine-leading-body); font-weight:var(--rhine-weight-label); }
|
|
9
|
+
.ds-choice-card__actions { display:flex; align-items:center; gap:8px; flex:none; }
|
package/preview/main.tsx
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { SettingsCompositionGallery, SettingsCompositionFixture } from './SettingsCompositionGallery';
|
|
2
|
+
import { TrayUsageGallery, TrayUsageFixture } from './TrayUsageGallery';
|
|
3
|
+
import { ChatGallery } from './ChatGallery';
|
|
1
4
|
import { BackgroundFiltersFixture } from './BackgroundFiltersFixture';
|
|
2
5
|
import { StrictMode, useEffect, useRef, useState } from 'react';
|
|
3
6
|
import { createRoot } from 'react-dom/client';
|
|
@@ -27,6 +30,10 @@ import { CompactFilterBarGallery } from './CompactFilterBarGallery';
|
|
|
27
30
|
import { DenseFilterControlsGallery } from './DenseFilterControlsGallery';
|
|
28
31
|
import { CheckboxRowGallery } from './CheckboxRowGallery';
|
|
29
32
|
import { ComponentTypographyGallery } from './ComponentTypographyGallery';
|
|
33
|
+
import { NavActionsGallery } from './NavActionsGallery';
|
|
34
|
+
import { SingleSearchGallery } from './SingleSearchGallery';
|
|
35
|
+
import { CompactSelectionGallery } from './CompactSelectionGallery';
|
|
36
|
+
import { ButtonActionsGallery } from './ButtonActionsGallery';
|
|
30
37
|
import { SummaryTabsGallery } from './SummaryTabsGallery';
|
|
31
38
|
|
|
32
39
|
function Gallery() {
|
|
@@ -48,6 +55,8 @@ function Gallery() {
|
|
|
48
55
|
const simulate = () => { setBusy(true); timer.current = setTimeout(() => { setBusy(false); setNotice(true); }, 900); };
|
|
49
56
|
return <GalleryShell motion={motion} onMotionChange={setMotion}>{section => <>
|
|
50
57
|
{section === 'theme-picker' && <ThemePickerGallery />}
|
|
58
|
+
{section === 'tray-usage' && <TrayUsageGallery />}
|
|
59
|
+
{section === 'settings-composition' && <SettingsCompositionGallery />}
|
|
51
60
|
{section === 'settings-dialog' && <SettingsDialogGallery />}
|
|
52
61
|
{section === 'overlay-bounds' && <OverlayBoundsGallery />}
|
|
53
62
|
{section === 'monitoring' && <MonitoringGallery />}
|
|
@@ -57,6 +66,11 @@ function Gallery() {
|
|
|
57
66
|
{section === 'compact-filters' && <><CompactFilterBarGallery /><DenseFilterControlsGallery /></>}
|
|
58
67
|
{section === 'checkbox-rows' && <CheckboxRowGallery />}
|
|
59
68
|
{section === 'component-typography' && <ComponentTypographyGallery />}
|
|
69
|
+
{section === 'nav-actions' && <NavActionsGallery />}
|
|
70
|
+
{section === 'agent-chat' && <ChatGallery />}
|
|
71
|
+
{section === 'single-search' && <SingleSearchGallery />}
|
|
72
|
+
{section === 'compact-selection' && <CompactSelectionGallery />}
|
|
73
|
+
{section === 'button-actions' && <ButtonActionsGallery />}
|
|
60
74
|
{section === 'summary-tabs' && <SummaryTabsGallery />}
|
|
61
75
|
{section === 'backgrounds' && <BackgroundGallery />}
|
|
62
76
|
{section === 'app-shell' && <ShellGallery />}
|
|
@@ -149,4 +163,4 @@ function Gallery() {
|
|
|
149
163
|
|
|
150
164
|
}
|
|
151
165
|
|
|
152
|
-
createRoot(document.getElementById('root')!).render(<StrictMode>{['orbit-entry', 'list-filters'].includes(new URLSearchParams(location.search).get('fixture') || '') ? <BackgroundFiltersFixture /> : new URLSearchParams(location.search).get('fixture') === 'overlay-bounds' ? <OverlayBoundsFixture /> : new URLSearchParams(location.search).get('fixture') === 'settings-dialog' ? <SettingsDialogFixture /> : new URLSearchParams(location.search).get('fixture') === 'theme-picker' ? <ThemePickerFixture /> : <Gallery />}</StrictMode>);
|
|
166
|
+
createRoot(document.getElementById('root')!).render(<StrictMode>{['orbit-entry', 'list-filters'].includes(new URLSearchParams(location.search).get('fixture') || '') ? <BackgroundFiltersFixture /> : new URLSearchParams(location.search).get('fixture') === 'overlay-bounds' ? <OverlayBoundsFixture /> : new URLSearchParams(location.search).get('fixture') === 'settings-dialog' ? <SettingsDialogFixture /> : new URLSearchParams(location.search).get('fixture') === 'theme-picker' ? <ThemePickerFixture /> : new URLSearchParams(location.search).get('fixture') === 'settings-composition' ? <SettingsCompositionFixture /> : new URLSearchParams(location.search).get('fixture') === 'tray-usage' ? <TrayUsageFixture /> : <Gallery />}</StrictMode>);
|
package/preview/packageInfo.ts
CHANGED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
.preview-tray-anchor { display:flex; justify-content:center; }
|
|
2
|
+
.preview-tray-feedback { display:grid; }
|
|
3
|
+
/* Every state contributes to the same intrinsic height, including wrapped error + retry. */
|
|
4
|
+
.preview-tray-feedback > div { grid-area:1 / 1; display:flex; flex-direction:column; align-items:flex-start; gap:var(--rhine-space-2); }
|