cardbuddy 0.6.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 +211 -0
- package/dist/cli.js +76 -0
- package/dist/config.js +63 -0
- package/dist/core.js +2 -0
- package/dist/preview-editor/assets/apple-touch-icon-BQcES9QK.png +0 -0
- package/dist/preview-editor/assets/default-image-wzZYQ86K.png +0 -0
- package/dist/preview-editor/assets/favicon-16-Cg6Nrosk.png +0 -0
- package/dist/preview-editor/assets/favicon-32-B_1744sp.png +0 -0
- package/dist/preview-editor/assets/index-BYB0hngG.js +72 -0
- package/dist/preview-editor/assets/index-CpBZEQDy.css +1 -0
- package/dist/preview-editor/assets/site-icon-CFMif8z4.png +0 -0
- package/dist/preview-editor/assets/svg-symbols-B2exBrAr.svg +118 -0
- package/dist/preview-editor/index.html +17 -0
- package/dist/server-core/_vendor/finmall-env.mjs +96 -0
- package/dist/server-core/assetProxy.js +154 -0
- package/dist/server-core/bbox.js +153 -0
- package/dist/server-core/browser.js +108 -0
- package/dist/server-core/buildDataFromSchema.js +50 -0
- package/dist/server-core/cardFiles.js +188 -0
- package/dist/server-core/cardHistory.js +156 -0
- package/dist/server-core/cardImage.js +85 -0
- package/dist/server-core/cardImportMeta.js +139 -0
- package/dist/server-core/cardListCache.js +68 -0
- package/dist/server-core/cardOpenFolder.js +135 -0
- package/dist/server-core/cardSave.js +127 -0
- package/dist/server-core/cardSidecar.js +72 -0
- package/dist/server-core/cardSync.js +259 -0
- package/dist/server-core/cardValidate.js +134 -0
- package/dist/server-core/index.js +82 -0
- package/dist/server-core/logger.js +241 -0
- package/dist/server-core/nodeModels.js +52 -0
- package/dist/server-core/renderStatus.js +207 -0
- package/dist/server-core/screenshot.js +123 -0
- package/dist/server-core/sliceStatic.js +44 -0
- package/dist/standalone.js +447 -0
- package/dist/vendored/App.vue +2330 -0
- package/dist/vendored/api.js +142 -0
- package/dist/vendored/assets/apple-touch-icon.png +0 -0
- package/dist/vendored/assets/default-image.png +0 -0
- package/dist/vendored/assets/favicon-16.png +0 -0
- package/dist/vendored/assets/favicon-32.png +0 -0
- package/dist/vendored/assets/logo.png +0 -0
- package/dist/vendored/assets/site-icon.png +0 -0
- package/dist/vendored/assets/svg-symbols.svg +118 -0
- package/dist/vendored/assets/svgIcons/icons.svg +54 -0
- package/dist/vendored/assets/svgIcons/index.js +39 -0
- package/dist/vendored/components/AbsoluteLayout.vue +42 -0
- package/dist/vendored/components/AlertCardInstance.vue +58 -0
- package/dist/vendored/components/AlertCardLayer.vue +59 -0
- package/dist/vendored/components/ApiPayloadTree.vue +168 -0
- package/dist/vendored/components/AppToast.vue +37 -0
- package/dist/vendored/components/Button.vue +37 -0
- package/dist/vendored/components/Canvas.vue +65 -0
- package/dist/vendored/components/CardGallery.vue +243 -0
- package/dist/vendored/components/CardPreview.vue +72 -0
- package/dist/vendored/components/CardSelect.vue +200 -0
- package/dist/vendored/components/CheckGroup.vue +131 -0
- package/dist/vendored/components/Column.vue +25 -0
- package/dist/vendored/components/ComponentPalette.vue +294 -0
- package/dist/vendored/components/ConfirmDialog.vue +39 -0
- package/dist/vendored/components/CustomPlaceholder.vue +33 -0
- package/dist/vendored/components/DatePickerOverlay.vue +345 -0
- package/dist/vendored/components/DebugPanel.vue +806 -0
- package/dist/vendored/components/DeviceOverlay.vue +70 -0
- package/dist/vendored/components/Divider.vue +22 -0
- package/dist/vendored/components/Dropdown.vue +191 -0
- package/dist/vendored/components/EventEditor.vue +247 -0
- package/dist/vendored/components/GridLayout.vue +67 -0
- package/dist/vendored/components/HoverGuides.vue +122 -0
- package/dist/vendored/components/Icon.vue +34 -0
- package/dist/vendored/components/Image.vue +34 -0
- package/dist/vendored/components/Input.vue +90 -0
- package/dist/vendored/components/JsonTree.vue +516 -0
- package/dist/vendored/components/ListLayout.vue +68 -0
- package/dist/vendored/components/LogValue.vue +51 -0
- package/dist/vendored/components/Map.vue +110 -0
- package/dist/vendored/components/MultiColumn.vue +49 -0
- package/dist/vendored/components/MultiText.vue +58 -0
- package/dist/vendored/components/NodeInspector.vue +295 -0
- package/dist/vendored/components/PickerOverlay.vue +131 -0
- package/dist/vendored/components/ProgressSlider.vue +167 -0
- package/dist/vendored/components/PropControl.vue +241 -0
- package/dist/vendored/components/PropTree.vue +128 -0
- package/dist/vendored/components/RadioButton.vue +74 -0
- package/dist/vendored/components/RemotePreview.vue +171 -0
- package/dist/vendored/components/RenderTree.vue +437 -0
- package/dist/vendored/components/Root.vue +30 -0
- package/dist/vendored/components/Row.vue +25 -0
- package/dist/vendored/components/SelectionOverlay.vue +105 -0
- package/dist/vendored/components/StructureTree.vue +949 -0
- package/dist/vendored/components/SvgCode.vue +19 -0
- package/dist/vendored/components/SvgSymbols.vue +30 -0
- package/dist/vendored/components/Switch.vue +38 -0
- package/dist/vendored/components/Text.vue +32 -0
- package/dist/vendored/components/Video.vue +70 -0
- package/dist/vendored/components/ViewContainer.vue +52 -0
- package/dist/vendored/components/base.js +12 -0
- package/dist/vendored/components/icons.js +47 -0
- package/dist/vendored/components/index.js +58 -0
- package/dist/vendored/components/setters/SetBgPosition.vue +64 -0
- package/dist/vendored/components/setters/SetBgSize.vue +90 -0
- package/dist/vendored/components/setters/SetCode.vue +90 -0
- package/dist/vendored/components/setters/SetColor.vue +244 -0
- package/dist/vendored/components/setters/SetCustomSelect.vue +58 -0
- package/dist/vendored/components/setters/SetExpr.vue +107 -0
- package/dist/vendored/components/setters/SetGradient.vue +125 -0
- package/dist/vendored/components/setters/SetGrid9.vue +68 -0
- package/dist/vendored/components/setters/SetIconSelect.vue +67 -0
- package/dist/vendored/components/setters/SetIfForm.vue +61 -0
- package/dist/vendored/components/setters/SetImage.vue +43 -0
- package/dist/vendored/components/setters/SetList.vue +139 -0
- package/dist/vendored/components/setters/SetNumber.vue +118 -0
- package/dist/vendored/components/setters/SetNumberStyle.vue +113 -0
- package/dist/vendored/components/setters/SetPadding.vue +77 -0
- package/dist/vendored/components/setters/SetSegment.vue +160 -0
- package/dist/vendored/components/setters/SetSelect.vue +51 -0
- package/dist/vendored/components/setters/SetSwitch.vue +49 -0
- package/dist/vendored/components/setters/SetText.vue +52 -0
- package/dist/vendored/components/setters/props.js +23 -0
- package/dist/vendored/fidelity/baseline-messageOverview.json +87 -0
- package/dist/vendored/fidelity/calibration.json +29 -0
- package/dist/vendored/fidelity/fidelity-messageOverview-after.html +114 -0
- package/dist/vendored/fidelity/fidelity-messageOverview-after.json +272 -0
- package/dist/vendored/fidelity/fidelity-messageOverview.html +185 -0
- package/dist/vendored/fidelity/fidelity-messageOverview.json +336 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_v2.html +262 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_v2.json +502 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_v3.html +201 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_v3.json +417 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_visual.html +125 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_visual.json +338 -0
- package/dist/vendored/index.html +16 -0
- package/dist/vendored/main.js +36 -0
- package/dist/vendored/meta/action-meta.json +141 -0
- package/dist/vendored/meta/component-meta.json +2714 -0
- package/dist/vendored/meta/directive-meta.json +40 -0
- package/dist/vendored/meta/style-meta.json +1086 -0
- package/dist/vendored/runtime/StyleParser.js +179 -0
- package/dist/vendored/runtime/adapter.js +166 -0
- package/dist/vendored/runtime/alert-card.js +125 -0
- package/dist/vendored/runtime/app-toast.js +33 -0
- package/dist/vendored/runtime/assets.js +16 -0
- package/dist/vendored/runtime/canvas-context.js +141 -0
- package/dist/vendored/runtime/card-runtime.js +289 -0
- package/dist/vendored/runtime/clipboard-node.js +83 -0
- package/dist/vendored/runtime/clipboard.js +29 -0
- package/dist/vendored/runtime/evaluate.js +152 -0
- package/dist/vendored/runtime/event-model.js +300 -0
- package/dist/vendored/runtime/hoverGuide.js +31 -0
- package/dist/vendored/runtime/jsapi-mock.js +256 -0
- package/dist/vendored/runtime/node-model.js +217 -0
- package/dist/vendored/runtime/property.js +364 -0
- package/dist/vendored/runtime/selectionOverlay.js +53 -0
- package/dist/vendored/runtime/toPreview.js +648 -0
- package/dist/vendored/standalone-main.js +36 -0
- package/dist/vendored/standalone.html +12 -0
- package/dist/vendored/styles/app.css +2565 -0
- package/dist/vendored/styles/card-gallery.css +234 -0
- package/dist/vendored/styles/card-select.css +281 -0
- package/dist/vendored/styles/components.css +383 -0
- package/dist/vendored/styles/dropdown.css +337 -0
- package/dist/vendored/styles/json-tree.css +308 -0
- package/dist/vendored/styles/node-inspector.css +1003 -0
- package/dist/vendored/styles/setters.css +962 -0
- package/dist/vendored/styles/standalone.css +27 -0
- package/dist/vendored/styles/structure-tree.css +320 -0
- package/package.json +42 -0
|
@@ -0,0 +1,2330 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
预览外壳:左侧卡片选择 + 顶部工具条 + 手机框内的实时渲染 +
|
|
3
|
+
右侧调试面板。
|
|
4
|
+
-->
|
|
5
|
+
<template>
|
|
6
|
+
<div class="app" :class="{ 'app--chrome-off': chromeOff }" :style="{ '--preview-width': previewWidth + 'px' }">
|
|
7
|
+
<header class="app__bar" v-show="!chromeOff">
|
|
8
|
+
<strong class="app__logo">
|
|
9
|
+
<img class="app__logo-icon" :src="logoUrl" alt="" />
|
|
10
|
+
CardBuddy
|
|
11
|
+
<span class="app__logo-version">v{{ appVersion }}</span>
|
|
12
|
+
</strong>
|
|
13
|
+
<div class="csel-combo">
|
|
14
|
+
<CardSelect class="csel--combo" :value="selectedId" :options="cardList" @change="onSelect" />
|
|
15
|
+
<button
|
|
16
|
+
class="csel-combo__btn"
|
|
17
|
+
type="button"
|
|
18
|
+
:disabled="!selectedId"
|
|
19
|
+
@click="openCardFolder"
|
|
20
|
+
title="在文件管理器中打开当前卡片所在文件夹"
|
|
21
|
+
>
|
|
22
|
+
<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">
|
|
23
|
+
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" />
|
|
24
|
+
<path d="M3 9h18" />
|
|
25
|
+
</svg>
|
|
26
|
+
</button>
|
|
27
|
+
</div>
|
|
28
|
+
<!-- 卡片网格预览:图标按钮,打开本地全部卡片的缩略图墙,点击任一张载入工作台 -->
|
|
29
|
+
<button
|
|
30
|
+
class="app__btn app__btn--icon"
|
|
31
|
+
type="button"
|
|
32
|
+
:disabled="!cardList.length"
|
|
33
|
+
@click="openGallery"
|
|
34
|
+
title="卡片网格预览"
|
|
35
|
+
>
|
|
36
|
+
<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>
|
|
37
|
+
</button>
|
|
38
|
+
<div
|
|
39
|
+
class="app__badge"
|
|
40
|
+
:class="'app__badge--' + validateBadge.state"
|
|
41
|
+
:title="badgeTitle"
|
|
42
|
+
v-show="!chromeOff"
|
|
43
|
+
>
|
|
44
|
+
<span class="app__badge-dot"></span>
|
|
45
|
+
<span class="app__badge-text">{{ badgeText }}</span>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="app__bar-actions">
|
|
48
|
+
<div class="app__op-group" role="group" aria-label="编辑历史与本地操作">
|
|
49
|
+
<!-- 撤销 / 重做:图标 + 文案;无可回退/重做版本时置灰 -->
|
|
50
|
+
<button class="app__btn app__btn--hist" type="button" :disabled="!preview || !canUndo" @click="undo" title="后退一步(撤销)">
|
|
51
|
+
<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-undo" /></svg>
|
|
52
|
+
<span>撤销</span>
|
|
53
|
+
</button>
|
|
54
|
+
<button class="app__btn app__btn--hist" type="button" :disabled="!preview || !canRedo" @click="redo" title="前进一步(重做)">
|
|
55
|
+
<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-redo" /></svg>
|
|
56
|
+
<span>重做</span>
|
|
57
|
+
</button>
|
|
58
|
+
<!-- 保存到本地:当前显示版本(撤销/重做导航到的历史版本,或编辑后未本地保存的版本)
|
|
59
|
+
与本地磁盘版本不一致时高亮可用;一致时置灰。点击把当前版本真正写回磁盘,
|
|
60
|
+
使下一次重新打开加载的是这个「真实版本」,而非任意历史记录。 -->
|
|
61
|
+
<button
|
|
62
|
+
class="app__btn app__btn--history-save"
|
|
63
|
+
type="button"
|
|
64
|
+
:disabled="!preview || !historyDirty || savingCard || loadingCard"
|
|
65
|
+
:class="{ 'is-dirty': historyDirty }"
|
|
66
|
+
@click="applyHistoryToLocal"
|
|
67
|
+
:title="historyDirty ? '当前版本与本地不一致,点击将该版本保存到本地' : '当前版本已与本地一致'"
|
|
68
|
+
>
|
|
69
|
+
<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-save" /></svg>
|
|
70
|
+
<span>保存</span>
|
|
71
|
+
</button>
|
|
72
|
+
<button class="app__btn app__btn--op" :disabled="!preview" @click="copySchema" title="复制 schema(含数据绑定编辑后的数据)">
|
|
73
|
+
<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-copy" /></svg>
|
|
74
|
+
<span>{{ copiedSchema ? '已复制 ✓' : '复制' }}</span>
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
<!-- 远端:合并「远端保存 / 远端预览 / 全量同步 / 选中卡片同步」为一个分组,组名「远端」,云图标 -->
|
|
78
|
+
<Dropdown
|
|
79
|
+
class="app__remote-menu"
|
|
80
|
+
size="md"
|
|
81
|
+
placement="bottom-end"
|
|
82
|
+
:disabled="savingRemote || syncingRemote"
|
|
83
|
+
:title="savingRemote ? '正在保存至远端…' : (syncingRemote ? '同步中…' : '远端操作:保存到 FinMall / 真机预览 / 全量或选中卡片同步')"
|
|
84
|
+
data-testid="remote-group"
|
|
85
|
+
>
|
|
86
|
+
<template #trigger="{ open }">
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
class="app__btn app__btn--remote"
|
|
90
|
+
:class="{ 'app__btn--warn': savedRemote && verifiedRemote === false, 'app__remote-trigger--active': open, 'is-processing': savingRemote || syncingRemote }"
|
|
91
|
+
:disabled="savingRemote || syncingRemote"
|
|
92
|
+
>
|
|
93
|
+
<svg v-if="savingRemote || syncingRemote" class="app__icon app__icon--spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-loading" /></svg>
|
|
94
|
+
<svg v-else-if="savedRemote && verifiedRemote !== false" 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-cloud-check" /></svg>
|
|
95
|
+
<svg v-else-if="savedRemote && verifiedRemote === false" 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-cloud-alert" /></svg>
|
|
96
|
+
<svg v-else 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-cloud" /></svg>
|
|
97
|
+
<span>远端</span>
|
|
98
|
+
<svg class="app__icon app__icon--chev" :class="{ 'app__icon--chev--open': open }" viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><use href="#icon-chevron" /></svg>
|
|
99
|
+
</button>
|
|
100
|
+
</template>
|
|
101
|
+
<template #default="{ close }"><ul class="app__remote-pop dd__menu" role="menu">
|
|
102
|
+
<li class="app__remote-item app__remote-item--save" :class="{ 'is-disabled': !preview || !selectedId || savingRemote || !hasCardSource }" :title="!hasCardSource ? '该卡未经「同步远程」导入,无远端来源信息,无法推送版本' : '版本推送:把本地卡片版本覆盖到远端 FinMall'" data-testid="save-remote" role="menuitem" @click="chooseRsave('save'); close()">
|
|
103
|
+
<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-upload-cloud" /></svg>
|
|
104
|
+
<span>版本推送</span>
|
|
105
|
+
</li>
|
|
106
|
+
<li class="app__remote-item" :class="{ 'is-disabled': !preview || !selectedId || !hasCardSource }" :title="!hasCardSource ? '该卡未经「同步远程」导入,无远端来源信息,无法生成真机预览二维码' : '在线预览:生成真机预览二维码(FinMall App 扫码)'" data-testid="remote-preview" role="menuitem" @click="chooseRsave('preview'); close()">
|
|
107
|
+
<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>
|
|
108
|
+
<span>在线预览</span>
|
|
109
|
+
</li>
|
|
110
|
+
<li class="app__remote-item app__remote-item--sync" :class="{ 'is-disabled': !preview || !selectedId || syncingRemote }" data-testid="sync-selected" role="menuitem" @click="chooseSync('selected'); close()">
|
|
111
|
+
<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-sync-box" /></svg>
|
|
112
|
+
<span>当前同步</span>
|
|
113
|
+
</li>
|
|
114
|
+
<li class="app__remote-item app__remote-item--sync" :class="{ 'is-disabled': syncingRemote }" data-testid="sync-full" role="menuitem" @click="chooseSync('full'); close()">
|
|
115
|
+
<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-refresh" /></svg>
|
|
116
|
+
<span>全量同步</span>
|
|
117
|
+
</li>
|
|
118
|
+
</ul></template>
|
|
119
|
+
</Dropdown>
|
|
120
|
+
<!-- 主题切换:深色(当前)/ 浅色,复用同名 CSS 变量整体换肤,状态持久化 -->
|
|
121
|
+
<button
|
|
122
|
+
class="theme-toggle"
|
|
123
|
+
type="button"
|
|
124
|
+
role="switch"
|
|
125
|
+
:aria-checked="theme === 'light'"
|
|
126
|
+
:title="theme === 'light' ? '切换到深色主题' : '切换到浅色主题'"
|
|
127
|
+
@click="toggleTheme"
|
|
128
|
+
>
|
|
129
|
+
<span class="theme-toggle__track">
|
|
130
|
+
<span class="theme-toggle__thumb">
|
|
131
|
+
<svg
|
|
132
|
+
v-if="theme === 'light'"
|
|
133
|
+
class="theme-toggle__icon"
|
|
134
|
+
viewBox="0 0 24 24"
|
|
135
|
+
fill="none"
|
|
136
|
+
stroke="currentColor"
|
|
137
|
+
stroke-width="2"
|
|
138
|
+
stroke-linecap="round"
|
|
139
|
+
stroke-linejoin="round"
|
|
140
|
+
aria-hidden="true"
|
|
141
|
+
>
|
|
142
|
+
<circle cx="12" cy="12" r="4.2" />
|
|
143
|
+
<line x1="12" y1="2.5" x2="12" y2="5" />
|
|
144
|
+
<line x1="12" y1="19" x2="12" y2="21.5" />
|
|
145
|
+
<line x1="2.5" y1="12" x2="5" y2="12" />
|
|
146
|
+
<line x1="19" y1="12" x2="21.5" y2="12" />
|
|
147
|
+
<line x1="4.9" y1="4.9" x2="6.8" y2="6.8" />
|
|
148
|
+
<line x1="17.2" y1="17.2" x2="19.1" y2="19.1" />
|
|
149
|
+
<line x1="4.9" y1="19.1" x2="6.8" y2="17.2" />
|
|
150
|
+
<line x1="17.2" y1="6.8" x2="19.1" y2="4.9" />
|
|
151
|
+
</svg>
|
|
152
|
+
<svg
|
|
153
|
+
v-else
|
|
154
|
+
class="theme-toggle__icon"
|
|
155
|
+
viewBox="0 0 24 24"
|
|
156
|
+
fill="none"
|
|
157
|
+
stroke="currentColor"
|
|
158
|
+
stroke-width="2"
|
|
159
|
+
stroke-linecap="round"
|
|
160
|
+
stroke-linejoin="round"
|
|
161
|
+
aria-hidden="true"
|
|
162
|
+
>
|
|
163
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
164
|
+
</svg>
|
|
165
|
+
</span>
|
|
166
|
+
</span>
|
|
167
|
+
</button>
|
|
168
|
+
<!-- 本地开发服务设置入口 -->
|
|
169
|
+
<div class="app__dev-svc">
|
|
170
|
+
<button
|
|
171
|
+
class="app__btn app__btn--icon"
|
|
172
|
+
type="button"
|
|
173
|
+
:title="devSvcTitle"
|
|
174
|
+
:class="devSvcClass"
|
|
175
|
+
@click="devServiceOpen = !devServiceOpen"
|
|
176
|
+
>
|
|
177
|
+
<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-link" /></svg>
|
|
178
|
+
</button>
|
|
179
|
+
<div v-if="devServiceOpen" class="app__dev-svc-panel" @click.stop>
|
|
180
|
+
<div class="app__dev-svc-title">本地开发服务</div>
|
|
181
|
+
<label class="app__dev-svc-row">
|
|
182
|
+
<span>启用</span>
|
|
183
|
+
<Switch v-model="devServiceForm.enabled" />
|
|
184
|
+
</label>
|
|
185
|
+
<label class="app__dev-svc-row app__dev-svc-row--col">
|
|
186
|
+
<span>服务地址</span>
|
|
187
|
+
<input type="text" v-model="devServiceForm.baseUrl" placeholder="http://127.0.0.1:8787" />
|
|
188
|
+
</label>
|
|
189
|
+
<label class="app__dev-svc-row app__dev-svc-row--col">
|
|
190
|
+
<span>访问令牌(可选)</span>
|
|
191
|
+
<input type="text" v-model="devServiceForm.token" placeholder="留空则不校验" />
|
|
192
|
+
</label>
|
|
193
|
+
<div class="app__dev-svc-actions">
|
|
194
|
+
<button class="ni-btn ni-btn--ghost" type="button" @click="devServiceOpen = false">关闭</button>
|
|
195
|
+
<button class="ni-btn" type="button" @click="saveDevService">保存</button>
|
|
196
|
+
</div>
|
|
197
|
+
<p class="app__dev-svc-hint" v-if="apiBaseResolved === null">
|
|
198
|
+
<template v-if="devSvcBlockReason === 'disabled'">已填写服务地址,但「启用」开关未打开。请开启「启用」开关并点击保存,本地开发服务才会生效。</template>
|
|
199
|
+
<template v-else>请填写本机 cardbuddy 服务地址(默认 http://127.0.0.1:8787),打开「启用」开关并保存后,编辑功能才会生效。</template>
|
|
200
|
+
</p>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
<!-- revision 依赖锚点:setData 自增后驱动本组件重渲染 -->
|
|
205
|
+
<span style="display: none">{{ rt.revision }}</span>
|
|
206
|
+
</header>
|
|
207
|
+
|
|
208
|
+
<!-- 部署态但未配置本地开发服务:顶部提示条(chrome=off 截图模式不显示) -->
|
|
209
|
+
<div v-if="apiBaseResolved === null && !chromeOff" class="app__nosvc-banner">
|
|
210
|
+
<template v-if="devSvcBlockReason === 'disabled'">
|
|
211
|
+
⚠ 本地开发服务未生效:已填写地址,但未打开「启用」开关。请点击右上角 「连接」图标打开「本地开发服务」设置,开启「启用」开关并保存后,编辑功能才会生效。
|
|
212
|
+
</template>
|
|
213
|
+
<template v-else>
|
|
214
|
+
⚠ 未配置本地开发服务:当前为部署态,编辑功能不可用。请点击右上角 「连接」图标打开「本地开发服务」设置,填写本机 cardbuddy 地址、打开「启用」开关并保存后,才能编辑卡片。
|
|
215
|
+
</template>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<!-- 已配置本地服务但实时连接已断开:顶部提示条(chrome=off 截图模式不显示) -->
|
|
219
|
+
<div v-if="apiBaseResolved !== null && devSvcDown && !chromeOff" class="app__nosvc-banner app__nosvc-banner--disconnected">
|
|
220
|
+
⚠ 本地开发服务已断开:连接失败或服务已停止,编辑 / 同步功能暂不可用。请确认本地 cardbuddy 是否运行,或点击右上角「连接」图标重新保存配置。
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<!-- 外部改了当前卡、无未保存改动 → 自动重载后的非阻塞轻提示(chrome=off 截图模式不显示) -->
|
|
224
|
+
<div v-if="externalReloadNote && !chromeOff" class="app__nosvc-banner app__extreload-banner">
|
|
225
|
+
↻ {{ externalReloadNote }}
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<main class="app__stage" ref="stageEl">
|
|
229
|
+
<div class="phone" :class="{ 'phone--dragging': dragging }" :style="{ width: previewWidth + 'px' }">
|
|
230
|
+
<!-- 缩放外框:占满整列(宽高由 CSS flex 填满);画布不做 transform 缩放,直接按实际尺寸布局 -->
|
|
231
|
+
<div class="phone__frame" :style="frameStyle">
|
|
232
|
+
<!-- 画布层:直接按实际列宽/列高布局(width/height 100%),不再使用 transform 缩放 -->
|
|
233
|
+
<div class="phone__scaler">
|
|
234
|
+
<!-- 设备工具条(类 Chrome DevTools 设备模式):下拉选择画布背景 -->
|
|
235
|
+
<div class="device-bar" v-show="!chromeOff">
|
|
236
|
+
<!-- 背景选择下拉框:无边框模式(工具栏文字式控件) -->
|
|
237
|
+
<Dropdown
|
|
238
|
+
borderless
|
|
239
|
+
size="xs"
|
|
240
|
+
placement="bottom-start"
|
|
241
|
+
:value="canvasBg"
|
|
242
|
+
:options="bgOptions.map((b) => ({ key: b.key, value: b.label + '背景' }))"
|
|
243
|
+
@change="chooseBg"
|
|
244
|
+
/>
|
|
245
|
+
<span class="device-bar__spacer"></span>
|
|
246
|
+
<span class="device-bar__label">宽度</span>
|
|
247
|
+
<!-- 预设尺寸下拉:与背景下拉同款;触发器实时显示当前画布宽度,展开为 375 / 188 两个预设,切换即动态改宽度(高度随列高自动一致) -->
|
|
248
|
+
<Dropdown
|
|
249
|
+
borderless
|
|
250
|
+
size="xs"
|
|
251
|
+
placement="bottom-end"
|
|
252
|
+
:value="canvasW"
|
|
253
|
+
:label="canvasW + ' px'"
|
|
254
|
+
:options="presetSizes"
|
|
255
|
+
@change="setPresetWidth"
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
258
|
+
<!--
|
|
259
|
+
固定视口:弹层(alertCard / alertDialog / toast)相对此容器 absolute inset:0 定位,
|
|
260
|
+
不随内层 phone__screen 的卡片内容滚动,保证遮罩始终精确覆盖可视区域。
|
|
261
|
+
-->
|
|
262
|
+
<div ref="phoneViewport" class="phone-viewport" @click="onCanvasClick">
|
|
263
|
+
<div
|
|
264
|
+
class="phone__screen"
|
|
265
|
+
:class="'phone__screen--bg-' + (chromeOff ? 'white' : canvasBg)"
|
|
266
|
+
:style="chromeOff && preview ? { width: (preview.view.width || 375) + 'px' } : null"
|
|
267
|
+
:data-card-ready="cardReady ? '1' : null"
|
|
268
|
+
>
|
|
269
|
+
<!--
|
|
270
|
+
:key 绑定 selectedId + reloadToken —— 切换卡片、远端同步后重载、重置导入
|
|
271
|
+
等场景时强制 RenderTree 重新挂载,从而保证新卡片/新数据的「初始化动作」
|
|
272
|
+
(onReady 等生命周期事件,如 assetOverview 计算 ratio)一定在 created 阶段
|
|
273
|
+
执行,不会出现重载后 init 不跑、依赖字段为 undefined 而渲染成 NaN% 的问题。
|
|
274
|
+
-->
|
|
275
|
+
<RenderTree v-if="preview" :key="selectedId + ':' + reloadToken" :node="preview.view" :scope="rt.state" :selected-node-id="selectedNodeId" />
|
|
276
|
+
<!-- 节点选中遮罩覆盖层:作为滚动容器子节点,随内容滚动并精确贴合选中元素 -->
|
|
277
|
+
<SelectionOverlay />
|
|
278
|
+
<!-- 结构树 hover 参考线:贯穿画布的荧光绿辅助线(左/右竖线、上/下横线),仅 hover 时显示 -->
|
|
279
|
+
<HoverGuides />
|
|
280
|
+
</div>
|
|
281
|
+
<!-- 弹窗卡片图层:alertCard / closeAlertCard 的展现层(在预览区域内弹出) -->
|
|
282
|
+
<AlertCardLayer :main-rt="rt" />
|
|
283
|
+
<!-- 设备级模拟 UI:alertDialog 对话框 / showToast 弱提示 / showPicker 选择器 / showDatePicker 日期选择器 -->
|
|
284
|
+
<DeviceOverlay />
|
|
285
|
+
<PickerOverlay />
|
|
286
|
+
<DatePickerOverlay />
|
|
287
|
+
</div>
|
|
288
|
+
<!-- 预览区底部状态条:显示画布宽高(左)+ 刷新按钮(右),chromeOff 时隐藏 -->
|
|
289
|
+
<div class="preview-statusbar" v-show="!chromeOff">
|
|
290
|
+
<span class="device-bar__info">{{ canvasW }} × {{ viewportHeight }}</span>
|
|
291
|
+
<span class="device-bar__spacer"></span>
|
|
292
|
+
<button
|
|
293
|
+
class="statusbar__refresh"
|
|
294
|
+
type="button"
|
|
295
|
+
:disabled="!selectedId"
|
|
296
|
+
@click="refreshCard"
|
|
297
|
+
title="刷新卡片,重新加载"
|
|
298
|
+
>
|
|
299
|
+
<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">
|
|
300
|
+
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
|
|
301
|
+
<path d="M21 3v6h-6" />
|
|
302
|
+
</svg>
|
|
303
|
+
</button>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<!-- 拖拽分隔条:位于预览列与操作面板之间,拖动调整预览宽度(手机自动缩放填充) -->
|
|
310
|
+
<div
|
|
311
|
+
v-show="!chromeOff"
|
|
312
|
+
class="splitter"
|
|
313
|
+
:class="{ 'is-dragging': dragging }"
|
|
314
|
+
@mousedown.prevent="startDrag"
|
|
315
|
+
title="拖拽调整预览区域宽度"
|
|
316
|
+
></div>
|
|
317
|
+
|
|
318
|
+
<DebugPanel
|
|
319
|
+
v-show="!chromeOff"
|
|
320
|
+
:rt="rt"
|
|
321
|
+
:preview="preview"
|
|
322
|
+
:schema="currentSchema"
|
|
323
|
+
:card-id="selectedId"
|
|
324
|
+
:reload-token="reloadToken"
|
|
325
|
+
:card-count="cardCount"
|
|
326
|
+
:selected-node-id="selectedNodeId"
|
|
327
|
+
:move-collapse-token="moveCollapseToken"
|
|
328
|
+
:active-tab="activeTab"
|
|
329
|
+
@schema-changed="onSchemaChanged"
|
|
330
|
+
@schema-commit="onSchemaCommit"
|
|
331
|
+
@select-node="onSelectNode"
|
|
332
|
+
@update:active-tab="activeTab = $event"
|
|
333
|
+
@add-node="onAddNode"
|
|
334
|
+
@move-node="onMoveNode"
|
|
335
|
+
@hover-node="onHoverNode"
|
|
336
|
+
@copy-selected="copySelectedNode"
|
|
337
|
+
@paste="pasteNode"
|
|
338
|
+
@duplicate="duplicateNode"
|
|
339
|
+
@delete-selected="deleteSelectedNode"
|
|
340
|
+
@reset-import="onRefresh"
|
|
341
|
+
:dev-svc-class="devSvcClass"
|
|
342
|
+
:dev-svc-title="devSvcTitle"
|
|
343
|
+
:dev-svc-label="devSvcLabel"
|
|
344
|
+
/>
|
|
345
|
+
</main>
|
|
346
|
+
<!-- 远端预览二维码弹层:点击顶栏「远端预览」按钮打开,手机(FinMall App)扫码真机查看 -->
|
|
347
|
+
<RemotePreview
|
|
348
|
+
:visible="remotePreviewOpen"
|
|
349
|
+
:page-id="currentSchema ? currentSchema.pageId : ''"
|
|
350
|
+
:schema="currentSchema"
|
|
351
|
+
@close="remotePreviewOpen = false"
|
|
352
|
+
/>
|
|
353
|
+
<!-- 登录凭据弹窗:远程同步检测到缺凭据时弹出,填写账号密码(MD5 后)写入 .local/credentials.json -->
|
|
354
|
+
<div v-if="loginModal" class="login-mask" @click.self="closeLoginModal">
|
|
355
|
+
<div class="login-modal" role="dialog" aria-modal="true">
|
|
356
|
+
<div class="login-modal__title">FinMall 登录凭据</div>
|
|
357
|
+
<div class="login-modal__desc">
|
|
358
|
+
本地登录凭据缺失,请填写账号与密码。密码仅做 MD5 后保存,不落明文。
|
|
359
|
+
</div>
|
|
360
|
+
<label class="login-modal__row">
|
|
361
|
+
<span class="login-modal__label">账号</span>
|
|
362
|
+
<input
|
|
363
|
+
class="login-modal__input"
|
|
364
|
+
type="text"
|
|
365
|
+
v-model="loginName"
|
|
366
|
+
placeholder="FinMall 登录账号"
|
|
367
|
+
:disabled="loginSubmitting"
|
|
368
|
+
@keyup.enter="submitLogin"
|
|
369
|
+
/>
|
|
370
|
+
</label>
|
|
371
|
+
<label class="login-modal__row">
|
|
372
|
+
<span class="login-modal__label">密码</span>
|
|
373
|
+
<input
|
|
374
|
+
class="login-modal__input"
|
|
375
|
+
type="password"
|
|
376
|
+
v-model="loginPassword"
|
|
377
|
+
placeholder="FinMall 登录密码"
|
|
378
|
+
:disabled="loginSubmitting"
|
|
379
|
+
@keyup.enter="submitLogin"
|
|
380
|
+
/>
|
|
381
|
+
</label>
|
|
382
|
+
<div v-if="loginError" class="login-modal__error">{{ loginError }}</div>
|
|
383
|
+
<div class="login-modal__actions">
|
|
384
|
+
<button
|
|
385
|
+
class="login-modal__btn login-modal__btn--ghost"
|
|
386
|
+
type="button"
|
|
387
|
+
:disabled="loginSubmitting"
|
|
388
|
+
@click="closeLoginModal"
|
|
389
|
+
>
|
|
390
|
+
取消
|
|
391
|
+
</button>
|
|
392
|
+
<button
|
|
393
|
+
class="login-modal__btn login-modal__btn--primary"
|
|
394
|
+
type="button"
|
|
395
|
+
:disabled="loginSubmitting"
|
|
396
|
+
@click="submitLogin"
|
|
397
|
+
>
|
|
398
|
+
{{ loginSubmitting ? '保存中…' : '保存并同步' }}
|
|
399
|
+
</button>
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
<!-- 组件选择面板:点击结构树每行的「+」后弹出,选择类型即插入对应节点 -->
|
|
404
|
+
<ComponentPalette
|
|
405
|
+
v-if="addNodePanelOpen"
|
|
406
|
+
@pick="onPickComponent"
|
|
407
|
+
@close="cancelAddNode"
|
|
408
|
+
/>
|
|
409
|
+
<!-- 二次确认弹窗:版本推送 / 当前同步 / 全量同步 等写/覆盖类操作前弹出,确认后才执行 -->
|
|
410
|
+
<ConfirmDialog
|
|
411
|
+
:visible="confirm.show"
|
|
412
|
+
:title="confirm.title"
|
|
413
|
+
:message="confirm.message"
|
|
414
|
+
:confirm-text="confirm.confirmText"
|
|
415
|
+
:cancel-text="confirm.cancelText"
|
|
416
|
+
:danger="confirm.danger"
|
|
417
|
+
@confirm="onConfirmDialog"
|
|
418
|
+
@cancel="onCancelDialog"
|
|
419
|
+
/>
|
|
420
|
+
<!-- 卡片网格预览弹窗:点击顶栏「网格预览」图标按钮打开,选择卡片即载入工作台 -->
|
|
421
|
+
<CardGallery
|
|
422
|
+
:visible="galleryOpen"
|
|
423
|
+
:cards="cardList"
|
|
424
|
+
:selected-id="selectedId"
|
|
425
|
+
@select="onSelect"
|
|
426
|
+
@close="closeGallery"
|
|
427
|
+
/>
|
|
428
|
+
<!-- App 级弱提示:顶栏异步操作(远端保存 / 同步)完成反馈,浮于 App 外壳顶部居中 -->
|
|
429
|
+
<AppToast />
|
|
430
|
+
</div>
|
|
431
|
+
</template>
|
|
432
|
+
|
|
433
|
+
<script>
|
|
434
|
+
import Adapter from '@/runtime/adapter.js'
|
|
435
|
+
import { createCardRuntime } from '@/runtime/card-runtime.js'
|
|
436
|
+
import { buildPreview, buildModelView, createComponentNode, isContainerMeta, getDefaultSlotKey, COMPONENT_META } from '@/runtime/toPreview.js'
|
|
437
|
+
// 节点树剪贴板:复制/粘贴/副本(Ctrl/⌘+C/V/D)共用
|
|
438
|
+
import {
|
|
439
|
+
setClipboardNode,
|
|
440
|
+
getClipboardNode,
|
|
441
|
+
getClipboardSourceCardId,
|
|
442
|
+
collectClassNames,
|
|
443
|
+
regenerateComponentIds,
|
|
444
|
+
} from '@/runtime/clipboard-node.js'
|
|
445
|
+
import RenderTree from '@/components/RenderTree.vue'
|
|
446
|
+
import DebugPanel from '@/components/DebugPanel.vue'
|
|
447
|
+
import CardSelect from '@/components/CardSelect.vue'
|
|
448
|
+
import AlertCardLayer from '@/components/AlertCardLayer.vue'
|
|
449
|
+
import DeviceOverlay from '@/components/DeviceOverlay.vue'
|
|
450
|
+
import PickerOverlay from '@/components/PickerOverlay.vue'
|
|
451
|
+
import DatePickerOverlay from '@/components/DatePickerOverlay.vue'
|
|
452
|
+
import SelectionOverlay from '@/components/SelectionOverlay.vue'
|
|
453
|
+
import HoverGuides from '@/components/HoverGuides.vue'
|
|
454
|
+
import { hoverGuide, setHoverEl, clearHoverEl } from '@/runtime/hoverGuide.js'
|
|
455
|
+
import RemotePreview from '@/components/RemotePreview.vue'
|
|
456
|
+
import AppToast from '@/components/AppToast.vue'
|
|
457
|
+
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
|
458
|
+
import CardGallery from '@/components/CardGallery.vue'
|
|
459
|
+
import ComponentPalette from '@/components/ComponentPalette.vue'
|
|
460
|
+
import Switch from '@/components/Switch.vue'
|
|
461
|
+
import { showAppToast } from '@/runtime/app-toast.js'
|
|
462
|
+
import { clearAllAlerts } from '@/runtime/alert-card.js'
|
|
463
|
+
import { installJsApiMock, clearDeviceUI } from '@/runtime/jsapi-mock.js'
|
|
464
|
+
import logoUrl from '@/assets/site-icon.png'
|
|
465
|
+
import { apiFetch, resolveApiBase, subscribeCardsChanged, loadDevServiceConfig, saveDevServiceConfig } from '@/api.js'
|
|
466
|
+
// 由 schema 单向派生 data 的共享实现(与 server/core 同源,避免逻辑漂移)。
|
|
467
|
+
// 预览渲染注入的数据绑定、本地基线比较、历史快照还原均使用此函数,使 data 永不读回 data.json 文件。
|
|
468
|
+
import { buildDataFromSchema } from '../server/core/buildDataFromSchema.js'
|
|
469
|
+
|
|
470
|
+
// 编辑器自身「保存到本地 / 保存至远端」写盘后,cardbuddy 的 watcher 会把这次落盘经 SSE
|
|
471
|
+
// 推回 cards-changed。这是「自己改自己」的回声,并非外部修改,用此窗口吸收,避免误弹外部重载
|
|
472
|
+
// 提示 / 冲突确认框。窗口仅作用于本窗口——其他窗口 / IDE / 远端同步推来的 cards-changed 仍照常提示。
|
|
473
|
+
const SELF_SAVE_SUPPRESS_MS = 2500
|
|
474
|
+
|
|
475
|
+
// 模块级单例:renderTree 通过 provide/inject 拿到的是稳定引用,
|
|
476
|
+
// 内部 rootScope 可经 setRootScope 整体替换而引用不变。
|
|
477
|
+
const adapter = new Adapter()
|
|
478
|
+
const cardRuntime = createCardRuntime({}, adapter)
|
|
479
|
+
// 开发期暴露 runtime 单例句柄,供 E2E 回归(如 L2-12 绑定数据整体替换实时同步画布)
|
|
480
|
+
// 直接驱动 rt.setRootScope —— 与 DebugPanel「绑定数据」Raw「应用」同源。生产构建不暴露。
|
|
481
|
+
if (typeof window !== 'undefined' && import.meta.env.DEV) window.__rt = cardRuntime
|
|
482
|
+
|
|
483
|
+
export default {
|
|
484
|
+
name: 'App',
|
|
485
|
+
components: { RenderTree, DebugPanel, CardSelect, AlertCardLayer, DeviceOverlay, PickerOverlay, DatePickerOverlay, SelectionOverlay, HoverGuides, RemotePreview, AppToast, ConfirmDialog, CardGallery, ComponentPalette, Switch },
|
|
486
|
+
provide() {
|
|
487
|
+
return {
|
|
488
|
+
adapter,
|
|
489
|
+
cardRuntime,
|
|
490
|
+
customComponents: {},
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
data() {
|
|
494
|
+
return {
|
|
495
|
+
adapter,
|
|
496
|
+
rt: cardRuntime,
|
|
497
|
+
logoUrl,
|
|
498
|
+
// 顶栏版本号小字:由 vite.config.mjs 经 define 内联的 __APP_VERSION__ 提供,
|
|
499
|
+
// 与 package.json 的 version 保持一致。
|
|
500
|
+
appVersion: __APP_VERSION__,
|
|
501
|
+
cardList: [], // 实时从 GET /__cards 拉取,不缓存启动快照
|
|
502
|
+
cardCount: 0,
|
|
503
|
+
galleryOpen: false, // 卡片网格预览弹窗开关
|
|
504
|
+
selectedId: '',
|
|
505
|
+
// 外部(IDE / 其他工具)改了当前卡、且平台无未保存改动时,自动重载后顶部短暂提示
|
|
506
|
+
// (非阻塞,区别于有未保存改动时的阻塞确认框)。
|
|
507
|
+
externalReloadNote: '',
|
|
508
|
+
externalReloadTimer: null,
|
|
509
|
+
// 自保存标记:记录「本窗口刚发起落盘」的卡片 id 与时刻,用于吸收 watcher 推回的 cards-changed 回声。
|
|
510
|
+
// 仅本窗口可见——其他窗口 / IDE / 远端同步触发的变更不会带此标记,仍照常提示外部重载。
|
|
511
|
+
selfSaveId: '',
|
|
512
|
+
selfSaveAt: 0,
|
|
513
|
+
preview: null,
|
|
514
|
+
// chrome=off:纯净截图模式(隐藏顶栏/设备条/调试面板,卡片根挂 data-card-ready),
|
|
515
|
+
// 供 /__screenshot 无头截图使用。由 URL ?chrome=off 触发。
|
|
516
|
+
chromeOff: false,
|
|
517
|
+
// 主题:'dark'(默认,当前配色)/ 'light'(浅色,白底)。持久化于 localStorage,切换即整体换肤。
|
|
518
|
+
theme: 'dark',
|
|
519
|
+
// 卡片首帧渲染完成标志:在 /__screenshot 模式下,screenshot.js 等待 [data-card-ready]
|
|
520
|
+
// 出现再截图(比 networkidle 稳,避免截到尚未挂载的空壳)。
|
|
521
|
+
cardReady: false,
|
|
522
|
+
reloadToken: 0,
|
|
523
|
+
// 拖拽移动成功令牌:每次「节点拖拽移动成功」自增,经 DebugPanel 透传给结构树,
|
|
524
|
+
// 用于触发「移动完成后:选中被拖节点 + 仅展开其祖先链、其余全部折叠」的行为。
|
|
525
|
+
moveCollapseToken: 0,
|
|
526
|
+
refreshing: false,
|
|
527
|
+
loadingCard: false, // 切卡/载入进行中:临时禁用「保存到本地」,避免沿用上一卡的脏状态或把上一卡误写进新卡
|
|
528
|
+
currentSchema: null,
|
|
529
|
+
currentCard: null, // 当前卡片的磁盘最新内容(实时载入的工作副本,非跨卡缓存)
|
|
530
|
+
// 导入来源元数据(Phase E 可追溯):GET /__import-meta/:id 的 meta,无侧车(本地新建卡)时为 null。
|
|
531
|
+
// 顶栏「版本推送」据此置灰——无来源则无远端卡片可推送版本。
|
|
532
|
+
cardImportMeta: null,
|
|
533
|
+
// 本地真实版本基线:载入(或「保存到本地」写盘)时记录磁盘当前 schema+data 的克隆。
|
|
534
|
+
// 用于判断「当前显示版本」是否与本地磁盘一致:撤销/重做到历史版本、或编辑未本地保存
|
|
535
|
+
// 时两者不一致 → 顶栏「保存到本地」按钮高亮可点;一致时按钮置灰。
|
|
536
|
+
localSchema: null,
|
|
537
|
+
localData: null,
|
|
538
|
+
selectedNodeId: '',
|
|
539
|
+
// 当前激活的调试面板 tab(接口调用/绑定数据/组件结构/卡片来源)。
|
|
540
|
+
// 画布 Ctrl/⌘ 点击会自动切到「组件结构」tab 并在节点树聚焦选中,无需预先停在结构树 tab。
|
|
541
|
+
activeTab: 'scope',
|
|
542
|
+
// 组件结构树「添加」面板:addNodePanelOpen 控制弹层显隐;addNodeTarget 记录
|
|
543
|
+
// 插入锚点与模式({ node, mode },来自结构树 add-node 事件):
|
|
544
|
+
// node = 右键/选中 viewNode(null 或 Root → 视作根);mode 决定落位规则:
|
|
545
|
+
// 'child' → 容器内默认插槽末尾追加子节点(根 → view.components 末尾);
|
|
546
|
+
// 'before' → 节点相邻兄弟之前插入;
|
|
547
|
+
// 'after' → 节点相邻兄弟之后插入;
|
|
548
|
+
// 'auto' → 顶部「+ 添加」原行为(容器→子节点末尾;非容器→其后兄弟)。
|
|
549
|
+
addNodePanelOpen: false,
|
|
550
|
+
addNodeTarget: null,
|
|
551
|
+
// 整卡校验徽标(Q3:仅 ok/error,不展开 bindingErrors 明细)
|
|
552
|
+
validateBadge: { state: 'idle', errors: 0, warnings: 0, renderOk: null, ts: 0 },
|
|
553
|
+
validateTimer: null,
|
|
554
|
+
copiedSchema: false,
|
|
555
|
+
copyTimer: null,
|
|
556
|
+
savedCard: false,
|
|
557
|
+
savingCard: false,
|
|
558
|
+
saveTimer: null,
|
|
559
|
+
savingRemote: false, // 远端保存(写回 FinMall)进行中
|
|
560
|
+
savedRemote: false, // 远端保存成功反馈
|
|
561
|
+
verifiedRemote: null, // 保存后读回校验结果:null=未校验/跳过,true=一致,false=不符
|
|
562
|
+
saveRemoteTimer: null,
|
|
563
|
+
syncingRemote: false,
|
|
564
|
+
syncedRemote: false,
|
|
565
|
+
remotePreviewOpen: false, // 「远端预览」二维码弹层开关
|
|
566
|
+
syncTarget: null, // 'full' | 'selected' | null —— 标记上一次/正在进行的同步模式,用于按钮反馈
|
|
567
|
+
syncTimer: null,
|
|
568
|
+
// 登录凭据弹窗(远程同步缺凭据时弹出,填写账号密码写入 .local/credentials.json)
|
|
569
|
+
loginModal: false,
|
|
570
|
+
loginName: '',
|
|
571
|
+
loginPassword: '',
|
|
572
|
+
loginSubmitting: false,
|
|
573
|
+
loginError: '',
|
|
574
|
+
// 二次确认弹窗(版本推送 / 当前同步 / 全量同步 等写/覆盖类操作前弹出,确认后才执行 onConfirm)
|
|
575
|
+
confirm: {
|
|
576
|
+
show: false,
|
|
577
|
+
title: '',
|
|
578
|
+
message: '',
|
|
579
|
+
confirmText: '继续',
|
|
580
|
+
cancelText: '取消',
|
|
581
|
+
danger: false,
|
|
582
|
+
onConfirm: null,
|
|
583
|
+
},
|
|
584
|
+
// 编辑历史(撤销/重做):每个快照为 { schema, data },data 即 rt.rootScope(数据绑定),
|
|
585
|
+
// 与 schema 一并快照,撤销时两者都还原(否则 syncDataToSchema 会用新数据覆盖回 schema)。
|
|
586
|
+
// past=可回退的「上一版本」快照(最新在末尾),future=可重做的快照,
|
|
587
|
+
// baseline=当前已提交版本的 { schema, data } 克隆(schema 比较时忽略 defaultValue 噪声,data 比较用深层相等)。
|
|
588
|
+
past: [],
|
|
589
|
+
future: [],
|
|
590
|
+
baseline: null,
|
|
591
|
+
_commitTimer: null, // 编辑落定防抖计时器,避免连续编辑产生过多快照
|
|
592
|
+
viewportHeight: 700, // 手机视口实际高度(高度自适应填充后由 ResizeObserver 实测更新)
|
|
593
|
+
// 画布背景模式:checker(棋盘格) / white(白色) / gray(浅灰)
|
|
594
|
+
canvasBg: 'gray',
|
|
595
|
+
bgOptions: [
|
|
596
|
+
{ key: 'checker', label: '棋盘' },
|
|
597
|
+
{ key: 'white', label: '白色' },
|
|
598
|
+
{ key: 'gray', label: '浅灰' },
|
|
599
|
+
],
|
|
600
|
+
// —— 预览区宽度可调(拖拽分隔条 / 预设尺寸,画布按实际像素宽度布局,无 transform 缩放)——
|
|
601
|
+
_previewWidthPx: 372, // 预览列宽度(像素,持久化到 localStorage);第一次打开未主动设置时默认 372px
|
|
602
|
+
presetSizes: [ // 预设尺寸下拉选项(仅宽度;高度随列高动态一致)
|
|
603
|
+
{ key: 375, value: '375' },
|
|
604
|
+
{ key: 430, value: '430' },
|
|
605
|
+
{ key: 188, value: '188' },
|
|
606
|
+
],
|
|
607
|
+
stageWidth: typeof window !== 'undefined' ? window.innerWidth : 1280, // 舞台实际宽度(ResizeObserver 实测后更新)
|
|
608
|
+
stageHeight: typeof window !== 'undefined' ? window.innerHeight - 52 : 748, // 舞台实际高度(contain 缩放的“高度上限”基准)
|
|
609
|
+
dragging: false, // 是否正在拖拽分隔条
|
|
610
|
+
_stageObserver: null,
|
|
611
|
+
_dragMove: null,
|
|
612
|
+
_dragUp: null,
|
|
613
|
+
// —— 本地开发服务(前后端分离)配置入口 ——
|
|
614
|
+
// devServiceForm:设置面板双向绑定的草稿;启用后前端所有 /__xxx 请求改走该地址。
|
|
615
|
+
devServiceOpen: false,
|
|
616
|
+
devServiceForm: loadDevServiceConfig(),
|
|
617
|
+
// apiBaseResolved:当前生效的 API 基址;null 表示部署态未配置(必须用本地服务才能编辑)。
|
|
618
|
+
apiBaseResolved: resolveApiBase(),
|
|
619
|
+
// devSvcLive:本地开发服务 / 同源 dev server 的实时连接态(由 SSE / Vite HMR 生命周期驱动):
|
|
620
|
+
// 'unknown' | 'connecting' | 'open' | 'closed'。与 apiBaseResolved(静态配置态)解耦,
|
|
621
|
+
// 用于顶栏「连接」图标真实反映服务端是否在运行。
|
|
622
|
+
devSvcLive: 'unknown',
|
|
623
|
+
// devSvcDown:是否已确认「本地开发服务断开」。与 devSvcLive 区分——
|
|
624
|
+
// EventSource 断链后会每 ~3s 自动重连并把 devSvcLive 翻回 'connecting',
|
|
625
|
+
// 若直接用 'closed' 判定提示条会闪烁消失;故用独立标志:仅在真正重连成功(open)时清除,
|
|
626
|
+
// 断开期间(含重连中)始终保持 true,提示条因此持续显示直到重连。
|
|
627
|
+
devSvcDown: false,
|
|
628
|
+
_unsubCardsChanged: null,
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
computed: {
|
|
632
|
+
currentBg() {
|
|
633
|
+
return this.bgOptions.find((b) => b.key === this.canvasBg) || this.bgOptions[0]
|
|
634
|
+
},
|
|
635
|
+
// 撤销可用:存在可回退的上一版本;重做可用:存在被撤销后的版本
|
|
636
|
+
canUndo() {
|
|
637
|
+
return this.past.length > 0
|
|
638
|
+
},
|
|
639
|
+
canRedo() {
|
|
640
|
+
return this.future.length > 0
|
|
641
|
+
},
|
|
642
|
+
// 部署态未生效的具体原因(基于设置面板草稿 devServiceForm 实时判断):
|
|
643
|
+
// 'empty' = 未填写服务地址;
|
|
644
|
+
// 'disabled' = 已填地址但未打开「启用」开关;
|
|
645
|
+
// null = 已正确配置(apiBaseResolved 不应为 null)。
|
|
646
|
+
devSvcBlockReason() {
|
|
647
|
+
const f = this.devServiceForm || {}
|
|
648
|
+
const hasUrl = !!String(f.baseUrl || '').trim()
|
|
649
|
+
if (!hasUrl) return 'empty'
|
|
650
|
+
if (!f.enabled) return 'disabled'
|
|
651
|
+
return null
|
|
652
|
+
},
|
|
653
|
+
// 剪贴板是否有可粘贴节点(供结构树「粘贴」按钮 disabled 态)
|
|
654
|
+
canPaste() {
|
|
655
|
+
return !!getClipboardNode()
|
|
656
|
+
},
|
|
657
|
+
// 当前卡片是否有导入来源信息(经同步远程导入才有 .import-meta.json):
|
|
658
|
+
// 无来源 = 本地新建卡,无远端卡片可推送版本,顶栏「版本推送」应置灰。
|
|
659
|
+
hasCardSource() {
|
|
660
|
+
return !!(this.cardImportMeta && this.cardImportMeta.source)
|
|
661
|
+
},
|
|
662
|
+
// 当前显示版本(schema 结构 + 数据绑定)是否与本地磁盘版本一致:
|
|
663
|
+
// 不一致(撤销/重做到历史版本、或编辑后未本地保存)时,顶栏「保存到本地」按钮高亮且可点击;
|
|
664
|
+
// 一致时按钮置灰不可用。
|
|
665
|
+
// 注意:数据绑定比较用 localData(= buildDataFromSchema(localSchema),载入/保存时记录),
|
|
666
|
+
// 而非 data.json 文件 —— data.json 仅为 schema 派生产物,永不读回。
|
|
667
|
+
historyDirty() {
|
|
668
|
+
if (!this.localSchema || !this.currentSchema) return false
|
|
669
|
+
return (
|
|
670
|
+
!this.structuralEqual(this.currentSchema, this.localSchema) ||
|
|
671
|
+
!this.dataEqual(this.rt && this.rt.rootScope, this.localData)
|
|
672
|
+
)
|
|
673
|
+
},
|
|
674
|
+
// 本地开发服务连接态聚合:静态配置态(apiBaseResolved) + 实时连接态(devSvcLive)。
|
|
675
|
+
// - null = 未配置(部署态必填 / 已填但未启用)
|
|
676
|
+
// - '' = 同源 dev 模式(页面本身由 dev server 提供)
|
|
677
|
+
// - 其它地址 = 已连接本地服务(8787)
|
|
678
|
+
devSvcConn() {
|
|
679
|
+
if (this.apiBaseResolved === null) return 'unconfigured'
|
|
680
|
+
if (this.devSvcLive === 'open') return 'open'
|
|
681
|
+
if (this.devSvcDown) return 'closed'
|
|
682
|
+
return 'connecting' // unknown 或 connecting 均视为「连接中 / 重连中」
|
|
683
|
+
},
|
|
684
|
+
// 顶栏「连接」图标 tooltip:按模式 + 实时连接态给出可读文案。
|
|
685
|
+
devSvcTitle() {
|
|
686
|
+
if (this.apiBaseResolved === null) {
|
|
687
|
+
return this.devSvcBlockReason === 'disabled'
|
|
688
|
+
? '已填地址但未开启用本地服务'
|
|
689
|
+
: '未配置本地开发服务(部署态必填)'
|
|
690
|
+
}
|
|
691
|
+
const prefix = this.apiBaseResolved === '' ? '同源开发服务' : '本地开发服务'
|
|
692
|
+
if (this.devSvcLive === 'open') return prefix + '已连接'
|
|
693
|
+
if (this.devSvcDown) return prefix + '已断开(连接失败或服务已停止,重连中…)'
|
|
694
|
+
return prefix + '连接中 / 重连中…'
|
|
695
|
+
},
|
|
696
|
+
// 顶栏「连接」图标配色态。
|
|
697
|
+
devSvcClass() {
|
|
698
|
+
if (this.apiBaseResolved === null) return 'app__btn--warn'
|
|
699
|
+
if (this.devSvcLive === 'open') return 'app__btn--connected'
|
|
700
|
+
if (this.devSvcDown) return 'app__btn--disconnected'
|
|
701
|
+
return 'app__btn--reconnecting'
|
|
702
|
+
},
|
|
703
|
+
// 底部状态条「连接」状态文案:与 devSvcClass / devSvcConn 同源,给出可朗读的简短状态(顶栏 tooltip 为完整说明)。
|
|
704
|
+
devSvcLabel() {
|
|
705
|
+
if (this.apiBaseResolved === null) return '未配置本地服务'
|
|
706
|
+
if (this.devSvcLive === 'open') return '本地服务已连接'
|
|
707
|
+
if (this.devSvcDown) return '本地服务已断开'
|
|
708
|
+
return '本地服务连接中…'
|
|
709
|
+
},
|
|
710
|
+
badgeText() {
|
|
711
|
+
const map = {
|
|
712
|
+
idle: '待校验',
|
|
713
|
+
pending: '校验中…',
|
|
714
|
+
ok: '校验通过',
|
|
715
|
+
error: '校验未通过',
|
|
716
|
+
nopython: '未配置校验',
|
|
717
|
+
fail: '校验服务异常',
|
|
718
|
+
}
|
|
719
|
+
return map[this.validateBadge.state] || '待校验'
|
|
720
|
+
},
|
|
721
|
+
badgeTitle() {
|
|
722
|
+
const b = this.validateBadge
|
|
723
|
+
if (b.state === 'ok') return '结构合法 + 渲染不抛错(不保证画得对,视觉正确性见截图)'
|
|
724
|
+
if (b.state === 'error') {
|
|
725
|
+
const parts = []
|
|
726
|
+
if (b.errors) parts.push(`契约错误 ${b.errors} 个`)
|
|
727
|
+
if (b.renderOk === false) parts.push('渲染抛出异常')
|
|
728
|
+
return '校验未通过:' + (parts.join(';') || '详见 /__validate 与 /__render-status')
|
|
729
|
+
}
|
|
730
|
+
if (b.state === 'nopython') return '未找到 python3/python,无法跑契约校验(请安装 Python 或设置 DSL_PYTHON)'
|
|
731
|
+
if (b.state === 'fail') return '校验服务异常(dev server 未启动或端点不可用)'
|
|
732
|
+
return ''
|
|
733
|
+
},
|
|
734
|
+
// 预览列宽度(像素):chromeOff 时占满整屏(截图/真机模式,非“宽度调整”场景);
|
|
735
|
+
// 否则使用 _previewWidthPx 并限制在 [188, 可视宽度/2],最大不得超过画布左侧可视区域的一半
|
|
736
|
+
previewWidth() {
|
|
737
|
+
if (this.chromeOff) return this.stageWidth
|
|
738
|
+
const total = this.stageWidth || 1
|
|
739
|
+
const minW = 188 // 最小宽度不得小于 188(预设最小尺寸)
|
|
740
|
+
const maxW = Math.max(minW, Math.floor(total / 2)) // 最大不得超过可视宽度的一半
|
|
741
|
+
const w = this._previewWidthPx || minW
|
|
742
|
+
return Math.max(minW, Math.min(maxW, w))
|
|
743
|
+
},
|
|
744
|
+
// 画布实际宽度(列宽,整数):device-bar__info 实时显示用,拖拽分隔条时随之变化
|
|
745
|
+
canvasW() {
|
|
746
|
+
return Math.round(this.previewWidth || 375)
|
|
747
|
+
},
|
|
748
|
+
// 缩放外框占满整列(宽高交由 CSS flex 填满;画布不再做 transform 缩放,直接按实际尺寸布局)
|
|
749
|
+
frameStyle() {
|
|
750
|
+
return {}
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
watch: {
|
|
754
|
+
// 切到「组件结构」以外的 tab 时,取消任何节点的选中状态
|
|
755
|
+
activeTab(val) {
|
|
756
|
+
if (val !== 'structure') {
|
|
757
|
+
this.selectedNodeId = ''
|
|
758
|
+
// 离开组件结构 tab:结构树被卸载,hover 参考线不再有意义,清除避免残留
|
|
759
|
+
clearHoverEl()
|
|
760
|
+
}
|
|
761
|
+
},
|
|
762
|
+
// 主题切换:把 'dark' / 'light' 写到 <html data-theme>,驱动 CSS 变量整体换肤
|
|
763
|
+
theme(val) {
|
|
764
|
+
document.documentElement.dataset.theme = val
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
beforeUnmount() {
|
|
768
|
+
if (this.viewportObserver) this.viewportObserver.disconnect()
|
|
769
|
+
if (this._stageObserver) this._stageObserver.disconnect()
|
|
770
|
+
if (this._dragMove) document.removeEventListener('mousemove', this._dragMove)
|
|
771
|
+
if (this._dragUp) document.removeEventListener('mouseup', this._dragUp)
|
|
772
|
+
if (this.saveTimer) clearTimeout(this.saveTimer)
|
|
773
|
+
if (this.syncTimer) clearTimeout(this.syncTimer)
|
|
774
|
+
if (this.saveRemoteTimer) clearTimeout(this.saveRemoteTimer)
|
|
775
|
+
window.removeEventListener('keydown', this.onKeydown)
|
|
776
|
+
},
|
|
777
|
+
mounted() {
|
|
778
|
+
// 视口高度随窗口弹性填充,实测高度同步到 device-bar 信息条
|
|
779
|
+
this.viewportObserver = new ResizeObserver((entries) => {
|
|
780
|
+
for (const entry of entries) {
|
|
781
|
+
this.viewportHeight = Math.round(entry.contentRect.height)
|
|
782
|
+
}
|
|
783
|
+
})
|
|
784
|
+
if (this.$refs.phoneViewport) this.viewportObserver.observe(this.$refs.phoneViewport)
|
|
785
|
+
// 预览区宽度可调:实测舞台尺寸,驱动 previewWidth/frameStyle 自适应窗口变化
|
|
786
|
+
this.loadPreviewWidth()
|
|
787
|
+
this._stageObserver = new ResizeObserver((entries) => {
|
|
788
|
+
for (const entry of entries) {
|
|
789
|
+
this.stageWidth = Math.round(entry.contentRect.width)
|
|
790
|
+
this.stageHeight = Math.round(entry.contentRect.height)
|
|
791
|
+
}
|
|
792
|
+
})
|
|
793
|
+
if (this.$refs.stageEl) this._stageObserver.observe(this.$refs.stageEl)
|
|
794
|
+
// 全局键盘快捷键:复制/粘贴/副本/删除/撤销/重做/取消选中(见 onKeydown)
|
|
795
|
+
window.addEventListener('keydown', this.onKeydown)
|
|
796
|
+
},
|
|
797
|
+
async created() {
|
|
798
|
+
// ynet.callJSAPI 统一桩:所有 jsapi 模拟集中在 runtime/jsapi-mock.js 分发
|
|
799
|
+
// (alertCard / closeAlertCard / alertDialog / showToast),不真正发请求。
|
|
800
|
+
installJsApiMock(cardRuntime)
|
|
801
|
+
// 纯净截图模式:?chrome=off 触发(隐藏调试外壳,见 template 的 v-show)
|
|
802
|
+
const chromeParam = new URLSearchParams(location.search).get('chrome')
|
|
803
|
+
this.chromeOff = chromeParam === 'off' || chromeParam === ''
|
|
804
|
+
// 主题:恢复本地持久化(默认深色);尽早应用到 <html>,避免首帧闪烁
|
|
805
|
+
try {
|
|
806
|
+
const t = localStorage.getItem('cardbuddy:theme')
|
|
807
|
+
if (t === 'light' || t === 'dark') this.theme = t
|
|
808
|
+
} catch (e) {
|
|
809
|
+
/* localStorage 不可用时忽略 */
|
|
810
|
+
}
|
|
811
|
+
document.documentElement.dataset.theme = this.theme
|
|
812
|
+
// 实时拉取卡片列表(不再依赖启动快照),再载入首张卡
|
|
813
|
+
await this.fetchCardList()
|
|
814
|
+
// 深链:若 URL 带 ?card=<id> 且命中列表,则预选该卡(供「远端预览」二维码扫码直达)
|
|
815
|
+
const deep = new URLSearchParams(location.search).get('card')
|
|
816
|
+
// 本地持久化:上次选中的卡片(无 URL 深链时优先恢复,而不是每次都回到首卡)
|
|
817
|
+
let persisted = null
|
|
818
|
+
try {
|
|
819
|
+
const v = localStorage.getItem('cardbuddy:selected-card')
|
|
820
|
+
if (v && this.cardList.some((c) => c.id === v)) persisted = v
|
|
821
|
+
} catch (e) {
|
|
822
|
+
/* localStorage 不可用时忽略 */
|
|
823
|
+
}
|
|
824
|
+
const initial =
|
|
825
|
+
deep && this.cardList.some((c) => c.id === deep)
|
|
826
|
+
? deep
|
|
827
|
+
: persisted || (this.cardList[0] && this.cardList[0].id)
|
|
828
|
+
if (initial) {
|
|
829
|
+
this.selectedId = initial
|
|
830
|
+
await this.loadCard(this.selectedId)
|
|
831
|
+
}
|
|
832
|
+
// 数据绑定(rt.rootScope)深度监听:任意编辑实时同步进 schema.dictionary.inputParams.defaultValue,
|
|
833
|
+
// 保证「保存到本地」基于最新数据绑定;预览也经 rt 响应式实时刷新。
|
|
834
|
+
// ⚠️ 此处【不】写历史——历史由「绑定数据」面板的「应用」按钮(schema-commit)落定,
|
|
835
|
+
// 与节点属性/事件面板一致:实时编辑只更新预览与内存,点「应用」才记一笔撤销记录。
|
|
836
|
+
this.$watch(
|
|
837
|
+
() => this.rt.rootScope,
|
|
838
|
+
() => {
|
|
839
|
+
this.syncDataToSchema()
|
|
840
|
+
},
|
|
841
|
+
{ deep: true }
|
|
842
|
+
)
|
|
843
|
+
// 监听 dev server 推送的磁盘变更事件:同步远程 / 其他窗口保存 / 手工编辑改了
|
|
844
|
+
// src/cards 后,预览重新拉取列表并在当前卡片受影响时即时重载,保证磁盘是唯一真相。
|
|
845
|
+
// 订阅磁盘变更推送:部署态经本地服务 SSE,开发期经 Vite HMR(见 @/api.js)
|
|
846
|
+
this.setupCardsChanged()
|
|
847
|
+
},
|
|
848
|
+
methods: {
|
|
849
|
+
/** 订阅磁盘变更推送:部署态经本地服务 SSE,开发期经 Vite HMR(见 @/api.js)。可重复调用以切换数据源。 */
|
|
850
|
+
setupCardsChanged() {
|
|
851
|
+
if (this._unsubCardsChanged) {
|
|
852
|
+
try { this._unsubCardsChanged() } catch (e) { /* ignore */ }
|
|
853
|
+
this._unsubCardsChanged = null
|
|
854
|
+
}
|
|
855
|
+
// 新订阅建立即进入「连接中」,待首帧 open / error 事件回写真实态
|
|
856
|
+
this.devSvcLive = 'connecting'
|
|
857
|
+
this._unsubCardsChanged = subscribeCardsChanged(
|
|
858
|
+
(payload) => this.onCardsChanged(payload),
|
|
859
|
+
(state) => this.onDevServiceState(state)
|
|
860
|
+
)
|
|
861
|
+
},
|
|
862
|
+
/**
|
|
863
|
+
* SSE / Vite HMR 连接态变化时回调,驱动顶栏「连接」图标实时反映服务端运行态。
|
|
864
|
+
* 断线 watchdog:若持续处于「连接中」超过 8s 仍未 open(服务端确已停止),翻为「已断开」,
|
|
865
|
+
* 避免用户看到永远转圈的黄灯误以为还活着;EventSource 后台仍会自动重连,恢复时 onopen 会翻回 open。
|
|
866
|
+
*/
|
|
867
|
+
onDevServiceState(state) {
|
|
868
|
+
const wasConnecting = this.devSvcLive === 'connecting'
|
|
869
|
+
this.devSvcLive = state
|
|
870
|
+
if (state === 'open') {
|
|
871
|
+
// 真正重连成功:清除「已断开」标志,顶部提示条随之关闭
|
|
872
|
+
this.devSvcDown = false
|
|
873
|
+
} else if (state === 'closed') {
|
|
874
|
+
// 服务确已彻底关闭(EventSource readyState===2 / 显式 close):标记已断开
|
|
875
|
+
this.devSvcDown = true
|
|
876
|
+
}
|
|
877
|
+
// 注意:state==='connecting' 且此前已 devSvcDown===true(断开后重试)时,
|
|
878
|
+
// 保持 devSvcDown 为 true,使提示条在重连过程中持续显示,不闪退。
|
|
879
|
+
if (state === 'connecting') {
|
|
880
|
+
// 仅在「从其他态首次进入连接中」时启动 watchdog;
|
|
881
|
+
// 浏览器 EventSource 断链后会每 ~3s 自动重连并重发 onerror('connecting'),
|
|
882
|
+
// 若每次都重置计时器,则永远到不了 8s —— 故重复 connecting 不再重置。
|
|
883
|
+
if (!wasConnecting && !this._svcWatchdog) {
|
|
884
|
+
this._svcWatchdog = setTimeout(() => {
|
|
885
|
+
if (this.devSvcLive === 'connecting') {
|
|
886
|
+
this.devSvcLive = 'closed'
|
|
887
|
+
// watchdog 确认服务停止:标记已断开(即便后续重试翻回 connecting 也保持)
|
|
888
|
+
this.devSvcDown = true
|
|
889
|
+
}
|
|
890
|
+
this._svcWatchdog = null
|
|
891
|
+
}, 8000)
|
|
892
|
+
}
|
|
893
|
+
} else if (this._svcWatchdog) {
|
|
894
|
+
clearTimeout(this._svcWatchdog)
|
|
895
|
+
this._svcWatchdog = null
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
/** 保存「本地开发服务」配置并即时切换数据源 */
|
|
899
|
+
saveDevService() {
|
|
900
|
+
saveDevServiceConfig(this.devServiceForm)
|
|
901
|
+
this.apiBaseResolved = resolveApiBase()
|
|
902
|
+
this.setupCardsChanged()
|
|
903
|
+
this.devServiceOpen = false
|
|
904
|
+
showAppToast('本地开发服务配置已保存', 'ok')
|
|
905
|
+
this.fetchCardList()
|
|
906
|
+
},
|
|
907
|
+
// —— 预览区宽度调节 ——
|
|
908
|
+
// 拖拽分隔条:拖动改变预览列宽度(直接写入 _previewWidthPx 像素,previewWidth/canvasW 自动联动),松手持久化
|
|
909
|
+
startDrag() {
|
|
910
|
+
if (this.chromeOff) return
|
|
911
|
+
this.dragging = true
|
|
912
|
+
document.body.style.cursor = 'col-resize'
|
|
913
|
+
document.body.style.userSelect = 'none'
|
|
914
|
+
const stage = this.$refs.stageEl
|
|
915
|
+
this._dragMove = (ev) => {
|
|
916
|
+
const rect = stage.getBoundingClientRect()
|
|
917
|
+
const minW = 188 // 最小宽度不得小于 188
|
|
918
|
+
const maxW = Math.max(minW, Math.floor(rect.width / 2)) // 最大不得超过可视宽度的一半
|
|
919
|
+
let w = ev.clientX - rect.left
|
|
920
|
+
w = Math.max(minW, Math.min(maxW, w))
|
|
921
|
+
this._previewWidthPx = w
|
|
922
|
+
}
|
|
923
|
+
this._dragUp = () => {
|
|
924
|
+
this.dragging = false
|
|
925
|
+
document.body.style.cursor = ''
|
|
926
|
+
document.body.style.userSelect = ''
|
|
927
|
+
document.removeEventListener('mousemove', this._dragMove)
|
|
928
|
+
document.removeEventListener('mouseup', this._dragUp)
|
|
929
|
+
this.savePreviewWidth()
|
|
930
|
+
}
|
|
931
|
+
document.addEventListener('mousemove', this._dragMove)
|
|
932
|
+
document.addEventListener('mouseup', this._dragUp)
|
|
933
|
+
},
|
|
934
|
+
// 读取上次保存的预览列宽度(像素);首次打开(无记录)时保持默认 372px
|
|
935
|
+
loadPreviewWidth() {
|
|
936
|
+
try {
|
|
937
|
+
const v = localStorage.getItem('cardbuddy:preview-width')
|
|
938
|
+
if (v != null) {
|
|
939
|
+
const px = parseInt(v, 10)
|
|
940
|
+
if (!Number.isNaN(px) && px >= 188) this._previewWidthPx = px
|
|
941
|
+
}
|
|
942
|
+
} catch (e) { /* localStorage 不可用时忽略 */ }
|
|
943
|
+
},
|
|
944
|
+
// 持久化预览列宽度(像素)到 localStorage
|
|
945
|
+
savePreviewWidth() {
|
|
946
|
+
try {
|
|
947
|
+
localStorage.setItem('cardbuddy:preview-width', String(Math.round(this._previewWidthPx)))
|
|
948
|
+
} catch (e) { /* 忽略 */ }
|
|
949
|
+
},
|
|
950
|
+
// 预设尺寸切换:375 / 188 一键设定预览列宽度;高度随列高动态保持一致(由 CSS flex 自动填充)
|
|
951
|
+
setPresetWidth(w) {
|
|
952
|
+
if (this.chromeOff) return
|
|
953
|
+
const total = this.stageWidth || 1
|
|
954
|
+
const minW = 188
|
|
955
|
+
const maxW = Math.max(minW, Math.floor(total / 2)) // 最大不得超过可视宽度的一半
|
|
956
|
+
this._previewWidthPx = Math.max(minW, Math.min(maxW, w))
|
|
957
|
+
this.savePreviewWidth()
|
|
958
|
+
},
|
|
959
|
+
/** 背景下拉:选中某项 */
|
|
960
|
+
chooseBg(key) {
|
|
961
|
+
this.canvasBg = key
|
|
962
|
+
},
|
|
963
|
+
onSelect(id) {
|
|
964
|
+
this.loadingCard = true
|
|
965
|
+
this.selectedId = id
|
|
966
|
+
// 持久化当前选中卡片,下次打开页面自动恢复(沿用 cardbuddy: 前缀约定)
|
|
967
|
+
try {
|
|
968
|
+
localStorage.setItem('cardbuddy:selected-card', id)
|
|
969
|
+
} catch (e) {
|
|
970
|
+
/* localStorage 不可用时忽略 */
|
|
971
|
+
}
|
|
972
|
+
this.loadCard(id, { clearAll: true })
|
|
973
|
+
},
|
|
974
|
+
/** 打开卡片网格预览弹窗 */
|
|
975
|
+
openGallery() {
|
|
976
|
+
if (!this.cardList.length) return
|
|
977
|
+
this.galleryOpen = true
|
|
978
|
+
},
|
|
979
|
+
/** 关闭卡片网格预览弹窗 */
|
|
980
|
+
closeGallery() {
|
|
981
|
+
this.galleryOpen = false
|
|
982
|
+
},
|
|
983
|
+
/**
|
|
984
|
+
* 顶栏「打开目录」按钮:请求本地开发服务在系统文件管理器打开
|
|
985
|
+
* 当前卡片对应文件夹(src/cards/<selectedId>)。跨平台(win/mac)由后端完成。
|
|
986
|
+
*/
|
|
987
|
+
async openCardFolder() {
|
|
988
|
+
if (!this.selectedId) return
|
|
989
|
+
try {
|
|
990
|
+
const res = await apiFetch('/__open-folder', {
|
|
991
|
+
method: 'POST',
|
|
992
|
+
headers: { 'Content-Type': 'application/json' },
|
|
993
|
+
body: JSON.stringify({ id: this.selectedId }),
|
|
994
|
+
})
|
|
995
|
+
const data = (res.headers.get('content-type') || '').includes('application/json')
|
|
996
|
+
? await res.json().catch(() => ({}))
|
|
997
|
+
: {}
|
|
998
|
+
if (!res.ok || !data.ok) {
|
|
999
|
+
showAppToast(data.error || '打开文件夹失败', 'err')
|
|
1000
|
+
} else {
|
|
1001
|
+
showAppToast('已打开卡片文件夹', 'ok')
|
|
1002
|
+
}
|
|
1003
|
+
} catch (e) {
|
|
1004
|
+
showAppToast('无法连接本地开发服务,请确认已启动', 'err')
|
|
1005
|
+
}
|
|
1006
|
+
},
|
|
1007
|
+
/**
|
|
1008
|
+
* 底部状态条刷新按钮:重新从本地开发服务拉取当前卡片并重建预览。
|
|
1009
|
+
*/
|
|
1010
|
+
async refreshCard() {
|
|
1011
|
+
if (!this.selectedId) return
|
|
1012
|
+
await this.loadCard(this.selectedId)
|
|
1013
|
+
},
|
|
1014
|
+
// 切换深/浅主题:写入 <html data-theme> 并持久化,刷新后保持
|
|
1015
|
+
toggleTheme() {
|
|
1016
|
+
this.theme = this.theme === 'light' ? 'dark' : 'light'
|
|
1017
|
+
try {
|
|
1018
|
+
localStorage.setItem('cardbuddy:theme', this.theme)
|
|
1019
|
+
} catch (e) {
|
|
1020
|
+
/* localStorage 不可用时忽略 */
|
|
1021
|
+
}
|
|
1022
|
+
},
|
|
1023
|
+
/**
|
|
1024
|
+
* 整卡校验徽标(Q3:仅 ok/error,不展开 bindingErrors 明细)。
|
|
1025
|
+
* 调 /__validate(契约硬校验)与 /__render-status(结构合法 + 渲染不抛错)两个既有端点,
|
|
1026
|
+
* 均读磁盘,故在「载入 / 本地保存」后刷新——编辑→本地保存→徽标即时反映。
|
|
1027
|
+
* 语义三处一致:ok = 结构合法 + 渲染不抛错,**不保证画得对**(视觉见截图)。
|
|
1028
|
+
* bindingErrors 明细不进徽标(已有 DebugPanel 状态栏单独展示)。
|
|
1029
|
+
*/
|
|
1030
|
+
async refreshValidateBadge() {
|
|
1031
|
+
const id = this.selectedId
|
|
1032
|
+
if (!id || !this.preview) return
|
|
1033
|
+
if (this.validateTimer) clearTimeout(this.validateTimer)
|
|
1034
|
+
this.validateBadge = { ...this.validateBadge, state: 'pending' }
|
|
1035
|
+
// 轻量去抖:连续保存/热更新时只取最后一次结果
|
|
1036
|
+
await new Promise((r) => {
|
|
1037
|
+
this.validateTimer = setTimeout(r, 120)
|
|
1038
|
+
})
|
|
1039
|
+
if (this.selectedId !== id) return // 已切卡,丢弃过期结果
|
|
1040
|
+
try {
|
|
1041
|
+
const [vRes, rRes] = await Promise.all([
|
|
1042
|
+
apiFetch(`/__validate/${encodeURIComponent(id)}`),
|
|
1043
|
+
apiFetch(`/__render-status/${encodeURIComponent(id)}`),
|
|
1044
|
+
])
|
|
1045
|
+
// 503 = 未装 python(环境缺失,非卡片问题)
|
|
1046
|
+
if (!vRes.ok) {
|
|
1047
|
+
this.validateBadge = { ...this.validateBadge, state: vRes.status === 503 ? 'nopython' : 'fail' }
|
|
1048
|
+
return
|
|
1049
|
+
}
|
|
1050
|
+
const v = await vRes.json().catch(() => null)
|
|
1051
|
+
const r = rRes.ok ? await rRes.json().catch(() => null) : null
|
|
1052
|
+
if (!v) {
|
|
1053
|
+
this.validateBadge = { ...this.validateBadge, state: 'fail' }
|
|
1054
|
+
return
|
|
1055
|
+
}
|
|
1056
|
+
const contractOk = !!v.ok
|
|
1057
|
+
// 渲染端点异常不强行判错(避免 dev server 预热期 SSR 冷启动误报)
|
|
1058
|
+
const renderOk = r ? !!r.renderOk : true
|
|
1059
|
+
const ok = contractOk && renderOk
|
|
1060
|
+
this.validateBadge = {
|
|
1061
|
+
state: ok ? 'ok' : 'error',
|
|
1062
|
+
errors: (v.errors || []).length,
|
|
1063
|
+
warnings: (v.warnings || []).length,
|
|
1064
|
+
renderOk,
|
|
1065
|
+
ts: Date.now(),
|
|
1066
|
+
}
|
|
1067
|
+
} catch (e) {
|
|
1068
|
+
// 网络/服务不可用:保底不阻断编辑,标记 fail
|
|
1069
|
+
this.validateBadge = { ...this.validateBadge, state: 'fail' }
|
|
1070
|
+
}
|
|
1071
|
+
},
|
|
1072
|
+
/** 实时从磁盘读取并载入指定卡片(GET /__card/<id>),不读任何缓存 */
|
|
1073
|
+
async loadCard(id, opts = {}) {
|
|
1074
|
+
if (!id) return
|
|
1075
|
+
this.loadingCard = true
|
|
1076
|
+
try {
|
|
1077
|
+
const res = await apiFetch(`/__card/${encodeURIComponent(id)}`)
|
|
1078
|
+
if (!res.ok) {
|
|
1079
|
+
this.rt.pushError('loadCard', 'HTTP ' + res.status)
|
|
1080
|
+
return
|
|
1081
|
+
}
|
|
1082
|
+
const fresh = await res.json()
|
|
1083
|
+
if (!fresh.schema) {
|
|
1084
|
+
this.rt.pushError('loadCard', 'schema 读取失败')
|
|
1085
|
+
return
|
|
1086
|
+
}
|
|
1087
|
+
// 切换卡片:清全部错误;刷新/重建预览:只清绑定错误,保留事件错误
|
|
1088
|
+
if (opts.clearAll) {
|
|
1089
|
+
this.rt.clearErrors()
|
|
1090
|
+
} else {
|
|
1091
|
+
this.rt.clearBindingErrors()
|
|
1092
|
+
}
|
|
1093
|
+
this.rt.clearApiCalls()
|
|
1094
|
+
// schema 被事件编辑器改后重建时保留当前数据绑定编辑,避免被磁盘 data(由 schema 派生)覆盖。
|
|
1095
|
+
// 数据绑定改为由 schema 单向派生,不再读回 data.json 文件。
|
|
1096
|
+
if (!opts.keepScope) this.rt.setRootScope(buildDataFromSchema(fresh.schema))
|
|
1097
|
+
this.currentCard = { schema: fresh.schema }
|
|
1098
|
+
this.currentSchema = fresh.schema
|
|
1099
|
+
// 重置并在载入后拉取导入来源元数据(Phase E):本地新建卡无 .import-meta.json → null,
|
|
1100
|
+
// 顶栏「版本推送」据此置灰(无来源则无远端可推送版本)。同步远程后 onRefresh 也会回到此处刷新。
|
|
1101
|
+
this.cardImportMeta = null
|
|
1102
|
+
this.fetchImportMeta(id)
|
|
1103
|
+
// 记录本地真实版本基线(磁盘当前内容),供「保存到本地」按钮判断与本地是否一致。
|
|
1104
|
+
// localData 由 schema 派生(与渲染注入同源),保证纯数据编辑也能触发脏标记。
|
|
1105
|
+
this.localSchema = this.cloneSchema(fresh.schema)
|
|
1106
|
+
this.localData = this.cloneData(buildDataFromSchema(fresh.schema))
|
|
1107
|
+
this.preview = buildPreview(fresh.schema)
|
|
1108
|
+
// Task #5:把 Property 树模型森林暴露到 window,供浏览器内 AI / skill 直接读取节点可编辑结构
|
|
1109
|
+
if (typeof window !== 'undefined') window.__cardModel = buildModelView(fresh.schema)
|
|
1110
|
+
// 标脏后重新挂 data-card-ready:截图模式下 screenshot.js 等该属性出现再截,
|
|
1111
|
+
// 保证截到的是已挂载的首帧而非空壳。先置 false 让切换卡片时属性短暂消失,
|
|
1112
|
+
// 待 RenderTree 重新挂载后于 nextTick 置 true。
|
|
1113
|
+
this.cardReady = false
|
|
1114
|
+
this.$nextTick(() => {
|
|
1115
|
+
this.cardReady = true
|
|
1116
|
+
})
|
|
1117
|
+
// 重建后立即把当前数据绑定同步进 schema(尤其 keepScope 时 currentSchema 被重置为磁盘版)
|
|
1118
|
+
this.syncDataToSchema()
|
|
1119
|
+
this.selectedNodeId = ''
|
|
1120
|
+
// 以刚载入的磁盘版本作为「已提交基线」(仅 schema;数据绑定由 schema.defaultValue 派生,单向)。
|
|
1121
|
+
// 撤销/重做历史快照也只存 schema,还原时由 buildDataFromSchema(schema) 重建数据绑定。
|
|
1122
|
+
this.baseline = {
|
|
1123
|
+
schema: this.cloneSchema(this.currentSchema),
|
|
1124
|
+
}
|
|
1125
|
+
// 递增 token 强制 RenderTree 重新挂载(重跑 init),保证新卡片 / 新 schema 的
|
|
1126
|
+
// 初始化动作(如 assetOverview 计算 ratio)一定在 created 阶段执行。
|
|
1127
|
+
this.reloadToken += 1
|
|
1128
|
+
// 切换主卡片时清理弹窗与设备级模拟 UI,避免残留引用旧上下文
|
|
1129
|
+
clearAllAlerts()
|
|
1130
|
+
clearDeviceUI()
|
|
1131
|
+
// 载入即刷新整卡校验徽标(端点读磁盘,载入后状态最新)
|
|
1132
|
+
this.refreshValidateBadge()
|
|
1133
|
+
// 载入编辑历史(落盘在 .history/):重置 past/future 并重建撤销栈
|
|
1134
|
+
await this.loadHistory(id)
|
|
1135
|
+
} finally {
|
|
1136
|
+
this.loadingCard = false
|
|
1137
|
+
}
|
|
1138
|
+
},
|
|
1139
|
+
/**
|
|
1140
|
+
* 编辑历史:深拷贝 schema(剥离运行时 _schemaNode 自引用)。
|
|
1141
|
+
*/
|
|
1142
|
+
cloneSchema(schema) {
|
|
1143
|
+
if (!schema) return null
|
|
1144
|
+
return JSON.parse(JSON.stringify(schema, (k, v) => (k === '_schemaNode' ? undefined : v)))
|
|
1145
|
+
},
|
|
1146
|
+
/**
|
|
1147
|
+
* 归一化用于结构比较:忽略由数据绑定推导写入的 dictionary.inputParams[].defaultValue
|
|
1148
|
+
* (这部分是数据绑定镜像,非结构性编辑,不应触发快照)。
|
|
1149
|
+
*/
|
|
1150
|
+
normalizeForCompare(schema) {
|
|
1151
|
+
const clone = this.cloneSchema(schema)
|
|
1152
|
+
if (clone && clone.dictionary && Array.isArray(clone.dictionary.inputParams)) {
|
|
1153
|
+
for (const p of clone.dictionary.inputParams) {
|
|
1154
|
+
if (p && typeof p === 'object') p.defaultValue = undefined
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
return clone
|
|
1158
|
+
},
|
|
1159
|
+
/** 结构性相等(忽略数据绑定 defaultValue 噪声) */
|
|
1160
|
+
structuralEqual(a, b) {
|
|
1161
|
+
if (!a || !b) return a === b
|
|
1162
|
+
return JSON.stringify(this.normalizeForCompare(a)) === JSON.stringify(this.normalizeForCompare(b))
|
|
1163
|
+
},
|
|
1164
|
+
/** 深拷贝数据绑定(rt.rootScope);同样剥离运行时 _schemaNode,避免自引用 */
|
|
1165
|
+
cloneData(data) {
|
|
1166
|
+
if (data == null) return data
|
|
1167
|
+
return JSON.parse(JSON.stringify(data, (k, v) => (k === '_schemaNode' ? undefined : v)))
|
|
1168
|
+
},
|
|
1169
|
+
/** 数据绑定(rt.rootScope)深层相等:用于判断绑定编辑是否产生了新快照 */
|
|
1170
|
+
dataEqual(a, b) {
|
|
1171
|
+
return JSON.stringify(a) === JSON.stringify(b)
|
|
1172
|
+
},
|
|
1173
|
+
/**
|
|
1174
|
+
* 把历史快照(某一旧版本 { schema })应用回当前编辑态:替换 currentSchema 与数据绑定,
|
|
1175
|
+
* 重建预览/模型视图,但不产生新的历史记录(撤销/重做本身是导航,不是新修改)。
|
|
1176
|
+
* 数据绑定由 buildDataFromSchema(schema) 从快照的 schema(含 defaultValue 镜像)重建,
|
|
1177
|
+
* 还原后再 syncDataToSchema,保证 schema.dictionary.inputParams.defaultValue 与新数据绑定一致。
|
|
1178
|
+
*/
|
|
1179
|
+
applySchemaSnapshot(snapshot) {
|
|
1180
|
+
const id = this.selectedId
|
|
1181
|
+
if (!id || !snapshot) return
|
|
1182
|
+
const schema = snapshot.schema || snapshot
|
|
1183
|
+
// 快照只存 schema;数据绑定由其派生。若老快照带 data 则优先沿用(历史兼容)。
|
|
1184
|
+
const data = snapshot.data != null ? snapshot.data : buildDataFromSchema(schema)
|
|
1185
|
+
this.currentSchema = this.cloneSchema(schema)
|
|
1186
|
+
this.rt.setRootScope(this.cloneData(data))
|
|
1187
|
+
this.preview = buildPreview(this.currentSchema)
|
|
1188
|
+
if (typeof window !== 'undefined') window.__cardModel = buildModelView(this.currentSchema)
|
|
1189
|
+
this.syncDataToSchema()
|
|
1190
|
+
this.selectedNodeId = ''
|
|
1191
|
+
this.reloadToken += 1
|
|
1192
|
+
this.refreshValidateBadge()
|
|
1193
|
+
},
|
|
1194
|
+
/** 提交一次编辑:与 baseline(仅 schema)比较,schema 结构或数据绑定任一变化即算改动,
|
|
1195
|
+
* 把「上一版本(baseline)」压入 past 并落盘(仅 schema),清空重做栈。
|
|
1196
|
+
* 数据绑定比较用 buildDataFromSchema(baseline.schema) 派生,与 baseline 记录时一致。 */
|
|
1197
|
+
commitHistory() {
|
|
1198
|
+
if (!this.currentSchema || !this.baseline) return
|
|
1199
|
+
const sameSchema = this.structuralEqual(this.currentSchema, this.baseline.schema)
|
|
1200
|
+
const sameData = this.dataEqual(this.rt.rootScope, buildDataFromSchema(this.baseline.schema))
|
|
1201
|
+
if (sameSchema && sameData) return
|
|
1202
|
+
const previous = { schema: this.baseline.schema }
|
|
1203
|
+
this.past.push(previous)
|
|
1204
|
+
if (this.past.length > 30) this.past.shift()
|
|
1205
|
+
this.future = []
|
|
1206
|
+
this.baseline = { schema: this.cloneSchema(this.currentSchema) }
|
|
1207
|
+
// 落盘:把「上一版本」写入卡片同目录 .history/(最多 30 个,由服务端裁剪)
|
|
1208
|
+
this.persistSnapshot(previous)
|
|
1209
|
+
},
|
|
1210
|
+
/** 编辑落定防抖:连续编辑(如连续改属性)合并为一次快照 */
|
|
1211
|
+
scheduleCommit() {
|
|
1212
|
+
if (this._commitTimer) clearTimeout(this._commitTimer)
|
|
1213
|
+
this._commitTimer = setTimeout(() => {
|
|
1214
|
+
this._commitTimer = null
|
|
1215
|
+
this.commitHistory()
|
|
1216
|
+
}, 500)
|
|
1217
|
+
},
|
|
1218
|
+
/** 把上一版本 { schema } 落盘到 .history/(fire-and-forget,失败仅告警;data 由服务端从 schema 派生) */
|
|
1219
|
+
persistSnapshot(snapshot) {
|
|
1220
|
+
const id = this.selectedId
|
|
1221
|
+
if (!id) return
|
|
1222
|
+
const schema = snapshot.schema || snapshot
|
|
1223
|
+
apiFetch(`/__history/${encodeURIComponent(id)}`, {
|
|
1224
|
+
method: 'POST',
|
|
1225
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1226
|
+
body: JSON.stringify({ schema }, (k, v) => (k === '_schemaNode' ? undefined : v), 2),
|
|
1227
|
+
}).catch((e) => console.warn('[历史快照落盘失败]', e))
|
|
1228
|
+
},
|
|
1229
|
+
/** 载入指定卡片的编辑历史(从 .history/ 读取,旧→新),用于页面重载后重建撤销栈 */
|
|
1230
|
+
async loadHistory(id) {
|
|
1231
|
+
this.past = []
|
|
1232
|
+
this.future = []
|
|
1233
|
+
try {
|
|
1234
|
+
const res = await apiFetch(`/__history/${encodeURIComponent(id)}`)
|
|
1235
|
+
if (!res.ok) return
|
|
1236
|
+
const r = await res.json().catch(() => null)
|
|
1237
|
+
const snaps = (r && r.snapshots) || []
|
|
1238
|
+
// 取末尾最多 30 个作为可回退的上一版本(最新在末尾;仅 schema,data 由 schema 派生)
|
|
1239
|
+
this.past = snaps.slice(-30).map((s) => ({
|
|
1240
|
+
schema: s.schema,
|
|
1241
|
+
data: s.data != null ? s.data : buildDataFromSchema(s.schema),
|
|
1242
|
+
}))
|
|
1243
|
+
} catch (e) {
|
|
1244
|
+
console.warn('[加载编辑历史失败]', e)
|
|
1245
|
+
}
|
|
1246
|
+
},
|
|
1247
|
+
/** 后退一步(撤销):弹出最近一次编辑前的版本并应用 */
|
|
1248
|
+
undo() {
|
|
1249
|
+
if (!this.canUndo) return
|
|
1250
|
+
const prev = this.past.pop()
|
|
1251
|
+
this.future.push(this.baseline)
|
|
1252
|
+
if (this.future.length > 30) this.future.shift()
|
|
1253
|
+
this.baseline = prev
|
|
1254
|
+
this.applySchemaSnapshot(prev)
|
|
1255
|
+
},
|
|
1256
|
+
/** 前进一步(重做):重放被撤销的版本 */
|
|
1257
|
+
redo() {
|
|
1258
|
+
if (!this.canRedo) return
|
|
1259
|
+
const next = this.future.pop()
|
|
1260
|
+
this.past.push(this.baseline)
|
|
1261
|
+
if (this.past.length > 30) this.past.shift()
|
|
1262
|
+
this.baseline = next
|
|
1263
|
+
this.applySchemaSnapshot(next)
|
|
1264
|
+
},
|
|
1265
|
+
/**
|
|
1266
|
+
* 顶栏「保存到本地」:把当前显示的历史版本(撤销/重做导航到的版本,或编辑后未保存的版本)
|
|
1267
|
+
* 真正写回本地磁盘(src/cards/<id>/schema.json + data.json)。写盘成功后 saveCard 会刷新
|
|
1268
|
+
* 本地基线,使按钮在「当前版本 == 本地版本」时自动置灰;下一次重新打开即加载该真实版本,
|
|
1269
|
+
* 而非任意历史记录。
|
|
1270
|
+
*/
|
|
1271
|
+
async applyHistoryToLocal() {
|
|
1272
|
+
// 切卡/载入进行中:currentSchema 仍是上一卡、selectedId 已是新卡,拦截以免把上一卡误写进新卡文件
|
|
1273
|
+
if (this.loadingCard) return
|
|
1274
|
+
if (!this.historyDirty || !this.preview || !this.selectedId) return
|
|
1275
|
+
const ok = await this.saveCard()
|
|
1276
|
+
if (ok) showAppToast('已将该版本保存到本地', 'ok')
|
|
1277
|
+
},
|
|
1278
|
+
/**
|
|
1279
|
+
* schema 被事件编辑器修改后,用内存中已编辑(事件编辑器直接改了 currentSchema 对象)
|
|
1280
|
+
* 的版本重建预览,不回读磁盘以免丢失编辑。强制重挂载以重跑 init 生命周期。
|
|
1281
|
+
*/
|
|
1282
|
+
/** 实时编辑(属性/事件/数据绑定)重建预览:仅刷新预览,不写历史。
|
|
1283
|
+
* 历史由「应用」按钮(onSchemaCommit)落定写入。 */
|
|
1284
|
+
onSchemaChanged() {
|
|
1285
|
+
const id = this.selectedId
|
|
1286
|
+
if (!id || !this.currentSchema) return
|
|
1287
|
+
this.reloadToken += 1
|
|
1288
|
+
this.preview = buildPreview(this.currentSchema)
|
|
1289
|
+
if (typeof window !== 'undefined') window.__cardModel = buildModelView(this.currentSchema)
|
|
1290
|
+
this.syncDataToSchema()
|
|
1291
|
+
this.selectedNodeId = ''
|
|
1292
|
+
},
|
|
1293
|
+
/** 「应用」按钮:以最新编辑重建预览,并写入一次历史(生成一条撤销记录)。
|
|
1294
|
+
* 区别于 onSchemaChanged:这里落定历史,实时编辑路径不写历史。 */
|
|
1295
|
+
onSchemaCommit() {
|
|
1296
|
+
const id = this.selectedId
|
|
1297
|
+
if (!id || !this.currentSchema) return
|
|
1298
|
+
this.reloadToken += 1
|
|
1299
|
+
this.preview = buildPreview(this.currentSchema)
|
|
1300
|
+
if (typeof window !== 'undefined') window.__cardModel = buildModelView(this.currentSchema)
|
|
1301
|
+
this.syncDataToSchema()
|
|
1302
|
+
this.selectedNodeId = ''
|
|
1303
|
+
this.commitHistory()
|
|
1304
|
+
},
|
|
1305
|
+
/**
|
|
1306
|
+
* 把数据绑定(rt.rootScope,即 data.json)的编辑实时同步回 schema 的
|
|
1307
|
+
* 权威数据位置:schema.dictionary.inputParams[i].defaultValue。
|
|
1308
|
+
* 这样「本地保存」时 schema.json 与 data.json 都携带最新数据绑定,
|
|
1309
|
+
* 二者同时生效、不会互相覆盖(data.json 本身即由 inputParams.defaultValue 派生)。
|
|
1310
|
+
*/
|
|
1311
|
+
syncDataToSchema() {
|
|
1312
|
+
const schema = this.currentSchema
|
|
1313
|
+
if (!schema || !schema.dictionary || !Array.isArray(schema.dictionary.inputParams)) return
|
|
1314
|
+
const root = this.rt && this.rt.rootScope
|
|
1315
|
+
if (!root || typeof root !== 'object') return
|
|
1316
|
+
const params = schema.dictionary.inputParams
|
|
1317
|
+
for (const key of Object.keys(root)) {
|
|
1318
|
+
const param = params.find((p) => p && (p.key === key || p.name === key))
|
|
1319
|
+
if (!param) continue
|
|
1320
|
+
// 深拷贝,避免数据绑定与 schema 共享同一引用(setRootScope 重建后会失联)
|
|
1321
|
+
param.defaultValue = JSON.parse(
|
|
1322
|
+
JSON.stringify(root[key], (k, v) => (k === '_schemaNode' ? undefined : v))
|
|
1323
|
+
)
|
|
1324
|
+
}
|
|
1325
|
+
},
|
|
1326
|
+
/** 结构树选中节点 */
|
|
1327
|
+
onSelectNode(id) {
|
|
1328
|
+
this.selectedNodeId = id || ''
|
|
1329
|
+
},
|
|
1330
|
+
/**
|
|
1331
|
+
* 在预览树(preview.view)中按运行时 id 递归查找对应节点。
|
|
1332
|
+
* @param {Object} view 预览树根(或任意子树入口)
|
|
1333
|
+
* @param {string} id 目标节点的运行时 id(即 selectedNodeId 的取值)
|
|
1334
|
+
* @returns {Object|null}
|
|
1335
|
+
*/
|
|
1336
|
+
findPreviewNodeById(view, id) {
|
|
1337
|
+
if (!view || id == null) return null
|
|
1338
|
+
const key = String(id)
|
|
1339
|
+
const walk = (node) => {
|
|
1340
|
+
if (!node) return null
|
|
1341
|
+
if (String(node.id) === key) return node
|
|
1342
|
+
const kids = []
|
|
1343
|
+
if (node.components) kids.push(...node.components)
|
|
1344
|
+
if (node.slots) for (const s of node.slots) if (s.components) kids.push(...s.components)
|
|
1345
|
+
for (const k of kids) {
|
|
1346
|
+
const r = walk(k)
|
|
1347
|
+
if (r) return r
|
|
1348
|
+
}
|
|
1349
|
+
return null
|
|
1350
|
+
}
|
|
1351
|
+
return walk(view)
|
|
1352
|
+
},
|
|
1353
|
+
/**
|
|
1354
|
+
* 复制选中节点:把其在 schema 树中的子树深拷贝进剪贴板(含子节点)。
|
|
1355
|
+
* 根节点或空选区不处理。
|
|
1356
|
+
*/
|
|
1357
|
+
copySelectedNode() {
|
|
1358
|
+
const id = this.selectedNodeId
|
|
1359
|
+
if (!id || id === '_root' || !this.currentSchema || !this.preview) return
|
|
1360
|
+
const vn = this.findPreviewNodeById(this.preview.view, id)
|
|
1361
|
+
if (!vn) return
|
|
1362
|
+
const loc = this.findSchemaNodeByComponentId(this.currentSchema.view, vn.componentId)
|
|
1363
|
+
if (!loc) return
|
|
1364
|
+
// 记录来源卡片 id,供跨卡片粘贴标注与兼容性校验
|
|
1365
|
+
setClipboardNode(loc.node, this.selectedId)
|
|
1366
|
+
showAppToast('已复制节点(含子节点)', 'ok')
|
|
1367
|
+
},
|
|
1368
|
+
/**
|
|
1369
|
+
* 粘贴剪贴板节点到当前选区:
|
|
1370
|
+
* - 选区为容器 → 追加到其默认插槽末尾(作为子节点);
|
|
1371
|
+
* - 选区为非容器 → 作为其后兄弟(同级相邻),禁止再把复制的节点插入为非容器的子节点;
|
|
1372
|
+
* - 选区为空或根 → 追加到根组件列表末尾。
|
|
1373
|
+
* 重复粘贴互不干扰(每次都重新克隆 + 重新生成 componentId)。
|
|
1374
|
+
*/
|
|
1375
|
+
pasteNode() {
|
|
1376
|
+
const clip = getClipboardNode()
|
|
1377
|
+
if (!clip || !this.currentSchema) return
|
|
1378
|
+
// 跨卡片兼容性校验:克隆子树引用的组件类型必须在本编辑器组件注册表中存在,
|
|
1379
|
+
// 否则粘贴到目标卡片会因找不到组件而渲染异常。未知类型直接阻断并提示。
|
|
1380
|
+
const classNames = collectClassNames(clip)
|
|
1381
|
+
const unknown = [...new Set(classNames.filter((c) => !COMPONENT_META[c]))]
|
|
1382
|
+
if (unknown.length) {
|
|
1383
|
+
showAppToast('粘贴失败:含未知组件类型 ' + unknown.join('、'), 'err')
|
|
1384
|
+
return
|
|
1385
|
+
}
|
|
1386
|
+
const clone = JSON.parse(
|
|
1387
|
+
JSON.stringify(clip, (k, v) => (k === '_schemaNode' ? undefined : v)),
|
|
1388
|
+
)
|
|
1389
|
+
regenerateComponentIds(clone)
|
|
1390
|
+
const anchor =
|
|
1391
|
+
this.selectedNodeId && this.selectedNodeId !== '_root'
|
|
1392
|
+
? this.findPreviewNodeById(this.preview.view, this.selectedNodeId)
|
|
1393
|
+
: null
|
|
1394
|
+
let position = 'root'
|
|
1395
|
+
// 显式约束:非容器节点禁止作为「粘贴为子节点」的目标——只有容器锚点才允许 'into',
|
|
1396
|
+
// 非容器锚点一律降级为 'after'(粘为紧随其后的同级兄弟),避免把节点误塞进不可承载子节点的节点内。
|
|
1397
|
+
if (anchor) position = isContainerMeta(anchor.className) ? 'into' : 'after'
|
|
1398
|
+
this.insertClone(clone, anchor, position)
|
|
1399
|
+
// 粘贴成功提示:区分同卡副本与跨卡片粘贴,来源卡片 id 来自剪贴板记录
|
|
1400
|
+
const src = getClipboardSourceCardId()
|
|
1401
|
+
if (src && src !== this.selectedId) {
|
|
1402
|
+
showAppToast(`已从卡片「${src}」跨卡片粘贴到「${this.selectedId}」`, 'ok')
|
|
1403
|
+
} else {
|
|
1404
|
+
showAppToast('已粘贴节点(含子节点)', 'ok')
|
|
1405
|
+
}
|
|
1406
|
+
},
|
|
1407
|
+
/**
|
|
1408
|
+
* 副本:把选中节点就地复制为一个新节点,插入到其原位置之后(兄弟)。
|
|
1409
|
+
* 根节点或空选区不处理。
|
|
1410
|
+
*/
|
|
1411
|
+
duplicateNode() {
|
|
1412
|
+
const id = this.selectedNodeId
|
|
1413
|
+
if (!id || id === '_root' || !this.currentSchema || !this.preview) return
|
|
1414
|
+
const vn = this.findPreviewNodeById(this.preview.view, id)
|
|
1415
|
+
if (!vn) return
|
|
1416
|
+
const loc = this.findSchemaNodeByComponentId(this.currentSchema.view, vn.componentId)
|
|
1417
|
+
if (!loc) return
|
|
1418
|
+
const clone = JSON.parse(
|
|
1419
|
+
JSON.stringify(loc.node, (k, v) => (k === '_schemaNode' ? undefined : v)),
|
|
1420
|
+
)
|
|
1421
|
+
regenerateComponentIds(clone)
|
|
1422
|
+
loc.parentArr.splice(loc.index + 1, 0, clone)
|
|
1423
|
+
this.afterStructuralEdit(clone.componentId)
|
|
1424
|
+
},
|
|
1425
|
+
/**
|
|
1426
|
+
* 把已生成的 schema 克隆节点插入指定位置并重建预览/选中/历史。
|
|
1427
|
+
* @param {Object} clone 已重新生成 componentId 的 schema 节点
|
|
1428
|
+
* @param {Object|null} anchor 锚点预览节点(null 表示根)
|
|
1429
|
+
* @param {'root'|'into'|'after'} position 插入位置
|
|
1430
|
+
*/
|
|
1431
|
+
insertClone(clone, anchor, position) {
|
|
1432
|
+
const schema = this.currentSchema
|
|
1433
|
+
if (position === 'root') {
|
|
1434
|
+
if (!schema.view.components) schema.view.components = []
|
|
1435
|
+
schema.view.components.push(clone)
|
|
1436
|
+
} else if (position === 'into') {
|
|
1437
|
+
const tgt = anchor && anchor._schemaNode
|
|
1438
|
+
if (!tgt) {
|
|
1439
|
+
if (!schema.view.components) schema.view.components = []
|
|
1440
|
+
schema.view.components.push(clone)
|
|
1441
|
+
this.afterStructuralEdit(clone.componentId)
|
|
1442
|
+
return
|
|
1443
|
+
}
|
|
1444
|
+
const slotKey = getDefaultSlotKey(anchor.className)
|
|
1445
|
+
let slot = (tgt.slots || []).find((s) => s.slotKey === slotKey)
|
|
1446
|
+
if (!slot) {
|
|
1447
|
+
if (!tgt.slots) tgt.slots = []
|
|
1448
|
+
slot = { slotKey, slotScope: false, slotSwitch: '1', components: [] }
|
|
1449
|
+
tgt.slots.push(slot)
|
|
1450
|
+
}
|
|
1451
|
+
if (!slot.components) slot.components = []
|
|
1452
|
+
slot.components.push(clone)
|
|
1453
|
+
} else {
|
|
1454
|
+
// after:作为锚点的同父兄弟,插入其后
|
|
1455
|
+
const loc = this.findSchemaNodeByComponentId(schema.view, anchor.componentId)
|
|
1456
|
+
if (!loc) {
|
|
1457
|
+
if (!schema.view.components) schema.view.components = []
|
|
1458
|
+
schema.view.components.push(clone)
|
|
1459
|
+
} else {
|
|
1460
|
+
loc.parentArr.splice(loc.index + 1, 0, clone)
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
this.afterStructuralEdit(clone.componentId)
|
|
1464
|
+
},
|
|
1465
|
+
/**
|
|
1466
|
+
* 结构性编辑(增/删/移动/粘贴/副本)后的统一收尾:
|
|
1467
|
+
* 重建预览 → 暴露模型 → 同步数据绑定 → 选中新节点 → 落定历史。
|
|
1468
|
+
* @param {string} [selectComponentId] 编辑后需选中的节点 componentId
|
|
1469
|
+
*/
|
|
1470
|
+
afterStructuralEdit(selectComponentId) {
|
|
1471
|
+
const schema = this.currentSchema
|
|
1472
|
+
this.reloadToken += 1
|
|
1473
|
+
this.preview = buildPreview(schema)
|
|
1474
|
+
if (typeof window !== 'undefined') window.__cardModel = buildModelView(schema)
|
|
1475
|
+
this.syncDataToSchema()
|
|
1476
|
+
this.selectedNodeId = selectComponentId
|
|
1477
|
+
? this.findViewIdByComponentId(this.preview.view, selectComponentId) || ''
|
|
1478
|
+
: ''
|
|
1479
|
+
this.scheduleCommit()
|
|
1480
|
+
},
|
|
1481
|
+
/** 删除选中节点(非根):从 schema 树移除并重建;可撤销。 */
|
|
1482
|
+
deleteSelectedNode() {
|
|
1483
|
+
const id = this.selectedNodeId
|
|
1484
|
+
if (!id || id === '_root' || !this.currentSchema || !this.preview) return
|
|
1485
|
+
const vn = this.findPreviewNodeById(this.preview.view, id)
|
|
1486
|
+
if (!vn) return
|
|
1487
|
+
const loc = this.findSchemaNodeByComponentId(this.currentSchema.view, vn.componentId)
|
|
1488
|
+
if (!loc) return
|
|
1489
|
+
loc.parentArr.splice(loc.index, 1)
|
|
1490
|
+
this.reloadToken += 1
|
|
1491
|
+
this.preview = buildPreview(this.currentSchema)
|
|
1492
|
+
if (typeof window !== 'undefined') window.__cardModel = buildModelView(this.currentSchema)
|
|
1493
|
+
this.syncDataToSchema()
|
|
1494
|
+
this.selectedNodeId = ''
|
|
1495
|
+
clearHoverEl()
|
|
1496
|
+
this.scheduleCommit()
|
|
1497
|
+
},
|
|
1498
|
+
/** 判断按键事件焦点是否在可编辑元素内(输入框/文本域/可选文本),此时不劫持快捷键 */
|
|
1499
|
+
isEditableTarget(e) {
|
|
1500
|
+
const t = e.target
|
|
1501
|
+
if (!t) return false
|
|
1502
|
+
const tag = t.tagName
|
|
1503
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
|
|
1504
|
+
if (t.isContentEditable) return true
|
|
1505
|
+
return false
|
|
1506
|
+
},
|
|
1507
|
+
/**
|
|
1508
|
+
* 全局键盘快捷键(编辑器标配):
|
|
1509
|
+
* Ctrl/⌘+C 复制节点 · Ctrl/⌘+V 粘贴 · Ctrl/⌘+D 副本 · Delete/Backspace 删除 ·
|
|
1510
|
+
* Ctrl/⌘+Z 撤销 · Ctrl/⌘+Shift+Z / Ctrl+Y 重做 · Esc 取消选中。
|
|
1511
|
+
* 焦点在输入框/JSON 编辑器等可编辑元素内时,仅放行 Esc,其余交还原生行为
|
|
1512
|
+
* (避免误删节点或劫持文本撤销/复制)。
|
|
1513
|
+
*/
|
|
1514
|
+
onKeydown(e) {
|
|
1515
|
+
if (this.chromeOff || !this.preview) return
|
|
1516
|
+
const editable = this.isEditableTarget(e)
|
|
1517
|
+
const ctrl = e.ctrlKey || e.metaKey
|
|
1518
|
+
const k = e.key
|
|
1519
|
+
|
|
1520
|
+
if (ctrl && (k === 'c' || k === 'C')) {
|
|
1521
|
+
if (editable) return
|
|
1522
|
+
this.copySelectedNode()
|
|
1523
|
+
e.preventDefault()
|
|
1524
|
+
return
|
|
1525
|
+
}
|
|
1526
|
+
if (ctrl && (k === 'v' || k === 'V')) {
|
|
1527
|
+
if (editable) return
|
|
1528
|
+
this.pasteNode()
|
|
1529
|
+
e.preventDefault()
|
|
1530
|
+
return
|
|
1531
|
+
}
|
|
1532
|
+
if (ctrl && (k === 'd' || k === 'D')) {
|
|
1533
|
+
if (editable) return
|
|
1534
|
+
this.duplicateNode()
|
|
1535
|
+
e.preventDefault()
|
|
1536
|
+
return
|
|
1537
|
+
}
|
|
1538
|
+
if (ctrl && (k === 'z' || k === 'Z')) {
|
|
1539
|
+
if (editable) return
|
|
1540
|
+
e.preventDefault()
|
|
1541
|
+
if (e.shiftKey) this.redo()
|
|
1542
|
+
else this.undo()
|
|
1543
|
+
return
|
|
1544
|
+
}
|
|
1545
|
+
if (ctrl && (k === 'y' || k === 'Y')) {
|
|
1546
|
+
if (editable) return
|
|
1547
|
+
e.preventDefault()
|
|
1548
|
+
this.redo()
|
|
1549
|
+
return
|
|
1550
|
+
}
|
|
1551
|
+
if (k === 'Delete' || k === 'Backspace') {
|
|
1552
|
+
if (editable) return
|
|
1553
|
+
if (!this.selectedNodeId || this.selectedNodeId === '_root') return
|
|
1554
|
+
e.preventDefault()
|
|
1555
|
+
this.deleteSelectedNode()
|
|
1556
|
+
return
|
|
1557
|
+
}
|
|
1558
|
+
if (k === 'Escape') {
|
|
1559
|
+
if (this.selectedNodeId) {
|
|
1560
|
+
this.selectedNodeId = ''
|
|
1561
|
+
e.preventDefault()
|
|
1562
|
+
}
|
|
1563
|
+
return
|
|
1564
|
+
}
|
|
1565
|
+
},
|
|
1566
|
+
/**
|
|
1567
|
+
* 结构树节点 hover:把对应节点(componentId / id)解析为画布 DOM 元素,
|
|
1568
|
+
* 驱动参考线框出该元素;鼠标移出节点区域(payload 为 null)则清除参考线。
|
|
1569
|
+
* 解析依据:RenderTree 在组件根元素挂载 data-cid = node.componentId || node.id,
|
|
1570
|
+
* 与结构树节点组件标识一致,故按 data-cid 反查即可定位画布元素。
|
|
1571
|
+
* @param {{componentId:*, id:*}|null} payload
|
|
1572
|
+
*/
|
|
1573
|
+
onHoverNode(payload) {
|
|
1574
|
+
if (!payload) { clearHoverEl(); return }
|
|
1575
|
+
// 离开事件:仅当离开的正是当前高亮节点时才清除(防止「进入 B 后再离开 A」的竞态误清)
|
|
1576
|
+
if (payload.leave) { clearHoverEl(payload.id); return }
|
|
1577
|
+
const screen = typeof document !== 'undefined' ? document.querySelector('.phone__screen') : null
|
|
1578
|
+
if (!screen) { clearHoverEl(payload.id); return }
|
|
1579
|
+
const esc = (v) => (window.CSS && CSS.escape) ? CSS.escape(String(v)) : String(v).replace(/["\\\]]/g, '\\$&')
|
|
1580
|
+
let el = null
|
|
1581
|
+
if (payload.componentId != null) el = screen.querySelector('[data-cid="' + esc(payload.componentId) + '"]')
|
|
1582
|
+
if (!el && payload.id != null) el = screen.querySelector('[data-cid="' + esc(payload.id) + '"]')
|
|
1583
|
+
if (el) setHoverEl(el, payload.id)
|
|
1584
|
+
else clearHoverEl(payload.id)
|
|
1585
|
+
},
|
|
1586
|
+
/**
|
|
1587
|
+
* 画布点击定位:仅当「组件结构」tab 激活且按住 Ctrl(Windows/Linux) 或 ⌘Cmd(macOS) 时生效。
|
|
1588
|
+
* 取点击位置最内层组件根元素([data-cid]),把其 componentId 解析回预览树节点,
|
|
1589
|
+
* 写入 selectedNodeId(复用现有选中机制:高亮 + 结构树展开祖先 + 滚动可见)。
|
|
1590
|
+
* 普通点击保持原行为不变(不干涉卡片自身运行期 click 事件)。
|
|
1591
|
+
* @param {MouseEvent} e
|
|
1592
|
+
*/
|
|
1593
|
+
onCanvasClick(e) {
|
|
1594
|
+
if (!e.ctrlKey && !e.metaKey) return
|
|
1595
|
+
if (this.chromeOff) return
|
|
1596
|
+
const el = e.target && e.target.closest ? e.target.closest('[data-cid]') : null
|
|
1597
|
+
if (!el) return
|
|
1598
|
+
const cid = el.getAttribute('data-cid')
|
|
1599
|
+
if (!cid) return
|
|
1600
|
+
const node = this.findPreviewNodeByCid(cid)
|
|
1601
|
+
if (!node) return
|
|
1602
|
+
// 自动切到「组件结构」tab,并在节点树中聚焦选中该节点。
|
|
1603
|
+
// 结构树挂载后会对 selectedNodeId 变化展开祖先并滚动可见;
|
|
1604
|
+
// 当前不在结构树 tab 时,结构树是首次挂载即带选中态,故其
|
|
1605
|
+
// selectedNodeId 监听器设为 immediate,挂载即 ensureVisible。
|
|
1606
|
+
this.activeTab = 'structure'
|
|
1607
|
+
this.selectedNodeId = node.id
|
|
1608
|
+
},
|
|
1609
|
+
/**
|
|
1610
|
+
* 在预览树(preview.view)中按 componentId(回退 id)递归查找对应节点。
|
|
1611
|
+
* @param {string} cid
|
|
1612
|
+
* @returns {Object|null}
|
|
1613
|
+
*/
|
|
1614
|
+
findPreviewNodeByCid(cid) {
|
|
1615
|
+
const view = this.preview && this.preview.view
|
|
1616
|
+
if (!view) return null
|
|
1617
|
+
const key = String(cid)
|
|
1618
|
+
const walk = (node) => {
|
|
1619
|
+
if (!node) return null
|
|
1620
|
+
const nodeCid = String(node.componentId != null ? node.componentId : node.id)
|
|
1621
|
+
if (nodeCid === key) return node
|
|
1622
|
+
const kids = []
|
|
1623
|
+
if (node.components) kids.push(...node.components)
|
|
1624
|
+
if (node.slots) for (const s of node.slots) if (s.components) kids.push(...s.components)
|
|
1625
|
+
for (const k of kids) {
|
|
1626
|
+
const r = walk(k)
|
|
1627
|
+
if (r) return r
|
|
1628
|
+
}
|
|
1629
|
+
return null
|
|
1630
|
+
}
|
|
1631
|
+
return walk(view)
|
|
1632
|
+
},
|
|
1633
|
+
/**
|
|
1634
|
+
* 结构树发起「添加」:打开组件选择面板,并记录插入锚点与模式。
|
|
1635
|
+
* @param {{node:Object|null, mode:string}} payload
|
|
1636
|
+
* node 为右键/选中的 viewNode(className 'Root' 或组件;null 视作根);
|
|
1637
|
+
* mode ∈ 'child' | 'before' | 'after' | 'auto',决定 onPickComponent 的落位规则。
|
|
1638
|
+
*/
|
|
1639
|
+
onAddNode(payload) {
|
|
1640
|
+
if (!this.currentSchema) return
|
|
1641
|
+
this.addNodeTarget = payload || null
|
|
1642
|
+
this.addNodePanelOpen = true
|
|
1643
|
+
},
|
|
1644
|
+
/** 关闭组件选择面板(取消添加) */
|
|
1645
|
+
cancelAddNode() {
|
|
1646
|
+
this.addNodePanelOpen = false
|
|
1647
|
+
this.addNodeTarget = null
|
|
1648
|
+
},
|
|
1649
|
+
/**
|
|
1650
|
+
* 在 schema 树中定位某节点所在的容器数组与下标(用于非容器节点的"插入兄弟")。
|
|
1651
|
+
* @returns {{containerArr:Array, index:number}|null}
|
|
1652
|
+
*/
|
|
1653
|
+
findSchemaLocation(node, needle) {
|
|
1654
|
+
const search = (containerArr) => {
|
|
1655
|
+
for (let i = 0; i < containerArr.length; i++) {
|
|
1656
|
+
const c = containerArr[i]
|
|
1657
|
+
if (c === needle) return { containerArr, index: i }
|
|
1658
|
+
const slots = c.slots || []
|
|
1659
|
+
for (const s of slots) {
|
|
1660
|
+
const r = search(s.components || [])
|
|
1661
|
+
if (r) return r
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
return null
|
|
1665
|
+
}
|
|
1666
|
+
return search((node && node.components) || [])
|
|
1667
|
+
},
|
|
1668
|
+
/** 在预览树中按 componentId 找到对应 viewNode 的运行时 id(选中键) */
|
|
1669
|
+
findViewIdByComponentId(viewNode, componentId) {
|
|
1670
|
+
if (!viewNode) return null
|
|
1671
|
+
if (viewNode.componentId === componentId) return viewNode.id
|
|
1672
|
+
const kids = []
|
|
1673
|
+
if (viewNode.components) kids.push(...viewNode.components)
|
|
1674
|
+
if (viewNode.slots) for (const s of viewNode.slots) if (s.components) kids.push(...s.components)
|
|
1675
|
+
for (const k of kids) {
|
|
1676
|
+
const r = this.findViewIdByComponentId(k, componentId)
|
|
1677
|
+
if (r) return r
|
|
1678
|
+
}
|
|
1679
|
+
return null
|
|
1680
|
+
},
|
|
1681
|
+
/** 在 schema 树中按 componentId 定位节点,返回 {node, parentArr, index, owner}(owner 为父节点或 'root') */
|
|
1682
|
+
findSchemaNodeByComponentId(view, compId) {
|
|
1683
|
+
const search = (arr, owner) => {
|
|
1684
|
+
for (let i = 0; i < arr.length; i++) {
|
|
1685
|
+
const c = arr[i]
|
|
1686
|
+
if (c.componentId === compId) return { node: c, parentArr: arr, index: i, owner }
|
|
1687
|
+
const slots = c.slots || []
|
|
1688
|
+
for (const s of slots) {
|
|
1689
|
+
const r = search(s.components || [], c)
|
|
1690
|
+
if (r) return r
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
return null
|
|
1694
|
+
}
|
|
1695
|
+
return search((view && view.components) || [], 'root')
|
|
1696
|
+
},
|
|
1697
|
+
/** compId 是否位于 node 子树内(防环:禁止把节点拖入自身或其后代) */
|
|
1698
|
+
isDescendantCompId(compId, node) {
|
|
1699
|
+
if (!node || !node.componentId) return false
|
|
1700
|
+
if (node.componentId === compId) return true
|
|
1701
|
+
const kids = []
|
|
1702
|
+
if (node.components) kids.push(...node.components)
|
|
1703
|
+
if (node.slots) for (const s of node.slots) if (s.components) kids.push(...s.components)
|
|
1704
|
+
for (const k of kids) if (this.isDescendantCompId(compId, k)) return true
|
|
1705
|
+
return false
|
|
1706
|
+
},
|
|
1707
|
+
/**
|
|
1708
|
+
* 结构性移动:把 sourceCompId 节点移动到 targetCompId 的相对位置。
|
|
1709
|
+
* @param {string} position 'before' | 'after' | 'inside'
|
|
1710
|
+
* @returns {Object|false} 被移动的 schema 节点;非法移动返回 false
|
|
1711
|
+
*/
|
|
1712
|
+
moveNode(sourceCompId, targetCompId, position) {
|
|
1713
|
+
const schema = this.currentSchema
|
|
1714
|
+
if (!schema || !schema.view) return false
|
|
1715
|
+
if (sourceCompId === targetCompId) return false
|
|
1716
|
+
const src = this.findSchemaNodeByComponentId(schema.view, sourceCompId)
|
|
1717
|
+
if (!src) return false
|
|
1718
|
+
const sourceNode = src.node
|
|
1719
|
+
// 防环:目标不能是源自身或其后代
|
|
1720
|
+
if (this.isDescendantCompId(targetCompId, sourceNode)) return false
|
|
1721
|
+
|
|
1722
|
+
let destArr
|
|
1723
|
+
let insertIndex
|
|
1724
|
+
if (targetCompId === '__root__') {
|
|
1725
|
+
if (!schema.view.components) schema.view.components = []
|
|
1726
|
+
destArr = schema.view.components
|
|
1727
|
+
insertIndex = destArr.length
|
|
1728
|
+
} else {
|
|
1729
|
+
const tgt = this.findSchemaNodeByComponentId(schema.view, targetCompId)
|
|
1730
|
+
if (!tgt) return false
|
|
1731
|
+
const targetNode = tgt.node
|
|
1732
|
+
if (position === 'inside') {
|
|
1733
|
+
if (!isContainerMeta(targetNode.className)) return false
|
|
1734
|
+
const slotKey = getDefaultSlotKey(targetNode.className)
|
|
1735
|
+
let slot = (targetNode.slots || []).find((s) => s.slotKey === slotKey)
|
|
1736
|
+
if (!slot) {
|
|
1737
|
+
if (!targetNode.slots) targetNode.slots = []
|
|
1738
|
+
slot = { slotKey, slotScope: false, slotSwitch: '1', components: [] }
|
|
1739
|
+
targetNode.slots.push(slot)
|
|
1740
|
+
}
|
|
1741
|
+
if (!slot.components) slot.components = []
|
|
1742
|
+
destArr = slot.components
|
|
1743
|
+
insertIndex = destArr.length
|
|
1744
|
+
} else {
|
|
1745
|
+
destArr = tgt.parentArr
|
|
1746
|
+
insertIndex = position === 'before' ? tgt.index : tgt.index + 1
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// 从原父数组移除(记录是否在同一数组且位于插入点之前,用于修正下标)
|
|
1751
|
+
const sameArr = destArr === src.parentArr
|
|
1752
|
+
const removeBefore = sameArr && src.index < insertIndex
|
|
1753
|
+
src.parentArr.splice(src.index, 1)
|
|
1754
|
+
if (removeBefore) insertIndex -= 1
|
|
1755
|
+
destArr.splice(insertIndex, 0, sourceNode)
|
|
1756
|
+
return sourceNode
|
|
1757
|
+
},
|
|
1758
|
+
/** 结构树拖拽移动:改写 currentSchema → 重建预览 → 选中移动后的节点。
|
|
1759
|
+
* 按需求:拖拽仅实时预览(不在此提交历史),历史由后续「应用/保存到本地」落定。 */
|
|
1760
|
+
onMoveNode({ sourceCompId, targetCompId, position }) {
|
|
1761
|
+
if (!this.currentSchema) return
|
|
1762
|
+
const moved = this.moveNode(sourceCompId, targetCompId, position)
|
|
1763
|
+
if (!moved) return
|
|
1764
|
+
this.reloadToken += 1
|
|
1765
|
+
this.preview = buildPreview(this.currentSchema)
|
|
1766
|
+
if (typeof window !== 'undefined') window.__cardModel = buildModelView(this.currentSchema)
|
|
1767
|
+
this.syncDataToSchema()
|
|
1768
|
+
const newId = this.findViewIdByComponentId(this.preview.view, sourceCompId)
|
|
1769
|
+
this.selectedNodeId = newId || ''
|
|
1770
|
+
// 仅当移动成功且能定位到新节点时才触发「折叠其他、仅展开祖先」行为
|
|
1771
|
+
if (newId) this.moveCollapseToken += 1
|
|
1772
|
+
},
|
|
1773
|
+
/**
|
|
1774
|
+
* 从组件选择面板选定类型后,按插入规则把新节点写进 currentSchema,再重建预览并选中新节点。
|
|
1775
|
+
* 规则:
|
|
1776
|
+
* - 锚点为根(或未指定)→ 追加到 view.components 末尾;
|
|
1777
|
+
* - 锚点为容器节点 → 追加到其默认插槽 children 末尾(插槽缺失时依 meta 建立);
|
|
1778
|
+
* - 锚点为非容器节点 → 在同父级、该节点之后插入兄弟节点;
|
|
1779
|
+
* 找不到父级时兜底追加到根。
|
|
1780
|
+
*/
|
|
1781
|
+
onPickComponent(className) {
|
|
1782
|
+
const payload = this.addNodeTarget || {}
|
|
1783
|
+
const targetNode = payload.node || null
|
|
1784
|
+
const mode = payload.mode || 'auto'
|
|
1785
|
+
const schema = this.currentSchema
|
|
1786
|
+
if (!schema) {
|
|
1787
|
+
this.cancelAddNode()
|
|
1788
|
+
return
|
|
1789
|
+
}
|
|
1790
|
+
let newNode
|
|
1791
|
+
try {
|
|
1792
|
+
newNode = createComponentNode(className)
|
|
1793
|
+
} catch (e) {
|
|
1794
|
+
console.error('[创建组件节点失败]', e)
|
|
1795
|
+
this.rt.pushError('addNode', String(e && e.message))
|
|
1796
|
+
this.cancelAddNode()
|
|
1797
|
+
return
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
const targetIsRoot = !targetNode || targetNode.className === 'Root'
|
|
1801
|
+
if ((mode === 'before' || mode === 'after') && !targetIsRoot) {
|
|
1802
|
+
// 相邻兄弟插入:在非根节点所在的容器数组内,按 'before'/'after' 相对位置落位
|
|
1803
|
+
const targetSchema = targetNode._schemaNode
|
|
1804
|
+
const loc = targetSchema ? this.findSchemaLocation(schema.view, targetSchema) : null
|
|
1805
|
+
if (!loc) {
|
|
1806
|
+
// 兜底:找不到父数组则追加到根末尾
|
|
1807
|
+
if (!schema.view.components) schema.view.components = []
|
|
1808
|
+
schema.view.components.push(newNode)
|
|
1809
|
+
} else {
|
|
1810
|
+
const idx = mode === 'before' ? loc.index : loc.index + 1
|
|
1811
|
+
loc.containerArr.splice(idx, 0, newNode)
|
|
1812
|
+
}
|
|
1813
|
+
} else if (targetIsRoot) {
|
|
1814
|
+
// 根:添加子节点('child')或 'auto' → 追加到 view.components 末尾
|
|
1815
|
+
if (!schema.view.components) schema.view.components = []
|
|
1816
|
+
schema.view.components.push(newNode)
|
|
1817
|
+
} else {
|
|
1818
|
+
// 'child' / 'auto' 且非根:容器 → 默认插槽末尾;非容器 → 其后兄弟
|
|
1819
|
+
const targetSchema = targetNode._schemaNode
|
|
1820
|
+
if (!targetSchema) {
|
|
1821
|
+
this.cancelAddNode()
|
|
1822
|
+
return
|
|
1823
|
+
}
|
|
1824
|
+
if (isContainerMeta(targetNode.className)) {
|
|
1825
|
+
const slotKey = getDefaultSlotKey(targetNode.className)
|
|
1826
|
+
let slot = (targetSchema.slots || []).find((s) => s.slotKey === slotKey)
|
|
1827
|
+
if (!slot) {
|
|
1828
|
+
if (!targetSchema.slots) targetSchema.slots = []
|
|
1829
|
+
slot = { slotKey, slotScope: false, slotSwitch: '1', components: [] }
|
|
1830
|
+
targetSchema.slots.push(slot)
|
|
1831
|
+
}
|
|
1832
|
+
if (!slot.components) slot.components = []
|
|
1833
|
+
slot.components.push(newNode)
|
|
1834
|
+
} else {
|
|
1835
|
+
const loc = this.findSchemaLocation(schema.view, targetSchema)
|
|
1836
|
+
if (!loc) {
|
|
1837
|
+
if (!schema.view.components) schema.view.components = []
|
|
1838
|
+
schema.view.components.push(newNode)
|
|
1839
|
+
} else {
|
|
1840
|
+
loc.containerArr.splice(loc.index + 1, 0, newNode)
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
this.cancelAddNode()
|
|
1846
|
+
// 重建预览并选中新节点(与 onSchemaChanged 同款重建路径)
|
|
1847
|
+
this.reloadToken += 1
|
|
1848
|
+
this.preview = buildPreview(schema)
|
|
1849
|
+
if (typeof window !== 'undefined') window.__cardModel = buildModelView(schema)
|
|
1850
|
+
this.syncDataToSchema()
|
|
1851
|
+
const newId = this.findViewIdByComponentId(this.preview.view, newNode.componentId)
|
|
1852
|
+
this.selectedNodeId = newId || ''
|
|
1853
|
+
// 结构性编辑落定后提交一次历史记录(防抖合并连续编辑)
|
|
1854
|
+
this.scheduleCommit()
|
|
1855
|
+
},
|
|
1856
|
+
/** 复制 schema(合并数据绑定编辑后的数据:写入 dictionary.inputParams.defaultValue,并保留顶层 .data) */
|
|
1857
|
+
async copySchema() {
|
|
1858
|
+
if (!this.currentSchema) return
|
|
1859
|
+
// 确保数据绑定已同步进 schema(实时 watcher 之外再加一道保险,避免编辑后立刻复制时落空)
|
|
1860
|
+
this.syncDataToSchema()
|
|
1861
|
+
let text = ''
|
|
1862
|
+
try {
|
|
1863
|
+
const merged = { ...this.currentSchema }
|
|
1864
|
+
if (this.rt && this.rt.rootScope) {
|
|
1865
|
+
merged.data = this.rt.rootScope
|
|
1866
|
+
}
|
|
1867
|
+
// 过滤掉 _schemaNode 引用(不可序列化)
|
|
1868
|
+
text = JSON.stringify(merged, (key, val) => {
|
|
1869
|
+
if (key === '_schemaNode') return undefined
|
|
1870
|
+
return val
|
|
1871
|
+
}, 2)
|
|
1872
|
+
} catch (e) {
|
|
1873
|
+
text = '(无法序列化)'
|
|
1874
|
+
}
|
|
1875
|
+
const ok = await this.doCopy(text)
|
|
1876
|
+
if (ok) {
|
|
1877
|
+
this.copiedSchema = true
|
|
1878
|
+
if (this.copyTimer) clearTimeout(this.copyTimer)
|
|
1879
|
+
this.copyTimer = setTimeout(() => { this.copiedSchema = false }, 1500)
|
|
1880
|
+
}
|
|
1881
|
+
},
|
|
1882
|
+
async doCopy(text) {
|
|
1883
|
+
try {
|
|
1884
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1885
|
+
await navigator.clipboard.writeText(text)
|
|
1886
|
+
return true
|
|
1887
|
+
}
|
|
1888
|
+
} catch (e) { /* fallback */ }
|
|
1889
|
+
try {
|
|
1890
|
+
const ta = document.createElement('textarea')
|
|
1891
|
+
ta.value = text
|
|
1892
|
+
ta.style.position = 'fixed'
|
|
1893
|
+
ta.style.opacity = '0'
|
|
1894
|
+
document.body.appendChild(ta)
|
|
1895
|
+
ta.select()
|
|
1896
|
+
const ok = document.execCommand('copy')
|
|
1897
|
+
document.body.removeChild(ta)
|
|
1898
|
+
return ok
|
|
1899
|
+
} catch (e) {
|
|
1900
|
+
return false
|
|
1901
|
+
}
|
|
1902
|
+
},
|
|
1903
|
+
/**
|
|
1904
|
+
* 从 FinMall UAT 拉取卡片最新 schema/封面并写回 src/cards。
|
|
1905
|
+
* 经 dev-server 的 /__sync 端点触发(端点内部复用 finmall-card-sync 的 syncCards,
|
|
1906
|
+
* 通过 connectOverCDP 连用户已开的 Chrome,在已登录的 FinMall 页内发起 API 请求)。
|
|
1907
|
+
* 纯前端无法直接调 FinMall:跨域 + 无 token,故必须经此中转。
|
|
1908
|
+
* @param {string} [cardCode] 传值时仅同步该卡;不传则拉取完整远程卡片列表逐张覆盖本地(全量同步)。
|
|
1909
|
+
*/
|
|
1910
|
+
async runSync(cardCode) {
|
|
1911
|
+
if (this.syncingRemote) return
|
|
1912
|
+
if (cardCode && !this.selectedId) return
|
|
1913
|
+
this.syncingRemote = true
|
|
1914
|
+
this.syncedRemote = false
|
|
1915
|
+
this.syncTarget = cardCode ? 'selected' : 'full'
|
|
1916
|
+
try {
|
|
1917
|
+
const res = await apiFetch('/__sync', {
|
|
1918
|
+
method: 'POST',
|
|
1919
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1920
|
+
body: JSON.stringify(cardCode ? { cardCode } : {}),
|
|
1921
|
+
})
|
|
1922
|
+
if (!res.ok) {
|
|
1923
|
+
const err = await res.json().catch(() => ({}))
|
|
1924
|
+
throw new Error(err.message || err.error || 'HTTP ' + res.status)
|
|
1925
|
+
}
|
|
1926
|
+
const r = await res.json()
|
|
1927
|
+
if (r.needCredentials) {
|
|
1928
|
+
// 凭据缺失:弹出登录表单,由用户填写账号密码后自动重试同步
|
|
1929
|
+
this.openLoginModal()
|
|
1930
|
+
return
|
|
1931
|
+
}
|
|
1932
|
+
if (!r.success) {
|
|
1933
|
+
const detail = (r.failed || []).map((f) => `${f.id}: ${f.error}`).join('; ')
|
|
1934
|
+
throw new Error(detail || '同步失败')
|
|
1935
|
+
}
|
|
1936
|
+
this.syncedRemote = true
|
|
1937
|
+
if (this.syncTimer) clearTimeout(this.syncTimer)
|
|
1938
|
+
this.syncTimer = setTimeout(() => { this.syncedRemote = false; this.syncTarget = null }, 1800)
|
|
1939
|
+
// 把刚拉取的最新卡片载入预览
|
|
1940
|
+
await this.onRefresh()
|
|
1941
|
+
// 操作完成提示:全量 / 选中卡片区分文案
|
|
1942
|
+
showAppToast(this.syncTarget === 'selected' ? '已同步当前卡片到本地' : '全量同步完成', 'ok')
|
|
1943
|
+
} catch (e) {
|
|
1944
|
+
console.error('[同步远程数据失败]', e)
|
|
1945
|
+
// 远端操作接口报错:仅弹出错误提示,不写入事件/绑定错误记录(避免污染事件面板)
|
|
1946
|
+
showAppToast('同步失败:' + (e && e.message), 'err')
|
|
1947
|
+
} finally {
|
|
1948
|
+
this.syncingRemote = false
|
|
1949
|
+
}
|
|
1950
|
+
},
|
|
1951
|
+
/** 全量同步:拉取完整远程卡片列表,逐张覆盖本地同名卡片(本地独有卡片保留)。 */
|
|
1952
|
+
syncRemote() {
|
|
1953
|
+
return this.runSync()
|
|
1954
|
+
},
|
|
1955
|
+
/** 仅同步当前选中卡片。 */
|
|
1956
|
+
syncSelected() {
|
|
1957
|
+
return this.runSync(this.selectedId)
|
|
1958
|
+
},
|
|
1959
|
+
/** 打开 FinMall 登录凭据弹窗(远程同步检测到缺凭据时调用) */
|
|
1960
|
+
openLoginModal() {
|
|
1961
|
+
this.loginModal = true
|
|
1962
|
+
this.loginName = ''
|
|
1963
|
+
this.loginPassword = ''
|
|
1964
|
+
this.loginError = ''
|
|
1965
|
+
},
|
|
1966
|
+
/** 关闭登录凭据弹窗(提交中不可关闭) */
|
|
1967
|
+
closeLoginModal() {
|
|
1968
|
+
if (this.loginSubmitting) return
|
|
1969
|
+
this.loginModal = false
|
|
1970
|
+
this.loginPassword = ''
|
|
1971
|
+
this.loginError = ''
|
|
1972
|
+
},
|
|
1973
|
+
/** 提交账号密码:写入本地凭据后,自动重试上一次同步 */
|
|
1974
|
+
async submitLogin() {
|
|
1975
|
+
if (this.loginSubmitting) return
|
|
1976
|
+
const loginName = this.loginName.trim()
|
|
1977
|
+
const loginPassword = this.loginPassword
|
|
1978
|
+
if (!loginName || !loginPassword) {
|
|
1979
|
+
this.loginError = '账号与密码均不能为空'
|
|
1980
|
+
return
|
|
1981
|
+
}
|
|
1982
|
+
this.loginSubmitting = true
|
|
1983
|
+
this.loginError = ''
|
|
1984
|
+
try {
|
|
1985
|
+
const res = await apiFetch('/__login-credentials', {
|
|
1986
|
+
method: 'POST',
|
|
1987
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1988
|
+
body: JSON.stringify({ loginName, loginPassword }),
|
|
1989
|
+
})
|
|
1990
|
+
const r = await res.json().catch(() => ({}))
|
|
1991
|
+
if (!res.ok || !r.ok) {
|
|
1992
|
+
throw new Error(r.message || r.error || 'HTTP ' + res.status)
|
|
1993
|
+
}
|
|
1994
|
+
this.loginModal = false
|
|
1995
|
+
this.loginPassword = ''
|
|
1996
|
+
showAppToast('凭据已保存,正在重试同步…', 'ok')
|
|
1997
|
+
// 沿用上一次同步模式自动重试
|
|
1998
|
+
await this.runSync(this.syncTarget === 'selected' ? this.selectedId : undefined)
|
|
1999
|
+
} catch (e) {
|
|
2000
|
+
this.loginError = '保存失败:' + (e && e.message)
|
|
2001
|
+
} finally {
|
|
2002
|
+
this.loginSubmitting = false
|
|
2003
|
+
}
|
|
2004
|
+
},
|
|
2005
|
+
/** 下拉菜单选中某项:执行对应同步(先二次确认) */
|
|
2006
|
+
chooseSync(target) {
|
|
2007
|
+
if (target === 'full') {
|
|
2008
|
+
if (this.syncingRemote) return
|
|
2009
|
+
this.requestConfirm({
|
|
2010
|
+
title: '全量同步',
|
|
2011
|
+
message: '即将从远端 FinMall 拉取完整卡片列表,并逐张覆盖本地同名卡片。本地独有卡片会保留,但被覆盖卡片的未保存改动将丢失,且无法撤销。是否继续?',
|
|
2012
|
+
confirmText: '继续同步',
|
|
2013
|
+
onConfirm: () => this.syncRemote(),
|
|
2014
|
+
})
|
|
2015
|
+
return
|
|
2016
|
+
}
|
|
2017
|
+
if (!this.preview || !this.selectedId || this.syncingRemote) return
|
|
2018
|
+
this.requestConfirm({
|
|
2019
|
+
title: '当前同步',
|
|
2020
|
+
message: `即将从远端 FinMall 拉取当前卡片「${this.selectedId}」的最新版本并覆盖本地。本地未保存的改动将丢失,且无法撤销。是否继续?`,
|
|
2021
|
+
confirmText: '继续同步',
|
|
2022
|
+
onConfirm: () => this.syncSelected(),
|
|
2023
|
+
})
|
|
2024
|
+
},
|
|
2025
|
+
/** 「远端」分组菜单项:保存到远端 / 远端预览(保存到远端先二次确认) */
|
|
2026
|
+
chooseRsave(target) {
|
|
2027
|
+
if (target === 'save') {
|
|
2028
|
+
if (!this.preview || !this.selectedId || this.savingRemote || !this.hasCardSource) return
|
|
2029
|
+
this.requestConfirm({
|
|
2030
|
+
title: '版本推送',
|
|
2031
|
+
message: `即将把本地卡片「${this.selectedId}」的当前版本推送并覆盖到远端 FinMall。线上版本会被立即更新,且该操作不可撤销。是否继续?`,
|
|
2032
|
+
confirmText: '继续推送',
|
|
2033
|
+
danger: true,
|
|
2034
|
+
onConfirm: () => this.saveToRemote(),
|
|
2035
|
+
})
|
|
2036
|
+
return
|
|
2037
|
+
}
|
|
2038
|
+
if (!this.preview || !this.selectedId || !this.hasCardSource) return
|
|
2039
|
+
this.remotePreviewOpen = true
|
|
2040
|
+
},
|
|
2041
|
+
/** 请求二次确认:统一在写/覆盖类操作前弹出,确认后执行 onConfirm 回调 */
|
|
2042
|
+
requestConfirm({ title, message, confirmText, cancelText, danger, onConfirm }) {
|
|
2043
|
+
this.confirm = {
|
|
2044
|
+
show: true,
|
|
2045
|
+
title,
|
|
2046
|
+
message,
|
|
2047
|
+
confirmText: confirmText || '继续',
|
|
2048
|
+
cancelText: cancelText || '取消',
|
|
2049
|
+
danger: !!danger,
|
|
2050
|
+
onConfirm,
|
|
2051
|
+
}
|
|
2052
|
+
},
|
|
2053
|
+
/** 确认弹窗点击「确认」:执行挂起的回调 */
|
|
2054
|
+
onConfirmDialog() {
|
|
2055
|
+
const cb = this.confirm.onConfirm
|
|
2056
|
+
this.confirm.show = false
|
|
2057
|
+
this.confirm.onConfirm = null
|
|
2058
|
+
if (typeof cb === 'function') cb()
|
|
2059
|
+
},
|
|
2060
|
+
/** 确认弹窗点击「取消」或遮罩:丢弃挂起操作 */
|
|
2061
|
+
onCancelDialog() {
|
|
2062
|
+
this.confirm.show = false
|
|
2063
|
+
this.confirm.onConfirm = null
|
|
2064
|
+
},
|
|
2065
|
+
/** 把预览内编辑后的最新数据绑定 + schema 写回 src/cards/<id>/(经 dev 中间件 POST) */
|
|
2066
|
+
async saveCard() {
|
|
2067
|
+
if (!this.preview || !this.selectedId) return
|
|
2068
|
+
this.savingCard = true
|
|
2069
|
+
const id = this.selectedId
|
|
2070
|
+
// 标记本窗口即将落盘:吸收随后 watcher 经 SSE 推回的 cards-changed 回声,避免误弹外部重载提示。
|
|
2071
|
+
// 落盘未成功时(catch)清除,以免误吞后续真正的外部改动。
|
|
2072
|
+
this.selfSaveId = id
|
|
2073
|
+
this.selfSaveAt = Date.now()
|
|
2074
|
+
try {
|
|
2075
|
+
// 保存前从磁盘校准元数据(pageId/name/desc 由远端/磁盘权威决定,内存编辑不修改它们),
|
|
2076
|
+
// 取磁盘最新值覆盖内存,避免滞后于同步结果把真实 cardId 写回成旧值。
|
|
2077
|
+
try {
|
|
2078
|
+
const fresh = await this.fetchDiskCard(this.selectedId)
|
|
2079
|
+
if (fresh && fresh.schema) {
|
|
2080
|
+
const disk = fresh.schema
|
|
2081
|
+
const mem = this.currentSchema
|
|
2082
|
+
if (mem) {
|
|
2083
|
+
if (disk.pageId) mem.pageId = disk.pageId
|
|
2084
|
+
if (disk.name) mem.name = disk.name
|
|
2085
|
+
if (disk.desc !== undefined) mem.desc = disk.desc
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
} catch { /* 磁盘读取失败不阻塞保存 */ }
|
|
2089
|
+
// 保存前确保数据绑定已同步进 schema.dictionary.inputParams.defaultValue
|
|
2090
|
+
this.syncDataToSchema()
|
|
2091
|
+
// 只发 schema:服务端写 schema.json 后会用 buildDataFromSchema(schema) 单向重算
|
|
2092
|
+
// data.json,data 永不随请求上传、也不作为写入来源。
|
|
2093
|
+
const payload = {
|
|
2094
|
+
schema: this.currentSchema,
|
|
2095
|
+
}
|
|
2096
|
+
const body = JSON.stringify(payload, (k, v) => (k === '_schemaNode' ? undefined : v), 2)
|
|
2097
|
+
const res = await apiFetch(`/__card/${encodeURIComponent(this.selectedId)}`, {
|
|
2098
|
+
method: 'POST',
|
|
2099
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2100
|
+
body,
|
|
2101
|
+
})
|
|
2102
|
+
if (!res.ok) {
|
|
2103
|
+
const err = await res.json().catch(() => ({}))
|
|
2104
|
+
throw new Error(err.message || 'HTTP ' + res.status)
|
|
2105
|
+
}
|
|
2106
|
+
// 写盘成功:更新内存工作副本,便于后续操作基于刚落盘的内容。磁盘变更也会经
|
|
2107
|
+
// watcher 触发 onCardsChanged 重新载入,二者最终一致。
|
|
2108
|
+
this.currentCard = { schema: payload.schema }
|
|
2109
|
+
// 写盘成功后,当前显示版本即成为本地真实版本:刷新本地基线,使顶栏
|
|
2110
|
+
// 「保存到本地」按钮在「当前版本 == 本地版本」时自动置灰。
|
|
2111
|
+
this.localSchema = this.cloneSchema(this.currentSchema)
|
|
2112
|
+
this.localData = this.cloneData(buildDataFromSchema(this.currentSchema))
|
|
2113
|
+
this.savedCard = true
|
|
2114
|
+
// 本地保存后磁盘已更新,刷新整卡校验徽标(端点读磁盘)
|
|
2115
|
+
this.refreshValidateBadge()
|
|
2116
|
+
if (this.saveTimer) clearTimeout(this.saveTimer)
|
|
2117
|
+
this.saveTimer = setTimeout(() => { this.savedCard = false }, 1800)
|
|
2118
|
+
return true
|
|
2119
|
+
} catch (e) {
|
|
2120
|
+
console.error('[本地保存失败]', e)
|
|
2121
|
+
this.rt.pushError('saveCard', String(e && e.message))
|
|
2122
|
+
// 落盘未成功:清除自保存标记,避免误吞后续真正的外部改动
|
|
2123
|
+
this.selfSaveId = ''
|
|
2124
|
+
this.selfSaveAt = 0
|
|
2125
|
+
return false
|
|
2126
|
+
} finally {
|
|
2127
|
+
this.savingCard = false
|
|
2128
|
+
}
|
|
2129
|
+
},
|
|
2130
|
+
/** 从磁盘读取指定卡片最新 schema/data(供保存前校准与刷新使用) */
|
|
2131
|
+
async fetchDiskCard(id) {
|
|
2132
|
+
const res = await apiFetch(`/__card/${encodeURIComponent(id)}`)
|
|
2133
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
2134
|
+
return res.json()
|
|
2135
|
+
},
|
|
2136
|
+
/**
|
|
2137
|
+
* 拉取当前卡片的导入来源元数据(Phase E):GET /__import-meta/:id。
|
|
2138
|
+
* 本地新建卡无 .import-meta.json → 端点 404 → 置 null(顶栏「版本推送」据此置灰)。
|
|
2139
|
+
* 与 DebugPanel「来源」tab 同源,但此处为顶栏按钮的响应式来源标志,覆盖每次切卡/同步刷新。
|
|
2140
|
+
*/
|
|
2141
|
+
async fetchImportMeta(id) {
|
|
2142
|
+
if (!id) { this.cardImportMeta = null; return }
|
|
2143
|
+
try {
|
|
2144
|
+
const res = await apiFetch(`/__import-meta/${encodeURIComponent(id)}`)
|
|
2145
|
+
if (!res.ok) { this.cardImportMeta = null; return }
|
|
2146
|
+
const r = await res.json().catch(() => ({}))
|
|
2147
|
+
this.cardImportMeta = (r && r.meta) || null
|
|
2148
|
+
} catch {
|
|
2149
|
+
this.cardImportMeta = null
|
|
2150
|
+
}
|
|
2151
|
+
},
|
|
2152
|
+
/**
|
|
2153
|
+
* 把当前预览的卡片 schema 写回 FinMall 远端(线上编辑器「保存」接口 IDC10010.do)。
|
|
2154
|
+
* 经 dev 中间件 /__save 触发:端点内部复用 finmall-card-sync 的 saveCardToRemote,
|
|
2155
|
+
* 通过已登录的 FinMall 浏览器会话(与「同步」共用)中转 POST,body 含 cardId(=pageId) 与
|
|
2156
|
+
* schemaValue(整张 schema 的 JSON 字符串)。纯前端无法直接调 FinMall(跨域 + 需 token),故须经此中转。
|
|
2157
|
+
* 无自动保存——仅手动触发。
|
|
2158
|
+
*/
|
|
2159
|
+
async saveToRemote() {
|
|
2160
|
+
if (!this.preview || !this.selectedId || this.savingRemote) return
|
|
2161
|
+
this.savingRemote = true
|
|
2162
|
+
this.savedRemote = false
|
|
2163
|
+
this.verifiedRemote = null
|
|
2164
|
+
try {
|
|
2165
|
+
// 保存前从磁盘校准 pageId:避免内存滞后于同步结果,把当前卡片误存到旧的远端 cardId。
|
|
2166
|
+
try {
|
|
2167
|
+
const fresh = await this.fetchDiskCard(this.selectedId)
|
|
2168
|
+
if (fresh && fresh.schema && fresh.schema.pageId && this.currentSchema) {
|
|
2169
|
+
this.currentSchema.pageId = fresh.schema.pageId
|
|
2170
|
+
}
|
|
2171
|
+
} catch { /* 磁盘读取失败不阻塞保存 */ }
|
|
2172
|
+
// 保存前确保数据绑定已同步进 schema.dictionary.inputParams.defaultValue
|
|
2173
|
+
this.syncDataToSchema()
|
|
2174
|
+
// 标记本窗口即将落盘(saveCardToRemote 会写回 src/cards),吸收 watcher 推回的 cards-changed 回声
|
|
2175
|
+
this.selfSaveId = this.selectedId
|
|
2176
|
+
this.selfSaveAt = Date.now()
|
|
2177
|
+
const res = await apiFetch('/__save', {
|
|
2178
|
+
method: 'POST',
|
|
2179
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2180
|
+
body: JSON.stringify(
|
|
2181
|
+
{ code: this.selectedId, schema: this.currentSchema },
|
|
2182
|
+
(k, v) => (k === '_schemaNode' ? undefined : v),
|
|
2183
|
+
2
|
|
2184
|
+
),
|
|
2185
|
+
})
|
|
2186
|
+
if (!res.ok) {
|
|
2187
|
+
const err = await res.json().catch(() => ({}))
|
|
2188
|
+
throw new Error(err.message || err.error || 'HTTP ' + res.status)
|
|
2189
|
+
}
|
|
2190
|
+
const r = await res.json()
|
|
2191
|
+
if (!r.ok) throw new Error(r.error || '远端保存失败')
|
|
2192
|
+
// verified: {ok, cardIdMatched, fingerprintMatched, diff, readBackError}
|
|
2193
|
+
// ok=true → 保存并读回一致;ok=false → 「交易成功」但内容未落库(FinMall 静默空返回)
|
|
2194
|
+
this.verifiedRemote = r.verified ? !!r.verified.ok : null
|
|
2195
|
+
this.savedRemote = true
|
|
2196
|
+
if (this.verifiedRemote === false) {
|
|
2197
|
+
const v = r.verified || {}
|
|
2198
|
+
const detail = (v.diff && v.diff.length)
|
|
2199
|
+
? '差异:' + v.diff.join(';')
|
|
2200
|
+
: (v.readBackError || '读回校验未通过')
|
|
2201
|
+
// 远端操作结果异常:仅弹出错误提示,不写入事件/绑定错误记录
|
|
2202
|
+
console.warn('[保存校验不符]', v)
|
|
2203
|
+
showAppToast('已保存至远端,但读回校验未通过', 'err')
|
|
2204
|
+
} else {
|
|
2205
|
+
showAppToast('已保存至远端', 'ok')
|
|
2206
|
+
}
|
|
2207
|
+
if (this.saveRemoteTimer) clearTimeout(this.saveRemoteTimer)
|
|
2208
|
+
this.saveRemoteTimer = setTimeout(() => { this.savedRemote = false; this.verifiedRemote = null }, 2500)
|
|
2209
|
+
} catch (e) {
|
|
2210
|
+
console.error('[保存至远端失败]', e)
|
|
2211
|
+
// 远端操作接口报错:仅弹出错误提示,不写入事件/绑定错误记录(避免污染事件面板)
|
|
2212
|
+
showAppToast('保存至远端失败:' + (e && e.message), 'err')
|
|
2213
|
+
// 落盘未成功:清除自保存标记,避免误吞后续真正的外部改动
|
|
2214
|
+
this.selfSaveId = ''
|
|
2215
|
+
this.selfSaveAt = 0
|
|
2216
|
+
} finally {
|
|
2217
|
+
this.savingRemote = false
|
|
2218
|
+
}
|
|
2219
|
+
},
|
|
2220
|
+
/** 实时拉取卡片列表(GET /__cards),替换下拉框选项,不缓存 */
|
|
2221
|
+
async fetchCardList() {
|
|
2222
|
+
try {
|
|
2223
|
+
const res = await apiFetch('/__cards')
|
|
2224
|
+
if (!res.ok) return
|
|
2225
|
+
const r = await res.json()
|
|
2226
|
+
const cards = (r && r.cards) || []
|
|
2227
|
+
this.cardList = cards
|
|
2228
|
+
this.cardCount = cards.length
|
|
2229
|
+
} catch (e) {
|
|
2230
|
+
console.error('[加载卡片列表失败]', e)
|
|
2231
|
+
}
|
|
2232
|
+
},
|
|
2233
|
+
/** 刷新当前卡片:从磁盘重新读取 schema/data 并强制重挂载 */
|
|
2234
|
+
async onRefresh() {
|
|
2235
|
+
const id = this.selectedId
|
|
2236
|
+
if (!id || this.refreshing) return
|
|
2237
|
+
this.refreshing = true
|
|
2238
|
+
try {
|
|
2239
|
+
await this.loadCard(id)
|
|
2240
|
+
// 立即刷新卡片列表:同步远程可能新增 / 重命名卡片,
|
|
2241
|
+
// 让顶栏下拉框即时反映,无需等待 dev server 文件监听事件。
|
|
2242
|
+
await this.fetchCardList()
|
|
2243
|
+
// 同步下拉框名称(若 schema.name 有改动,以内存态为准兜底)
|
|
2244
|
+
const item = this.cardList.find((c) => c.id === id)
|
|
2245
|
+
if (item && this.currentSchema && this.currentSchema.name) item.name = this.currentSchema.name
|
|
2246
|
+
} catch (e) {
|
|
2247
|
+
console.error('[刷新卡片失败]', e)
|
|
2248
|
+
this.rt.pushError('refresh', String(e && e.message))
|
|
2249
|
+
} finally {
|
|
2250
|
+
this.refreshing = false
|
|
2251
|
+
}
|
|
2252
|
+
},
|
|
2253
|
+
/**
|
|
2254
|
+
* 处理 dev server 推送的磁盘变更事件(同步远程 / 其他窗口保存 / 手工编辑)。
|
|
2255
|
+
* - 始终重新拉取卡片列表(新增 / 改名 / 删除的卡即时反映在下拉框)。
|
|
2256
|
+
* - 若变更的是当前选中卡片的文件,重新从磁盘载入,使预览即时生效
|
|
2257
|
+
* (这正是「连接本地开发服务后,编辑器外改文件,预览立刻刷新」的关键路径)。
|
|
2258
|
+
* - 若当前卡片存在未保存编辑,先用既有 ConfirmDialog 二次确认,避免静默丢弃编辑。
|
|
2259
|
+
* 用极短防抖合并同步写盘的突发多文件变更,避免重复重载。
|
|
2260
|
+
*/
|
|
2261
|
+
onCardsChanged(payload) {
|
|
2262
|
+
const file = (payload && payload.file) || ''
|
|
2263
|
+
// 编辑历史快照落盘在 src/cards/<id>/.history/ 下写 .json,不属于卡片本体变更,
|
|
2264
|
+
// 须忽略,否则会触发 loadCard 自动重载、丢弃未保存编辑并关闭属性面板。
|
|
2265
|
+
if (/\.history[\\/]/.test(file)) return
|
|
2266
|
+
// data.json 是 schema 的单向派生产物,其变更(写盘重算 / 外部直改)不应触发重载;
|
|
2267
|
+
// 真正触发重载的只有 schema.json 变更(此处与 8787 watcher 形成双保险)。
|
|
2268
|
+
if (/data\.json$/i.test(file)) return
|
|
2269
|
+
const m = file.match(/src[\\/]cards[\\/]([^\\/]+)[\\/]/)
|
|
2270
|
+
const changedId = m ? m[1] : null
|
|
2271
|
+
if (this._cardsChangedTimer) return
|
|
2272
|
+
this._cardsChangedTimer = setTimeout(() => {
|
|
2273
|
+
this._cardsChangedTimer = null
|
|
2274
|
+
this.fetchCardList()
|
|
2275
|
+
if (changedId && changedId === this.selectedId) {
|
|
2276
|
+
// 本窗口自己的「保存到本地 / 保存至远端」刚落盘,watcher 推回的 cards-changed 是回声,
|
|
2277
|
+
// 并非外部修改——内存态已由 saveCard / saveToRemote 就地更新,不应弹外部重载提示或冲突确认框。
|
|
2278
|
+
// 仅吸收本窗口的回声;其他窗口 / IDE / 远端同步的变更仍照常提示(其 selfSaveId 不会被设置)。
|
|
2279
|
+
if (this.selfSaveId === changedId && Date.now() - this.selfSaveAt < SELF_SAVE_SUPPRESS_MS) {
|
|
2280
|
+
this.selfSaveId = ''
|
|
2281
|
+
this.selfSaveAt = 0
|
|
2282
|
+
return
|
|
2283
|
+
}
|
|
2284
|
+
if (this.historyDirty) {
|
|
2285
|
+
// 当前卡有未保存编辑,外部又改了文件 → 二次确认,避免静默丢弃编辑
|
|
2286
|
+
this.promptExternalReload(changedId)
|
|
2287
|
+
} else {
|
|
2288
|
+
// 无未保存改动:外部改动直接自动重载(符合「主动刷新重载」诉求),
|
|
2289
|
+
// 但给一条非阻塞轻提示,避免「改了却毫无感知」。
|
|
2290
|
+
const name = (this.currentSchema && this.currentSchema.name) || changedId
|
|
2291
|
+
this.showExternalReloadNote(`卡片「${name}」已在编辑器外被修改,已自动重新载入预览`)
|
|
2292
|
+
this.loadCard(this.selectedId)
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
}, 120)
|
|
2296
|
+
},
|
|
2297
|
+
/**
|
|
2298
|
+
* 外部(IDE / 其他工具 / 远端同步)改动了当前卡片文件,而平台有未保存编辑时,
|
|
2299
|
+
* 复用既有的 ConfirmDialog 弹二次确认(与同步发布 / 版本推送同款组件):
|
|
2300
|
+
* - 「重新载入」:放弃本地未保存改动,从磁盘重新载入(onConfirm 执行 loadCard)。
|
|
2301
|
+
* - 「保留我的改动」:取消,保留编辑器内未保存编辑、不重载。
|
|
2302
|
+
* 已有确认弹窗打开时跳过,避免同一波变更重复弹窗。
|
|
2303
|
+
*/
|
|
2304
|
+
promptExternalReload(id) {
|
|
2305
|
+
if (this.confirm.show) return
|
|
2306
|
+
const name = (this.currentSchema && this.currentSchema.name) || id
|
|
2307
|
+
this.requestConfirm({
|
|
2308
|
+
title: '卡片已在编辑器外被修改',
|
|
2309
|
+
message: `卡片「${name}」的 schema / data 在编辑器外发生了变更(例如 IDE 或其他工具覆盖了文件)。\n当前编辑器存在未保存的改动,是否放弃本地未保存改动并重新载入磁盘版本?`,
|
|
2310
|
+
confirmText: '重新载入',
|
|
2311
|
+
cancelText: '保留我的改动',
|
|
2312
|
+
danger: false,
|
|
2313
|
+
onConfirm: () => this.loadCard(id),
|
|
2314
|
+
})
|
|
2315
|
+
},
|
|
2316
|
+
/**
|
|
2317
|
+
* 外部改了当前卡、但平台无未保存改动时的非阻塞轻提示:顶部短暂出现、自动消失。
|
|
2318
|
+
* 与 promptExternalReload(阻塞确认)互补——脏时弹确认、非脏时给提示。
|
|
2319
|
+
*/
|
|
2320
|
+
showExternalReloadNote(msg) {
|
|
2321
|
+
this.externalReloadNote = msg
|
|
2322
|
+
if (this.externalReloadTimer) clearTimeout(this.externalReloadTimer)
|
|
2323
|
+
this.externalReloadTimer = setTimeout(() => {
|
|
2324
|
+
this.externalReloadNote = ''
|
|
2325
|
+
this.externalReloadTimer = null
|
|
2326
|
+
}, 3500)
|
|
2327
|
+
},
|
|
2328
|
+
},
|
|
2329
|
+
}
|
|
2330
|
+
</script>
|