cardbuddy 0.7.0 → 0.9.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.
Files changed (36) hide show
  1. package/README.md +21 -0
  2. package/dist/cli.js +5 -0
  3. package/dist/preview-editor/assets/index-CrcrVs4F.css +1 -0
  4. package/dist/preview-editor/assets/index-DnpHHKWl.js +80 -0
  5. package/dist/preview-editor/index.html +2 -2
  6. package/dist/server-core/codeBridge.js +359 -0
  7. package/dist/server-core/index.js +27 -2
  8. package/dist/server-core/logStream.js +275 -0
  9. package/dist/standalone.js +33 -6
  10. package/dist/vendored/App.vue +28 -19
  11. package/dist/vendored/ansi.js +185 -0
  12. package/dist/vendored/api.js +164 -28
  13. package/dist/vendored/components/ApiCallPanel.vue +13 -1
  14. package/dist/vendored/components/ApiPayloadTree.vue +34 -1
  15. package/dist/vendored/components/CardGallery.vue +2 -2
  16. package/dist/vendored/components/ComponentPalette.vue +46 -39
  17. package/dist/vendored/components/ConfirmDialog.vue +2 -2
  18. package/dist/vendored/components/DebugPanel.vue +420 -69
  19. package/dist/vendored/components/EventEditor.vue +29 -3
  20. package/dist/vendored/components/LogValue.vue +2 -1
  21. package/dist/vendored/components/NodeInspector.vue +1 -1
  22. package/dist/vendored/components/PropControl.vue +6 -2
  23. package/dist/vendored/components/RemotePreview.vue +3 -1
  24. package/dist/vendored/components/StructureTree.vue +2 -0
  25. package/dist/vendored/components/setters/SetCode.vue +111 -3
  26. package/dist/vendored/styles/app.css +529 -96
  27. package/dist/vendored/styles/card-gallery.css +4 -11
  28. package/dist/vendored/styles/card-select.css +0 -15
  29. package/dist/vendored/styles/dropdown.css +3 -15
  30. package/dist/vendored/styles/json-tree.css +1 -1
  31. package/dist/vendored/styles/node-inspector.css +204 -131
  32. package/dist/vendored/styles/setters.css +91 -2
  33. package/dist/vendored/styles/structure-tree.css +27 -12
  34. package/package.json +1 -1
  35. package/dist/preview-editor/assets/index-BlscexDE.js +0 -79
  36. package/dist/preview-editor/assets/index-dlbSPb5W.css +0 -1
@@ -4,6 +4,8 @@
4
4
  * 设计目标(前后端分离方案 Phase 2):
5
5
  * - 开发期(npm run dev,由 tools/dev.mjs 同起 cardbuddy):apiBase 默认
6
6
  * http://127.0.0.1:<FINMALL_CARD_PORT>(.env.finmall,默认 8787),与部署态一致。
7
+ * 若页面不是经回环地址打开(局域网 IP / 其它本机 IP),则退化为同源 '',
8
+ * 由 Vite dev server 代理到 8787,避开浏览器私有网络访问(PNA)拦截。
7
9
  * - 部署态(vite build 静态产物):默认读 localStorage 的「本地开发服务」配置;
8
10
  * 未配置时 apiBase = null,调用方据此提示「请先配置本地开发服务」。
9
11
  * - 本地开发服务(cardbuddy,默认 http://127.0.0.1:8787)经反射 CORS 放行任意
@@ -37,6 +39,23 @@ const DEFAULT_CONFIG = {
37
39
  token: '',
38
40
  }
39
41
 
42
+ // 页面是否经「非回环主机名」打开(Vite host:true 会额外打印局域网 IP / 其它本机地址,
43
+ // 例如 http://192.168.x.x:5180、http://2.0.0.1:5180)。此时浏览器把页面归为
44
+ // public/private 地址空间,而 cardbuddy 只绑 127.0.0.1(loopback),Chrome 的私有网络
45
+ // 访问(PNA)规则会以「The request client is not a secure context and the resource is in
46
+ // more-private address space `loopback`」直接拦掉 fetch / EventSource(net::ERR_FAILED),
47
+ // 表现为「本地开发服务已断开」。开发期改走同源(''):请求打到页面自己的 host,
48
+ // 由 vite.config.mjs 的 server.proxy 在 Node 侧转发到 127.0.0.1:<FINMALL_CARD_PORT>
49
+ // —— 服务端转发不受 CORS / PNA 约束。回环访问(localhost / 127.0.0.1)不受影响,
50
+ // 仍直连 8787 跨源链路,与部署态一致。
51
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
52
+ function isNonLoopbackDevHost() {
53
+ if (typeof window === 'undefined' || !window.location) return false
54
+ const { protocol, hostname } = window.location
55
+ if (protocol !== 'http:' && protocol !== 'https:') return false
56
+ return !LOOPBACK_HOSTS.has(hostname)
57
+ }
58
+
40
59
  export function loadDevServiceConfig() {
41
60
  try {
42
61
  const raw = localStorage.getItem(LS_KEY)
@@ -68,6 +87,8 @@ export function saveDevServiceConfig(cfg) {
68
87
  * - Vite dev server(import.meta.env.DEV 且无覆盖)→ 默认本地开发服务
69
88
  * http://127.0.0.1:<FINMALL_CARD_PORT>(.env.finmall,默认 8787)
70
89
  * (前后端分离:Vite 不再托管卡片后端,改由 tools/dev.mjs 同起的 cardbuddy 提供)
90
+ * 例外:页面经非回环主机名打开时返回 ''(同源),改由 Vite dev server 代理到 8787,
91
+ * 绕开浏览器私有网络访问(PNA)拦截,详见 isNonLoopbackDevHost 注释
71
92
  * - 部署态静态产物且未配置 → null(调用方需提示配置)
72
93
  */
73
94
  export function resolveApiBase() {
@@ -76,7 +97,9 @@ export function resolveApiBase() {
76
97
  if (override) return String(override).replace(/\/+$/, '')
77
98
  const cfg = loadDevServiceConfig()
78
99
  if (cfg.enabled && cfg.baseUrl) return cfg.baseUrl.replace(/\/+$/, '')
79
- if (import.meta.env && import.meta.env.DEV) return DEFAULT_CONFIG.baseUrl
100
+ if (import.meta.env && import.meta.env.DEV) {
101
+ return isNonLoopbackDevHost() ? '' : DEFAULT_CONFIG.baseUrl
102
+ }
80
103
  return null
81
104
  }
82
105
 
@@ -104,39 +127,152 @@ export async function apiFetch(path, opts = {}) {
104
127
  * @param {(state:'connecting'|'open'|'closed')=>void} [onState] 连接态变化回调,
105
128
  * 用于驱动顶栏「连接」图标实时反映服务端运行态(SSE readyState / Vite HMR ws 生命周期)。
106
129
  */
107
- export function subscribeCardsChanged(cb, onState) {
108
- const base = resolveApiBase()
109
- const emit = (s) => { if (typeof onState === 'function') onState(s) }
110
- if (base) {
111
- // 本地服务:SSE(浏览器原生自动重连)
112
- const es = new EventSource(base + '/__events')
113
- const handler = (e) => {
114
- try {
115
- cb(e.data ? JSON.parse(e.data) : {})
116
- } catch (_) {
117
- cb({})
118
- }
130
+ /**
131
+ * 共享 SSE 连接池:浏览器对同一 origin 的 HTTP/1.1 并发连接约 6 条,而 /__events 是长连接,
132
+ * 每条订阅各开一个 EventSource 会常驻占位(cards-changed + code-bridge 已占 2 条,服务日志
133
+ * 面板再占 1 条后,普通 API 请求只剩 3 条并发余量,截图 / 校验这类慢请求易排队)。
134
+ * 三类事件本就同源同端点,故按 base 复用一条连接、按事件名分发,引用计数归零才真正 close。
135
+ * @type {Map<string, { es: EventSource, states: Set<(s:string)=>void>, refs: number }>}
136
+ */
137
+ const ssePool = new Map()
138
+
139
+ function acquireSse(base) {
140
+ let entry = ssePool.get(base)
141
+ if (!entry) {
142
+ // EventSource 不能自定义请求头,配置了 token 时改经查询参数携带(服务端 tokenMiddleware 已支持)
143
+ const token = devServiceToken()
144
+ const es = new EventSource(base + '/__events' + (token ? '?token=' + encodeURIComponent(token) : ''))
145
+ entry = { es, states: new Set(), refs: 0 }
146
+ ssePool.set(base, entry)
147
+ es.onopen = () => {
148
+ for (const cb of entry.states) cb('open')
119
149
  }
120
- es.addEventListener('cards-changed', handler)
121
- es.onopen = () => emit('open')
122
150
  es.onerror = () => {
123
151
  // 断链时 onerror 触发,浏览器会自动重连:
124
152
  // readyState===0(CONNECTING) 表示正在重连;===2(CLOSED) 表示已彻底关闭(如显式 close)。
125
- emit(es.readyState === 2 ? 'closed' : 'connecting')
153
+ const state = es.readyState === 2 ? 'closed' : 'connecting'
154
+ for (const cb of entry.states) cb(state)
155
+ }
156
+ }
157
+ entry.refs++
158
+ return entry
159
+ }
160
+
161
+ function releaseSse(base, entry) {
162
+ entry.refs = Math.max(0, entry.refs - 1)
163
+ if (entry.refs === 0 && ssePool.get(base) === entry) {
164
+ ssePool.delete(base)
165
+ try {
166
+ entry.es.close()
167
+ } catch (_) {
168
+ /* ignore */
126
169
  }
127
- emit('connecting') // 刚创建即处于 CONNECTING,先置为「连接中」
128
- return () => es.close()
129
170
  }
130
- // 同源 dev server:沿用 Vite HMR(底层 WebSocket,同样具备连接态)
131
- if (import.meta.hot) {
132
- import.meta.hot.on('cards-changed', cb)
133
- // 页面由同源 dev server 提供,初始即视为已连接;ws 断开 / 重连时更新状态
134
- import.meta.hot.on('vite:ws:connect', () => emit('open'))
135
- import.meta.hot.on('vite:ws:disconnect', () => emit('connecting'))
136
- emit('open')
137
- return () => {
138
- /* HMR 无显式 off,noop */
171
+ }
172
+
173
+ /**
174
+ * 通用 SSE 订阅工厂:监听 /__events 上的一组事件名,返回取消订阅函数。
175
+ * base === ''(开发期同源代理)同样走 SSE:请求相对当前页面 host,由 Vite 转发到 8787,
176
+ * 连接态因此能真实反映 cardbuddy 运行态。
177
+ * @param {string[]} events 事件名列表
178
+ * @param {{ hmrEvents?: string[] }} [opts] hmrEvents:base 为 null(部署态未配置)时
179
+ * 回退到 Vite HMR 通道的事件名(仅 cards-changed 仍有该历史通道)。
180
+ */
181
+ function makeSseSubscriber(events, { hmrEvents = [] } = {}) {
182
+ return function subscribe(cb, onState) {
183
+ const base = resolveApiBase()
184
+ const emit = (s) => { if (typeof onState === 'function') onState(s) }
185
+ if (base !== null) {
186
+ // 本地服务 / 同源代理:SSE(浏览器原生自动重连),同 base 复用一条连接
187
+ const entry = acquireSse(base)
188
+ const handler = (e) => {
189
+ try {
190
+ cb(e.data ? JSON.parse(e.data) : {})
191
+ } catch (_) {
192
+ cb({})
193
+ }
194
+ }
195
+ for (const name of events) entry.es.addEventListener(name, handler)
196
+ entry.states.add(emit)
197
+ // 复用已建立的连接时立即回写真实态,避免顶栏「连接」图标无谓闪「连接中」
198
+ emit(entry.es.readyState === 1 ? 'open' : entry.es.readyState === 2 ? 'closed' : 'connecting')
199
+ return () => {
200
+ for (const name of events) entry.es.removeEventListener(name, handler)
201
+ entry.states.delete(emit)
202
+ releaseSse(base, entry)
203
+ }
204
+ }
205
+ // 同源 dev server:沿用 Vite HMR(底层 WebSocket,同样具备连接态)
206
+ if (import.meta.hot && hmrEvents.length) {
207
+ for (const name of hmrEvents) import.meta.hot.on(name, cb)
208
+ // 页面由同源 dev server 提供,初始即视为已连接;ws 断开 / 重连时更新状态
209
+ import.meta.hot.on('vite:ws:connect', () => emit('open'))
210
+ import.meta.hot.on('vite:ws:disconnect', () => emit('connecting'))
211
+ emit('open')
212
+ return () => {
213
+ /* HMR 无显式 off,noop */
214
+ }
139
215
  }
216
+ return () => {}
140
217
  }
141
- return () => {}
218
+ }
219
+
220
+ /** 订阅磁盘变更推送(cards-changed)。 */
221
+ export const subscribeCardsChanged = makeSseSubscriber(['cards-changed'], { hmrEvents: ['cards-changed'] })
222
+
223
+ /** 订阅外部编辑器代码桥事件(code-bridge-changed / code-bridge-closed)。 */
224
+ export const subscribeCodeBridge = makeSseSubscriber(['code-bridge-changed', 'code-bridge-closed'])
225
+
226
+ /**
227
+ * 订阅本地服务日志流(event: service-log)。
228
+ * 载荷 { lines: [{ seq, t, text }] },text 为含 ANSI 的原始终端行,由 preview/ansi.js 还原配色。
229
+ *
230
+ * 连接开销说明:每条订阅各占一个 EventSource(浏览器对同源的并发连接有限),
231
+ * 因此服务日志采用「面板打开才订阅、关闭即退订」的懒连接策略(见 DebugPanel),
232
+ * 不与常驻的 cards-changed / code-bridge 抢连接。
233
+ */
234
+ export const subscribeServiceLog = makeSseSubscriber(['service-log'])
235
+
236
+ /**
237
+ * 取服务日志历史 / 断线补漏(GET /__logs)。
238
+ * @param {{ limit?: number, since?: number }} [opts] since:只取 seq 大于该值的行
239
+ * @returns {Promise<{ ok:boolean, enabled:boolean, seq:number, dropped:number, buffered:number, startedAt:number, pid:number, lines:{seq:number,t:number,text:string}[] }>}
240
+ */
241
+ export async function fetchServiceLogs({ limit = 300, since = 0 } = {}) {
242
+ const q = new URLSearchParams({ limit: String(limit) })
243
+ if (since) q.set('since', String(since))
244
+ const res = await apiFetch('/__logs?' + q.toString())
245
+ if (!res.ok) throw new Error('HTTP ' + res.status)
246
+ return res.json()
247
+ }
248
+
249
+ /**
250
+ * 代码桥 REST 封装(端点见 server/core/codeBridge.js):
251
+ * open 建立会话并拉起编辑器;fetch 拉取外部保存后的最新代码;
252
+ * heartbeat 维持会话(TTL 兜底清理);close 结束会话并删除临时文件。
253
+ */
254
+ export async function codeBridgeOpen(code, label = '') {
255
+ const res = await apiFetch('/__code-bridge', {
256
+ method: 'POST',
257
+ headers: { 'Content-Type': 'application/json' },
258
+ body: JSON.stringify({ code, label }),
259
+ })
260
+ if (!res.ok) throw new Error(`code-bridge open failed: ${res.status}`)
261
+ return res.json()
262
+ }
263
+
264
+ export async function codeBridgeFetch(id) {
265
+ const res = await apiFetch(`/__code-bridge/${id}`)
266
+ if (!res.ok) throw new Error(`code-bridge fetch failed: ${res.status}`)
267
+ return res.json()
268
+ }
269
+
270
+ export async function codeBridgeHeartbeat(id) {
271
+ const res = await apiFetch(`/__code-bridge/${id}/heartbeat`, { method: 'POST' })
272
+ return res.ok
273
+ }
274
+
275
+ export async function codeBridgeClose(id) {
276
+ const res = await apiFetch(`/__code-bridge/${id}`, { method: 'DELETE' })
277
+ return res.ok
142
278
  }
@@ -32,7 +32,19 @@
32
32
  </button>
33
33
  </div>
34
34
  <div class="apipanel__body">
35
- <p v-if="!apiCalls.length" class="debug__empty">暂无接口调用</p>
35
+ <!-- 空状态占位:虚线圆形徽章 + 插头图标(与全局 24 网格线性图标同语言)+ 文案 + 弱提示 -->
36
+ <div v-if="!apiCalls.length" class="empty-state">
37
+ <span class="empty-state__badge" aria-hidden="true">
38
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
39
+ <path d="M9 7V3" />
40
+ <path d="M15 7V3" />
41
+ <path d="M6 7h12v4a6 6 0 0 1-12 0V7z" />
42
+ <path d="M12 17v4" />
43
+ </svg>
44
+ </span>
45
+ <p class="debug__empty">暂无API调用</p>
46
+ <p class="empty-state__hint">卡片事件触发 ynet.callJSAPI / postMessage 后,调用流水会实时显示在这里</p>
47
+ </div>
36
48
  <ul class="debug__list">
37
49
  <li v-for="(c, i) in apiCalls" :key="i" class="debug__item debug__item--api">
38
50
  <div class="debug__api-row">
@@ -15,7 +15,26 @@
15
15
  </div>
16
16
 
17
17
  <template v-else>
18
- <div class="apt__list">
18
+ <!-- 日志场景(rootCollapsed):根对象/数组先收成一行摘要、点击展开(Chrome 控制台风格),
19
+ 避免每个实参默认逐键占行稀释日志密度;接口调用面板不传该 prop,行为不变 -->
20
+ <div
21
+ v-if="rootCollapsed && isRoot"
22
+ class="apt__branch apt__branch--root"
23
+ @click="rootOpen = !rootOpen"
24
+ >
25
+ <span class="apt__caret" :class="{ 'apt__caret--open': rootOpen }">▶</span>
26
+ <span class="jt__bracket">{{ isArrayRoot ? '[' : '{' }}</span>
27
+ <span v-if="!rootOpen" class="jt__preview">{{ preview(value) }}</span>
28
+ <template v-else>
29
+ <span class="jt__count">{{ rootCount }} {{ isArrayRoot ? '项' : '键' }}</span>
30
+ </template>
31
+ <span v-if="!rootOpen" class="jt__bracket">{{ isArrayRoot ? ']' : '}' }}</span>
32
+ </div>
33
+ <div
34
+ v-show="!(rootCollapsed && isRoot) || rootOpen"
35
+ class="apt__list"
36
+ :class="{ 'apt__list--nested': rootCollapsed && isRoot && rootOpen }"
37
+ >
19
38
  <div v-for="(entry, i) in entries" :key="i" class="apt__node">
20
39
  <!-- 复合类型且未超最大深度:可折叠分支 -->
21
40
  <div v-if="canExpand(entry)" class="apt__branch" @click="toggle(entry)">
@@ -58,6 +77,10 @@
58
77
  </div>
59
78
  </div>
60
79
  </div>
80
+ <div v-if="rootCollapsed && isRoot && rootOpen" class="apt__close">
81
+ <span class="jt__bracket">{{ isArrayRoot ? ']' : '}' }}</span>
82
+ <span class="jt__count">{{ rootCount }} {{ isArrayRoot ? '项' : '键' }}</span>
83
+ </div>
61
84
  </template>
62
85
  </div>
63
86
  </template>
@@ -81,17 +104,27 @@ export default {
81
104
  // 最大可展开深度(保护):达到该深度的复合节点不再可展开,
82
105
  // 避免日志中循环引用 / 超深结构在展开时无限递归。默认 100(接口调用场景基本无感)。
83
106
  maxDepth: { type: Number, default: 100 },
107
+ // 根节点默认折叠成一行摘要(事件日志使用);接口调用面板保持逐行铺开
108
+ rootCollapsed: { type: Boolean, default: false },
84
109
  },
85
110
  data() {
86
111
  return {
87
112
  // 若未传入共享状态(理论只发生在根),自行创建,保证递归子组件拿到同一引用
88
113
  localOpenState: this.openState || reactive({}),
114
+ // 根摘要行的展开态(仅 rootCollapsed 场景使用)
115
+ rootOpen: false,
89
116
  }
90
117
  },
91
118
  computed: {
92
119
  isRoot() {
93
120
  return this.depth === 0
94
121
  },
122
+ isArrayRoot() {
123
+ return Array.isArray(this.value)
124
+ },
125
+ rootCount() {
126
+ return this.isArrayRoot ? this.value.length : Object.keys(this.value || {}).length
127
+ },
95
128
  state() {
96
129
  return this.openState || this.localOpenState
97
130
  },
@@ -10,9 +10,9 @@
10
10
  搜索框按 id / 名称过滤;Esc 或点遮罩关闭;当前选中卡高亮。
11
11
  -->
12
12
  <template>
13
- <transition name="cg-fade">
13
+ <transition name="modal">
14
14
  <div v-if="visible" class="cg-mask" @click.self="close">
15
- <div class="cg-modal" role="dialog" aria-modal="true" aria-label="卡片网格预览">
15
+ <div class="cg-modal modal-card" role="dialog" aria-modal="true" aria-label="卡片网格预览">
16
16
  <header class="cg-modal__head">
17
17
  <div class="cg-modal__title">
18
18
  <svg class="app__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-grid" /></svg>
@@ -5,7 +5,7 @@
5
5
  -->
6
6
  <template>
7
7
  <div class="cp__mask" @click.self="$emit('close')">
8
- <div class="cp" role="dialog" aria-label="选择组件类型">
8
+ <div class="cp modal-card" role="dialog" aria-label="选择组件类型">
9
9
  <div class="cp__head">
10
10
  <span class="cp__title">添加组件</span>
11
11
  <span class="cp__hint">选择要插入的组件类型</span>
@@ -129,11 +129,13 @@ export default {
129
129
  .cp__mask {
130
130
  position: fixed;
131
131
  inset: 0;
132
- background: var(--scrim);
132
+ /* 与确认框 .confirm-mask 同款模糊遮罩:暗色半透明 + 2px 背景模糊 */
133
+ background: var(--scrim-modal);
134
+ backdrop-filter: blur(var(--scrim-modal-blur));
133
135
  display: flex;
134
136
  align-items: center;
135
137
  justify-content: center;
136
- z-index: 1000;
138
+ z-index: var(--z-modal);
137
139
  }
138
140
 
139
141
  .cp {
@@ -142,8 +144,8 @@ export default {
142
144
  max-height: calc(100vh - 64px);
143
145
  background: var(--bg-panel);
144
146
  border: 1px solid var(--border-strong);
145
- border-radius: 10px;
146
- box-shadow: 0 18px 48px var(--shadow-pop);
147
+ border-radius: var(--radius-lg);
148
+ box-shadow: var(--shadow-4);
147
149
  display: flex;
148
150
  flex-direction: column;
149
151
  overflow: hidden;
@@ -152,29 +154,35 @@ export default {
152
154
 
153
155
  .cp__head {
154
156
  display: flex;
155
- align-items: baseline;
157
+ align-items: center;
156
158
  gap: 10px;
157
- padding: 14px 16px;
158
- border-bottom: 1px solid var(--border-strong);
159
+ padding: 10px 16px;
160
+ border-bottom: 1px solid var(--border);
159
161
  }
160
162
  .cp__title {
161
- font-size: 15px;
163
+ font-size: 13px;
162
164
  font-weight: 600;
163
165
  }
164
166
  .cp__hint {
165
- font-size: 12px;
166
- color: var(--text-dim);
167
+ font-size: 11px;
168
+ color: var(--text-faint);
167
169
  }
168
170
  .cp__close {
169
171
  margin-left: auto;
172
+ display: inline-flex;
173
+ align-items: center;
174
+ justify-content: center;
175
+ width: 22px;
176
+ height: 22px;
177
+ padding: 0;
170
178
  background: transparent;
171
179
  border: none;
172
- color: var(--text-dim);
173
- font-size: 16px;
180
+ border-radius: 6px;
181
+ color: var(--text-faint);
182
+ font-size: 15px;
174
183
  cursor: pointer;
175
184
  line-height: 1;
176
- padding: 2px 6px;
177
- border-radius: 4px;
185
+ transition: background 0.15s, color 0.15s;
178
186
  }
179
187
  .cp__close:hover {
180
188
  background: var(--bg-hover);
@@ -182,16 +190,16 @@ export default {
182
190
  }
183
191
 
184
192
  .cp__body {
185
- padding: 8px 16px 16px;
193
+ padding: 10px 16px 16px;
186
194
  overflow-y: auto;
187
195
  }
188
196
 
189
197
  .cp__group {
190
- margin-top: 12px;
198
+ margin-top: 10px;
191
199
  }
192
200
  .cp__group-title {
193
- margin: 0 0 8px;
194
- font-size: 12px;
201
+ margin: 0 0 6px;
202
+ font-size: 11px;
195
203
  font-weight: 600;
196
204
  color: var(--text-faint);
197
205
  letter-spacing: 0.04em;
@@ -200,7 +208,7 @@ export default {
200
208
  .cp__grid {
201
209
  display: grid;
202
210
  grid-template-columns: repeat(4, 1fr);
203
- gap: 8px;
211
+ gap: 6px;
204
212
  }
205
213
 
206
214
  .cp__item {
@@ -208,21 +216,17 @@ export default {
208
216
  flex-direction: column;
209
217
  align-items: center;
210
218
  gap: 4px;
211
- padding: 10px 6px;
212
- background: var(--bg-input);
219
+ padding: 8px 6px;
220
+ background: transparent;
213
221
  border: 1px solid var(--border);
214
- border-radius: 8px;
222
+ border-radius: 6px;
215
223
  cursor: pointer;
216
224
  color: var(--text);
217
- transition: border-color 0.12s, background 0.12s, transform 0.08s;
225
+ transition: border-color 0.15s, background 0.15s;
218
226
  }
219
227
  .cp__item:hover {
220
- border-color: var(--accent);
221
- background: var(--bg-hover);
222
- transform: translateY(-1px);
223
- }
224
- .cp__item:active {
225
- transform: translateY(0);
228
+ border-color: var(--accent-border);
229
+ background: var(--accent-soft);
226
230
  }
227
231
 
228
232
  .cp__avatar {
@@ -234,14 +238,17 @@ export default {
234
238
  justify-content: center;
235
239
  font-size: 15px;
236
240
  font-weight: 600;
237
- color: var(--on-accent);
238
- background: var(--accent);
241
+ color: var(--text-dim);
242
+ background: var(--bg-hover);
239
243
  }
244
+ /* 分组语义色:布局 = accent、基础 = ok,均用 soft 底 + 同色线性图标,与全局轻风格一致 */
240
245
  .cp__avatar[data-g='layout'] {
241
- background: var(--accent-2);
246
+ color: var(--accent);
247
+ background: var(--accent-soft);
242
248
  }
243
249
  .cp__avatar[data-g='basic'] {
244
- background: var(--ok);
250
+ color: var(--ok);
251
+ background: var(--ok-soft);
245
252
  }
246
253
  .cp__avatar svg {
247
254
  display: block;
@@ -272,14 +279,14 @@ export default {
272
279
  .cp__search {
273
280
  box-sizing: border-box;
274
281
  width: 100%;
275
- height: 32px;
282
+ height: 28px;
276
283
  margin-bottom: 10px;
277
- padding: 0 10px;
278
- font-size: 13px;
284
+ padding: 0 8px;
285
+ font-size: 12px;
279
286
  color: var(--text);
280
287
  background: var(--bg-input);
281
288
  border: 1px solid var(--border);
282
- border-radius: 8px;
289
+ border-radius: 6px;
283
290
  outline: none;
284
291
  }
285
292
  .cp__search:focus {
@@ -287,7 +294,7 @@ export default {
287
294
  }
288
295
  .cp__empty {
289
296
  margin: 8px 0;
290
- color: var(--text-dim);
297
+ color: var(--text-faint);
291
298
  font-size: 12px;
292
299
  text-align: center;
293
300
  }
@@ -1,7 +1,7 @@
1
1
  <template>
2
- <transition name="confirm-fade">
2
+ <transition name="modal">
3
3
  <div v-if="visible" class="confirm-mask" @click.self="$emit('cancel')">
4
- <div class="confirm-modal" role="alertdialog" aria-modal="true" :aria-label="title">
4
+ <div class="confirm-modal modal-card" role="alertdialog" aria-modal="true" :aria-label="title">
5
5
  <div class="confirm-modal__title" :class="{ 'is-danger': severity === 'danger', 'is-warn': severity === 'warn' }">
6
6
  <svg v-if="severity === 'danger'" class="confirm-modal__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-no-entry" /></svg>
7
7
  <svg v-else-if="severity === 'warn'" class="confirm-modal__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-alert-triangle" /></svg>