@changfenhuang/dsh-genui 0.9.3 → 0.9.7
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 +17 -0
- package/README.md +7 -18
- package/README.zh-CN.md +13 -18
- package/SKILL.md +4 -2
- package/lib/assets/three.js +1 -1
- package/lib/client.js +3 -3
- package/lib/index.js +273 -61
- package/lib/types/client/GenuiBlock.d.ts +5 -2
- package/lib/types/client/TemplateDrawer.d.ts +7 -0
- package/lib/types/client/achievement-store.d.ts +35 -0
- package/lib/types/client/achievement-toast.d.ts +2 -0
- package/lib/types/client/achievements.d.ts +48 -0
- package/lib/types/client/blocks/advanced.d.ts +1 -1
- package/lib/types/client/blocks/image.d.ts +6 -0
- package/lib/types/client/blocks/state.d.ts +2 -0
- package/lib/types/client/fence-render.d.ts +3 -0
- package/lib/types/client/gallery.d.ts +3 -5
- package/lib/types/client/guard.d.ts +10 -5
- package/lib/types/client/panel.d.ts +4 -1
- package/lib/types/client/spec.d.ts +8 -1
- package/lib/types/client/templates.d.ts +22 -0
- package/lib/types/plugin/chart-contract.d.ts +7 -0
- package/lib/types/plugin/index.d.ts +1 -1
- package/package.json +9 -8
- package/src/client/GenuiBlock.module.css +141 -10
- package/src/client/GenuiBlock.tsx +53 -25
- package/src/client/PlotBlock.tsx +25 -10
- package/src/client/TemplateDrawer.module.css +152 -0
- package/src/client/TemplateDrawer.tsx +137 -0
- package/src/client/achievement-store.ts +151 -0
- package/src/client/achievement-toast.tsx +68 -0
- package/src/client/achievements.ts +111 -0
- package/src/client/blocks/advanced.tsx +34 -25
- package/src/client/blocks/charts.tsx +4 -4
- package/src/client/blocks/image.tsx +25 -0
- package/src/client/blocks/render-node.tsx +3 -0
- package/src/client/blocks/state.ts +2 -0
- package/src/client/dom-fence.tsx +12 -5
- package/src/client/fence-render.tsx +35 -11
- package/src/client/gallery.ts +4 -5
- package/src/client/guard.ts +125 -29
- package/src/client/index.tsx +13 -0
- package/src/client/panel.tsx +109 -10
- package/src/client/safe-math.ts +29 -1
- package/src/client/scene3d-core.ts +5 -2
- package/src/client/spec.ts +9 -1
- package/src/client/templates.ts +280 -0
- package/src/plugin/chart-contract.ts +103 -0
- package/src/plugin/index.ts +48 -2
- package/src/plugin/tool.ts +25 -4
package/src/client/PlotBlock.tsx
CHANGED
|
@@ -194,29 +194,44 @@ export const PlotBlock = memo(function PlotBlock({
|
|
|
194
194
|
|
|
195
195
|
useEffect(() => {
|
|
196
196
|
if (!playing || animParam === null) return
|
|
197
|
-
const
|
|
197
|
+
const key = `${animParam.si}:${animParam.param.name}`
|
|
198
|
+
let cycleFrom = params[key] ?? animParam.param.value
|
|
198
199
|
const to = animParam.param.animateTo!
|
|
199
|
-
const duration = animParam.param.durationMs ?? 4000
|
|
200
|
-
|
|
200
|
+
const duration = Math.max(0, animParam.param.durationMs ?? 4000)
|
|
201
|
+
let cycleStart = performance.now()
|
|
201
202
|
const tick = (now: number): void => {
|
|
202
|
-
const t = Math.min(1, (now -
|
|
203
|
+
const t = duration === 0 ? 1 : Math.min(1, Math.max(0, (now - cycleStart) / duration))
|
|
203
204
|
const eased = 1 - Math.pow(1 - t, 3) // easeOutCubic
|
|
204
|
-
const value =
|
|
205
|
-
setParams(prev => ({ ...prev, [
|
|
205
|
+
const value = cycleFrom + (to - cycleFrom) * eased
|
|
206
|
+
setParams(prev => ({ ...prev, [key]: value }))
|
|
206
207
|
setAnimProgress(t)
|
|
207
208
|
if (t < 1) {
|
|
208
209
|
animRef.current = requestAnimationFrame(tick)
|
|
209
210
|
} else if (animParam.param.loop === true) {
|
|
210
|
-
|
|
211
|
+
cycleFrom = animParam.param.value
|
|
212
|
+
cycleStart = now
|
|
213
|
+
setParams(prev => ({ ...prev, [key]: cycleFrom }))
|
|
211
214
|
setAnimProgress(0)
|
|
212
|
-
animRef.current = requestAnimationFrame(
|
|
215
|
+
animRef.current = requestAnimationFrame(tick)
|
|
213
216
|
} else {
|
|
217
|
+
animRef.current = null
|
|
214
218
|
setPlaying(false)
|
|
215
219
|
}
|
|
216
220
|
}
|
|
217
221
|
animRef.current = requestAnimationFrame(tick)
|
|
218
|
-
return () => {
|
|
219
|
-
|
|
222
|
+
return () => {
|
|
223
|
+
if (animRef.current !== null) cancelAnimationFrame(animRef.current)
|
|
224
|
+
animRef.current = null
|
|
225
|
+
}
|
|
226
|
+
}, [
|
|
227
|
+
playing,
|
|
228
|
+
animParam?.si,
|
|
229
|
+
animParam?.param.name,
|
|
230
|
+
animParam?.param.value,
|
|
231
|
+
animParam?.param.animateTo,
|
|
232
|
+
animParam?.param.durationMs,
|
|
233
|
+
animParam?.param.loop,
|
|
234
|
+
])
|
|
220
235
|
|
|
221
236
|
const xMin = view.xMin
|
|
222
237
|
const xMax = view.xMax
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/* TemplateDrawer: the GenUI template center inside the session panel body.
|
|
2
|
+
Tokens mirror GenuiBlock.module.css (--dsw-alias-* sheets, fallbacks last). */
|
|
3
|
+
|
|
4
|
+
.wrap {
|
|
5
|
+
display: flex;
|
|
6
|
+
flex-direction: column;
|
|
7
|
+
gap: 12px;
|
|
8
|
+
padding: 12px 14px;
|
|
9
|
+
min-height: 0;
|
|
10
|
+
overflow: auto;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* ---- achievements tab ---- */
|
|
14
|
+
.achievements {
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
gap: 8px;
|
|
18
|
+
min-height: 0;
|
|
19
|
+
overflow: auto;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* ---- category chips ---- */
|
|
23
|
+
.cats {
|
|
24
|
+
display: flex;
|
|
25
|
+
flex-wrap: wrap;
|
|
26
|
+
gap: 8px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.cat {
|
|
30
|
+
padding: 4px 12px;
|
|
31
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
|
|
32
|
+
border-radius: 999px;
|
|
33
|
+
background: transparent;
|
|
34
|
+
color: var(--dsw-alias-label-secondary, var(--dsl-g-font-muted-fallback, rgba(255, 255, 255, 0.6)));
|
|
35
|
+
font-size: 12px;
|
|
36
|
+
line-height: 1.5;
|
|
37
|
+
cursor: pointer;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.cat:hover {
|
|
41
|
+
background: var(--dsw-alias-fill-hover, rgba(255, 255, 255, 0.06));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.catActive {
|
|
45
|
+
border-color: var(--dsw-alias-state-business-primary, #4f8ef7);
|
|
46
|
+
color: var(--dsw-alias-state-business-primary, #4f8ef7);
|
|
47
|
+
background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 14%, transparent);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* ---- template cards ---- */
|
|
51
|
+
.list {
|
|
52
|
+
display: grid;
|
|
53
|
+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
54
|
+
gap: 8px;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.card {
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
gap: 4px;
|
|
61
|
+
padding: 10px 12px;
|
|
62
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
|
|
63
|
+
border-radius: var(--dsl-g-radius-surface, 12px);
|
|
64
|
+
background: var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, 0.04));
|
|
65
|
+
color: var(--dsw-alias-label-primary);
|
|
66
|
+
text-align: left;
|
|
67
|
+
cursor: pointer;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.card:hover {
|
|
71
|
+
border-color: var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.2));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.cardActive {
|
|
75
|
+
border-color: var(--dsw-alias-state-business-primary, #4f8ef7);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.cardName {
|
|
79
|
+
font-size: 13px;
|
|
80
|
+
font-weight: 600;
|
|
81
|
+
line-height: 1.5;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.cardMeta {
|
|
85
|
+
font-size: 11px;
|
|
86
|
+
color: var(--dsw-alias-label-tertiary, rgba(255, 255, 255, 0.45));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.cardDesc {
|
|
90
|
+
font-size: 12px;
|
|
91
|
+
line-height: 1.5;
|
|
92
|
+
color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.6));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ---- detail preview ---- */
|
|
96
|
+
.detail {
|
|
97
|
+
display: flex;
|
|
98
|
+
flex-direction: column;
|
|
99
|
+
gap: 8px;
|
|
100
|
+
border-top: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
|
|
101
|
+
padding-top: 12px;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.toolbar {
|
|
105
|
+
display: flex;
|
|
106
|
+
align-items: center;
|
|
107
|
+
gap: 8px;
|
|
108
|
+
flex-wrap: wrap;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.toolbarTitle {
|
|
112
|
+
flex: 1;
|
|
113
|
+
min-width: 0;
|
|
114
|
+
font-size: 13px;
|
|
115
|
+
font-weight: 600;
|
|
116
|
+
color: var(--dsw-alias-label-primary);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.try,
|
|
120
|
+
.copy {
|
|
121
|
+
padding: 5px 12px;
|
|
122
|
+
border-radius: var(--dsl-g-radius-control, 8px);
|
|
123
|
+
border: 1px solid transparent;
|
|
124
|
+
font-size: 12px;
|
|
125
|
+
line-height: 1.5;
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.try {
|
|
130
|
+
border-color: var(--dsw-alias-state-business-primary, #4f8ef7);
|
|
131
|
+
color: var(--dsw-alias-state-business-primary, #4f8ef7);
|
|
132
|
+
background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 14%, transparent);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.try:hover {
|
|
136
|
+
background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 22%, transparent);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.copy {
|
|
140
|
+
border-color: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
|
|
141
|
+
background: transparent;
|
|
142
|
+
color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.6));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.preview {
|
|
146
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
|
|
147
|
+
border-radius: var(--dsl-g-radius-surface, 12px);
|
|
148
|
+
padding: 12px;
|
|
149
|
+
min-height: 0;
|
|
150
|
+
overflow: auto;
|
|
151
|
+
background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.08));
|
|
152
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TemplateDrawer — GenUI 模板中心(0.9.4 新手引导)。
|
|
3
|
+
*
|
|
4
|
+
* 分类浏览 → 点击卡片 → 内嵌预览(demo spec 由 GenuiBlock 直接渲染,
|
|
5
|
+
* dogfooding)+「试用」插入输入框 /「复制指令」。用户先看到效果,
|
|
6
|
+
* 再决定把指令发给模型——genui 能力说明书。
|
|
7
|
+
*/
|
|
8
|
+
import { useMemo, useRef, useState } from 'react'
|
|
9
|
+
import { GENUI_TEMPLATES, type GenuiTemplate } from './templates.ts'
|
|
10
|
+
import { GenuiBlock } from './GenuiBlock.tsx'
|
|
11
|
+
import { ErrorBoundary } from './ErrorBoundary.tsx'
|
|
12
|
+
import { panelStateKey } from './interaction-store.ts'
|
|
13
|
+
import { buildAchievementsSpec } from './achievements.ts'
|
|
14
|
+
import { getAchievementSnapshot, subscribeAchievements } from './achievement-store.ts'
|
|
15
|
+
import { useSyncExternalStore } from 'react'
|
|
16
|
+
import css from './TemplateDrawer.module.css'
|
|
17
|
+
|
|
18
|
+
export interface TemplateDrawerProps {
|
|
19
|
+
/** 试用:把模板指令插入当前输入框草稿。 */
|
|
20
|
+
onUse: (instruction: string) => void
|
|
21
|
+
/** 当前面板(模板中心/成就页),由面板 header 按钮控制。 */
|
|
22
|
+
tab: 'templates' | 'achievements'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CATEGORIES = ['全部', '仪表盘', '数据', '流程', '图表', '交互', '测验', '高级'] as const
|
|
26
|
+
type Category = (typeof CATEGORIES)[number]
|
|
27
|
+
|
|
28
|
+
/** Copy to clipboard with a legacy fallback (like GenuiCopy). */
|
|
29
|
+
function copyText(text: string): Promise<void> {
|
|
30
|
+
if (navigator.clipboard !== undefined) return navigator.clipboard.writeText(text)
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
try {
|
|
33
|
+
const ta = document.createElement('textarea')
|
|
34
|
+
ta.value = text
|
|
35
|
+
ta.style.position = 'fixed'
|
|
36
|
+
ta.style.opacity = '0'
|
|
37
|
+
document.body.appendChild(ta)
|
|
38
|
+
ta.select()
|
|
39
|
+
document.execCommand('copy')
|
|
40
|
+
ta.remove()
|
|
41
|
+
resolve()
|
|
42
|
+
} catch (error) {
|
|
43
|
+
reject(error instanceof Error ? error : new Error(String(error)))
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function TemplateDrawer({ onUse, tab }: TemplateDrawerProps) {
|
|
49
|
+
const [category, setCategory] = useState<Category>('全部')
|
|
50
|
+
const [selected, setSelected] = useState<GenuiTemplate | null>(null)
|
|
51
|
+
const [copied, setCopied] = useState(false)
|
|
52
|
+
const timer = useRef(0)
|
|
53
|
+
// Achievements tab: live state snapshot.
|
|
54
|
+
const achievements = useSyncExternalStore(subscribeAchievements, () => getAchievementSnapshot())
|
|
55
|
+
const achievementSpec = useMemo(
|
|
56
|
+
() => buildAchievementsSpec(achievements.state, achievements.unlocked),
|
|
57
|
+
[achievements],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const items = useMemo(
|
|
61
|
+
() => (category === '全部' ? [...GENUI_TEMPLATES] : GENUI_TEMPLATES.filter(t => t.category === category)),
|
|
62
|
+
[category],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const copy = async (text: string): Promise<void> => {
|
|
66
|
+
try {
|
|
67
|
+
await copyText(text)
|
|
68
|
+
setCopied(true)
|
|
69
|
+
window.clearTimeout(timer.current)
|
|
70
|
+
timer.current = window.setTimeout(() => setCopied(false), 1500)
|
|
71
|
+
} catch {
|
|
72
|
+
// Clipboard denied: the instruction stays in the drawer; nothing else
|
|
73
|
+
// to do — the use flow does not depend on it.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className={css.wrap} data-genui-templates={tab === 'templates' ? undefined : 'achievements'}>
|
|
79
|
+
{tab === 'achievements' ? (
|
|
80
|
+
<div className={css.achievements} data-genui-achievements>
|
|
81
|
+
<ErrorBoundary label="成就页">
|
|
82
|
+
<GenuiBlock spec={achievementSpec} stateKey={panelStateKey('genui-achievements', JSON.stringify(achievementSpec))} />
|
|
83
|
+
</ErrorBoundary>
|
|
84
|
+
</div>
|
|
85
|
+
) : (
|
|
86
|
+
<>
|
|
87
|
+
<div className={css.cats} role="tablist" aria-label="模板分类">
|
|
88
|
+
{CATEGORIES.map(c => (
|
|
89
|
+
<button
|
|
90
|
+
key={c}
|
|
91
|
+
type="button"
|
|
92
|
+
role="tab"
|
|
93
|
+
aria-selected={category === c}
|
|
94
|
+
className={`${css.cat}${category === c ? ` ${css.catActive}` : ''}`}
|
|
95
|
+
onClick={() => { setCategory(c); setSelected(null) }}
|
|
96
|
+
>
|
|
97
|
+
{c}
|
|
98
|
+
</button>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
<div className={css.list}>
|
|
102
|
+
{items.map(tpl => (
|
|
103
|
+
<button
|
|
104
|
+
key={tpl.id}
|
|
105
|
+
type="button"
|
|
106
|
+
className={`${css.card}${selected?.id === tpl.id ? ` ${css.cardActive}` : ''}`}
|
|
107
|
+
onClick={() => setSelected(prev => (prev?.id === tpl.id ? null : tpl))}
|
|
108
|
+
>
|
|
109
|
+
<span className={css.cardName}>{tpl.name}</span>
|
|
110
|
+
<span className={css.cardMeta}>{tpl.category}</span>
|
|
111
|
+
<span className={css.cardDesc}>{tpl.description}</span>
|
|
112
|
+
</button>
|
|
113
|
+
))}
|
|
114
|
+
</div>
|
|
115
|
+
{selected !== null && (
|
|
116
|
+
<div className={css.detail} data-genui-template-preview>
|
|
117
|
+
<div className={css.toolbar}>
|
|
118
|
+
<span className={css.toolbarTitle}>{selected.name}</span>
|
|
119
|
+
<button type="button" className={css.try} onClick={() => onUse(selected.instruction)}>
|
|
120
|
+
试用:插入输入框
|
|
121
|
+
</button>
|
|
122
|
+
<button type="button" className={css.copy} onClick={() => void copy(selected.instruction)}>
|
|
123
|
+
{copied ? '✓ 已复制' : '复制指令'}
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
<div className={css.preview}>
|
|
127
|
+
<ErrorBoundary label="模板预览">
|
|
128
|
+
<GenuiBlock spec={selected.demo} stateKey={panelStateKey('genui-tpl', selected.id)} />
|
|
129
|
+
</ErrorBoundary>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
</>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* achievement-store.ts — 成就状态存储与埋点入口(模块级单例)。
|
|
3
|
+
*
|
|
4
|
+
* 只存计数与解锁时间戳;绝不含消息/内容。localStorage 持久化,
|
|
5
|
+
* 订阅接口供 toast 与成就页消费。埋点入口:
|
|
6
|
+
* - recordFence(spec):一次「内容不重复」的界面渲染(按 spec 指纹去重,
|
|
7
|
+
* LRU 指纹表跨刷新防重放重复计数)
|
|
8
|
+
* - recordPanel():面板 dock 出现
|
|
9
|
+
* - recordInteraction():组件动作回传(去抖后)
|
|
10
|
+
* - recordTemplateUse():模板中心试用
|
|
11
|
+
*/
|
|
12
|
+
import type { GenuiSpec } from './spec.ts'
|
|
13
|
+
import { ACHIEVEMENTS, checkAchievements, countSpecKinds, emptyState, type AchieveState, type AchievementDef } from './achievements.ts'
|
|
14
|
+
|
|
15
|
+
const STORE_KEY = 'dsh.genui.achievements'
|
|
16
|
+
const SEEN_KEY = 'dsh.genui.achievements.seen'
|
|
17
|
+
const SEEN_MAX = 200
|
|
18
|
+
|
|
19
|
+
interface AchieveStore {
|
|
20
|
+
state: AchieveState
|
|
21
|
+
unlocked: Record<string, number>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let store: AchieveStore = loadStore()
|
|
25
|
+
const listeners = new Set<() => void>()
|
|
26
|
+
/** 解锁 toast 队列(FIFO;成就页打开时也读)。 */
|
|
27
|
+
let toastQueue: AchievementDef[] = []
|
|
28
|
+
|
|
29
|
+
function loadStore(): AchieveStore {
|
|
30
|
+
try {
|
|
31
|
+
const raw = localStorage.getItem(STORE_KEY)
|
|
32
|
+
if (raw === null) return { state: emptyState(), unlocked: {} }
|
|
33
|
+
const parsed = JSON.parse(raw) as Partial<AchieveStore>
|
|
34
|
+
const state = { ...emptyState(), ...(parsed.state ?? {}) }
|
|
35
|
+
return { state, unlocked: typeof parsed.unlocked === 'object' && parsed.unlocked !== null ? parsed.unlocked as Record<string, number> : {} }
|
|
36
|
+
} catch {
|
|
37
|
+
return { state: emptyState(), unlocked: {} }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function saveStore(): void {
|
|
42
|
+
try {
|
|
43
|
+
localStorage.setItem(STORE_KEY, JSON.stringify(store))
|
|
44
|
+
} catch {
|
|
45
|
+
// Quota / privacy-mode failures are non-fatal: achievements still tick
|
|
46
|
+
// in memory for this page session.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readSeen(): string[] {
|
|
51
|
+
try {
|
|
52
|
+
const raw = localStorage.getItem(SEEN_KEY)
|
|
53
|
+
if (raw === null) return []
|
|
54
|
+
const parsed = JSON.parse(raw) as unknown
|
|
55
|
+
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
|
|
56
|
+
} catch {
|
|
57
|
+
return []
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeSeen(list: string[]): void {
|
|
62
|
+
try {
|
|
63
|
+
localStorage.setItem(SEEN_KEY, JSON.stringify(list.slice(0, SEEN_MAX)))
|
|
64
|
+
} catch {
|
|
65
|
+
// non-fatal
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function emit(): void {
|
|
70
|
+
for (const listener of listeners) listener()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 应用一次状态更新:持久化 + 检查新解锁(进 toast 队列)+ 通知订阅者。 */
|
|
74
|
+
function applyDelta(patch: Partial<AchieveState>): void {
|
|
75
|
+
store.state = { ...store.state, ...patch }
|
|
76
|
+
const fresh = checkAchievements(store.state, store.unlocked)
|
|
77
|
+
for (const ach of fresh) store.unlocked[ach.id] = Date.now()
|
|
78
|
+
if (fresh.length > 0) toastQueue.push(...fresh)
|
|
79
|
+
saveStore()
|
|
80
|
+
emit()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 53-bit string hash (cyrb53-style): dedupe fingerprints are stored as
|
|
84
|
+
* hashes, never as the serialized spec itself — localStorage must not
|
|
85
|
+
* persist conversation-derived content (table rows, text, …). */
|
|
86
|
+
function fingerprintOf(spec: GenuiSpec): string {
|
|
87
|
+
const s = JSON.stringify(spec)
|
|
88
|
+
let h1 = 0xdeadbeef
|
|
89
|
+
let h2 = 0x41c6ce57
|
|
90
|
+
for (let i = 0; i < s.length; i++) {
|
|
91
|
+
const ch = s.charCodeAt(i)
|
|
92
|
+
h1 = Math.imul(h1 ^ ch, 2654435761)
|
|
93
|
+
h2 = Math.imul(h2 ^ ch, 1597334677)
|
|
94
|
+
}
|
|
95
|
+
return `${(h1 >>> 0).toString(36)}-${(h2 >>> 0).toString(36)}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 记录一次「内容不重复」的 fence 渲染。 */
|
|
99
|
+
export function recordFence(spec: GenuiSpec): void {
|
|
100
|
+
const fingerprint = fingerprintOf(spec)
|
|
101
|
+
const seen = readSeen()
|
|
102
|
+
if (seen.includes(fingerprint)) return
|
|
103
|
+
writeSeen([fingerprint, ...seen])
|
|
104
|
+
const kinds = countSpecKinds(spec)
|
|
105
|
+
applyDelta({
|
|
106
|
+
fences: store.state.fences + 1,
|
|
107
|
+
charts: store.state.charts + (kinds.charts > 0 ? 1 : 0),
|
|
108
|
+
advanced: store.state.advanced + (kinds.advanced > 0 ? 1 : 0),
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 记录一次面板 dock 出现(仅会话从无到有时)。 */
|
|
113
|
+
export function recordPanel(): void {
|
|
114
|
+
applyDelta({ panels: store.state.panels + 1 })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 记录一次组件交互动作回传。 */
|
|
118
|
+
export function recordInteraction(): void {
|
|
119
|
+
applyDelta({ interactions: store.state.interactions + 1 })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 记录一次模板试用。 */
|
|
123
|
+
export function recordTemplateUse(): void {
|
|
124
|
+
applyDelta({ templates: store.state.templates + 1 })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 当前状态快照(订阅者读取)。 */
|
|
128
|
+
export function getAchievementSnapshot(): { state: AchieveState, unlocked: Record<string, number> } {
|
|
129
|
+
return store
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 取走新解锁(toast 消费;一次性)。 */
|
|
133
|
+
export function consumeUnlocks(): AchievementDef[] {
|
|
134
|
+
const queue = toastQueue
|
|
135
|
+
toastQueue = []
|
|
136
|
+
return queue
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 订阅状态变化(toast 与成就页)。 */
|
|
140
|
+
export function subscribeAchievements(listener: () => void): () => void {
|
|
141
|
+
listeners.add(listener)
|
|
142
|
+
return () => listeners.delete(listener)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 成就页 spec 数据源(面板内渲染用)。 */
|
|
146
|
+
export function achievementCount(): { total: number, unlocked: number } {
|
|
147
|
+
return {
|
|
148
|
+
total: ACHIEVEMENTS.length,
|
|
149
|
+
unlocked: ACHIEVEMENTS.filter(a => store.unlocked[a.id] !== undefined).length,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* achievement-toast.tsx — 解锁 toast 栈(右下角,3.6s 自动消退)。
|
|
3
|
+
*
|
|
4
|
+
* 独立 React root(document.body 级):面板/inline fence 任一解锁都会
|
|
5
|
+
* 弹——不依赖面板存在。消费 achievement-store 的新解锁队列。
|
|
6
|
+
*/
|
|
7
|
+
import { createElement, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
|
8
|
+
import { createRoot } from 'react-dom/client'
|
|
9
|
+
import { consumeUnlocks, subscribeAchievements } from './achievement-store.ts'
|
|
10
|
+
import type { AchievementDef } from './achievements.ts'
|
|
11
|
+
import css from './GenuiBlock.module.css'
|
|
12
|
+
|
|
13
|
+
interface ToastItem {
|
|
14
|
+
key: number
|
|
15
|
+
ach: AchievementDef
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let toastKey = 0
|
|
19
|
+
|
|
20
|
+
/** 底部固定栈:显示队列中的最后一个(FIFO 展开为纵向栈)。 */
|
|
21
|
+
function AchievementToasts() {
|
|
22
|
+
const [, force] = useSyncExternalStore(subscribeAchievements, () => '')
|
|
23
|
+
const [items, setItems] = useState<ToastItem[]>([])
|
|
24
|
+
const timers = useRef<Map<number, number>>(new Map())
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const queue = consumeUnlocks()
|
|
28
|
+
if (queue.length === 0) return
|
|
29
|
+
const next: ToastItem[] = queue.map(ach => ({ key: ++toastKey, ach }))
|
|
30
|
+
setItems(prev => [...prev, ...next])
|
|
31
|
+
for (const item of next) {
|
|
32
|
+
const t = window.setTimeout(() => {
|
|
33
|
+
setItems(prev => prev.filter(i => i.key !== item.key))
|
|
34
|
+
timers.current.delete(item.key)
|
|
35
|
+
}, 3600)
|
|
36
|
+
timers.current.set(item.key, t)
|
|
37
|
+
}
|
|
38
|
+
}, [force])
|
|
39
|
+
|
|
40
|
+
useEffect(() => () => {
|
|
41
|
+
for (const t of timers.current.values()) window.clearTimeout(t)
|
|
42
|
+
}, [])
|
|
43
|
+
|
|
44
|
+
if (items.length === 0) return null
|
|
45
|
+
return createElement('div', { className: css.achToasts, 'data-genui-achievement-toasts': true },
|
|
46
|
+
items.map(item => createElement('div', { key: item.key, className: css.achToast },
|
|
47
|
+
createElement('span', { className: css.achToastBadge, 'aria-hidden': true }, '🏆'),
|
|
48
|
+
createElement('div', { className: css.achToastBody },
|
|
49
|
+
createElement('div', { className: css.achToastName }, `成就解锁:${item.ach.name}`),
|
|
50
|
+
createElement('div', { className: css.achToastDesc }, item.ach.description),
|
|
51
|
+
),
|
|
52
|
+
)),
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 挂载 toast 栈(apply 调用;返回卸载函数)。 */
|
|
57
|
+
export function mountAchievementToasts(): () => void {
|
|
58
|
+
if (typeof document === 'undefined') return () => {}
|
|
59
|
+
const host = document.createElement('div')
|
|
60
|
+
host.dataset.dshGenuiAchievements = '1'
|
|
61
|
+
document.body.appendChild(host)
|
|
62
|
+
const root = createRoot(host)
|
|
63
|
+
root.render(createElement(AchievementToasts))
|
|
64
|
+
return () => {
|
|
65
|
+
root.unmount()
|
|
66
|
+
host.remove()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* achievements.ts — GenUI 探索成就(0.9.5)。
|
|
3
|
+
*
|
|
4
|
+
* 轻量本地成就:只统计「使用事件计数」(渲染过的 fence、面板出现、
|
|
5
|
+
* 交互回传、模板试用),绝不读消息/内容;状态存 localStorage,跨会话
|
|
6
|
+
* 保留。解锁时进 toast 队列(achievement-toast 消费),面板「成就」
|
|
7
|
+
* tab 用 dsh-ui 渲染自己的成就页(dogfooding)。
|
|
8
|
+
*
|
|
9
|
+
* 设计与 dsh-achievements 的分层快照思路一致,但零宿主改动:埋点全部
|
|
10
|
+
* 在本包的渲染/交互路径上(GenuiBlock/TemplateDrawer/GenuiPanel)。
|
|
11
|
+
*/
|
|
12
|
+
import type { GenuiSpec, GenuiNode } from './spec.ts'
|
|
13
|
+
|
|
14
|
+
/** 累积使用计数(成就的输入)。 */
|
|
15
|
+
export interface AchieveState {
|
|
16
|
+
/** 渲染过的不重复 fence 数。 */
|
|
17
|
+
fences: number
|
|
18
|
+
/** 面板(panel dock)出现过的会话数。 */
|
|
19
|
+
panels: number
|
|
20
|
+
/** 交互组件动作回传次数(去抖后)。 */
|
|
21
|
+
interactions: number
|
|
22
|
+
/** 模板试用次数。 */
|
|
23
|
+
templates: number
|
|
24
|
+
/** 图表节点(chart/plot/echart)出现过的 fence 数。 */
|
|
25
|
+
charts: number
|
|
26
|
+
/** 高级节点(scene3d/mermaid/diagram)出现过的 fence 数。 */
|
|
27
|
+
advanced: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function emptyState(): AchieveState {
|
|
31
|
+
return { fences: 0, panels: 0, interactions: 0, templates: 0, charts: 0, advanced: 0 }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AchievementDef {
|
|
35
|
+
id: string
|
|
36
|
+
name: string
|
|
37
|
+
description: string
|
|
38
|
+
/** 解锁前隐藏名称/描述(彩蛋)。 */
|
|
39
|
+
hidden?: boolean
|
|
40
|
+
/** 稀有度。 */
|
|
41
|
+
rarity: 'common' | 'rare' | 'legendary'
|
|
42
|
+
check: (s: AchieveState) => boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const fences = (n: number) => (s: AchieveState): boolean => s.fences >= n
|
|
46
|
+
|
|
47
|
+
export const ACHIEVEMENTS: readonly AchievementDef[] = [
|
|
48
|
+
{ id: 'first-fence', name: '初次相见', description: '渲染了第一块 GenUI 界面。', rarity: 'common', check: fences(1) },
|
|
49
|
+
{ id: 'fence-5', name: '渐入佳境', description: '累计渲染 5 个界面。', rarity: 'common', check: fences(5) },
|
|
50
|
+
{ id: 'fence-25', name: '界面编织者', description: '累计渲染 25 个界面。', rarity: 'rare', check: fences(25) },
|
|
51
|
+
{ id: 'fence-50', name: '蓝图之魂', description: '累计渲染 50 个界面——你是 GenUI 的老朋友了。', rarity: 'legendary', hidden: true, check: fences(50) },
|
|
52
|
+
{ id: 'first-interaction', name: '按钮按得动', description: '触发了一次组件交互(动作回传)。', rarity: 'common', check: s => s.interactions >= 1 },
|
|
53
|
+
{ id: 'interaction-10', name: '小小操纵者', description: '累计触发 10 次组件交互。', rarity: 'rare', check: s => s.interactions >= 10 },
|
|
54
|
+
{ id: 'first-panel', name: '钉在墙上', description: '第一次把内容钉进了会话面板。', rarity: 'common', check: s => s.panels >= 1 },
|
|
55
|
+
{ id: 'panel-5', name: '面板管家', description: '5 个会话都用过面板。', rarity: 'rare', check: s => s.panels >= 5 },
|
|
56
|
+
{ id: 'first-chart', name: '图表爱好者', description: '渲染了第一张图表(chart/plot/echart)。', rarity: 'common', check: s => s.charts >= 1 },
|
|
57
|
+
{ id: 'chart-10', name: '数据可视化人', description: '10 个界面里出现过图表。', rarity: 'rare', check: s => s.charts >= 10 },
|
|
58
|
+
{ id: 'first-advanced', name: '高级玩家', description: '用上了 3D/图表引擎/架构图之一。', rarity: 'rare', check: s => s.advanced >= 1 },
|
|
59
|
+
{ id: 'template-1', name: '模板学员', description: '从模板中心试用了一个模板。', rarity: 'common', check: s => s.templates >= 1 },
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
/** 统计一个 spec 里出现的相关节点类别(与 guard 同口径遍历)。 */
|
|
63
|
+
export function countSpecKinds(spec: GenuiSpec): { charts: number, advanced: number } {
|
|
64
|
+
let charts = 0
|
|
65
|
+
let advanced = 0
|
|
66
|
+
const walk = (list: unknown): void => {
|
|
67
|
+
if (!Array.isArray(list)) return
|
|
68
|
+
for (const item of list) {
|
|
69
|
+
if (typeof item !== 'object' || item === null) continue
|
|
70
|
+
const v = item as Record<string, unknown>
|
|
71
|
+
const type = v.type
|
|
72
|
+
if (type === 'chart' || type === 'plot' || type === 'echart') charts += 1
|
|
73
|
+
if (type === 'scene3d' || type === 'mermaid' || type === 'diagram') advanced += 1
|
|
74
|
+
// 容器与列表项递归
|
|
75
|
+
if (Array.isArray(v.items)) walk(v.items as unknown[])
|
|
76
|
+
if (Array.isArray((v as { tabs?: unknown }).tabs)) {
|
|
77
|
+
for (const tab of (v as { tabs: Array<{ items?: unknown }> }).tabs) walk(tab.items)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
walk(spec.items)
|
|
82
|
+
return { charts, advanced }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 生成成就页 spec(dsh-ui 渲染):进度 stat + 解锁列表 + 稀有度徽标。 */
|
|
86
|
+
export function buildAchievementsSpec(state: AchieveState, unlocked: Record<string, number>): GenuiSpec {
|
|
87
|
+
const total = ACHIEVEMENTS.length
|
|
88
|
+
const unlockedCount = ACHIEVEMENTS.filter(a => unlocked[a.id] !== undefined).length
|
|
89
|
+
const items: GenuiNode[] = [
|
|
90
|
+
{ type: 'grid', cols: 3, items: [
|
|
91
|
+
{ type: 'stat', label: '已解锁', value: `${unlockedCount} / ${total}` },
|
|
92
|
+
{ type: 'stat', label: '渲染界面', value: String(state.fences) },
|
|
93
|
+
{ type: 'stat', label: '组件交互', value: String(state.interactions) },
|
|
94
|
+
] },
|
|
95
|
+
{ type: 'progress', label: '探索进度', value: Math.round(unlockedCount / total * 100), valueLabel: `${Math.round(unlockedCount / total * 100)}%` },
|
|
96
|
+
{ type: 'list', items: ACHIEVEMENTS.map(a => ({
|
|
97
|
+
type: 'row',
|
|
98
|
+
items: [
|
|
99
|
+
{ type: 'badge', label: a.rarity === 'legendary' ? '传说' : a.rarity === 'rare' ? '稀有' : '普通', tone: unlocked[a.id] !== undefined ? 'success' : 'warn' },
|
|
100
|
+
{ type: 'text', size: 'body', content: a.hidden && unlocked[a.id] === undefined ? '?' : a.name },
|
|
101
|
+
{ type: 'text', size: 'muted', content: a.hidden && unlocked[a.id] === undefined ? '继续探索以揭示' : a.description },
|
|
102
|
+
],
|
|
103
|
+
})) },
|
|
104
|
+
]
|
|
105
|
+
return { title: 'GenUI 探索成就', items }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 检查给定状态下的新解锁(不受 hidden 限制——规则只管阈值)。 */
|
|
109
|
+
export function checkAchievements(state: AchieveState, unlocked: Record<string, number>): AchievementDef[] {
|
|
110
|
+
return ACHIEVEMENTS.filter(a => unlocked[a.id] === undefined && a.check(state))
|
|
111
|
+
}
|