cardbuddy 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/README.md +21 -6
- package/dist/cli.js +17 -6
- package/dist/config.js +20 -2
- package/dist/portGuard.js +293 -0
- package/dist/preview-editor/assets/index-B4VZgNSK.css +1 -0
- package/dist/preview-editor/assets/index-jl4YJzKv.js +180 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/server-core/_vendor/finmall-env.mjs +4 -4
- package/dist/server-core/ai-chat.js +3710 -0
- package/dist/server-core/assetProxy.js +3 -22
- package/dist/server-core/buildDataFromSchema.js +36 -4
- package/dist/server-core/cardFiles.js +95 -2
- package/dist/server-core/cardImage.js +14 -0
- package/dist/server-core/cardListCache.js +0 -17
- package/dist/server-core/cardSave.js +2 -0
- package/dist/server-core/cardValidate.js +3 -0
- package/dist/server-core/index.js +6 -5
- package/dist/server-core/logStream.js +1 -6
- package/dist/server-core/sliceUpload.js +8 -1
- package/dist/standalone.js +24 -8
- package/dist/vendored/App.vue +405 -125
- package/dist/vendored/api.js +4 -4
- package/dist/vendored/assets/svgIcons/icons.svg +28 -0
- package/dist/vendored/components/AiSettings.vue +392 -0
- package/dist/vendored/components/CardGallery.vue +51 -5
- package/dist/vendored/components/ChatComposer.vue +413 -0
- package/dist/vendored/components/ChatMessage.vue +383 -0
- package/dist/vendored/components/ChatPanel.vue +937 -0
- package/dist/vendored/components/ComponentPalette.vue +1 -1
- package/dist/vendored/components/DebugPanel.vue +732 -90
- package/dist/vendored/components/Dropdown.vue +4 -2
- package/dist/vendored/components/MarkdownBody.vue +64 -0
- package/dist/vendored/components/NewCardDialog.vue +132 -0
- package/dist/vendored/components/NumberStepper.vue +125 -0
- package/dist/vendored/components/ProgressSlider.vue +9 -1
- package/dist/vendored/components/RemotePreview.vue +1 -1
- package/dist/vendored/components/StructureTree.vue +2 -2
- package/dist/vendored/main.js +1 -0
- package/dist/vendored/runtime/adapter.js +1 -1
- package/dist/vendored/runtime/ai-api.js +165 -0
- package/dist/vendored/runtime/clipboard-node.js +0 -5
- package/dist/vendored/runtime/markdown.js +140 -0
- package/dist/vendored/skills/.manifest.json +1101 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +249 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +678 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/derive_data_json.py +81 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +105 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +624 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +900 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/slice_upload.mjs +699 -0
- package/dist/vendored/skills/shared/finmall-config.mjs +124 -0
- package/dist/vendored/styles/ai-chat.css +1850 -0
- package/dist/vendored/styles/app.css +725 -213
- package/dist/vendored/styles/card-gallery.css +48 -10
- package/dist/vendored/styles/card-select.css +13 -6
- package/dist/vendored/styles/dropdown.css +1 -2
- package/dist/vendored/styles/json-tree.css +3 -3
- package/dist/vendored/styles/node-inspector.css +13 -289
- package/dist/vendored/styles/setters.css +48 -49
- package/dist/vendored/styles/structure-tree.css +6 -11
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-B9MOriNv.js +0 -80
- package/dist/preview-editor/assets/index-CwD14gm3.css +0 -1
- package/dist/vendored/assets/logo.png +0 -0
package/dist/vendored/api.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 设计目标(前后端分离方案 Phase 2):
|
|
5
5
|
* - 开发期(npm run dev,由 tools/dev.mjs 同起 cardbuddy):apiBase 默认
|
|
6
|
-
* http://127.0.0.1:<FINMALL_CARD_PORT>(.env.
|
|
6
|
+
* http://127.0.0.1:<FINMALL_CARD_PORT>(.env.cb,默认 8787),与部署态一致。
|
|
7
7
|
* 若页面不是经回环地址打开(局域网 IP / 其它本机 IP),则退化为同源 '',
|
|
8
8
|
* 由 Vite dev server 代理到 8787,避开浏览器私有网络访问(PNA)拦截。
|
|
9
9
|
* - 部署态(vite build 静态产物):默认读 localStorage 的「本地开发服务」配置;
|
|
@@ -28,8 +28,8 @@ const PREVIEW_API_BASE =
|
|
|
28
28
|
? window.__CARD_DEV_PREVIEW_BASE__ || window.__CARD_DEV_OFFLINE_BASE__ || ''
|
|
29
29
|
: ''
|
|
30
30
|
|
|
31
|
-
// 本地开发服务默认端口:与 .env.
|
|
32
|
-
// 开发期 .env.
|
|
31
|
+
// 本地开发服务默认端口:与 .env.cb 的 FINMALL_CARD_PORT 保持一致(默认 8787)。
|
|
32
|
+
// 开发期 .env.cb 经 vite envPrefix 暴露为 import.meta.env.FINMALL_CARD_PORT,
|
|
33
33
|
// 前端据此连接;该值同时由启动器注入 cardbuddy 子进程(DSL_DEV_PORT)使其监听同一端口。
|
|
34
34
|
const CARD_DEV_PORT = Number(import.meta.env.FINMALL_CARD_PORT || 8787)
|
|
35
35
|
|
|
@@ -85,7 +85,7 @@ export function saveDevServiceConfig(cfg) {
|
|
|
85
85
|
* - 显式覆盖(VITE_API_BASE,手动 / 部署态注入)→ 该 URL
|
|
86
86
|
* - 用户已在设置启用并填了地址 → 该地址(去尾斜杠)
|
|
87
87
|
* - Vite dev server(import.meta.env.DEV 且无覆盖)→ 默认本地开发服务
|
|
88
|
-
* http://127.0.0.1:<FINMALL_CARD_PORT>(.env.
|
|
88
|
+
* http://127.0.0.1:<FINMALL_CARD_PORT>(.env.cb,默认 8787)
|
|
89
89
|
* (前后端分离:Vite 不再托管卡片后端,改由 tools/dev.mjs 同起的 cardbuddy 提供)
|
|
90
90
|
* 例外:页面经非回环主机名打开时返回 ''(同源),改由 Vite dev server 代理到 8787,
|
|
91
91
|
* 绕开浏览器私有网络访问(PNA)拦截,详见 isNonLoopbackDevHost 注释
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
<symbol id="icon-save" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></symbol>
|
|
9
9
|
<symbol id="icon-copy" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></symbol>
|
|
10
10
|
<symbol id="icon-loading" viewBox="0 0 24 24"><line x1="12" y1="2" x2="12" y2="6"></line><line x1="12" y1="18" x2="12" y2="22"></line><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line><line x1="2" y1="12" x2="6" y2="12"></line><line x1="18" y1="12" x2="22" y2="12"></line><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"></line></symbol>
|
|
11
|
+
<symbol id="icon-activity" viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></symbol>
|
|
11
12
|
<symbol id="icon-cloud-check" viewBox="0 0 24 24"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path><polyline points="9 15 11 17 14 13"></polyline></symbol>
|
|
12
13
|
<symbol id="icon-cloud-alert" viewBox="0 0 24 24"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path><line x1="12" y1="13" x2="12" y2="16"></line><line x1="12" y1="18" x2="12.01" y2="18"></line></symbol>
|
|
13
14
|
<symbol id="icon-cloud" viewBox="0 0 24 24"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path></symbol>
|
|
@@ -27,6 +28,33 @@
|
|
|
27
28
|
<!-- 连接(link):本地开发服务「连接前后端」语义图标,呼应主题【连接】 -->
|
|
28
29
|
<symbol id="icon-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></symbol>
|
|
29
30
|
|
|
31
|
+
<!-- AI 开发助手(sparkles):悬浮入口 / 面板标题 / 思考态,与主题「智能辅助」呼应 -->
|
|
32
|
+
<symbol id="icon-sparkles" viewBox="0 0 24 24"><path d="M12 3l1.9 4.9L18.8 9.8l-4.9 1.9L12 16.6l-1.9-4.9L5.2 9.8l4.9-1.9L12 3z"></path><path d="M18 15.5l.9 2.2 2.2.9-2.2.9-.9 2.2-.9-2.2-2.2-.9 2.2-.9.9-2.2z"></path></symbol>
|
|
33
|
+
<!-- 终止生成(实心方块):流式进行中的「停止」按钮 -->
|
|
34
|
+
<symbol id="icon-stop" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" stroke="none"></rect></symbol>
|
|
35
|
+
<!-- 发送(arrow-up):输入框发送态 -->
|
|
36
|
+
<symbol id="icon-send" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="5 12 12 5 19 12"></polyline></symbol>
|
|
37
|
+
<!-- 附件(paperclip):本地文件 / 图片挂载入口 -->
|
|
38
|
+
<symbol id="icon-paperclip" viewBox="0 0 24 24"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></symbol>
|
|
39
|
+
<!-- 文件夹加号:挂载本地文件夹(如 Sketch MeaXure 标注导出目录) -->
|
|
40
|
+
<symbol id="icon-folder-plus" viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path><line x1="12" y1="11" x2="12" y2="17"></line><line x1="9" y1="14" x2="15" y2="14"></line></symbol>
|
|
41
|
+
<!-- 图片(image):图片类附件缩略占位 -->
|
|
42
|
+
<symbol id="icon-image" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></symbol>
|
|
43
|
+
<!-- 文件(file-text):文本类附件占位 -->
|
|
44
|
+
<symbol id="icon-file-text" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></symbol>
|
|
45
|
+
<!-- 关闭(x):面板 / 弹层关闭,替代文本 ✕ -->
|
|
46
|
+
<symbol id="icon-x" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></symbol>
|
|
47
|
+
<!-- 工具调用(wrench):assistant 消息里的工具执行行 -->
|
|
48
|
+
<symbol id="icon-eye" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></symbol>
|
|
49
|
+
<symbol id="icon-eye-off" viewBox="0 0 24 24"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"></path><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"></path><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></symbol>
|
|
50
|
+
<symbol id="icon-wrench" viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></symbol>
|
|
51
|
+
<!-- 回到底部(arrow-down):滚动脱离底部时的悬浮按钮 -->
|
|
52
|
+
<symbol id="icon-arrow-down" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"></line><polyline points="19 12 12 19 5 12"></polyline></symbol>
|
|
53
|
+
<!-- 帮助(help-circle):表单字段的「?」详情入口,点开看说明浮层 -->
|
|
54
|
+
<symbol id="icon-help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></symbol>
|
|
55
|
+
<!-- 展开箭头(chevron-right):折叠行展开态,CSS 旋转 90° 即为收起态 -->
|
|
56
|
+
<symbol id="icon-chevron-right" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></symbol>
|
|
57
|
+
|
|
30
58
|
<!-- ===== 16x16 小图标(下拉 / 卡片选择 / 设置器) ===== -->
|
|
31
59
|
<symbol id="icon-chevron" viewBox="0 0 16 16"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
|
|
32
60
|
<symbol id="icon-check" viewBox="0 0 16 16"><path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="aiset__mask" @click.self="$emit('close')">
|
|
3
|
+
<section class="aiset" role="dialog" aria-modal="true" aria-label="AI 模型设置">
|
|
4
|
+
<header class="aiset__head">
|
|
5
|
+
<svg class="aiset__head-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-sparkles" /></svg>
|
|
6
|
+
<span class="aiset__title">模型设置</span>
|
|
7
|
+
<button type="button" class="aiset__x" title="关闭" @click="$emit('close')">
|
|
8
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-x" /></svg>
|
|
9
|
+
</button>
|
|
10
|
+
</header>
|
|
11
|
+
|
|
12
|
+
<!-- 字段说明一律收进「?」浮层:面板只留标签 + 控件,需要时点开看详情 -->
|
|
13
|
+
<div class="aiset__body">
|
|
14
|
+
<div class="aiset__field">
|
|
15
|
+
<div class="aiset__labelrow">
|
|
16
|
+
<label class="aiset__label" for="aiset-baseurl">Base URL</label>
|
|
17
|
+
<button type="button" class="aiset__help" :class="{ 'is-on': help === 'baseUrl' }" :aria-expanded="help === 'baseUrl'" title="查看说明" @click.stop="toggleHelp('baseUrl')">
|
|
18
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-help" /></svg>
|
|
19
|
+
</button>
|
|
20
|
+
</div>
|
|
21
|
+
<input id="aiset-baseurl" v-model="form.baseUrl" class="aiset__input" type="text" placeholder="https://api.example.com/v1" spellcheck="false" autocomplete="off" />
|
|
22
|
+
<p v-if="help === 'baseUrl'" class="aiset__pop">OpenAI 兼容网关的基址,<strong>不含</strong> <code>/chat/completions</code> 后缀(后端会自己拼)。例:<code>https://api.openai.com/v1</code>。</p>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div class="aiset__field">
|
|
26
|
+
<div class="aiset__labelrow">
|
|
27
|
+
<label class="aiset__label" for="aiset-model">模型名</label>
|
|
28
|
+
<button type="button" class="aiset__help" :class="{ 'is-on': help === 'model' }" :aria-expanded="help === 'model'" title="查看说明" @click.stop="toggleHelp('model')">
|
|
29
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-help" /></svg>
|
|
30
|
+
</button>
|
|
31
|
+
</div>
|
|
32
|
+
<input id="aiset-model" v-model="form.model" class="aiset__input" type="text" placeholder="gpt-4o / GLM-4.6 / deepseek-chat" spellcheck="false" autocomplete="off" />
|
|
33
|
+
<p v-if="help === 'model'" class="aiset__pop">必须与网关侧的模型标识完全一致。建卡这类多步工具调用建议用支持 function calling 的模型。</p>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div class="aiset__field">
|
|
37
|
+
<div class="aiset__labelrow">
|
|
38
|
+
<label class="aiset__label" for="aiset-key">API Key</label>
|
|
39
|
+
<button type="button" class="aiset__help" :class="{ 'is-on': help === 'apiKey' }" :aria-expanded="help === 'apiKey'" title="查看说明" @click.stop="toggleHelp('apiKey')">
|
|
40
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-help" /></svg>
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="aiset__keywrap">
|
|
44
|
+
<input
|
|
45
|
+
id="aiset-key"
|
|
46
|
+
v-model="form.apiKey"
|
|
47
|
+
class="aiset__input aiset__input--key"
|
|
48
|
+
:type="reveal ? 'text' : 'password'"
|
|
49
|
+
:placeholder="keyPlaceholder"
|
|
50
|
+
spellcheck="false"
|
|
51
|
+
autocomplete="off"
|
|
52
|
+
/>
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
class="aiset__eye"
|
|
56
|
+
:title="reveal ? '掩码显示密钥' : '明文显示密钥'"
|
|
57
|
+
:aria-pressed="reveal"
|
|
58
|
+
@click="reveal = !reveal"
|
|
59
|
+
>
|
|
60
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use :href="reveal ? '#icon-eye-off' : '#icon-eye'" /></svg>
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
<p v-if="help === 'apiKey'" class="aiset__pop">
|
|
64
|
+
密钥保存在项目根 <code>.env.cb.local</code>(个人覆盖层,已被 .gitignore 忽略,不会进版本库)。
|
|
65
|
+
打开面板自动回填已保存的密钥(默认掩码),点输入框尾部小眼睛切换明文;<strong>清空后保存即删除</strong>已保存的密钥。
|
|
66
|
+
</p>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div class="aiset__field">
|
|
70
|
+
<div class="aiset__labelrow">
|
|
71
|
+
<label class="aiset__label">单次输出上限</label>
|
|
72
|
+
<span class="aiset__unit">max_tokens</span>
|
|
73
|
+
<button type="button" class="aiset__help" :class="{ 'is-on': help === 'maxTokens' }" :aria-expanded="help === 'maxTokens'" title="查看说明" @click.stop="toggleHelp('maxTokens')">
|
|
74
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-help" /></svg>
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="aiset__stepperrow">
|
|
78
|
+
<NumberStepper
|
|
79
|
+
id="aiset-maxtok"
|
|
80
|
+
class="aiset__stepper"
|
|
81
|
+
:value="form.maxTokens > 0 ? String(form.maxTokens) : ''"
|
|
82
|
+
:step="1024"
|
|
83
|
+
:min="0"
|
|
84
|
+
:max="262144"
|
|
85
|
+
:placeholder="maxTokensPlaceholder"
|
|
86
|
+
@input="onMaxTokens"
|
|
87
|
+
/>
|
|
88
|
+
<button type="button" class="aiset__btn" :disabled="probing || saving || testing" @click="probe">
|
|
89
|
+
{{ probing ? '探测中…' : '探测' }}
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
<p v-if="maxTokensOver" class="aiset__warnline">
|
|
93
|
+
超过网关已知上限 {{ knownCapLimit }}:对话时会主动夹取到该值,建议留空走自动
|
|
94
|
+
</p>
|
|
95
|
+
<p v-if="help === 'maxTokens'" class="aiset__pop">
|
|
96
|
+
<strong>普通用户不用管这一项:留空 = 自动</strong>。后端自己协商:先读网关
|
|
97
|
+
<code>/v1/models</code> 的上下文窗口(免费、毫秒级,最多等 2 秒,超时先用默认 24576 开跑),
|
|
98
|
+
拿到后取 <code>min(窗口, 32768)</code> 当作单次输出上限(输出预算 ≠ 上下文窗口,建卡一轮 32k 足够)。
|
|
99
|
+
读不到元数据也不会偷偷发试探请求;想知道窗口多大就点「探测」做一次二分试探(会产生一次真实补全,约十几秒)。
|
|
100
|
+
手填值超过网关接受上限时,对话开跑前会主动夹取并说明;真被 400 拒绝才会自动减半重试,
|
|
101
|
+
减半成功的值记为「接受上限」(**不是窗口**:窗口与输出上限是两回事,有网关窗口 262144 但 max_tokens 硬上限 131072)。
|
|
102
|
+
只有某轮明确报「被单次输出上限截断」才需要手填更大值。注意:推理模型的思考过程也算进这个上限。
|
|
103
|
+
<br />当前自动生效值:<strong>{{ config.maxTokens > 0 ? config.maxTokens : 24576 }}</strong>;
|
|
104
|
+
网关上下文窗口:{{ config.maxTokensWindow > 0 ? config.maxTokensWindow : '未拿到(网关可能不提供元数据)' }};
|
|
105
|
+
接受的单次输出上限:{{ config.maxTokensCap > 0 ? config.maxTokensCap : '未知(点「探测」,或撞过一次 400 后自动回写)' }}。
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<div class="aiset__field">
|
|
110
|
+
<div class="aiset__labelrow">
|
|
111
|
+
<label class="aiset__label">单轮步数上限</label>
|
|
112
|
+
<span class="aiset__unit">0 / 留空 = 不限</span>
|
|
113
|
+
<button type="button" class="aiset__help" :class="{ 'is-on': help === 'maxSteps' }" :aria-expanded="help === 'maxSteps'" title="查看说明" @click.stop="toggleHelp('maxSteps')">
|
|
114
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-help" /></svg>
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
<div class="aiset__stepperrow">
|
|
118
|
+
<NumberStepper
|
|
119
|
+
id="aiset-maxsteps"
|
|
120
|
+
class="aiset__stepper"
|
|
121
|
+
:value="form.maxSteps > 0 ? String(form.maxSteps) : ''"
|
|
122
|
+
:step="8"
|
|
123
|
+
:min="0"
|
|
124
|
+
:max="1000"
|
|
125
|
+
placeholder="留空 = 不限(推荐)"
|
|
126
|
+
@input="onMaxSteps"
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
<p v-if="help === 'maxSteps'" class="aiset__pop">
|
|
130
|
+
<strong>普通用户不用管这一项:留空 = 不限</strong>。设计稿建卡一轮要连跑
|
|
131
|
+
<code>spec_audit → crop_slices → expand_ir → iterate × N → check_fidelity</code> 十几到几十次调用,
|
|
132
|
+
硬顶只会把用户变成「继续」按钮,而步数多本身不代表 AI 在空转。
|
|
133
|
+
失控由三道<strong>精准熔断</strong>兜住:①连续 3 轮「同工具 + 同参数」判定原地打转,跳过执行并收尾汇报
|
|
134
|
+
②单轮墙钟到点(<code>AI_TURN_TIMEOUT_MS</code>,默认 {{ turnTimeoutMinutes }} 分钟,用到 80% 先催收敛)
|
|
135
|
+
③每 24 步注入一次收敛提醒(不打断)。任何一种收尾都会给出「做到哪 / 卡在哪 / 下一步说什么」,上下文不丢,回复「继续」接着做。
|
|
136
|
+
另外单轮上下文超预算时会自动折叠<strong>早期</strong>工具结果与图像(最近 10 条保持原文),避免撑爆网关窗口。
|
|
137
|
+
只有想硬性控成本时才填一个数(>0 即恢复旧行为:剩 2 步催收敛、到顶收尾汇总)。
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<div class="aiset__field">
|
|
142
|
+
<!-- 仅「勾选框 + 标题」包进 label:点说明 / 问号不应误触开关 -->
|
|
143
|
+
<div class="aiset__labelrow">
|
|
144
|
+
<span class="aiset__label">深度思考</span>
|
|
145
|
+
<button type="button" class="aiset__help" :class="{ 'is-on': help === 'thinking' }" :aria-expanded="help === 'thinking'" title="查看说明" @click.stop="toggleHelp('thinking')">
|
|
146
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-help" /></svg>
|
|
147
|
+
</button>
|
|
148
|
+
<Switch v-model="form.thinking" class="aiset__switch" />
|
|
149
|
+
</div>
|
|
150
|
+
<p v-if="help === 'thinking'" class="aiset__pop">
|
|
151
|
+
开启:模型先长篇推理再动手,规划更稳但慢很多、更费 token。关闭:直接出结果,实测同一网关快数倍。
|
|
152
|
+
关闭依赖网关支持 <code>chat_template_kwargs.enable_thinking</code>(vLLM / SGLang 系);网关不认时后端会自动回退成带思考并在对话里提示一次。
|
|
153
|
+
另外,<strong>某一轮思考超时</strong>时后端会自动只在那一轮关闭深度思考并重试,逼出结果。
|
|
154
|
+
</p>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<div class="aiset__field">
|
|
158
|
+
<div class="aiset__labelrow">
|
|
159
|
+
<span class="aiset__label">多模态视觉</span>
|
|
160
|
+
<button type="button" class="aiset__help" :class="{ 'is-on': help === 'vision' }" :aria-expanded="help === 'vision'" title="查看说明" @click.stop="toggleHelp('vision')">
|
|
161
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-help" /></svg>
|
|
162
|
+
</button>
|
|
163
|
+
<Switch v-model="form.vision" class="aiset__switch" />
|
|
164
|
+
</div>
|
|
165
|
+
<p v-if="help === 'vision'" class="aiset__pop">
|
|
166
|
+
开启后首轮对话会<strong>自动探测</strong>网关是否真支持图像(比较带图 / 不带图的 prompt_tokens):支持则把渲染截图、设计稿 vs 渲染并排对比图、你挂载的图片注入上下文,AI 可直接读图找差异;
|
|
167
|
+
不支持会自动降级为数值闭环(pixelDiff.match)并在对话里提示一次。注入前降采样到长边 ≤ 1024px / ≤ 900KB,每轮最多 2 张;对话记录落盘不含图像。
|
|
168
|
+
</p>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div v-if="ping" class="aiset__ping" :class="ping.ok ? 'is-ok' : 'is-bad'">
|
|
172
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
173
|
+
<use :href="ping.ok ? '#icon-check' : '#icon-alert-triangle'" />
|
|
174
|
+
</svg>
|
|
175
|
+
<span>{{ ping.text }}</span>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<footer class="aiset__foot">
|
|
180
|
+
<button type="button" class="aiset__btn" :disabled="testing || saving" @click="test">
|
|
181
|
+
<svg class="aiset__btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use :href="testing ? '#icon-loading' : '#icon-activity'" /></svg>
|
|
182
|
+
<span>{{ testing ? '测试中…' : '测试连接' }}</span>
|
|
183
|
+
</button>
|
|
184
|
+
<span class="aiset__foot-gap"></span>
|
|
185
|
+
<button type="button" class="aiset__btn" :disabled="saving" @click="$emit('close')">取消</button>
|
|
186
|
+
<button type="button" class="aiset__btn aiset__btn--primary" :disabled="saving || testing" @click="save">
|
|
187
|
+
{{ saving ? '保存中…' : '保存' }}
|
|
188
|
+
</button>
|
|
189
|
+
</footer>
|
|
190
|
+
</section>
|
|
191
|
+
</div>
|
|
192
|
+
</template>
|
|
193
|
+
|
|
194
|
+
<script>
|
|
195
|
+
import NumberStepper from './NumberStepper.vue'
|
|
196
|
+
import Switch from './Switch.vue'
|
|
197
|
+
import { saveAiConfig, pingAi, fetchAiConfig, probeMaxTokens } from '../runtime/ai-api.js'
|
|
198
|
+
|
|
199
|
+
export default {
|
|
200
|
+
name: 'AiSettings',
|
|
201
|
+
components: { NumberStepper, Switch },
|
|
202
|
+
props: {
|
|
203
|
+
/** 面板已加载的配置(密钥脱敏):{ baseUrl, model, hasKey, apiKeyMasked, maxTokens, maxSteps, thinking, vision, turnTimeoutMs } */
|
|
204
|
+
config: { type: Object, default: () => ({ baseUrl: '', model: '', hasKey: false, apiKeyMasked: '' }) },
|
|
205
|
+
},
|
|
206
|
+
emits: ['close', 'saved'],
|
|
207
|
+
data() {
|
|
208
|
+
return {
|
|
209
|
+
form: {
|
|
210
|
+
baseUrl: this.config.baseUrl || '',
|
|
211
|
+
model: this.config.model || '',
|
|
212
|
+
apiKey: '',
|
|
213
|
+
thinking: this.config.thinking !== false,
|
|
214
|
+
vision: this.config.vision === true,
|
|
215
|
+
// 留空(0) = 自动:后端按网关窗口协商;不再预填默认值误导用户以为必须填
|
|
216
|
+
// maxTokensAuto = 用户没手填过,即使后端回了「自动解析出的实际值」也保持输入框空白
|
|
217
|
+
maxTokens: this.config.maxTokensAuto ? 0 : Number(this.config.maxTokens) > 0 ? Number(this.config.maxTokens) : 0,
|
|
218
|
+
// 单轮步数上限:0 / 留空 = 不限(默认)。>0 才恢复旧硬顶行为
|
|
219
|
+
maxSteps: Number(this.config.maxSteps) > 0 ? Number(this.config.maxSteps) : 0,
|
|
220
|
+
},
|
|
221
|
+
reveal: false,
|
|
222
|
+
saving: false,
|
|
223
|
+
testing: false,
|
|
224
|
+
probing: false,
|
|
225
|
+
ping: null,
|
|
226
|
+
/** 当前展开的字段说明(null = 全部收起):一次只开一个,点面板外或 Esc 关闭 */
|
|
227
|
+
help: null,
|
|
228
|
+
/** 已存密钥是否成功回填:回填成功时输入框即真值(清空保存 = 删除密钥) */
|
|
229
|
+
keyLoaded: false,
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
computed: {
|
|
233
|
+
/** 墙钟上限(分钟):后端按 AI_TURN_TIMEOUT_MS 回传,面板只做人话展示 */
|
|
234
|
+
turnTimeoutMinutes() {
|
|
235
|
+
const ms = Number(this.config.turnTimeoutMs) > 0 ? Number(this.config.turnTimeoutMs) : 3600000
|
|
236
|
+
return Math.round(ms / 60000)
|
|
237
|
+
},
|
|
238
|
+
/** 占位符直接把「自动」解析成的实际值告诉用户:不用猜该填多大 */
|
|
239
|
+
/** 网关已知上限:接受的输出上限优先(减半 / 试探证据),缺时退窗口(窗口也是必 400 的绝对边界) */
|
|
240
|
+
knownCapLimit() {
|
|
241
|
+
const cap = Number(this.config.maxTokensCap) > 0 ? Number(this.config.maxTokensCap) : 0
|
|
242
|
+
if (cap) return cap
|
|
243
|
+
return Number(this.config.maxTokensWindow) > 0 ? Number(this.config.maxTokensWindow) : 0
|
|
244
|
+
},
|
|
245
|
+
/** 手填值超网关已知上限:输入框下红线提示 */
|
|
246
|
+
maxTokensOver() {
|
|
247
|
+
return this.knownCapLimit > 0 && this.form.maxTokens > this.knownCapLimit
|
|
248
|
+
},
|
|
249
|
+
maxTokensPlaceholder() {
|
|
250
|
+
const n = Number(this.config.maxTokens) > 0 ? Number(this.config.maxTokens) : 0
|
|
251
|
+
return n > 0 ? `留空 = 自动(当前按 ${n})` : '留空 = 自动'
|
|
252
|
+
},
|
|
253
|
+
keyPlaceholder() {
|
|
254
|
+
if (this.keyLoaded) return this.form.apiKey ? '' : '已保存的密钥为空,请填写新密钥'
|
|
255
|
+
if (this.config.hasKey) return `已保存(${this.config.apiKeyMasked || '••••'}),回显失败:留空沿用、填写覆盖`
|
|
256
|
+
return 'sk-…'
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
created() {
|
|
260
|
+
this.loadKey()
|
|
261
|
+
},
|
|
262
|
+
mounted() {
|
|
263
|
+
document.addEventListener('click', this.onDocClick, true)
|
|
264
|
+
document.addEventListener('keydown', this.onKeydown)
|
|
265
|
+
},
|
|
266
|
+
beforeUnmount() {
|
|
267
|
+
document.removeEventListener('click', this.onDocClick, true)
|
|
268
|
+
document.removeEventListener('keydown', this.onKeydown)
|
|
269
|
+
},
|
|
270
|
+
methods: {
|
|
271
|
+
toggleHelp(id) {
|
|
272
|
+
this.help = this.help === id ? null : id
|
|
273
|
+
},
|
|
274
|
+
/** 点浮层与问号以外的任意处即收起(捕获阶段,避免被 stopPropagation 挡住) */
|
|
275
|
+
onDocClick(e) {
|
|
276
|
+
if (!this.help) return
|
|
277
|
+
const el = e.target
|
|
278
|
+
if (el instanceof Element && el.closest('.aiset__pop, .aiset__help')) return
|
|
279
|
+
this.help = null
|
|
280
|
+
},
|
|
281
|
+
onKeydown(e) {
|
|
282
|
+
if (e.key === 'Escape' && this.help) {
|
|
283
|
+
e.stopPropagation()
|
|
284
|
+
this.help = null
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
/** NumberStepper 原样上抛文本:这里负责解析成数字,空串 / 非法值回落默认 */
|
|
288
|
+
onMaxTokens(text) {
|
|
289
|
+
const n = Number(String(text).trim())
|
|
290
|
+
this.form.maxTokens = Number.isFinite(n) && n >= 0 ? Math.round(n) : 0
|
|
291
|
+
},
|
|
292
|
+
/** 单轮步数上限:同上,空串 / 0 = 不限(后端据此关掉硬顶,只留熔断器) */
|
|
293
|
+
onMaxSteps(text) {
|
|
294
|
+
const n = Number(String(text).trim())
|
|
295
|
+
this.form.maxSteps = Number.isFinite(n) && n > 0 ? Math.round(n) : 0
|
|
296
|
+
},
|
|
297
|
+
/** 探测网关窗口上限并回填:来源 models 元数据 / 二分试探 */
|
|
298
|
+
async probe() {
|
|
299
|
+
this.probing = true
|
|
300
|
+
this.ping = null
|
|
301
|
+
try {
|
|
302
|
+
const res = await probeMaxTokens()
|
|
303
|
+
const win = Number(res.window) > 0 ? Number(res.window) : 0
|
|
304
|
+
const cap = Number(res.cap) > 0 ? Number(res.cap) : 0
|
|
305
|
+
if ((win > 0 || cap > 0) && Number(res.maxTokens) > 0) {
|
|
306
|
+
this.form.maxTokens = Number(res.maxTokens)
|
|
307
|
+
const src =
|
|
308
|
+
res.source === 'models-meta'
|
|
309
|
+
? '模型元数据'
|
|
310
|
+
: res.source === 'probe'
|
|
311
|
+
? '二分试探'
|
|
312
|
+
: res.source === 'halved'
|
|
313
|
+
? '减半回写'
|
|
314
|
+
: res.source || '探测'
|
|
315
|
+
const facts = []
|
|
316
|
+
if (win > 0) facts.push(`上下文窗口 ${win}`)
|
|
317
|
+
if (cap > 0) facts.push(`接受单次输出上限 ${cap}`)
|
|
318
|
+
this.ping = {
|
|
319
|
+
ok: true,
|
|
320
|
+
text: `网关${facts.join('、')}(来源:${src})→ 留空自动取 ${res.maxTokens},已填入;留空保存则继续走自动`,
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
this.ping = {
|
|
324
|
+
ok: false,
|
|
325
|
+
text: '探测未拿到网关窗口与接受上限(网关没提供 /v1/models 元数据,试探请求也被拒):建议留空走自动,后端用默认值并按网关反馈夹取 / 减半',
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
} catch (e) {
|
|
329
|
+
this.ping = { ok: false, text: `探测失败:${e.message || e}` }
|
|
330
|
+
} finally {
|
|
331
|
+
this.probing = false
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
/** 回填已保存的密钥:优先 reveal 明文;旧版后端直接回明文 apiKey 字段,同样兼容 */
|
|
335
|
+
async loadKey() {
|
|
336
|
+
try {
|
|
337
|
+
const cfg = await fetchAiConfig(true)
|
|
338
|
+
const key = typeof cfg.apiKey === 'string' ? cfg.apiKey : ''
|
|
339
|
+
if (key) {
|
|
340
|
+
this.form.apiKey = key
|
|
341
|
+
this.keyLoaded = true
|
|
342
|
+
}
|
|
343
|
+
// 自动模式(没手填)保持空白,只把解析值留在占位符里
|
|
344
|
+
this.form.maxTokens = cfg.maxTokensAuto || !(Number(cfg.maxTokens) > 0) ? 0 : Number(cfg.maxTokens)
|
|
345
|
+
this.form.maxSteps = Number(cfg.maxSteps) > 0 ? Number(cfg.maxSteps) : 0
|
|
346
|
+
if (cfg.thinking !== undefined) this.form.thinking = cfg.thinking !== false
|
|
347
|
+
if (cfg.vision !== undefined) this.form.vision = cfg.vision === true
|
|
348
|
+
} catch {
|
|
349
|
+
/* 回显失败退化为占位掩码提示,不阻断其它字段的保存 */
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
/** 提交体:回填成功以输入框为准(空串 = 清除);未回填且未填写 → 不带 apiKey(后端保持原值) */
|
|
353
|
+
payload() {
|
|
354
|
+
const out = {
|
|
355
|
+
baseUrl: this.form.baseUrl.trim(),
|
|
356
|
+
model: this.form.model.trim(),
|
|
357
|
+
thinking: Boolean(this.form.thinking),
|
|
358
|
+
vision: Boolean(this.form.vision),
|
|
359
|
+
maxTokens: Number(this.form.maxTokens) > 0 ? Number(this.form.maxTokens) : 0,
|
|
360
|
+
maxSteps: Number(this.form.maxSteps) > 0 ? Number(this.form.maxSteps) : 0,
|
|
361
|
+
}
|
|
362
|
+
if (this.keyLoaded || this.form.apiKey.trim()) out.apiKey = this.form.apiKey.trim()
|
|
363
|
+
return out
|
|
364
|
+
},
|
|
365
|
+
async test() {
|
|
366
|
+
this.testing = true
|
|
367
|
+
this.ping = null
|
|
368
|
+
try {
|
|
369
|
+
const res = await pingAi(this.payload())
|
|
370
|
+
this.ping = res.ok
|
|
371
|
+
? { ok: true, text: `连接正常 · ${res.model || this.form.model} · ${res.latencyMs}ms${res.reply ? ` · 回复「${res.reply}」` : ''}` }
|
|
372
|
+
: { ok: false, text: `连接失败${res.status ? `(HTTP ${res.status})` : ''}:${res.message || '未知错误'}` }
|
|
373
|
+
} catch (e) {
|
|
374
|
+
this.ping = { ok: false, text: `测试失败:${e.message || e}` }
|
|
375
|
+
} finally {
|
|
376
|
+
this.testing = false
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
async save() {
|
|
380
|
+
this.saving = true
|
|
381
|
+
try {
|
|
382
|
+
const res = await saveAiConfig(this.payload())
|
|
383
|
+
this.$emit('saved', res)
|
|
384
|
+
} catch (e) {
|
|
385
|
+
this.ping = { ok: false, text: `保存失败:${e.message || e}` }
|
|
386
|
+
} finally {
|
|
387
|
+
this.saving = false
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
</script>
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
缩略图按可见性懒加载(IntersectionObserver),避免一次打开对 70+ 张图同时发起请求。
|
|
9
9
|
点击任一张 → emit select(id)(父级 onSelect 载入工作台并持久化)+ close 关闭弹窗。
|
|
10
10
|
搜索框按 id / 名称过滤;Esc 或点遮罩关闭;当前选中卡高亮。
|
|
11
|
+
每格右下角常驻删除钮(仅图标、hover / 键盘聚焦显现):点击弹警告确认,
|
|
12
|
+
确认后 emit delete(id) 交父级调 DELETE /__card/<id> 删除本地卡片文件夹。
|
|
11
13
|
-->
|
|
12
14
|
<template>
|
|
13
15
|
<transition name="modal">
|
|
@@ -67,28 +69,55 @@
|
|
|
67
69
|
@error="onImgError(card.id)"
|
|
68
70
|
/>
|
|
69
71
|
<div v-else-if="!visibleIds.has(card.id)" class="cg-tile__skeleton">预览加载中…</div>
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
</div>
|
|
72
|
+
<!-- 无封面(空白新卡无位图、且空白卡跳过无头截图直接 404):
|
|
73
|
+
回退工程统一占位图(与 Image 组件兜底图同源),浅色底与缩略图区调性一致 -->
|
|
74
|
+
<img v-else class="cg-tile__img cg-tile__img--placeholder" :src="placeholderUrl" :alt="`暂无封面:${card.name}`" />
|
|
74
75
|
</div>
|
|
75
76
|
<div class="cg-tile__meta">
|
|
76
77
|
<span class="cg-tile__name" :title="card.name">{{ card.name }}</span>
|
|
77
78
|
<span class="cg-tile__id" :title="card.id">{{ card.id }}</span>
|
|
78
79
|
</div>
|
|
80
|
+
<!-- 删除钮:仅图标不带文字,悬浮于格子右下角。格子本身是 button,
|
|
81
|
+
这里用 span + role=button 避免 button 套 button 的非法结构;
|
|
82
|
+
click.stop 阻止冒泡到格子的「选中载入」。 -->
|
|
83
|
+
<span
|
|
84
|
+
class="cg-tile__del"
|
|
85
|
+
role="button"
|
|
86
|
+
tabindex="0"
|
|
87
|
+
:title="`删除本地卡片 ${card.id}`"
|
|
88
|
+
:aria-label="`删除本地卡片 ${card.id}`"
|
|
89
|
+
@click.stop="askDelete(card)"
|
|
90
|
+
@keydown.enter.stop.prevent="askDelete(card)"
|
|
91
|
+
@keydown.space.stop.prevent="askDelete(card)"
|
|
92
|
+
>
|
|
93
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-trash" /></svg>
|
|
94
|
+
</span>
|
|
79
95
|
</button>
|
|
80
96
|
<p v-if="!filtered.length" class="cg-empty">无匹配卡片</p>
|
|
81
97
|
</div>
|
|
82
98
|
</div>
|
|
99
|
+
<!-- 删除确认:警告级(橙黄 + 三角图标);删除本地目录不可逆,文案明示影响范围 -->
|
|
100
|
+
<ConfirmDialog
|
|
101
|
+
:visible="pendingDelete != null"
|
|
102
|
+
title="删除卡片"
|
|
103
|
+
:message="pendingDelete ? `将删除本地卡片目录 src/cards/${pendingDelete.id}/(schema / 切图 / 预览图一并删除),不可恢复。确定删除「${pendingDelete.name}」吗?` : ''"
|
|
104
|
+
confirm-text="删除"
|
|
105
|
+
severity="warn"
|
|
106
|
+
@confirm="confirmDelete"
|
|
107
|
+
@cancel="pendingDelete = null"
|
|
108
|
+
/>
|
|
83
109
|
</div>
|
|
84
110
|
</transition>
|
|
85
111
|
</template>
|
|
86
112
|
|
|
87
113
|
<script>
|
|
88
114
|
import { resolveApiBase } from '@/api.js'
|
|
115
|
+
import { DEFAULT_IMAGE_URL } from '@/runtime/assets.js'
|
|
116
|
+
import ConfirmDialog from './ConfirmDialog.vue'
|
|
89
117
|
|
|
90
118
|
export default {
|
|
91
119
|
name: 'CardGallery',
|
|
120
|
+
components: { ConfirmDialog },
|
|
92
121
|
props: {
|
|
93
122
|
/** 是否显示弹窗 */
|
|
94
123
|
visible: { type: Boolean, default: false },
|
|
@@ -97,7 +126,7 @@ export default {
|
|
|
97
126
|
/** 当前选中卡片 id,用于高亮 */
|
|
98
127
|
selectedId: { type: String, default: '' },
|
|
99
128
|
},
|
|
100
|
-
emits: ['select', 'close'],
|
|
129
|
+
emits: ['select', 'close', 'delete'],
|
|
101
130
|
data() {
|
|
102
131
|
return {
|
|
103
132
|
query: '',
|
|
@@ -110,6 +139,10 @@ export default {
|
|
|
110
139
|
observer: null,
|
|
111
140
|
// 截图端点 base(弹窗打开时按当前 apiBase 解析一次)
|
|
112
141
|
base: '',
|
|
142
|
+
// 待删除确认的卡片(null = 无确认中);确认后才 emit delete 交父级真删
|
|
143
|
+
pendingDelete: null,
|
|
144
|
+
// 无封面占位图(工程统一兜底图,Vite 统一套 base)
|
|
145
|
+
placeholderUrl: DEFAULT_IMAGE_URL,
|
|
113
146
|
}
|
|
114
147
|
},
|
|
115
148
|
created() {
|
|
@@ -163,6 +196,19 @@ export default {
|
|
|
163
196
|
window.removeEventListener('keydown', this.onKey)
|
|
164
197
|
},
|
|
165
198
|
methods: {
|
|
199
|
+
/** 格子删除钮:进入警告确认(不直接删,避免误触丢目录) */
|
|
200
|
+
askDelete(card) {
|
|
201
|
+
this.pendingDelete = card
|
|
202
|
+
},
|
|
203
|
+
/** 确认删除:清掉本格懒加载 / 失败标记后上抛父级(父级调接口 + 刷列表 + 兜底换卡) */
|
|
204
|
+
confirmDelete() {
|
|
205
|
+
const card = this.pendingDelete
|
|
206
|
+
this.pendingDelete = null
|
|
207
|
+
if (!card) return
|
|
208
|
+
this.visibleIds.delete(card.id)
|
|
209
|
+
this.failed.delete(card.id)
|
|
210
|
+
this.$emit('delete', card.id)
|
|
211
|
+
},
|
|
166
212
|
/** 缩略图地址:复用本地静态服务 /__card-image/<id>,直接读取卡片目录下的同名预览图
|
|
167
213
|
* (src/cards/<id>/<id>.png),不触发实时无头截图。 */
|
|
168
214
|
thumbUrl(id) {
|