@linhey/rhine-design 0.9.1 → 0.11.0
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 +21 -0
- package/CONTRIBUTING.md +3 -1
- package/README.md +18 -15
- package/components/AdvancedForms.tsx +22 -8
- package/components/COMMON_COMPONENTS.md +6 -3
- package/components/Forms.tsx +14 -1
- package/components/ListFilterBar.tsx +5 -3
- package/components/Overlays.tsx +3 -2
- package/components/README.md +13 -5
- package/components/SPACING.md +9 -0
- package/components/TYPOGRAPHY.md +2 -0
- package/components/Toast.tsx +10 -4
- package/components/checkbox-row.css +12 -0
- package/components/common.css +3 -1
- package/components/compact-forms.css +20 -0
- package/components/components.tsx +13 -7
- package/components/index.ts +1 -0
- package/components/media-parity.css +4 -4
- package/components/patterns.css +3 -3
- package/components/styles.css +17 -2
- package/components/theme-picker.css +2 -2
- package/docs/APPLICATION_COMPONENTS.md +2 -2
- package/docs/CHECKBOX_ROWS.md +36 -0
- package/docs/COMPACT_FILTER_CONTROLS.md +38 -0
- package/docs/COMPONENT_TYPOGRAPHY_AUDIT.md +44 -0
- package/docs/DESIGN_SYSTEM.md +10 -1
- package/docs/DESIGN_SYSTEM_AUDIT.md +4 -4
- package/docs/DISCLOSURE_VERIFICATION.md +49 -0
- package/docs/INTERACTION_PATTERNS.md +9 -1
- package/docs/LIST_FILTER_BAR.md +5 -1
- package/docs/REVIEW_TOOL.md +128 -0
- package/docs/REVIEW_VERIFICATION.md +56 -0
- package/docs/SUMMARY_TABS.md +77 -0
- package/docs/THEMING.md +1 -1
- package/docs/assets/disclosure/arknights-400.png +0 -0
- package/docs/assets/disclosure/neo-brutalism-400.png +0 -0
- package/docs/assets/disclosure/rhine-400.png +0 -0
- package/docs/assets/disclosure/rhine-native200.png +0 -0
- package/examples/review/http/App.tsx +46 -0
- package/examples/review/http/catalog.tsx +44 -0
- package/examples/review/http/details.ts +1 -0
- package/examples/review/http/vite.config.ts +8 -0
- package/examples/review/platform/Workspace.tsx +34 -0
- package/examples/review/platform/catalog.tsx +46 -0
- package/examples/review/platform/vite.config.ts +8 -0
- package/examples/review/product.css +3 -0
- package/package.json +17 -5
- package/preview/CheckboxRowGallery.tsx +26 -0
- package/preview/CommonGallery.tsx +1 -1
- package/preview/CompactFilterBarGallery.tsx +19 -0
- package/preview/ComponentTypographyGallery.tsx +23 -0
- package/preview/DenseFilterControlsGallery.tsx +21 -0
- package/preview/GalleryShell.tsx +4 -3
- package/preview/OrbitGallery.tsx +1 -1
- package/preview/SpacingGallery.tsx +9 -1
- package/preview/SummaryTabsGallery.tsx +37 -0
- package/preview/main.tsx +14 -3
- package/preview/packageInfo.ts +5 -0
- package/review/canvas.tsx +107 -0
- package/review/dev-client.mjs +17 -0
- package/review/feedback.ts +17 -0
- package/review/index.ts +2 -0
- package/review/isolation.ts +64 -0
- package/review/lifecycle.ts +59 -0
- package/review/protocol.ts +7 -0
- package/review/review.css +41 -0
- package/review/shell.tsx +165 -0
- package/review/state.ts +41 -0
- package/review/types.ts +59 -0
- package/review/vite.d.mts +10 -0
- package/review/vite.mjs +107 -0
- package/scripts/check-checkbox-rows.js +65 -0
- package/scripts/check-component-typography.js +76 -0
- package/scripts/check-dense-filters.js +60 -0
- package/scripts/check-review-package.mjs +11 -0
- package/scripts/check-review-recovery.js +47 -0
- package/scripts/check-review.js +153 -0
- package/skills/rhine-design/SKILL.md +2 -2
- package/skills/rhine-design/references/API_MAP.md +6 -0
- package/skills/rhine-design/references/TASK_MAP.md +2 -0
- package/skills/rhine-design/references/integration.md +1 -1
- package/tests/CheckboxRow.test.tsx +53 -0
- package/tests/CommonComponents.test.tsx +8 -0
- package/tests/CompactFilterBar.test.tsx +50 -0
- package/tests/DenseFilterControls.test.tsx +79 -0
- package/tests/DesignSystem.test.tsx +32 -0
- package/tests/Review.test.tsx +117 -0
- package/tests/ReviewBuild.test.tsx +59 -0
- package/tests/ReviewShell.test.tsx +63 -0
- package/tsconfig.json +5 -2
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Badge, KeyValue, Select, Stack, Surface, Tabs, Text, TextField, Toolbar } from '../components';
|
|
3
|
+
|
|
4
|
+
export function SummaryTabsGallery() {
|
|
5
|
+
const [width, setWidth] = useState('520');
|
|
6
|
+
const [scenario, setScenario] = useState('normal');
|
|
7
|
+
const [selected, setSelected] = useState('primary');
|
|
8
|
+
const pending = scenario === 'loading', failed = scenario === 'error';
|
|
9
|
+
return <section id="summary-tabs"><Stack gap={6}>
|
|
10
|
+
<Toolbar title="带摘要的页签" description="名称与状态在同一组,身份摘要紧随;每个对象的状态始终可见。">
|
|
11
|
+
<Select label="局部容器宽度" value={width} onChange={event => setWidth(event.target.value)}>
|
|
12
|
+
<option value="320">320px · 窄容器</option><option value="520">520px · 标准</option><option value="100%">自适应</option>
|
|
13
|
+
</Select>
|
|
14
|
+
<Select label="摘要页签场景" value={scenario} onChange={event => {setScenario(event.target.value); if(event.target.value === 'disabled') setSelected('primary');}}>
|
|
15
|
+
<option value="normal">正常</option><option value="loading">读取中</option><option value="error">读取失败</option><option value="disabled">禁用</option><option value="long">长身份与名称</option>
|
|
16
|
+
</Select>
|
|
17
|
+
</Toolbar>
|
|
18
|
+
<Surface><Stack gap={6}>
|
|
19
|
+
<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' ? '研究项目 · 跨区域联合研发与长期设计验证团队' : '研发 · 研究项目',
|
|
22
|
+
status:<Badge tone={failed ? 'error' : pending ? 'neutral' : 'success'}>{failed ? '读取失败' : pending ? '读取中' : '同步中'}</Badge>,
|
|
23
|
+
content:<Stack gap={4}><Text>主工作区内容</Text><TextField label="主工作区草稿" placeholder="切换后保留已输入内容"/></Stack>},
|
|
24
|
+
{value:'team',label:'协作工作区',description:'协作 · 设计项目',disabled:scenario === 'disabled',status:<Badge>{scenario === 'disabled' ? '暂不可用' : '已停止'}</Badge>,
|
|
25
|
+
content:<Stack gap={4}><Text>协作工作区内容</Text><TextField label="协作工作区草稿" placeholder="与另一页签独立"/></Stack>},
|
|
26
|
+
]}/>
|
|
27
|
+
</div>
|
|
28
|
+
<Text variant="caption" tone="muted">先输入草稿,再使用左右方向键或 Home / End 切换。场景与主题变化不清空面板。</Text>
|
|
29
|
+
</Stack></Surface>
|
|
30
|
+
<Surface><Stack gap={4}><Text as="h2" variant="heading">尺寸与层级</Text><KeyValue items={[
|
|
31
|
+
{key:'title',label:'名称',value:'14 / 22 · 600'}, {key:'description',label:'身份摘要',value:'12 / 18 · 400'},
|
|
32
|
+
{key:'padding',label:'上下内距',value:'12px / 12px'}, {key:'status',label:'名称到状态',value:'8px'},
|
|
33
|
+
{key:'identity',label:'名称组到摘要',value:'4px'}, {key:'tabs',label:'页签之间',value:'16px'},
|
|
34
|
+
{key:'panel',label:'分隔线到面板',value:'24px'}, {key:'selected',label:'选中指示线',value:'2px,覆盖当前页签宽度'},
|
|
35
|
+
]}/><Text variant="caption" tone="muted">状态复用 Badge,各主题保留自己的文字与边框;不固定总高度。</Text></Stack></Surface>
|
|
36
|
+
</Stack></section>;
|
|
37
|
+
}
|
package/preview/main.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { BackgroundFiltersFixture } from './BackgroundFiltersFixture';
|
|
2
2
|
import { StrictMode, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { createRoot } from 'react-dom/client';
|
|
4
|
-
import { Badge, Button, Checkbox, Disclosure, EmptyState, Logo, Notice, Progress, SegmentedControl, Select, SelectionCard, Skeleton, Surface, Tabs, Text, TextField, Toolbar, type TextVariant } from '../components/index';
|
|
4
|
+
import { Badge, Button, Checkbox, Disclosure, EmptyState, Link, Logo, Notice, Progress, SegmentedControl, Select, SelectionCard, Skeleton, Surface, Tabs, Text, TextField, Toolbar, type TextVariant } from '../components/index';
|
|
5
5
|
import './preview.css';
|
|
6
6
|
import { OverlayBoundsFixture, OverlayBoundsGallery } from './OverlayBoundsGallery';
|
|
7
7
|
import { SettingsDialogGallery, SettingsDialogFixture } from './SettingsDialogGallery';
|
|
@@ -11,6 +11,7 @@ import { MotionGallery } from './MotionGallery';
|
|
|
11
11
|
import { StartupGallery } from './StartupGallery';
|
|
12
12
|
import { PatternGallery } from './PatternGallery';
|
|
13
13
|
import { GalleryShell } from './GalleryShell';
|
|
14
|
+
import { componentCount } from './packageInfo';
|
|
14
15
|
import { SpacingGallery } from './SpacingGallery';
|
|
15
16
|
import { LineHeightGallery } from './LineHeightGallery';
|
|
16
17
|
import { SubscriptionGallery } from './SubscriptionGallery';
|
|
@@ -22,6 +23,11 @@ import { MonitoringGallery } from './MonitoringGallery';
|
|
|
22
23
|
import { DateRangeFilterGallery } from './DateRangeFilterGallery';
|
|
23
24
|
import { ListFilterBarGallery } from './ListFilterBarGallery';
|
|
24
25
|
import { CompactAccountGallery } from './CompactAccountGallery';
|
|
26
|
+
import { CompactFilterBarGallery } from './CompactFilterBarGallery';
|
|
27
|
+
import { DenseFilterControlsGallery } from './DenseFilterControlsGallery';
|
|
28
|
+
import { CheckboxRowGallery } from './CheckboxRowGallery';
|
|
29
|
+
import { ComponentTypographyGallery } from './ComponentTypographyGallery';
|
|
30
|
+
import { SummaryTabsGallery } from './SummaryTabsGallery';
|
|
25
31
|
|
|
26
32
|
function Gallery() {
|
|
27
33
|
const [motion, setMotion] = useState('system');
|
|
@@ -48,6 +54,10 @@ function Gallery() {
|
|
|
48
54
|
{section === 'date-range-filter' && <DateRangeFilterGallery />}
|
|
49
55
|
{section === 'list-filter-bar' && <ListFilterBarGallery />}
|
|
50
56
|
{section === 'compact-account' && <CompactAccountGallery />}
|
|
57
|
+
{section === 'compact-filters' && <><CompactFilterBarGallery /><DenseFilterControlsGallery /></>}
|
|
58
|
+
{section === 'checkbox-rows' && <CheckboxRowGallery />}
|
|
59
|
+
{section === 'component-typography' && <ComponentTypographyGallery />}
|
|
60
|
+
{section === 'summary-tabs' && <SummaryTabsGallery />}
|
|
51
61
|
{section === 'backgrounds' && <BackgroundGallery />}
|
|
52
62
|
{section === 'app-shell' && <ShellGallery />}
|
|
53
63
|
{section === 'orbit' && <OrbitGallery />}
|
|
@@ -57,7 +67,7 @@ function Gallery() {
|
|
|
57
67
|
{section === 'media' && <MediaGallery />}
|
|
58
68
|
{section === 'subscriptions' && <SubscriptionGallery />}
|
|
59
69
|
{section === 'top' && <>
|
|
60
|
-
<div className="ds-hero"><div><p className="ds-kicker">FOUNDATIONS / COMPONENTS / MOTION</p><h1>精密,有序。<br /><span>每一次交互都有回应。</span></h1><p>
|
|
70
|
+
<div className="ds-hero"><div><p className="ds-kicker">FOUNDATIONS / COMPONENTS / MOTION</p><h1>精密,有序。<br /><span>每一次交互都有回应。</span></h1><p>{componentCount} 个通用组件,共用一套视觉变量和动效规则。<br />展台运行当前源码,可能包含未发布能力;接入前请核对安装版本与<Link href="https://github.com/linhay/rhine-design/blob/main/CHANGELOG.md">版本记录</Link>。</p></div></div>
|
|
61
71
|
<div className="ds-overview-links">{[
|
|
62
72
|
['foundation','视觉基础','从色彩、间距与材质开始。'],
|
|
63
73
|
['typography','字体与文字','清晰的层级,稳定的阅读节奏。'],
|
|
@@ -118,6 +128,7 @@ function Gallery() {
|
|
|
118
128
|
<div className="ds-grid"><div><Checkbox label="显示卡片勾选框" checked={showSelectionIndicator} onChange={event => setShowSelectionIndicator(event.target.checked)} /><SelectionCard showIndicator={showSelectionIndicator} label="可选择容器" selected={selected} onSelectedChange={setSelected}><span className="ds-caption">适用于账号、模板或可取消选择项。<br />同一位置切换底色与边框,不抬升卡片。</span><div className="ds-gap-top"><Badge tone={selected?'success':'neutral'}>{selected?'已选择':'未选择'}</Badge></div></SelectionCard><p className="ds-caption">SelectionCard · 背景与边界 120ms</p></div>
|
|
119
129
|
<Surface><div className="ds-component-title"><h3>SegmentedControl</h3><span>原生单选 / 120ms</span></div><SegmentedControl label="展示指标" items={[{value:'tokens',label:'Token'},{value:'requests',label:'请求数'},{value:'cost',label:'费用',disabled:true}]} value={metric} onValueChange={setMetric} /><p className="ds-caption">当前指标:{metric==='tokens'?'Token':'请求数'}。方向键切换,自动跳过禁用项。</p></Surface></div>
|
|
120
130
|
<Surface className="ds-gap-top"><div className="ds-component-title"><h3>Tabs</h3><span>下划线与内容进入 180ms</span></div><Tabs label="组件说明" value={tab} onValueChange={setTab} items={[{value:'tokens',label:'视觉变量',content:<p>颜色、间距、字体与动效集中定义。业务页面只消费语义变量。</p>},{value:'disabled',label:'预留示例',disabled:true,content:null},{value:'motion',label:'动效规则',content:<p>内容进入最多移动 3px。保留隐藏面板状态,切换不触发重复业务操作。</p>}]} /></Surface>
|
|
131
|
+
<Text className="ds-gap-top">需要同时展示对象身份和状态时,查看<a href="#summary-tabs">带摘要的页签规范与交互展台</a>。</Text>
|
|
121
132
|
</section>}
|
|
122
133
|
|
|
123
134
|
{section === 'feedback' && <section id="feedback"><div className="ds-section-title"><span>04</span><h2>告诉用户发生了什么</h2></div>
|
|
@@ -130,7 +141,7 @@ function Gallery() {
|
|
|
130
141
|
</section>}
|
|
131
142
|
|
|
132
143
|
{section === 'disclosure' && <section id="disclosure"><div className="ds-section-title"><span>05</span><h2>逐步展开,保持可读</h2></div>
|
|
133
|
-
<div className="ds-grid"><Surface><div className="ds-component-title"><h3>Disclosure</h3><span>实测高度 / 200ms</span></div><Disclosure title="查看组件动画规则"><p>展开和收起都支持中途反向。关闭后内容不参与 Tab 导航。</p><Button variant="secondary" onClick={()=>setNotice(true)}>内容内操作</Button><p>减少动态模式直接到达最终状态,信息不会丢失。</p></Disclosure></Surface>
|
|
144
|
+
<div className="ds-grid"><Surface><div className="ds-component-title"><h3>Disclosure</h3><span>实测高度 / 200ms</span></div><Disclosure title="查看组件动画规则"><p>展开和收起都支持中途反向。关闭后内容不参与 Tab 导航。</p><Button variant="secondary" onClick={()=>setNotice(true)}>内容内操作</Button><p>减少动态模式直接到达最终状态,信息不会丢失。</p></Disclosure><Disclosure title="相邻分组共用一条分隔线"><p>分组连续排列,不加组间 gap;相邻边框合并,展开和收起各自独立。</p></Disclosure></Surface>
|
|
134
145
|
<EmptyState title="暂无记录" description="用一句话说明当前状态,按业务需要提供一个下一步操作。" action={<Button variant="secondary" onClick={()=>setNotice(true)}>演示下一步</Button>} /></div>
|
|
135
146
|
</section>}
|
|
136
147
|
{['data','forms','utilities','overlays'].includes(section) && <CommonGallery key={section} section={section} />}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Component, useEffect, type ReactNode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { flushSync } from 'react-dom';
|
|
4
|
+
import { ThemeProvider } from '../components/theme';
|
|
5
|
+
import { Notice } from '../components';
|
|
6
|
+
import { defineReview, catalogInfo, parseReviewState, serializeReviewState } from './state';
|
|
7
|
+
import { createMockFetch, installIsolation, type ReviewHttpHandler } from './isolation';
|
|
8
|
+
import { ReviewLifecycle } from './lifecycle';
|
|
9
|
+
import { isReviewMessage, sendReview } from './protocol';
|
|
10
|
+
import type { ReviewCatalog, ReviewEnvironment, ReviewScene, ReviewSelection, ReviewSession } from './types';
|
|
11
|
+
import './review.css';
|
|
12
|
+
|
|
13
|
+
class RenderBoundary extends Component<{ children: ReactNode; report(message: string): void }, { error: boolean }> {
|
|
14
|
+
state = { error: false };
|
|
15
|
+
static getDerivedStateFromError() { return { error: true }; }
|
|
16
|
+
componentDidCatch(error: Error) { this.props.report(`场景渲染失败:${error.message}`); }
|
|
17
|
+
render() { return this.state.error ? <p role="alert">场景渲染失败,请查看评审反馈并重置。</p> : this.props.children; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function mountReviewCanvas(loadCatalog: () => Promise<{ default: ReviewCatalog }>) {
|
|
21
|
+
let state: ReviewSelection | undefined;
|
|
22
|
+
let requestId = 0;
|
|
23
|
+
const report = (message: string, kind = 'info', scene = state?.scene) => sendReview(parent, { type: 'feedback', message, kind, scene });
|
|
24
|
+
const failed = () => sendReview(parent, { type: 'failed', requestId });
|
|
25
|
+
const isolation = installIsolation(message => report(message, 'error'));
|
|
26
|
+
// The handler slot is changed only by the currently active setup context.
|
|
27
|
+
let http: ReviewHttpHandler | undefined;
|
|
28
|
+
let requestSignal = AbortSignal.abort();
|
|
29
|
+
window.fetch = createMockFetch(() => http, () => requestSignal, message => report(message, 'error'));
|
|
30
|
+
const root = createRoot(document.getElementById('root')!);
|
|
31
|
+
const media = matchMedia('(prefers-reduced-motion: reduce)');
|
|
32
|
+
let session: ReviewSession | undefined;
|
|
33
|
+
let scene: ReviewScene | undefined;
|
|
34
|
+
let renderGeneration = 0;
|
|
35
|
+
const environment = (): ReviewEnvironment => ({
|
|
36
|
+
theme: state!.theme, motion: state!.motion,
|
|
37
|
+
reducedMotion: state!.motion === 'reduced' || (state!.motion === 'system' && media.matches),
|
|
38
|
+
paused: state!.motion === 'paused',
|
|
39
|
+
});
|
|
40
|
+
const render = () => {
|
|
41
|
+
if (!session || !scene || !state) return;
|
|
42
|
+
const env = environment();
|
|
43
|
+
document.documentElement.style.zoom = String(state.zoom);
|
|
44
|
+
document.documentElement.dataset.rhineMotion = env.reducedMotion ? 'reduced' : env.paused ? 'paused' : 'full';
|
|
45
|
+
const committedRequest = requestId;
|
|
46
|
+
const renderedScene = scene.id;
|
|
47
|
+
const content = <RenderBoundary key={renderGeneration} report={message => { report(message, 'error', renderedScene); failed(); }}>
|
|
48
|
+
<SceneContent session={session} environment={env} committed={() => sendReview(parent, { type: 'rendered', requestId: committedRequest })} />
|
|
49
|
+
</RenderBoundary>;
|
|
50
|
+
root.render(scene.themeRoot === 'host' ? content : <ThemeProvider theme={env.theme} data-rhine-motion={env.reducedMotion ? 'reduced' : env.paused ? 'paused' : 'full'}>{content}</ThemeProvider>);
|
|
51
|
+
};
|
|
52
|
+
let catalog: ReviewCatalog;
|
|
53
|
+
try { catalog = defineReview((await loadCatalog()).default); }
|
|
54
|
+
catch (error) { report(`场景目录加载失败:${String(error)}`, 'error'); failed(); root.render(<Notice title="场景目录加载失败" />); return; }
|
|
55
|
+
const info = catalogInfo(catalog);
|
|
56
|
+
const lifecycle = new ReviewLifecycle({
|
|
57
|
+
clear() {
|
|
58
|
+
session = undefined; scene = undefined; renderGeneration++;
|
|
59
|
+
flushSync(() => root.render(null));
|
|
60
|
+
},
|
|
61
|
+
context(signal, selected) {
|
|
62
|
+
isolation.reset();
|
|
63
|
+
requestSignal = signal;
|
|
64
|
+
http = undefined;
|
|
65
|
+
return {
|
|
66
|
+
mockHttp(next) { signal.throwIfAborted(); http = next; },
|
|
67
|
+
report(message, kind) { if (!signal.aborted) report(message, kind, selected.id); },
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
ready(next, selected) { session = next; scene = selected; render(); },
|
|
71
|
+
error(error, selected) { report(`场景生命周期失败:${String(error)}`, 'error', selected); },
|
|
72
|
+
failed,
|
|
73
|
+
});
|
|
74
|
+
const ready = () => sendReview(parent, { type: 'ready', catalog: info });
|
|
75
|
+
let selectionKey = '';
|
|
76
|
+
const receive = (event: MessageEvent) => {
|
|
77
|
+
if (!isReviewMessage(event, parent)) return;
|
|
78
|
+
if (event.data.type === 'hello') { ready(); return; }
|
|
79
|
+
if (event.data.type !== 'select' || typeof event.data.hash !== 'string' || !Number.isSafeInteger(event.data.requestId)) return;
|
|
80
|
+
requestId = event.data.requestId;
|
|
81
|
+
state = parseReviewState(event.data.hash, info);
|
|
82
|
+
const selection = state;
|
|
83
|
+
// Canonicalizing in both documents prevents unsupported values from crossing the boundary.
|
|
84
|
+
const key = JSON.stringify([state.scene, state.dimensions]);
|
|
85
|
+
if (key !== selectionKey || event.data.reset === true) {
|
|
86
|
+
selectionKey = key;
|
|
87
|
+
report('正在初始化合成场景…');
|
|
88
|
+
void lifecycle.select(catalog.scenes.find(s => s.id === selection.scene)!, selection.dimensions, event.data.reset ? 'reset' : 'select');
|
|
89
|
+
} else render();
|
|
90
|
+
sendReview(parent, { type: 'state', hash: serializeReviewState(state) });
|
|
91
|
+
};
|
|
92
|
+
window.addEventListener('message', receive);
|
|
93
|
+
media.addEventListener('change', render);
|
|
94
|
+
const rejection = (event: PromiseRejectionEvent) => { if (event.reason?.name !== 'AbortError') report(`异步操作失败:${String(event.reason)}`, 'error'); };
|
|
95
|
+
window.addEventListener('unhandledrejection', rejection);
|
|
96
|
+
window.addEventListener('pagehide', () => {
|
|
97
|
+
window.removeEventListener('message', receive);
|
|
98
|
+
media.removeEventListener('change', render);
|
|
99
|
+
window.removeEventListener('unhandledrejection', rejection);
|
|
100
|
+
void lifecycle.dispose();
|
|
101
|
+
}, { once: true });
|
|
102
|
+
ready();
|
|
103
|
+
}
|
|
104
|
+
function SceneContent({ session, environment, committed }: { session: ReviewSession; environment: ReviewEnvironment; committed(): void }) {
|
|
105
|
+
useEffect(committed, [committed]);
|
|
106
|
+
return session.render(environment);
|
|
107
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Style-only development client. The review server intentionally has no HMR transport.
|
|
2
|
+
// CSS transformed by Vite 6/8 imports these helpers even with server.hmr:false.
|
|
3
|
+
const styles = new Map();
|
|
4
|
+
export function updateStyle(id, content) {
|
|
5
|
+
let style = styles.get(id);
|
|
6
|
+
if (!style) { style = document.createElement('style'); style.dataset.reviewStyle = id; document.head.append(style); styles.set(id, style); }
|
|
7
|
+
style.textContent = content;
|
|
8
|
+
}
|
|
9
|
+
export function removeStyle(id) { styles.get(id)?.remove(); styles.delete(id); }
|
|
10
|
+
export function createHotContext() {
|
|
11
|
+
const noop = () => {};
|
|
12
|
+
return { data: {}, accept: noop, acceptExports: noop, dispose: noop, prune: noop, decline: noop, invalidate: noop, on: noop, off: noop, send: noop };
|
|
13
|
+
}
|
|
14
|
+
export function injectQuery(url, query) {
|
|
15
|
+
const [path, hash] = url.split('#');
|
|
16
|
+
return `${path}${path.includes('?') ? '&' : '?'}${query}${hash === undefined ? '' : `#${hash}`}`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ReviewFeedback {
|
|
2
|
+
id: number;
|
|
3
|
+
scene: string;
|
|
4
|
+
message: string;
|
|
5
|
+
kind: 'info' | 'error';
|
|
6
|
+
}
|
|
7
|
+
export interface ReviewFeedbackLog {
|
|
8
|
+
entries: ReviewFeedback[];
|
|
9
|
+
errors: number;
|
|
10
|
+
omittedErrors: number;
|
|
11
|
+
}
|
|
12
|
+
export const emptyFeedback = (): ReviewFeedbackLog => ({ entries: [], errors: 0, omittedErrors: 0 });
|
|
13
|
+
export function appendFeedback(log: ReviewFeedbackLog, entry: ReviewFeedback): ReviewFeedbackLog {
|
|
14
|
+
const entries = [...log.entries, { ...entry, message: entry.message.slice(0, 2000) }];
|
|
15
|
+
const omitted = entries.splice(0, Math.max(0, entries.length - 20));
|
|
16
|
+
return { entries, errors: log.errors + Number(entry.kind === 'error'), omittedErrors: log.omittedErrors + omitted.filter(item => item.kind === 'error').length };
|
|
17
|
+
}
|
package/review/index.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { defineReview, parseReviewState, serializeReviewState } from './state';
|
|
2
|
+
export type { ReviewCatalog, ReviewCatalogInfo, ReviewContext, ReviewDimension, ReviewEnvironment, ReviewMotion, ReviewOption, ReviewScene, ReviewSceneInfo, ReviewSelection, ReviewSession } from './types';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type ReviewHttpHandler = (request: Request) => Response | undefined | Promise<Response | undefined>;
|
|
2
|
+
export class MemoryStorage implements Storage {
|
|
3
|
+
private values = new Map<string, string>();
|
|
4
|
+
get length() { return this.values.size; }
|
|
5
|
+
clear() { this.values.clear(); }
|
|
6
|
+
getItem(key: string) { return this.values.get(String(key)) ?? null; }
|
|
7
|
+
key(index: number) { return [...this.values.keys()][index] ?? null; }
|
|
8
|
+
removeItem(key: string) { this.values.delete(String(key)); }
|
|
9
|
+
setItem(key: string, value: string) { this.values.set(String(key), String(value)); }
|
|
10
|
+
}
|
|
11
|
+
export function createMockFetch(getHandler: () => ReviewHttpHandler | undefined, getSignal: () => AbortSignal, report: (message: string) => void): typeof fetch {
|
|
12
|
+
return async (input, init) => {
|
|
13
|
+
const url = typeof input === 'string' ? new URL(input, location.href).href : input;
|
|
14
|
+
const request = new Request(url, init);
|
|
15
|
+
const signal = AbortSignal.any([request.signal, getSignal()]);
|
|
16
|
+
signal.throwIfAborted();
|
|
17
|
+
const scoped = new Request(request, { signal, credentials: 'omit' });
|
|
18
|
+
const handler = getHandler();
|
|
19
|
+
// Race cancellation even if a product handler ignores its signal or never resolves.
|
|
20
|
+
return new Promise<Response>((resolve, reject) => {
|
|
21
|
+
const abort = () => reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
22
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
23
|
+
Promise.resolve().then(() => { signal.throwIfAborted(); return handler?.(scoped); }).then(response => {
|
|
24
|
+
signal.throwIfAborted();
|
|
25
|
+
if (!response) {
|
|
26
|
+
const message = `未实现模拟请求:${scoped.method} ${new URL(scoped.url).pathname}`;
|
|
27
|
+
report(message);
|
|
28
|
+
throw new Error(message);
|
|
29
|
+
}
|
|
30
|
+
resolve(response);
|
|
31
|
+
}).catch(reject).finally(() => signal.removeEventListener('abort', abort));
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Defense in depth for trusted synthetic product code; not an untrusted-code sandbox. */
|
|
36
|
+
export function installIsolation(report: (message: string) => void) {
|
|
37
|
+
const storage = [new MemoryStorage(), new MemoryStorage()];
|
|
38
|
+
Object.defineProperty(window, 'localStorage', { configurable: true, value: storage[0] });
|
|
39
|
+
Object.defineProperty(window, 'sessionStorage', { configurable: true, value: storage[1] });
|
|
40
|
+
Object.defineProperty(document, 'cookie', { configurable: true, get: () => '', set: () => { report('预览不写入会话 Cookie'); } });
|
|
41
|
+
const blocked = (name: string) => { const message = `预览未实现操作:${name}`; report(message); throw new Error(message); };
|
|
42
|
+
for (const name of ['XMLHttpRequest', 'WebSocket', 'EventSource', 'Worker', 'SharedWorker'] as const) {
|
|
43
|
+
Object.defineProperty(window, name, { configurable: true, value: function () { return blocked(name); } });
|
|
44
|
+
}
|
|
45
|
+
Object.defineProperty(window, 'indexedDB', { configurable: true, get: () => blocked('IndexedDB') });
|
|
46
|
+
Object.defineProperty(window, 'caches', { configurable: true, get: () => blocked('CacheStorage') });
|
|
47
|
+
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, get: () => blocked('ServiceWorker') });
|
|
48
|
+
Object.defineProperty(navigator, 'sendBeacon', { configurable: true, value: () => { report('预览不发送 Beacon'); return false; } });
|
|
49
|
+
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => blocked('Clipboard'), readText: async () => blocked('Clipboard') } });
|
|
50
|
+
window.open = () => { report('预览不打开外部窗口'); return null; };
|
|
51
|
+
document.addEventListener('click', event => {
|
|
52
|
+
const link = (event.target as Element)?.closest?.('a');
|
|
53
|
+
if (!link || event.defaultPrevented) return;
|
|
54
|
+
const url = new URL(link.href);
|
|
55
|
+
const localHash = url.origin === location.origin && url.pathname === location.pathname && url.search === location.search && Boolean(url.hash);
|
|
56
|
+
if (link.hasAttribute('download') || link.target || !localHash) {
|
|
57
|
+
event.preventDefault(); report('预览不执行下载或整页跳转;产品路由需使用 SPA 导航适配');
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
document.addEventListener('submit', event => {
|
|
61
|
+
if (!event.defaultPrevented) { event.preventDefault(); report('预览表单需要产品 onSubmit 模拟适配'); }
|
|
62
|
+
});
|
|
63
|
+
return { reset: () => storage.forEach(item => item.clear()) };
|
|
64
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ReviewContext, ReviewScene, ReviewSession } from './types';
|
|
2
|
+
|
|
3
|
+
export class ReviewLifecycle {
|
|
4
|
+
private generation = 0;
|
|
5
|
+
private active?: { scene: string; abort: AbortController; cleanups: (() => void | Promise<void>)[]; session?: ReviewSession; disposed: boolean; disposal?: Promise<void> };
|
|
6
|
+
constructor(private readonly hooks: {
|
|
7
|
+
clear(): void;
|
|
8
|
+
ready(session: ReviewSession, scene: ReviewScene): void;
|
|
9
|
+
error(error: unknown, scene?: string): void;
|
|
10
|
+
failed?(scene: string): void;
|
|
11
|
+
context(signal: AbortSignal, scene: ReviewScene): Pick<ReviewContext, 'mockHttp' | 'report'>;
|
|
12
|
+
}) {}
|
|
13
|
+
private cleanup(run: NonNullable<ReviewLifecycle['active']>, reset: boolean): Promise<void> {
|
|
14
|
+
if (run.disposal) return run.disposal;
|
|
15
|
+
run.disposed = true;
|
|
16
|
+
run.abort.abort();
|
|
17
|
+
const operations = [...run.cleanups.reverse()];
|
|
18
|
+
if (run.session) {
|
|
19
|
+
if (reset && run.session.reset) operations.unshift(() => run.session!.reset!());
|
|
20
|
+
operations.push(() => run.session!.dispose());
|
|
21
|
+
}
|
|
22
|
+
run.disposal = (async () => {
|
|
23
|
+
for (const operation of operations) {
|
|
24
|
+
try { await operation(); } catch (error) { this.hooks.error(error, run.scene); }
|
|
25
|
+
}
|
|
26
|
+
})();
|
|
27
|
+
return run.disposal;
|
|
28
|
+
}
|
|
29
|
+
async select(scene: ReviewScene, dimensions: Record<string, string>, reason: 'select' | 'reset' = 'select') {
|
|
30
|
+
const generation = ++this.generation;
|
|
31
|
+
const previous = this.active;
|
|
32
|
+
try { this.hooks.clear(); } catch (error) { this.hooks.error(error, previous?.scene); }
|
|
33
|
+
if (previous) await this.cleanup(previous, reason === 'reset');
|
|
34
|
+
if (generation !== this.generation) return;
|
|
35
|
+
const run = { scene: scene.id, abort: new AbortController(), cleanups: [] as (() => void | Promise<void>)[], session: undefined as ReviewSession | undefined, disposed: false };
|
|
36
|
+
this.active = run;
|
|
37
|
+
const context: ReviewContext = {
|
|
38
|
+
...this.hooks.context(run.abort.signal, scene), dimensions: Object.freeze({ ...dimensions }), signal: run.abort.signal, reason,
|
|
39
|
+
onDispose: cleanup => {
|
|
40
|
+
if (run.disposed) void Promise.resolve().then(cleanup).catch(error => this.hooks.error(error, scene.id));
|
|
41
|
+
else run.cleanups.push(cleanup);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
try {
|
|
45
|
+
const session = await scene.setup(context);
|
|
46
|
+
if (generation !== this.generation || run.disposed) { await session.dispose(); return; }
|
|
47
|
+
run.session = session;
|
|
48
|
+
this.hooks.ready(session, scene);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (!run.abort.signal.aborted) { this.hooks.error(error, scene.id); this.hooks.failed?.(scene.id); }
|
|
51
|
+
await this.cleanup(run, false);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async dispose() {
|
|
55
|
+
++this.generation;
|
|
56
|
+
try { this.hooks.clear(); } catch (error) { this.hooks.error(error, this.active?.scene); }
|
|
57
|
+
if (this.active) await this.cleanup(this.active, false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const protocol = 'rhine-review/2';
|
|
2
|
+
export function isReviewMessage(event: MessageEvent, source: Window | null): boolean {
|
|
3
|
+
return event.origin === location.origin && event.source === source && event.data?.protocol === protocol;
|
|
4
|
+
}
|
|
5
|
+
export function sendReview(target: Window, data: Record<string, unknown>) {
|
|
6
|
+
target.postMessage({ ...data, protocol }, location.origin);
|
|
7
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
html, body, #root { margin: 0; min-height: 100%; }
|
|
2
|
+
.rhine-review-shell { min-height: 100vh; background: var(--rhine-bg); color: var(--rhine-ink); }
|
|
3
|
+
.rhine-review-header { display: flex; gap: 12px; align-items: center; padding: 16px 24px; border-bottom: 1px solid var(--rhine-line); flex-wrap: wrap; }
|
|
4
|
+
.rhine-review-brand { display: flex; align-items: center; gap: 12px; margin-right: auto; }
|
|
5
|
+
.rhine-review-brand strong { display: block; font-size: 16px; line-height: 24px; font-weight: 600; }
|
|
6
|
+
.rhine-review-brand small { display: block; font-size: 12px; line-height: 18px; font-weight: 400; opacity: .7; }
|
|
7
|
+
.rhine-review-menu { display: none; }
|
|
8
|
+
.rhine-review-body { display: grid; grid-template-columns: 232px minmax(0, 1fr); }
|
|
9
|
+
.rhine-review-directory { padding: 20px; border-right: 1px solid var(--rhine-line); }
|
|
10
|
+
.rhine-review-directory h2 { margin-top: 24px; font-size: 12px; line-height: 18px; font-weight: 600; opacity: .7; }
|
|
11
|
+
.rhine-review-directory a { display: block; padding: 10px 12px; color: inherit; text-decoration: none; font-size: 14px; line-height: 22px; font-weight: 400; overflow-wrap: anywhere; }
|
|
12
|
+
.rhine-review-directory a[aria-current] { background: var(--rhine-selected); font-weight: 600; box-shadow: inset 3px 0 var(--rhine-green); }
|
|
13
|
+
.rhine-review-main { min-width: 0; }
|
|
14
|
+
.rhine-review-controls { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; padding: 20px; }
|
|
15
|
+
.rhine-review-controls > .rhine-field { flex: 1 1 140px; min-width: 0; }
|
|
16
|
+
.rhine-review-feedback, .rhine-review-notes { padding: 12px 20px; font-size: 13px; line-height: 20px; font-weight: 400; overflow-wrap: anywhere; }
|
|
17
|
+
.rhine-review-feedback[role=alert] { border-left: 3px solid var(--rhine-error); }
|
|
18
|
+
.rhine-review-feedback p { margin: 0 0 8px; }
|
|
19
|
+
.rhine-review-feedback [role=alert] { border-left: 3px solid var(--rhine-error); padding-left: 10px; }
|
|
20
|
+
.rhine-review-options { margin: 0 20px; padding: 0 0 8px; font-size: 13px; line-height: 20px; font-weight: 400; }
|
|
21
|
+
.rhine-review-options summary, .rhine-review-log summary { cursor: pointer; padding: 10px 0; }
|
|
22
|
+
.rhine-review-options .rhine-review-controls { padding: 12px 0; }
|
|
23
|
+
.rhine-review-options p { color: var(--rhine-muted); margin: 0; }
|
|
24
|
+
.rhine-review-recovery { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 12px 0; }
|
|
25
|
+
.rhine-review-log ol { max-height: 240px; overflow: auto; padding-left: 24px; }
|
|
26
|
+
.rhine-review-log li { padding: 8px 0; }
|
|
27
|
+
.rhine-review-log li strong, .rhine-review-log li span { display: block; }
|
|
28
|
+
.rhine-review-log li[data-kind=error] { color: var(--rhine-error); }
|
|
29
|
+
.rhine-review-size { margin: 0; padding: 8px 20px; color: var(--rhine-muted); font-size: 12px; line-height: 18px; font-weight: 400; }
|
|
30
|
+
.rhine-review-viewport { overflow: auto; padding: 16px; background: var(--rhine-surface); }
|
|
31
|
+
.rhine-review-viewport iframe { display: block; border: 1px solid var(--rhine-line); min-height: 650px; height: 72vh; margin: 0 auto; background: var(--rhine-bg); box-sizing: border-box; }
|
|
32
|
+
.rhine-review-notes summary { cursor: pointer; min-height: 32px; }
|
|
33
|
+
[data-rhine-motion=paused] *, [data-rhine-motion=paused] *::before, [data-rhine-motion=paused] *::after { animation-play-state: paused !important; }
|
|
34
|
+
@media (max-width: 760px) {
|
|
35
|
+
.rhine-review-menu { display: inline-flex; }
|
|
36
|
+
.rhine-review-body { grid-template-columns: minmax(0, 1fr); }
|
|
37
|
+
.rhine-review-directory { display: none; border-bottom: 1px solid var(--rhine-line); }
|
|
38
|
+
.rhine-review-directory[data-open=true] { display: block; }
|
|
39
|
+
.rhine-review-header { padding: 12px; }
|
|
40
|
+
.rhine-review-controls { padding: 12px; }
|
|
41
|
+
}
|
package/review/shell.tsx
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { Button, Logo, Select, TextField } from '../components';
|
|
4
|
+
import { ThemeProvider, themes } from '../components/theme';
|
|
5
|
+
import { defaultWidths, parseReviewState, serializeReviewState } from './state';
|
|
6
|
+
import { isReviewMessage, sendReview } from './protocol';
|
|
7
|
+
import { appendFeedback, emptyFeedback, type ReviewFeedback } from './feedback';
|
|
8
|
+
import type { ReviewCatalogInfo, ReviewSelection } from './types';
|
|
9
|
+
import './review.css';
|
|
10
|
+
|
|
11
|
+
type Phase = 'connecting' | 'loading' | 'ready' | 'failed' | 'timed-out';
|
|
12
|
+
export const reviewWaitMs = 10_000;
|
|
13
|
+
|
|
14
|
+
export function ReviewShell() {
|
|
15
|
+
const frame = useRef<HTMLIFrameElement>(null);
|
|
16
|
+
const [frameEpoch, setFrameEpoch] = useState(0);
|
|
17
|
+
const [catalog, setCatalog] = useState<ReviewCatalogInfo>();
|
|
18
|
+
const [state, setState] = useState<ReviewSelection>();
|
|
19
|
+
const [query, setQuery] = useState('');
|
|
20
|
+
const [log, setLog] = useState(emptyFeedback);
|
|
21
|
+
const [phase, setPhase] = useState<Phase>('connecting');
|
|
22
|
+
const [menu, setMenu] = useState(false);
|
|
23
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
24
|
+
const stateRef = useRef(state);
|
|
25
|
+
const connected = useRef(false);
|
|
26
|
+
const sequence = useRef(0);
|
|
27
|
+
const pendingRequest = useRef(0);
|
|
28
|
+
const feedbackSequence = useRef(0);
|
|
29
|
+
const timeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
30
|
+
stateRef.current = state;
|
|
31
|
+
const record = useCallback((message: string, kind: ReviewFeedback['kind'] = 'info', scene = '画布') => {
|
|
32
|
+
const entry = { id: ++feedbackSequence.current, message, kind, scene };
|
|
33
|
+
setLog(previous => appendFeedback(previous, entry));
|
|
34
|
+
}, []);
|
|
35
|
+
const stopWaiting = useCallback(() => { clearTimeout(timeout.current); timeout.current = undefined; }, []);
|
|
36
|
+
const waitFor = useCallback((next: 'connecting' | 'loading') => {
|
|
37
|
+
stopWaiting(); setPhase(next);
|
|
38
|
+
timeout.current = setTimeout(() => {
|
|
39
|
+
setPhase('timed-out');
|
|
40
|
+
record(next === 'connecting' ? '连接画布超时,请检查资源加载后重新载入画布。' : '场景初始化或清理超过 10 秒,尚未确认完成。可继续等待,或重新载入画布。', 'error', next === 'connecting' ? '画布' : stateRef.current?.scene);
|
|
41
|
+
}, reviewWaitMs);
|
|
42
|
+
}, [record, stopWaiting]);
|
|
43
|
+
const select = useCallback((selection: ReviewSelection, reset = false) => {
|
|
44
|
+
if (!connected.current || !frame.current?.contentWindow) return;
|
|
45
|
+
pendingRequest.current = ++sequence.current;
|
|
46
|
+
waitFor('loading');
|
|
47
|
+
sendReview(frame.current.contentWindow, { type: 'select', hash: serializeReviewState(selection), requestId: pendingRequest.current, reset });
|
|
48
|
+
}, [waitFor]);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
connected.current = false; pendingRequest.current = 0;
|
|
51
|
+
waitFor('connecting');
|
|
52
|
+
const receive = (event: MessageEvent) => {
|
|
53
|
+
if (!isReviewMessage(event, frame.current?.contentWindow ?? null)) return;
|
|
54
|
+
if (event.data.type === 'ready') {
|
|
55
|
+
// Repeated hello acknowledgements must not restart a pending initialization.
|
|
56
|
+
if (connected.current) return;
|
|
57
|
+
connected.current = true;
|
|
58
|
+
const next = event.data.catalog as ReviewCatalogInfo;
|
|
59
|
+
setCatalog(next);
|
|
60
|
+
const selection = parseReviewState(location.hash, next);
|
|
61
|
+
history.replaceState(null, '', serializeReviewState(selection));
|
|
62
|
+
setState(selection);
|
|
63
|
+
document.title = `${next.title} · Rhine Review`;
|
|
64
|
+
} else if (event.data.type === 'feedback' && typeof event.data.message === 'string') {
|
|
65
|
+
record(event.data.message, event.data.kind === 'error' ? 'error' : 'info', typeof event.data.scene === 'string' ? event.data.scene : '画布');
|
|
66
|
+
} else if ((event.data.type === 'rendered' || event.data.type === 'failed') && event.data.requestId === pendingRequest.current) {
|
|
67
|
+
stopWaiting(); setPhase(event.data.type === 'rendered' ? 'ready' : 'failed');
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
window.addEventListener('message', receive);
|
|
71
|
+
const handshake = setInterval(() => {
|
|
72
|
+
if (!connected.current && frame.current?.contentWindow) sendReview(frame.current.contentWindow, { type: 'hello' });
|
|
73
|
+
}, 500);
|
|
74
|
+
return () => { window.removeEventListener('message', receive); clearInterval(handshake); stopWaiting(); };
|
|
75
|
+
}, [frameEpoch, record, stopWaiting, waitFor]);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!catalog) return;
|
|
78
|
+
const navigate = () => {
|
|
79
|
+
const next = parseReviewState(location.hash, catalog);
|
|
80
|
+
history.replaceState(null, '', serializeReviewState(next)); setState(next);
|
|
81
|
+
};
|
|
82
|
+
window.addEventListener('popstate', navigate); window.addEventListener('hashchange', navigate);
|
|
83
|
+
return () => { window.removeEventListener('popstate', navigate); window.removeEventListener('hashchange', navigate); };
|
|
84
|
+
}, [catalog]);
|
|
85
|
+
useEffect(() => { if (state) select(state); }, [state, select]);
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const element = frame.current;
|
|
88
|
+
if (!element) return;
|
|
89
|
+
const measure = () => setSize({ width: element.clientWidth, height: element.clientHeight });
|
|
90
|
+
measure();
|
|
91
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
92
|
+
const observer = new ResizeObserver(measure); observer.observe(element);
|
|
93
|
+
return () => observer.disconnect();
|
|
94
|
+
}, [frameEpoch]);
|
|
95
|
+
const update = (patch: Partial<ReviewSelection>) => {
|
|
96
|
+
if (!state || !catalog) return;
|
|
97
|
+
const next = parseReviewState(serializeReviewState({ ...state, ...patch }), catalog);
|
|
98
|
+
history.pushState(null, '', serializeReviewState(next)); setState(next);
|
|
99
|
+
};
|
|
100
|
+
const reload = () => {
|
|
101
|
+
// A new browsing context terminates noncooperative code; never start new setup in a stuck context.
|
|
102
|
+
stopWaiting(); connected.current = false;
|
|
103
|
+
record('已重新载入画布;旧会话与未保存草稿已清空,评审条件保留。', 'info', state?.scene);
|
|
104
|
+
setFrameEpoch(value => value + 1);
|
|
105
|
+
};
|
|
106
|
+
const scene = catalog?.scenes.find(item => item.id === state?.scene);
|
|
107
|
+
const matching = catalog?.scenes.filter(item => `${item.title} ${item.group ?? ''} ${item.id}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())) ?? [];
|
|
108
|
+
const trouble = phase === 'failed' || phase === 'timed-out';
|
|
109
|
+
const lastError = [...log.entries].reverse().find(entry => entry.kind === 'error');
|
|
110
|
+
const phaseLabel = { connecting: '正在连接场景画布…', loading: '正在初始化或清理合成场景…', ready: '合成场景已就绪 · 无业务后端', failed: '场景未能完成,请查看反馈或重新载入画布。', 'timed-out': '画布响应较慢,尚未确认完成。' }[phase];
|
|
111
|
+
return <ThemeProvider theme={state?.theme ?? 'rhine'} className="rhine-review-shell">
|
|
112
|
+
<header className="rhine-review-header">
|
|
113
|
+
<div className="rhine-review-brand"><Logo size={28} /><div><strong>{catalog?.title ?? 'Rhine Review'}</strong><small>产品设计评审 · 合成数据</small></div></div>
|
|
114
|
+
<Button id="review-menu" className="rhine-review-menu" variant="secondary" aria-expanded={menu} aria-controls="review-directory" onClick={() => setMenu(!menu)}>场景目录</Button>
|
|
115
|
+
<Button variant="secondary" disabled={!state} onClick={async () => {
|
|
116
|
+
try { await navigator.clipboard.writeText(location.href); record('评审链接已复制'); }
|
|
117
|
+
catch { record('复制未完成,请复制浏览器地址', 'error'); }
|
|
118
|
+
}}>复制链接</Button>
|
|
119
|
+
</header>
|
|
120
|
+
<div className="rhine-review-body">
|
|
121
|
+
<aside id="review-directory" className="rhine-review-directory" data-open={menu} onKeyDown={event => {
|
|
122
|
+
if (event.key === 'Escape' && menu) { setMenu(false); document.getElementById('review-menu')?.focus(); }
|
|
123
|
+
}}>
|
|
124
|
+
<TextField label="搜索场景" value={query} onChange={event => setQuery(event.target.value)} />
|
|
125
|
+
<nav aria-label="评审场景">{[...new Set(matching.map(item => item.group ?? '场景'))].map(group => <section key={group}><h2>{group}</h2>{matching.filter(item => (item.group ?? '场景') === group).map(item => <a key={item.id} aria-current={item.id === scene?.id ? 'page' : undefined} href={state ? serializeReviewState({ ...state, scene: item.id, dimensions: {} }) : '#'} onClick={event => { event.preventDefault(); update({ scene: item.id, dimensions: {} }); setMenu(false); if (menu) document.getElementById('review-menu')?.focus(); }}>{item.title}</a>)}</section>)}</nav>
|
|
126
|
+
{catalog && !matching.length && <p>没有匹配场景</p>}
|
|
127
|
+
</aside>
|
|
128
|
+
<main className="rhine-review-main">
|
|
129
|
+
{state && <>
|
|
130
|
+
<div className="rhine-review-controls" aria-label="评审设置">
|
|
131
|
+
<Select label="界面主题" value={state.theme} onChange={event => update({ theme: event.target.value as ReviewSelection['theme'] })}>{themes.map(theme => <option key={theme.name} value={theme.name}>{theme.label}</option>)}</Select>
|
|
132
|
+
<Select label="画布宽度" value={state.width} onChange={event => update({ width: event.target.value === 'fit' ? 'fit' : Number(event.target.value) })}><option value="fit">适应容器</option>{(catalog?.widths ?? defaultWidths).map(width => <option key={width} value={width}>{width} px</option>)}</Select>
|
|
133
|
+
{scene?.dimensions?.map(dimension => <Select key={dimension.id} label={dimension.label} value={state.dimensions[dimension.id]} onChange={event => update({ dimensions: { ...state.dimensions, [dimension.id]: event.target.value } })}>{dimension.options.map(option => <option key={option.id} value={option.id}>{option.label}</option>)}</Select>)}
|
|
134
|
+
<Button variant="secondary" onClick={() => select(state, true)}>重置场景</Button>
|
|
135
|
+
</div>
|
|
136
|
+
<details className="rhine-review-options"><summary>更多设置 · {state.zoom * 100}% CSS zoom · {{ system: '跟随系统', full: '完整动态', reduced: '减少动态', paused: '暂停' }[state.motion]}</summary>
|
|
137
|
+
<div className="rhine-review-controls">
|
|
138
|
+
<Select label="CSS 缩放" value={state.zoom} onChange={event => update({ zoom: Number(event.target.value) as 1 | 2 })}><option value="1">100%</option><option value="2">200%(CSS zoom)</option></Select>
|
|
139
|
+
<Select label="动态偏好" value={state.motion} onChange={event => update({ motion: event.target.value as ReviewSelection['motion'] })}><option value="system">跟随系统</option>{scene?.motionControls?.includes('full') && <option value="full">完整动态</option>}<option value="reduced">减少动态</option>{scene?.motionControls?.includes('paused') && <option value="paused">暂停</option>}</Select>
|
|
140
|
+
{!trouble && <Button variant="secondary" onClick={reload}>重新载入画布</Button>}
|
|
141
|
+
</div><p>重新载入会清空未保存草稿,并保留当前评审条件。</p>
|
|
142
|
+
</details>
|
|
143
|
+
</>}
|
|
144
|
+
<div className="rhine-review-feedback">
|
|
145
|
+
<p role="status">{phaseLabel}</p>
|
|
146
|
+
{trouble && <div className="rhine-review-recovery"><Button variant="secondary" onClick={reload}>重新载入画布</Button><span>清空未保存草稿,保留当前评审条件。</span></div>}
|
|
147
|
+
{log.errors > 0 && <p role="alert">{log.errors} 条错误待确认{lastError ? ` · ${lastError.message}` : ''}</p>}
|
|
148
|
+
{log.entries.length > 0 && <details className="rhine-review-log"><summary>评审反馈 · {log.entries.length} 条记录{log.errors ? ` · ${log.errors} 条错误待确认` : ''}</summary>
|
|
149
|
+
<ol>{log.entries.map(entry => <li key={entry.id} data-kind={entry.kind}><strong>{catalog?.scenes.find(item => item.id === entry.scene)?.title ?? entry.scene}</strong><span>{entry.message}</span></li>)}</ol>
|
|
150
|
+
{log.omittedErrors > 0 && <p>另有 {log.omittedErrors} 条较早错误未展示,仍计入待确认数量。</p>}
|
|
151
|
+
<Button variant="secondary" onClick={() => setLog(emptyFeedback())}>确认并清除反馈</Button>
|
|
152
|
+
</details>}
|
|
153
|
+
</div>
|
|
154
|
+
<p className="rhine-review-size" aria-label="画布尺寸">画布 {size.width} × {size.height} px · {(state?.zoom ?? 1) * 100}% CSS zoom · 等效内容宽度约 {Math.round(size.width / (state?.zoom ?? 1))} CSS px</p>
|
|
155
|
+
<div className="rhine-review-viewport">
|
|
156
|
+
<iframe key={frameEpoch} ref={frame} title="产品场景画布" src={new URL('canvas.html', location.href).href} sandbox="allow-scripts allow-same-origin allow-forms" referrerPolicy="no-referrer" onLoad={() => {
|
|
157
|
+
if (frame.current?.contentWindow) sendReview(frame.current.contentWindow, { type: 'hello' });
|
|
158
|
+
}} style={{ width: state?.width === 'fit' || !state ? '100%' : state.width }} />
|
|
159
|
+
</div>
|
|
160
|
+
{scene && <details className="rhine-review-notes"><summary>场景说明与验证状态</summary><p>{scene.description ?? '此场景使用产品提供的合成数据。'}</p>{scene.source && <p>源码:<code>{scene.source}</code></p>}{scene.issue && /^https:\/\//.test(scene.issue) && <a href={scene.issue} target="_blank" rel="noreferrer">关联 issue</a>}<p>{scene.validation ?? '验证状态由产品维护;可交互预览不等于生产或原生验收。'}</p></details>}
|
|
161
|
+
</main>
|
|
162
|
+
</div>
|
|
163
|
+
</ThemeProvider>;
|
|
164
|
+
}
|
|
165
|
+
export function mountReviewShell() { createRoot(document.getElementById('root')!).render(<ReviewShell />); }
|