cardbuddy 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (313) hide show
  1. package/README.md +46 -5
  2. package/dist/cli.js +51 -1
  3. package/dist/config.js +51 -16
  4. package/dist/core.js +2 -2
  5. package/dist/init.js +425 -0
  6. package/dist/preview-editor/assets/index-CHuGGIR3.js +276 -0
  7. package/dist/preview-editor/assets/{index-B4VZgNSK.css → index-CQScMhyg.css} +1 -1
  8. package/dist/preview-editor/index.html +2 -2
  9. package/dist/standalone.js +8 -0
  10. package/dist/vendored/mcp/client.mjs +537 -0
  11. package/dist/vendored/mcp/tools.mjs +268 -0
  12. package/dist/vendored/preview/assets/default-image.png +0 -0
  13. package/dist/{server-core → vendored/server/core}/ai-chat.js +82 -38
  14. package/dist/{server-core → vendored/server/core}/browser.js +1 -1
  15. package/dist/{server-core → vendored/server/core}/cardSave.js +8 -3
  16. package/dist/{server-core → vendored/server/core}/cardSync.js +6 -14
  17. package/dist/{server-core → vendored/server/core}/cardValidate.js +11 -8
  18. package/dist/{server-core → vendored/server/core}/index.js +5 -3
  19. package/dist/vendored/server/core/skillPaths.js +124 -0
  20. package/dist/{server-core → vendored/server/core}/sliceUpload.js +5 -8
  21. package/dist/vendored/skills/README.md +93 -0
  22. package/dist/vendored/skills/card-doctor/SKILL.md +52 -0
  23. package/dist/vendored/skills/card-skill-trainer/SKILL.md +312 -0
  24. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r038n.json +43 -0
  25. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r039n.json +47 -0
  26. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r050n.json +48 -0
  27. package/dist/vendored/skills/card-skill-trainer/scripts/build_dataset.py +472 -0
  28. package/dist/vendored/skills/card-skill-trainer/scripts/build_sample.mjs +162 -0
  29. package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +241 -0
  30. package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +525 -0
  31. package/dist/vendored/skills/card-skill-trainer/scripts/round_diff.mjs +133 -0
  32. package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +1043 -0
  33. package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +195 -0
  34. package/dist/vendored/skills/card-skill-trainer/scripts/test_missing_slice.py +122 -0
  35. package/dist/vendored/skills/dsl-card-ai/SKILL.md +191 -0
  36. package/dist/vendored/skills/dsl-card-generator/SKILL.md +480 -0
  37. package/dist/vendored/skills/dsl-card-generator/config/render-contract.json +11 -0
  38. package/dist/vendored/skills/dsl-card-generator/references/amount_formatting.md +138 -0
  39. package/dist/vendored/skills/dsl-card-generator/references/card_index.json +1597 -0
  40. package/dist/vendored/skills/dsl-card-generator/references/card_templates.json +384 -0
  41. package/dist/vendored/skills/dsl-card-generator/references/card_to_summary.md +56 -0
  42. package/dist/vendored/skills/dsl-card-generator/references/component_rules.md +76 -0
  43. package/dist/vendored/skills/dsl-card-generator/references/component_specs.md +393 -0
  44. package/dist/vendored/skills/dsl-card-generator/references/dsl_structure.md +208 -0
  45. package/dist/vendored/skills/dsl-card-generator/references/event_patterns.md +287 -0
  46. package/dist/vendored/skills/dsl-card-generator/references/expression_engine.md +121 -0
  47. package/dist/vendored/skills/dsl-card-generator/references/field_name_dict.json +34 -0
  48. package/dist/vendored/skills/dsl-card-generator/references/field_naming.md +101 -0
  49. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +928 -0
  50. package/dist/vendored/skills/dsl-card-generator/references/handcrafted/README.md +69 -0
  51. package/dist/vendored/skills/dsl-card-generator/references/handcrafted/examples_index.json +2967 -0
  52. package/dist/vendored/skills/dsl-card-generator/references/handcrafted/patterns.json +2835 -0
  53. package/dist/vendored/skills/dsl-card-generator/references/handcrafted/recipes_v2.json +648 -0
  54. package/dist/vendored/skills/dsl-card-generator/references/handcrafted/recipes_v2_schema.md +295 -0
  55. package/dist/vendored/skills/dsl-card-generator/references/image_diff.md +48 -0
  56. package/dist/vendored/skills/dsl-card-generator/references/image_to_card.md +129 -0
  57. package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +212 -0
  58. package/dist/vendored/skills/dsl-card-generator/references/message-center-pattern.md +149 -0
  59. package/dist/vendored/skills/dsl-card-generator/references/platform_jsapi.md +196 -0
  60. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +54 -0
  61. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +197 -0
  62. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/absolute-badge.json +84 -0
  63. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/accordion-group-toggle.json +18 -0
  64. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/alertCard-pair.json +12 -0
  65. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/alertDialog-call.json +12 -0
  66. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-countdown.json +15 -0
  67. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-donut.json +15 -0
  68. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-gauge-score.json +11 -0
  69. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-radar.json +10 -0
  70. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-title-head.json +13 -0
  71. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/globalSet.json +12 -0
  72. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/label-col-modes.json +21 -0
  73. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/list-expand-collapse.json +17 -0
  74. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/money-format.json +11 -0
  75. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/opacity-switch.json +130 -0
  76. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/progress-bar.json +25 -0
  77. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/tab-billMode.json +15 -0
  78. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +21 -0
  79. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +797 -0
  80. package/dist/vendored/skills/dsl-card-generator/references/rules.md +235 -0
  81. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +177 -0
  82. package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +609 -0
  83. package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +131 -0
  84. package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +118 -0
  85. package/dist/vendored/skills/dsl-card-generator/scripts/analyze_component_usage.mjs +153 -0
  86. package/dist/vendored/skills/dsl-card-generator/scripts/apply_opacity_pattern.mjs +343 -0
  87. package/dist/vendored/skills/dsl-card-generator/scripts/build_card_index.mjs +108 -0
  88. package/dist/vendored/skills/dsl-card-generator/scripts/build_card_templates.mjs +190 -0
  89. package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +257 -0
  90. package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +79 -0
  91. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +98 -0
  92. package/dist/vendored/skills/dsl-card-generator/scripts/describe_card.mjs +265 -0
  93. package/dist/vendored/skills/dsl-card-generator/scripts/diff_bands.mjs +257 -0
  94. package/dist/vendored/skills/dsl-card-generator/scripts/diff_card.mjs +316 -0
  95. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +873 -0
  96. package/dist/vendored/skills/dsl-card-generator/scripts/extract_handcrafted.py +1199 -0
  97. package/dist/vendored/skills/dsl-card-generator/scripts/extract_subtree.mjs +191 -0
  98. package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +389 -0
  99. package/dist/vendored/skills/dsl-card-generator/scripts/inventory_schemas.py +160 -0
  100. package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +700 -0
  101. package/dist/vendored/skills/dsl-card-generator/scripts/lib/design_image.mjs +56 -0
  102. package/dist/vendored/skills/dsl-card-generator/scripts/lib/design_sample.mjs +328 -0
  103. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout-tokens.mjs +79 -0
  104. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.mjs +103 -0
  105. package/dist/vendored/skills/dsl-card-generator/scripts/lib/scaffold.mjs +575 -0
  106. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +81 -0
  107. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +147 -0
  108. package/dist/vendored/skills/dsl-card-generator/scripts/lint_autofix.mjs +363 -0
  109. package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +1399 -0
  110. package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +249 -0
  111. package/dist/vendored/skills/dsl-card-generator/scripts/register_card_template.mjs +208 -0
  112. package/dist/vendored/skills/dsl-card-generator/scripts/render_baseline.mjs +231 -0
  113. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +801 -0
  114. package/dist/vendored/skills/dsl-card-generator/scripts/render_diff.mjs +239 -0
  115. package/dist/vendored/skills/dsl-card-generator/scripts/resolve_card_template.mjs +73 -0
  116. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +447 -0
  117. package/dist/vendored/skills/dsl-card-generator/scripts/scaffold_card.mjs +81 -0
  118. package/dist/vendored/skills/dsl-card-generator/scripts/slices_normalize.mjs +316 -0
  119. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +462 -0
  120. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +7735 -0
  121. package/dist/vendored/skills/dsl-card-generator/scripts/spec_tree.mjs +355 -0
  122. package/dist/vendored/skills/dsl-card-generator/scripts/validate_all_schemas.py +71 -0
  123. package/dist/vendored/skills/dsl-card-generator/vendor/preview/assets/default-image-wzZYQ86K.png +0 -0
  124. package/dist/vendored/skills/dsl-card-generator/vendor/preview/assets/standalone-C9k-07y7.css +1 -0
  125. package/dist/vendored/skills/dsl-card-generator/vendor/preview/assets/standalone-oFRM4f4V.js +55 -0
  126. package/dist/vendored/skills/dsl-card-generator/vendor/preview/assets/svg-symbols-B2exBrAr.svg +118 -0
  127. package/dist/vendored/skills/dsl-card-generator/vendor/preview/meta/component-meta.json +2714 -0
  128. package/dist/vendored/skills/dsl-card-generator/vendor/preview/meta/directive-meta.json +40 -0
  129. package/dist/vendored/skills/dsl-card-generator/vendor/preview/meta/style-meta.json +1086 -0
  130. package/dist/vendored/{standalone.html → skills/dsl-card-generator/vendor/preview/standalone.html} +2 -1
  131. package/dist/vendored/skills/finmall-card-sync/SKILL.md +262 -0
  132. package/dist/vendored/skills/finmall-session-navigator/SKILL.md +169 -0
  133. package/dist/vendored/skills/finmall-session-navigator/scripts/finmall_login.mjs +882 -0
  134. package/dist/vendored/skills/finmall-session-navigator/scripts/package-lock.json +59 -0
  135. package/dist/vendored/skills/finmall-session-navigator/scripts/package.json +10 -0
  136. package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +96 -0
  137. package/dist/vendored/skills/shared/README.md +55 -0
  138. package/dist/vendored/skills/shared/auth-model.md +79 -0
  139. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +329 -0
  140. package/dist/vendored/skills/sketch-spec-to-card/references/boundary.json +14 -0
  141. package/dist/vendored/skills/sketch-spec-to-card/references/card_refs.json +13 -0
  142. package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +103 -0
  143. package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +797 -0
  144. package/dist/vendored/skills/sketch-spec-to-card/scripts/card_refs.py +94 -0
  145. package/dist/vendored/skills/sketch-spec-to-card/scripts/cluster_states.py +706 -0
  146. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +1781 -0
  147. package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +318 -0
  148. package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +1118 -0
  149. package/dist/vendored/templates/helloCard/data.json +10 -0
  150. package/dist/vendored/templates/helloCard/schema.json +238 -0
  151. package/dist/vendored/tools/link-skills.mjs +485 -0
  152. package/package.json +2 -1
  153. package/dist/preview-editor/assets/index-jl4YJzKv.js +0 -180
  154. package/dist/vendored/App.vue +0 -2858
  155. package/dist/vendored/ansi.js +0 -185
  156. package/dist/vendored/assets/apple-touch-icon.png +0 -0
  157. package/dist/vendored/assets/default-image.png +0 -0
  158. package/dist/vendored/assets/favicon-16.png +0 -0
  159. package/dist/vendored/assets/favicon-32.png +0 -0
  160. package/dist/vendored/assets/site-icon.png +0 -0
  161. package/dist/vendored/assets/svgIcons/icons.svg +0 -87
  162. package/dist/vendored/assets/svgIcons/index.js +0 -39
  163. package/dist/vendored/components/AiSettings.vue +0 -392
  164. package/dist/vendored/components/AlertCardInstance.vue +0 -58
  165. package/dist/vendored/components/AlertCardLayer.vue +0 -59
  166. package/dist/vendored/components/ApiCallPanel.vue +0 -127
  167. package/dist/vendored/components/ApiPayloadTree.vue +0 -201
  168. package/dist/vendored/components/AppToast.vue +0 -37
  169. package/dist/vendored/components/CardGallery.vue +0 -291
  170. package/dist/vendored/components/CardPreview.vue +0 -72
  171. package/dist/vendored/components/CardSelect.vue +0 -206
  172. package/dist/vendored/components/ChatComposer.vue +0 -413
  173. package/dist/vendored/components/ChatMessage.vue +0 -383
  174. package/dist/vendored/components/ChatPanel.vue +0 -937
  175. package/dist/vendored/components/ComponentPalette.vue +0 -301
  176. package/dist/vendored/components/ConfirmDialog.vue +0 -54
  177. package/dist/vendored/components/DatePickerOverlay.vue +0 -345
  178. package/dist/vendored/components/DebugPanel.vue +0 -1734
  179. package/dist/vendored/components/DeviceOverlay.vue +0 -70
  180. package/dist/vendored/components/Dropdown.vue +0 -193
  181. package/dist/vendored/components/EventEditor.vue +0 -273
  182. package/dist/vendored/components/HoverGuides.vue +0 -122
  183. package/dist/vendored/components/JsonTree.vue +0 -547
  184. package/dist/vendored/components/LogValue.vue +0 -52
  185. package/dist/vendored/components/MarkdownBody.vue +0 -64
  186. package/dist/vendored/components/NewCardDialog.vue +0 -132
  187. package/dist/vendored/components/NodeInspector.vue +0 -295
  188. package/dist/vendored/components/NumberStepper.vue +0 -125
  189. package/dist/vendored/components/PickerOverlay.vue +0 -131
  190. package/dist/vendored/components/PropControl.vue +0 -245
  191. package/dist/vendored/components/PropTree.vue +0 -128
  192. package/dist/vendored/components/RemotePreview.vue +0 -173
  193. package/dist/vendored/components/SelectionOverlay.vue +0 -105
  194. package/dist/vendored/components/StructureTree.vue +0 -1002
  195. package/dist/vendored/components/Switch.vue +0 -38
  196. package/dist/vendored/components/setters/SetBgPosition.vue +0 -64
  197. package/dist/vendored/components/setters/SetBgSize.vue +0 -90
  198. package/dist/vendored/components/setters/SetCode.vue +0 -198
  199. package/dist/vendored/components/setters/SetColor.vue +0 -244
  200. package/dist/vendored/components/setters/SetCustomSelect.vue +0 -58
  201. package/dist/vendored/components/setters/SetExpr.vue +0 -107
  202. package/dist/vendored/components/setters/SetGradient.vue +0 -125
  203. package/dist/vendored/components/setters/SetGrid9.vue +0 -68
  204. package/dist/vendored/components/setters/SetIconSelect.vue +0 -67
  205. package/dist/vendored/components/setters/SetIfForm.vue +0 -61
  206. package/dist/vendored/components/setters/SetImage.vue +0 -43
  207. package/dist/vendored/components/setters/SetList.vue +0 -139
  208. package/dist/vendored/components/setters/SetNumber.vue +0 -118
  209. package/dist/vendored/components/setters/SetNumberStyle.vue +0 -113
  210. package/dist/vendored/components/setters/SetPadding.vue +0 -77
  211. package/dist/vendored/components/setters/SetSegment.vue +0 -160
  212. package/dist/vendored/components/setters/SetSelect.vue +0 -51
  213. package/dist/vendored/components/setters/SetSwitch.vue +0 -49
  214. package/dist/vendored/components/setters/SetText.vue +0 -52
  215. package/dist/vendored/components/setters/props.js +0 -23
  216. package/dist/vendored/fidelity/baseline-messageOverview.json +0 -87
  217. package/dist/vendored/fidelity/calibration.json +0 -29
  218. package/dist/vendored/fidelity/fidelity-messageOverview-after.html +0 -114
  219. package/dist/vendored/fidelity/fidelity-messageOverview-after.json +0 -272
  220. package/dist/vendored/fidelity/fidelity-messageOverview.html +0 -185
  221. package/dist/vendored/fidelity/fidelity-messageOverview.json +0 -336
  222. package/dist/vendored/fidelity/fidelity-messageOverview_v2.html +0 -262
  223. package/dist/vendored/fidelity/fidelity-messageOverview_v2.json +0 -502
  224. package/dist/vendored/fidelity/fidelity-messageOverview_v3.html +0 -201
  225. package/dist/vendored/fidelity/fidelity-messageOverview_v3.json +0 -417
  226. package/dist/vendored/fidelity/fidelity-messageOverview_visual.html +0 -125
  227. package/dist/vendored/fidelity/fidelity-messageOverview_visual.json +0 -338
  228. package/dist/vendored/index.html +0 -16
  229. package/dist/vendored/main.js +0 -37
  230. package/dist/vendored/runtime/ai-api.js +0 -165
  231. package/dist/vendored/runtime/alert-card.js +0 -125
  232. package/dist/vendored/runtime/app-toast.js +0 -33
  233. package/dist/vendored/runtime/clipboard-node.js +0 -78
  234. package/dist/vendored/runtime/clipboard.js +0 -29
  235. package/dist/vendored/runtime/event-model.js +0 -300
  236. package/dist/vendored/runtime/hoverGuide.js +0 -31
  237. package/dist/vendored/runtime/jsapi-mock.js +0 -256
  238. package/dist/vendored/runtime/markdown.js +0 -140
  239. package/dist/vendored/runtime/node-model.js +0 -217
  240. package/dist/vendored/skills/.manifest.json +0 -1101
  241. package/dist/vendored/standalone-main.js +0 -36
  242. package/dist/vendored/styles/ai-chat.css +0 -1850
  243. package/dist/vendored/styles/app.css +0 -3648
  244. package/dist/vendored/styles/card-gallery.css +0 -285
  245. package/dist/vendored/styles/card-select.css +0 -288
  246. package/dist/vendored/styles/components.css +0 -383
  247. package/dist/vendored/styles/dropdown.css +0 -324
  248. package/dist/vendored/styles/json-tree.css +0 -308
  249. package/dist/vendored/styles/node-inspector.css +0 -800
  250. package/dist/vendored/styles/setters.css +0 -1050
  251. package/dist/vendored/styles/standalone.css +0 -27
  252. package/dist/vendored/styles/structure-tree.css +0 -460
  253. /package/dist/vendored/{api.js → preview/api.js} +0 -0
  254. /package/dist/vendored/{assets → preview/assets}/svg-symbols.svg +0 -0
  255. /package/dist/vendored/{components → preview/components}/AbsoluteLayout.vue +0 -0
  256. /package/dist/vendored/{components → preview/components}/Button.vue +0 -0
  257. /package/dist/vendored/{components → preview/components}/Canvas.vue +0 -0
  258. /package/dist/vendored/{components → preview/components}/CheckGroup.vue +0 -0
  259. /package/dist/vendored/{components → preview/components}/Column.vue +0 -0
  260. /package/dist/vendored/{components → preview/components}/CustomPlaceholder.vue +0 -0
  261. /package/dist/vendored/{components → preview/components}/Divider.vue +0 -0
  262. /package/dist/vendored/{components → preview/components}/GridLayout.vue +0 -0
  263. /package/dist/vendored/{components → preview/components}/Icon.vue +0 -0
  264. /package/dist/vendored/{components → preview/components}/Image.vue +0 -0
  265. /package/dist/vendored/{components → preview/components}/Input.vue +0 -0
  266. /package/dist/vendored/{components → preview/components}/ListLayout.vue +0 -0
  267. /package/dist/vendored/{components → preview/components}/Map.vue +0 -0
  268. /package/dist/vendored/{components → preview/components}/MultiColumn.vue +0 -0
  269. /package/dist/vendored/{components → preview/components}/MultiText.vue +0 -0
  270. /package/dist/vendored/{components → preview/components}/ProgressSlider.vue +0 -0
  271. /package/dist/vendored/{components → preview/components}/RadioButton.vue +0 -0
  272. /package/dist/vendored/{components → preview/components}/RenderTree.vue +0 -0
  273. /package/dist/vendored/{components → preview/components}/Root.vue +0 -0
  274. /package/dist/vendored/{components → preview/components}/Row.vue +0 -0
  275. /package/dist/vendored/{components → preview/components}/SvgCode.vue +0 -0
  276. /package/dist/vendored/{components → preview/components}/SvgSymbols.vue +0 -0
  277. /package/dist/vendored/{components → preview/components}/Text.vue +0 -0
  278. /package/dist/vendored/{components → preview/components}/Video.vue +0 -0
  279. /package/dist/vendored/{components → preview/components}/ViewContainer.vue +0 -0
  280. /package/dist/vendored/{components → preview/components}/base.js +0 -0
  281. /package/dist/vendored/{components → preview/components}/icons.js +0 -0
  282. /package/dist/vendored/{components → preview/components}/index.js +0 -0
  283. /package/dist/vendored/{meta → preview/meta}/component-meta.json +0 -0
  284. /package/dist/vendored/{meta → preview/meta}/directive-meta.json +0 -0
  285. /package/dist/vendored/{meta → preview/meta}/style-meta.json +0 -0
  286. /package/dist/vendored/{runtime → preview/runtime}/StyleParser.js +0 -0
  287. /package/dist/vendored/{runtime → preview/runtime}/adapter.js +0 -0
  288. /package/dist/vendored/{runtime → preview/runtime}/assets.js +0 -0
  289. /package/dist/vendored/{runtime → preview/runtime}/canvas-context.js +0 -0
  290. /package/dist/vendored/{runtime → preview/runtime}/card-runtime.js +0 -0
  291. /package/dist/vendored/{runtime → preview/runtime}/evaluate.js +0 -0
  292. /package/dist/vendored/{runtime → preview/runtime}/property.js +0 -0
  293. /package/dist/vendored/{runtime → preview/runtime}/selectionOverlay.js +0 -0
  294. /package/dist/vendored/{runtime → preview/runtime}/toPreview.js +0 -0
  295. /package/dist/{server-core → vendored/server/core}/assetProxy.js +0 -0
  296. /package/dist/{server-core → vendored/server/core}/bbox.js +0 -0
  297. /package/dist/{server-core → vendored/server/core}/buildDataFromSchema.js +0 -0
  298. /package/dist/{server-core → vendored/server/core}/cardFiles.js +0 -0
  299. /package/dist/{server-core → vendored/server/core}/cardHistory.js +0 -0
  300. /package/dist/{server-core → vendored/server/core}/cardImage.js +0 -0
  301. /package/dist/{server-core → vendored/server/core}/cardImportMeta.js +0 -0
  302. /package/dist/{server-core → vendored/server/core}/cardListCache.js +0 -0
  303. /package/dist/{server-core → vendored/server/core}/cardOpenFolder.js +0 -0
  304. /package/dist/{server-core → vendored/server/core}/cardSidecar.js +0 -0
  305. /package/dist/{server-core → vendored/server/core}/codeBridge.js +0 -0
  306. /package/dist/{server-core → vendored/server/core}/logStream.js +0 -0
  307. /package/dist/{server-core → vendored/server/core}/logger.js +0 -0
  308. /package/dist/{server-core → vendored/server/core}/nodeModels.js +0 -0
  309. /package/dist/{server-core → vendored/server/core}/renderStatus.js +0 -0
  310. /package/dist/{server-core → vendored/server/core}/screenshot.js +0 -0
  311. /package/dist/{server-core → vendored/server/core}/sliceStatic.js +0 -0
  312. /package/dist/vendored/{meta → skills/dsl-card-generator/vendor/preview/meta}/action-meta.json +0 -0
  313. /package/dist/{server-core/_vendor → vendored/tools}/finmall-env.mjs +0 -0
@@ -1,2858 +0,0 @@
1
- <!--
2
- 预览外壳:左侧卡片选择 + 顶部工具条 + 手机框内的实时渲染 +
3
- 右侧调试面板。
4
- -->
5
- <template>
6
- <div class="app" :class="{ 'app--chrome-off': chromeOff }" :style="{ '--preview-width': previewWidth + 'px', '--api-panel-width': apiPanelWidth + 'px', '--debug-min': debugMinWidth + '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
- <!-- 新增空白卡片:紧邻网格预览的图标按钮;弹窗收 ID(必填、本地重名前置拦截)+ 描述(选填),创建成功切换到新卡 -->
39
- <button
40
- class="app__btn app__btn--icon"
41
- type="button"
42
- @click="openNewCard"
43
- title="新增空白卡片"
44
- >
45
- <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-folder-plus" /></svg>
46
- </button>
47
- <div
48
- ref="validateBadgeEl"
49
- class="app__badge"
50
- :class="'app__badge--' + validateBadge.state"
51
- :title="badgeTitle"
52
- v-show="!chromeOff"
53
- >
54
- <span class="app__badge-dot"></span>
55
- <span class="app__badge-text">{{ badgeText }}</span>
56
- </div>
57
- <div class="app__bar-actions">
58
- <div class="app__op-group" role="group" aria-label="编辑历史与本地操作">
59
- <!-- 撤销 / 重做:图标 + 文案;无可回退/重做版本时置灰 -->
60
- <button class="app__btn app__btn--hist" type="button" :disabled="!preview || !canUndo" @click="undo" title="后退一步(撤销)">
61
- <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>
62
- <span>撤销</span>
63
- </button>
64
- <button class="app__btn app__btn--hist" type="button" :disabled="!preview || !canRedo" @click="redo" title="前进一步(重做)">
65
- <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>
66
- <span>重做</span>
67
- </button>
68
- <!-- 保存到本地:当前显示版本(撤销/重做导航到的历史版本,或编辑后未本地保存的版本)
69
- 与本地磁盘版本不一致时高亮可用;一致时置灰。点击把当前版本真正写回磁盘,
70
- 使下一次重新打开加载的是这个「真实版本」,而非任意历史记录。 -->
71
- <button
72
- class="app__btn app__btn--history-save"
73
- type="button"
74
- :disabled="!preview || !historyDirty || savingCard || loadingCard"
75
- :class="{ 'is-dirty': historyDirty }"
76
- @click="applyHistoryToLocal"
77
- :title="historyDirty ? '当前版本与本地不一致,点击将该版本保存到本地' : '当前版本已与本地一致'"
78
- >
79
- <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>
80
- <span>保存</span>
81
- </button>
82
- <button class="app__btn app__btn--op" :disabled="!preview" @click="copySchema" title="复制 schema(含变量 mock 编辑后的数据)">
83
- <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>
84
- <span>{{ copiedSchema ? '已复制 ✓' : '复制' }}</span>
85
- </button>
86
- </div>
87
- <!-- 远端:合并「远端保存 / 远端预览 / 切图上传 / 全量同步 / 选中卡片同步」为一个分组,组名「远端」,云图标 -->
88
- <Dropdown
89
- class="app__remote-menu"
90
- size="md"
91
- placement="bottom-end"
92
- :disabled="savingRemote || syncingRemote || uploadingSlices"
93
- :title="savingRemote ? '正在保存至远端…' : (syncingRemote ? '同步中…' : (uploadingSlices ? '正在上传切图…' : '远端操作:保存到 FinMall / 真机预览 / 切图上传 / 全量或选中卡片同步'))"
94
- data-testid="remote-group"
95
- >
96
- <template #trigger="{ open }">
97
- <button
98
- type="button"
99
- class="app__btn app__btn--remote"
100
- :class="{ 'app__btn--warn': savedRemote && verifiedRemote === false, 'app__remote-trigger--active': open, 'is-processing': savingRemote || syncingRemote || uploadingSlices }"
101
- :disabled="savingRemote || syncingRemote || uploadingSlices"
102
- >
103
- <svg v-if="savingRemote || syncingRemote || uploadingSlices" 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>
104
- <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>
105
- <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>
106
- <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>
107
- <span>远端</span>
108
- <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>
109
- </button>
110
- </template>
111
- <template #default="{ close }"><ul class="app__remote-pop dd__menu" role="menu">
112
- <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()">
113
- <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>
114
- <span>版本推送</span>
115
- </li>
116
- <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()">
117
- <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>
118
- <span>在线预览</span>
119
- </li>
120
- <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()">
121
- <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>
122
- <span>当前同步</span>
123
- </li>
124
- <li class="app__remote-item app__remote-item--sync" :class="{ 'is-disabled': syncingRemote }" data-testid="sync-full" role="menuitem" @click="chooseSync('full'); close()">
125
- <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>
126
- <span>全量同步</span>
127
- </li>
128
- <!-- 切图上传:仅点击时执行,把「当前卡片引用到的」src/slices 切图上传到 FinMall 图片库
129
- (CC0000.do),并把该卡 schema/data 里的 /slices/ 引用改写为返回的 @filestore/ 线上地址。
130
- 没有任何自动上传路径;共享切图若已上传过(sha256 命中 src/slices/manifest.json)直接复用地址。
131
- 不要求 hasCardSource:本地自建卡同样需要先把切图传上去,才能推版本 / 出真机预览。 -->
132
- <li class="app__remote-item app__remote-item--upload" :class="{ 'is-disabled': !preview || !selectedId || uploadingSlices }" :title="uploadingSlices ? '正在上传切图…' : '切图上传:把当前卡片引用的本地切图(src/slices)上传到 FinMall 图片库,并把卡片里的 /slices/ 引用替换为线上地址(@filestore/…);本地切图文件保留'" data-testid="slice-upload" role="menuitem" @click="chooseRsave('slices'); close()">
133
- <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-image-upload" /></svg>
134
- <span>切图上传</span>
135
- </li>
136
- </ul></template>
137
- </Dropdown>
138
- <!-- 接口调用面板开关:右侧常驻面板的展开/收起,状态持久化 -->
139
- <button
140
- class="app__btn app__btn--icon"
141
- :class="{ 'app__btn--active': apiPanelOpen }"
142
- type="button"
143
- :title="apiPanelOpen ? '收起接口调用面板' : '打开接口调用面板'"
144
- :aria-pressed="apiPanelOpen"
145
- @click="toggleApiPanel"
146
- >
147
- <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">
148
- <rect x="3" y="4" width="18" height="16" rx="2" />
149
- <line x1="15" y1="4" x2="15" y2="20" />
150
- </svg>
151
- </button>
152
- <!-- 主题切换:深色(当前)/ 浅色,复用同名 CSS 变量整体换肤,状态持久化 -->
153
- <button
154
- class="theme-toggle"
155
- type="button"
156
- role="switch"
157
- :aria-checked="theme === 'light'"
158
- :title="theme === 'light' ? '切换到深色主题' : '切换到浅色主题'"
159
- @click="toggleTheme"
160
- >
161
- <span class="theme-toggle__track">
162
- <span class="theme-toggle__thumb">
163
- <svg
164
- v-if="theme === 'light'"
165
- class="theme-toggle__icon"
166
- viewBox="0 0 24 24"
167
- fill="none"
168
- stroke="currentColor"
169
- stroke-width="2"
170
- stroke-linecap="round"
171
- stroke-linejoin="round"
172
- aria-hidden="true"
173
- >
174
- <circle cx="12" cy="12" r="4.2" />
175
- <line x1="12" y1="2.5" x2="12" y2="5" />
176
- <line x1="12" y1="19" x2="12" y2="21.5" />
177
- <line x1="2.5" y1="12" x2="5" y2="12" />
178
- <line x1="19" y1="12" x2="21.5" y2="12" />
179
- <line x1="4.9" y1="4.9" x2="6.8" y2="6.8" />
180
- <line x1="17.2" y1="17.2" x2="19.1" y2="19.1" />
181
- <line x1="4.9" y1="19.1" x2="6.8" y2="17.2" />
182
- <line x1="17.2" y1="6.8" x2="19.1" y2="4.9" />
183
- </svg>
184
- <svg
185
- v-else
186
- class="theme-toggle__icon"
187
- viewBox="0 0 24 24"
188
- fill="none"
189
- stroke="currentColor"
190
- stroke-width="2"
191
- stroke-linecap="round"
192
- stroke-linejoin="round"
193
- aria-hidden="true"
194
- >
195
- <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
196
- </svg>
197
- </span>
198
- </span>
199
- </button>
200
- <!-- 本地开发服务设置入口 -->
201
- <div class="app__dev-svc">
202
- <button
203
- class="app__btn app__btn--icon"
204
- type="button"
205
- :title="devSvcTitle"
206
- :class="devSvcClass"
207
- @click="devServiceOpen = !devServiceOpen"
208
- >
209
- <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>
210
- </button>
211
- <!-- 点遮罩关闭:与事件帮助气泡等轻量浮层同一交互 -->
212
- <div v-if="devServiceOpen" class="app__dev-svc-mask" @click="devServiceOpen = false"></div>
213
- <Transition name="pop">
214
- <div v-if="devServiceOpen" class="app__dev-svc-panel" @click.stop>
215
- <div class="app__dev-svc-head">
216
- <span class="app__dev-svc-title">本地开发服务</span>
217
- <button class="app__dev-svc-close" type="button" title="关闭" @click="devServiceOpen = false"></button>
218
- </div>
219
- <div class="app__dev-svc-body">
220
- <label class="app__dev-svc-row">
221
- <span>启用</span>
222
- <Switch v-model="devServiceForm.enabled" />
223
- </label>
224
- <label class="app__dev-svc-row app__dev-svc-row--col">
225
- <span>服务地址</span>
226
- <input type="text" v-model="devServiceForm.baseUrl" placeholder="http://127.0.0.1:8787" />
227
- </label>
228
- <label class="app__dev-svc-row app__dev-svc-row--col">
229
- <span>访问令牌(可选)</span>
230
- <input type="text" v-model="devServiceForm.token" placeholder="留空则不校验" />
231
- </label>
232
- <p class="app__dev-svc-hint" v-if="apiBaseResolved === null">
233
- <template v-if="devSvcBlockReason === 'disabled'">已填写服务地址,但「启用」开关未打开。请开启「启用」开关并点击保存,本地开发服务才会生效。</template>
234
- <template v-else>请填写本机 cardbuddy 服务地址(默认 http://127.0.0.1:8787),打开「启用」开关并保存后,编辑功能才会生效。</template>
235
- </p>
236
- <div class="app__dev-svc-actions">
237
- <button class="ni-btn ni-btn--ghost" type="button" @click="devServiceOpen = false">关闭</button>
238
- <button class="ni-btn" type="button" @click="saveDevService">保存</button>
239
- </div>
240
- </div>
241
- </div>
242
- </Transition>
243
- </div>
244
- </div>
245
- <span style="display: none">{{ rt.revision }}</span>
246
- </header>
247
-
248
- <!-- AI 开发助手悬浮入口:右下角圆形按钮(尺寸 / 配色走规范 Token,图标取自 sprite) -->
249
- <button
250
- class="ai-fab"
251
- type="button"
252
- :class="{ 'ai-fab--active': aiChatOpen }"
253
- :title="aiChatOpen ? 'AI 开发助手(已打开)' : 'AI 开发助手'"
254
- :aria-expanded="aiChatOpen ? 'true' : 'false'"
255
- data-testid="ai-fab"
256
- @click="aiChatOpen = !aiChatOpen"
257
- >
258
- <svg class="ai-fab__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-sparkles" /></svg>
259
- </button>
260
-
261
- <!-- AI 开发助手对话面板:右侧抽屉,宽度可拖(320px ~ 视口 50%,默认 33%),对话记录按卡片落盘 -->
262
- <ChatPanel
263
- :visible="aiChatOpen"
264
- :card-id="selectedId"
265
- :card-name="selectedCardName"
266
- @close="aiChatOpen = false"
267
- />
268
-
269
- <!-- 部署态但未配置本地开发服务:顶部提示条(chrome=off 截图模式不显示) -->
270
- <div v-if="apiBaseResolved === null && !chromeOff" class="app__nosvc-banner">
271
- <template v-if="devSvcBlockReason === 'disabled'">
272
- ⚠ 本地开发服务未生效:已填写地址,但未打开「启用」开关。请点击右上角 「连接」图标打开「本地开发服务」设置,开启「启用」开关并保存后,编辑功能才会生效。
273
- </template>
274
- <template v-else>
275
- ⚠ 未配置本地开发服务:当前为部署态,编辑功能不可用。请点击右上角 「连接」图标打开「本地开发服务」设置,填写本机 cardbuddy 地址、打开「启用」开关并保存后,才能编辑卡片。
276
- </template>
277
- </div>
278
-
279
- <!-- 已配置本地服务但实时连接已断开:顶部提示条(chrome=off 截图模式不显示) -->
280
- <div v-if="apiBaseResolved !== null && devSvcDown && !chromeOff" class="app__nosvc-banner app__nosvc-banner--disconnected">
281
- ⚠ 本地开发服务已断开:连接失败或服务已停止,编辑 / 同步功能暂不可用。请确认本地 cardbuddy 是否运行,或点击右上角「连接」图标重新保存配置。
282
- </div>
283
-
284
- <!-- 外部改了当前卡、无未保存改动 → 自动重载后的非阻塞轻提示(chrome=off 截图模式不显示) -->
285
- <div v-if="externalReloadNote && !chromeOff" class="app__nosvc-banner app__extreload-banner">
286
- ↻ {{ externalReloadNote }}
287
- </div>
288
-
289
- <main class="app__stage" ref="stageEl">
290
- <div class="phone" :class="{ 'phone--dragging': dragging }" :style="{ width: previewWidth + 'px' }">
291
- <!-- 缩放外框:占满整列(宽高由 CSS flex 填满);画布不做 transform 缩放,直接按实际尺寸布局 -->
292
- <div class="phone__frame" :style="frameStyle">
293
- <!-- 画布层:直接按实际列宽/列高布局(width/height 100%),不再使用 transform 缩放 -->
294
- <div class="phone__scaler">
295
- <!-- 画布工具条:原 device-bar(顶 28px)+ preview-statusbar(底 28px)合并为一条 32px,
296
- 竖向空间还给手机视口。左组=改变画布(背景 / 宽度预设),右组=画布现状与动作(尺寸读数 / 刷新);
297
- 预览列窄于 300px 时收起读数与文案只留图标(188 预设下仍单行可用)。 -->
298
- <div class="canvas-bar" :class="{ 'canvas-bar--tight': previewWidth < 300 }" v-show="!chromeOff">
299
- <!-- 画布背景:触发器带当前底色小样,不展开下拉也可见当前模式 -->
300
- <Dropdown
301
- borderless
302
- size="xs"
303
- placement="bottom-start"
304
- :value="canvasBg"
305
- :options="bgOptions.map((b) => ({ key: b.key, value: b.label + '背景' }))"
306
- @change="chooseBg"
307
- >
308
- <template #trigger>
309
- <span class="canvas-bar__chip" :title="'画布背景:' + currentBg.label">
310
- <i class="canvas-bar__swatch" :class="'canvas-bar__swatch--' + canvasBg"></i>
311
- <span class="canvas-bar__txt">{{ currentBg.label }}背景</span>
312
- <svg class="canvas-bar__chev" viewBox="0 0 16 16" aria-hidden="true"><use href="#icon-chevron" /></svg>
313
- </span>
314
- </template>
315
- </Dropdown>
316
- <!-- 宽度预设:触发器直接显示当前宽度,省去独立的「宽度」标签 -->
317
- <Dropdown
318
- borderless
319
- size="xs"
320
- placement="bottom-start"
321
- :value="canvasW"
322
- :options="presetSizes"
323
- @change="setPresetWidth"
324
- >
325
- <template #trigger>
326
- <span class="canvas-bar__chip" title="画布宽度预设(高度随列高自适应)">
327
- <svg class="canvas-bar__icn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="8 7 4 12 8 17" /><polyline points="16 7 20 12 16 17" /><line x1="4" y1="12" x2="20" y2="12" /></svg>
328
- <span class="canvas-bar__txt">{{ canvasW }} px</span>
329
- <svg class="canvas-bar__chev" viewBox="0 0 16 16" aria-hidden="true"><use href="#icon-chevron" /></svg>
330
- </span>
331
- </template>
332
- </Dropdown>
333
- <span class="canvas-bar__spacer"></span>
334
- <span class="canvas-bar__size" title="画布实际尺寸(宽 × 高,高度随窗口自适应)">{{ canvasW }} × {{ viewportHeight }}</span>
335
- <button
336
- class="canvas-bar__iconbtn"
337
- :class="{ 'is-loading': refreshing }"
338
- type="button"
339
- :disabled="!selectedId || refreshing"
340
- title="刷新卡片,重新加载"
341
- @click="refreshCard"
342
- >
343
- <svg class="canvas-bar__icn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
344
- <path d="M21 12a9 9 0 1 1-2.64-6.36" />
345
- <path d="M21 3v6h-6" />
346
- </svg>
347
- </button>
348
- </div>
349
- <!--
350
- 固定视口:弹层(alertCard / alertDialog / toast)相对此容器 absolute inset:0 定位,
351
- 不随内层 phone__screen 的卡片内容滚动,保证遮罩始终精确覆盖可视区域。
352
- -->
353
- <div ref="phoneViewport" class="phone-viewport" @click="onCanvasClick">
354
- <div
355
- class="phone__screen"
356
- :class="'phone__screen--bg-' + (chromeOff ? 'white' : canvasBg)"
357
- :style="chromeOff && preview ? { width: (preview.view.width || 375) + 'px' } : null"
358
- :data-card-ready="cardReady ? '1' : null"
359
- >
360
- <!--
361
- :key 绑定 selectedId + reloadToken —— 切换卡片、远端同步后重载、重置导入
362
- 等场景时强制 RenderTree 重新挂载,从而保证新卡片/新数据的「初始化动作」
363
- (onReady 等生命周期事件,如 assetOverview 计算 ratio)一定在 created 阶段
364
- 执行,不会出现重载后 init 不跑、依赖字段为 undefined 而渲染成 NaN% 的问题。
365
- -->
366
- <RenderTree v-if="preview" :key="selectedId + ':' + reloadToken" :node="preview.view" :scope="rt.state" :selected-node-id="selectedNodeId" />
367
- <!-- 节点选中遮罩覆盖层:作为滚动容器子节点,随内容滚动并精确贴合选中元素 -->
368
- <SelectionOverlay />
369
- <!-- 结构树 hover 参考线:贯穿画布的荧光绿辅助线(左/右竖线、上/下横线),仅 hover 时显示 -->
370
- <HoverGuides />
371
- </div>
372
- <!-- 弹窗卡片图层:alertCard / closeAlertCard 的展现层(在预览区域内弹出) -->
373
- <AlertCardLayer :main-rt="rt" />
374
- <!-- 设备级模拟 UI:alertDialog 对话框 / showToast 弱提示 / showPicker 选择器 / showDatePicker 日期选择器 -->
375
- <DeviceOverlay />
376
- <PickerOverlay />
377
- <DatePickerOverlay />
378
- </div>
379
- </div>
380
- </div>
381
- </div>
382
-
383
-
384
- <!-- 左分隔条:预览列 | 工作区 接缝。底面板 / 状态栏的左缘即此缝(随预览宽度联动),
385
- 故定位在 .app__stage 通到底部,全程可拖;右分隔条才收进两栏容器内。 -->
386
- <div
387
- v-show="!chromeOff"
388
- class="splitter"
389
- :class="{ 'is-dragging': dragging }"
390
- @pointerdown.prevent="startDrag"
391
- title="拖拽调整预览区域宽度"
392
- ></div>
393
-
394
- <!-- 中右工作区:上为「调试面板 + 接口调用面板」两栏,下为贯穿两栏的共同状态栏。
395
- 状态栏与底部弹出面板(运行态 / 事件 / 变量绑定)均由 DebugPanel 内 Teleport 注入
396
- #app-workside-bar / #app-workside-panels,宽度贯穿中右两栏;Teleport 目标须在 DOM 中
397
- 先于面板挂载,故两个空容器写在前面,以 flex order 视觉置底。 -->
398
- <div class="workside" v-show="!chromeOff">
399
- <div id="app-workside-bar" class="workside__bar"></div>
400
- <div id="app-workside-panels" class="workside__panels"></div>
401
- <div class="workside__cols" ref="colsEl">
402
- <!-- 拖拽分隔条:置于两栏容器内(定位上下文),只贯穿列区高度,不穿过底部弹出面板与状态栏 -->
403
- <!-- 中右分隔条:调试面板 | 接口调用面板 接缝,拖动调整右栏宽度。
404
- 约束:中栏最小宽度 ≥ 屏宽 1/3,右栏最小宽度 ≥ 320px(见 startApiDrag) -->
405
- <div
406
- v-show="!chromeOff && apiPanelOpen"
407
- class="splitter splitter--api"
408
- :class="{ 'is-dragging': apiDragging }"
409
- @pointerdown.prevent="startApiDrag"
410
- title="拖拽调整接口调用面板宽度"
411
- ></div>
412
- <DebugPanel
413
- :chrome-off="chromeOff"
414
- :rt="rt"
415
- :preview="preview"
416
- :schema="currentSchema"
417
- :card-id="selectedId"
418
- :reload-token="reloadToken"
419
- :card-count="cardCount"
420
- :selected-node-id="selectedNodeId"
421
- :move-collapse-token="moveCollapseToken"
422
- :active-tab="activeTab"
423
- @schema-changed="onSchemaChanged"
424
- @schema-commit="onSchemaCommit"
425
- @select-node="onSelectNode"
426
- @update:active-tab="activeTab = $event"
427
- @add-node="onAddNode"
428
- @move-node="onMoveNode"
429
- @hover-node="onHoverNode"
430
- @copy-selected="copySelectedNode"
431
- @paste="pasteNode"
432
- @duplicate="duplicateNode"
433
- @delete-selected="deleteSelectedNode"
434
- @reset-import="onRefresh"
435
- :dev-svc-class="devSvcClass"
436
- :dev-svc-title="devSvcTitle"
437
- :dev-svc-label="devSvcLabel"
438
- :dev-svc-conn="devSvcConn"
439
- />
440
-
441
- <!-- 右侧常驻「接口调用」面板:与预览列 / 调试面板呈左中右布局,
442
- 展开/收起由顶栏按钮与面板内 ✕ 控制,初始状态持久化 -->
443
- <ApiCallPanel
444
- v-if="apiPanelOpen"
445
- :rt="rt"
446
- @close="apiPanelOpen = false"
447
- />
448
- </div>
449
- </div>
450
- </main>
451
- <!-- 远端预览二维码弹层:点击顶栏「远端预览」按钮打开,手机(FinMall App)扫码真机查看 -->
452
- <RemotePreview
453
- :visible="remotePreviewOpen"
454
- :page-id="currentSchema ? currentSchema.pageId : ''"
455
- :schema="currentSchema"
456
- @close="remotePreviewOpen = false"
457
- />
458
- <!-- 登录凭据弹窗:远程同步检测到缺凭据时弹出,填写账号密码(MD5 后)写入 .local/credentials.json -->
459
- <Transition name="modal">
460
- <div v-if="loginModal" class="login-mask" @click.self="closeLoginModal">
461
- <div class="login-modal modal-card" role="dialog" aria-modal="true">
462
- <div class="login-modal__title">FinMall 登录凭据</div>
463
- <div class="login-modal__desc">
464
- 本地登录凭据缺失,请填写账号与密码。密码仅做 MD5 后保存,不落明文。
465
- </div>
466
- <label class="login-modal__row">
467
- <span class="login-modal__label">账号</span>
468
- <input
469
- class="login-modal__input"
470
- type="text"
471
- v-model="loginName"
472
- placeholder="FinMall 登录账号"
473
- :disabled="loginSubmitting"
474
- @keyup.enter="submitLogin"
475
- />
476
- </label>
477
- <label class="login-modal__row">
478
- <span class="login-modal__label">密码</span>
479
- <input
480
- class="login-modal__input"
481
- type="password"
482
- v-model="loginPassword"
483
- placeholder="FinMall 登录密码"
484
- :disabled="loginSubmitting"
485
- @keyup.enter="submitLogin"
486
- />
487
- </label>
488
- <div v-if="loginError" class="login-modal__error">{{ loginError }}</div>
489
- <div class="login-modal__actions">
490
- <button
491
- class="login-modal__btn login-modal__btn--ghost"
492
- type="button"
493
- :disabled="loginSubmitting"
494
- @click="closeLoginModal"
495
- >
496
- 取消
497
- </button>
498
- <button
499
- class="login-modal__btn login-modal__btn--primary"
500
- type="button"
501
- :disabled="loginSubmitting"
502
- @click="submitLogin"
503
- >
504
- {{ loginSubmitting ? '保存中…' : '保存并同步' }}
505
- </button>
506
- </div>
507
- </div>
508
- </div>
509
- </Transition>
510
- <!-- 组件选择面板:点击结构树每行的「+」后弹出,选择类型即插入对应节点 -->
511
- <Transition name="modal">
512
- <ComponentPalette
513
- v-if="addNodePanelOpen"
514
- @pick="onPickComponent"
515
- @close="cancelAddNode"
516
- />
517
- </Transition>
518
- <!-- 二次确认弹窗:版本推送 / 当前同步 / 全量同步 等写/覆盖类操作前弹出,确认后才执行 -->
519
- <ConfirmDialog
520
- :visible="confirm.show"
521
- :title="confirm.title"
522
- :message="confirm.message"
523
- :confirm-text="confirm.confirmText"
524
- :cancel-text="confirm.cancelText"
525
- :severity="confirm.severity"
526
- @confirm="onConfirmDialog"
527
- @cancel="onCancelDialog"
528
- />
529
- <!-- 卡片网格预览弹窗:点击顶栏「网格预览」图标按钮打开,选择卡片即载入工作台 -->
530
- <CardGallery
531
- :visible="galleryOpen"
532
- :cards="cardList"
533
- :selected-id="selectedId"
534
- @select="onSelect"
535
- @close="closeGallery"
536
- @delete="onGalleryDelete"
537
- />
538
- <!-- 新增空白卡片弹窗:创建成功后关弹窗 + 刷列表 + 切换到新卡;existing-ids 供 ID 重名前置判定 -->
539
- <NewCardDialog
540
- :visible="newCardOpen"
541
- :existing-ids="cardIds"
542
- @close="newCardOpen = false"
543
- @created="onNewCardCreated"
544
- />
545
- <!-- App 级弱提示:顶栏异步操作(远端保存 / 同步)完成反馈,浮于 App 外壳顶部居中 -->
546
- <AppToast />
547
- </div>
548
- </template>
549
-
550
- <script>
551
- import Adapter from '@/runtime/adapter.js'
552
- import { createCardRuntime } from '@/runtime/card-runtime.js'
553
- import { buildPreview, buildModelView, createComponentNode, isContainerMeta, getDefaultSlotKey, COMPONENT_META } from '@/runtime/toPreview.js'
554
- // 节点树剪贴板:复制/粘贴/副本(Ctrl/⌘+C/V/D)共用
555
- import {
556
- setClipboardNode,
557
- getClipboardNode,
558
- getClipboardSourceCardId,
559
- collectClassNames,
560
- regenerateComponentIds,
561
- } from '@/runtime/clipboard-node.js'
562
- import RenderTree from '@/components/RenderTree.vue'
563
- import DebugPanel from '@/components/DebugPanel.vue'
564
- import ApiCallPanel from '@/components/ApiCallPanel.vue'
565
- import CardSelect from '@/components/CardSelect.vue'
566
- import AlertCardLayer from '@/components/AlertCardLayer.vue'
567
- import DeviceOverlay from '@/components/DeviceOverlay.vue'
568
- import PickerOverlay from '@/components/PickerOverlay.vue'
569
- import DatePickerOverlay from '@/components/DatePickerOverlay.vue'
570
- import SelectionOverlay from '@/components/SelectionOverlay.vue'
571
- import HoverGuides from '@/components/HoverGuides.vue'
572
- import { hoverGuide, setHoverEl, clearHoverEl } from '@/runtime/hoverGuide.js'
573
- import RemotePreview from '@/components/RemotePreview.vue'
574
- import AppToast from '@/components/AppToast.vue'
575
- import ConfirmDialog from '@/components/ConfirmDialog.vue'
576
- import CardGallery from '@/components/CardGallery.vue'
577
- import NewCardDialog from '@/components/NewCardDialog.vue'
578
- import ComponentPalette from '@/components/ComponentPalette.vue'
579
- import Switch from '@/components/Switch.vue'
580
- import ChatPanel from '@/components/ChatPanel.vue'
581
- import { showAppToast } from '@/runtime/app-toast.js'
582
- import { clearAllAlerts } from '@/runtime/alert-card.js'
583
- import { installJsApiMock, clearDeviceUI } from '@/runtime/jsapi-mock.js'
584
- import logoUrl from '@/assets/site-icon.png'
585
- import { apiFetch, resolveApiBase, subscribeCardsChanged, loadDevServiceConfig, saveDevServiceConfig } from '@/api.js'
586
- // 由 schema 单向派生 data 的共享实现(与 server/core 同源,避免逻辑漂移)。
587
- // 预览渲染注入的数据绑定、本地基线比较、历史快照还原均使用此函数,使 data 永不读回 data.json 文件。
588
- import { buildDataFromSchema, readMocks } from '../server/core/buildDataFromSchema.js'
589
- // 编辑器预览态派生 data 时叠加 dictionary.mocks(设计期覆盖);服务端写盘仍只用 defaultValue。
590
- const WITH_MOCKS = { includeMocks: true }
591
-
592
- // 编辑器自身「保存到本地 / 保存至远端」写盘后,cardbuddy 的 watcher 会把这次落盘经 SSE
593
- // 推回 cards-changed。这是「自己改自己」的回声,并非外部修改,用此窗口吸收,避免误弹外部重载
594
- // 提示 / 冲突确认框。窗口仅作用于本窗口——其他窗口 / IDE / 远端同步推来的 cards-changed 仍照常提示。
595
- const SELF_SAVE_SUPPRESS_MS = 2500
596
-
597
- // 模块级单例:renderTree 通过 provide/inject 拿到的是稳定引用,
598
- // 内部 rootScope 可经 setRootScope 整体替换而引用不变。
599
- const adapter = new Adapter()
600
- const cardRuntime = createCardRuntime({}, adapter)
601
- // 开发期暴露 runtime 单例句柄,供 E2E 回归(如 L2-12 rootScope 整体替换实时同步画布)
602
- // 直接驱动 rt.setRootScope —— 与 DebugPanel「变量 → 配置变量 mock」Raw「应用」同源。生产构建不暴露。
603
- if (typeof window !== 'undefined' && import.meta.env.DEV) window.__rt = cardRuntime
604
-
605
- // 三栏(预览列 | 调试面板 | 接口调用面板)布局约束的单一来源:
606
- // 拖拽夹取与 computed 都从这里取值,避免「两套口径」在极限位置互相打架导致接缝跳动 / 整行溢出。
607
- const PREVIEW_MIN = 188 // 预览列硬下限(= 最小机型预设宽)
608
- const API_MIN = 320 // 接口调用面板常规下限(空间不足时按 apiPanelBounds 优雅退让,不整行溢出)
609
- const DEBUG_MIN_FLOOR = 200 // 中栏(调试面板)硬下限;软目标为屏宽 1/3
610
- const API_ABS_MIN = 160 // 极窄窗口下面板的绝对下限,保证不撑破容器
611
-
612
- export default {
613
- name: 'App',
614
- components: { RenderTree, DebugPanel, ApiCallPanel, CardSelect, AlertCardLayer, DeviceOverlay, PickerOverlay, DatePickerOverlay, SelectionOverlay, HoverGuides, RemotePreview, AppToast, ConfirmDialog, CardGallery, NewCardDialog, ComponentPalette, Switch, ChatPanel },
615
- provide() {
616
- return {
617
- adapter,
618
- cardRuntime,
619
- customComponents: {},
620
- }
621
- },
622
- data() {
623
- return {
624
- adapter,
625
- rt: cardRuntime,
626
- logoUrl,
627
- // 顶栏版本号小字:由 vite.config.mjs 经 define 内联的 __APP_VERSION__ 提供,
628
- // 与 package.json 的 version 保持一致。
629
- appVersion: __APP_VERSION__,
630
- cardList: [], // 实时从 GET /__cards 拉取,不缓存启动快照
631
- cardCount: 0,
632
- galleryOpen: false,
633
- // 新增空白卡片弹窗开关(顶栏图标按钮触发)
634
- newCardOpen: false, // 卡片网格预览弹窗开关
635
- selectedId: '',
636
- // 外部(IDE / 其他工具)改了当前卡、且平台无未保存改动时,自动重载后顶部短暂提示
637
- // (非阻塞,区别于有未保存改动时的阻塞确认框)。
638
- externalReloadNote: '',
639
- externalReloadTimer: null,
640
- // 自保存标记:记录「本窗口刚发起落盘」的卡片 id 与时刻,用于吸收 watcher 推回的 cards-changed 回声。
641
- // 仅本窗口可见——其他窗口 / IDE / 远端同步触发的变更不会带此标记,仍照常提示外部重载。
642
- selfSaveId: '',
643
- selfSaveAt: 0,
644
- preview: null,
645
- // chrome=off:纯净截图模式(隐藏顶栏/设备条/调试面板,卡片根挂 data-card-ready),
646
- // 供 /__screenshot 无头截图使用。由 URL ?chrome=off 触发。
647
- chromeOff: false,
648
- // 主题:'dark'(默认,当前配色)/ 'light'(浅色,白底)。持久化于 localStorage,切换即整体换肤。
649
- theme: 'dark',
650
- // 卡片首帧渲染完成标志:在 /__screenshot 模式下,screenshot.js 等待 [data-card-ready]
651
- // 出现再截图(比 networkidle 稳,避免截到尚未挂载的空壳)。
652
- cardReady: false,
653
- reloadToken: 0,
654
- // 拖拽移动成功令牌:每次「节点拖拽移动成功」自增,经 DebugPanel 透传给结构树,
655
- // 用于触发「移动完成后:选中被拖节点 + 仅展开其祖先链、其余全部折叠」的行为。
656
- moveCollapseToken: 0,
657
- refreshing: false,
658
- loadingCard: false, // 切卡/载入进行中:临时禁用「保存到本地」,避免沿用上一卡的脏状态或把上一卡误写进新卡
659
- currentSchema: null,
660
- currentCard: null, // 当前卡片的磁盘最新内容(实时载入的工作副本,非跨卡缓存)
661
- // 导入来源元数据(Phase E 可追溯):GET /__import-meta/:id 的 meta,无侧车(本地新建卡)时为 null。
662
- // 顶栏「版本推送」据此置灰——无来源则无远端卡片可推送版本。
663
- cardImportMeta: null,
664
- // 本地真实版本基线:载入(或「保存到本地」写盘)时记录磁盘当前 schema+data 的克隆。
665
- // 用于判断「当前显示版本」是否与本地磁盘一致:撤销/重做到历史版本、或编辑未本地保存
666
- // 时两者不一致 → 顶栏「保存到本地」按钮高亮可点;一致时按钮置灰。
667
- localSchema: null,
668
- localData: null,
669
- selectedNodeId: '',
670
- // 当前激活的调试面板 tab(变量/组件结构/卡片来源)。
671
- // 画布 Ctrl/⌘ 点击会自动切到「组件结构」tab 并在节点树聚焦选中,无需预先停在结构树 tab。
672
- activeTab: 'vars',
673
- // 右侧常驻「接口调用」面板:展开/收起由顶栏按钮与面板内 ✕ 控制,
674
- // 初始状态持久化到 localStorage(cardbuddy:api-panel-open)
675
- apiPanelOpen: true,
676
- // 组件结构树「添加」面板:addNodePanelOpen 控制弹层显隐;addNodeTarget 记录
677
- // 插入锚点与模式({ node, mode },来自结构树 add-node 事件):
678
- // node = 右键/选中 viewNode(null 或 Root → 视作根);mode 决定落位规则:
679
- // 'child' → 容器内默认插槽末尾追加子节点(根 → view.components 末尾);
680
- // 'before' → 节点相邻兄弟之前插入;
681
- // 'after' → 节点相邻兄弟之后插入;
682
- // 'auto' → 顶部「+ 添加」原行为(容器→子节点末尾;非容器→其后兄弟)。
683
- addNodePanelOpen: false,
684
- addNodeTarget: null,
685
- // 整卡校验徽标(Q3:仅 ok/error,不展开 bindingErrors 明细)
686
- validateBadge: { state: 'idle', errors: 0, warnings: 0, renderOk: null, ts: 0 },
687
- validateTimer: null,
688
- copiedSchema: false,
689
- copyTimer: null,
690
- savedCard: false,
691
- savingCard: false,
692
- saveTimer: null,
693
- savingRemote: false, // 远端保存(写回 FinMall)进行中
694
- savedRemote: false, // 远端保存成功反馈
695
- verifiedRemote: null, // 保存后读回校验结果:null=未校验/跳过,true=一致,false=不符
696
- saveRemoteTimer: null,
697
- syncingRemote: false,
698
- syncedRemote: false,
699
- remotePreviewOpen: false, // 「远端预览」二维码弹层开关
700
- syncTarget: null, // 'full' | 'selected' | null —— 标记上一次/正在进行的同步模式,用于按钮反馈
701
- syncTimer: null,
702
- uploadingSlices: false, // 「切图上传」进行中(远端分组,仅用户点击触发,无自动上传)
703
- // 登录凭据弹窗(远程同步缺凭据时弹出,填写账号密码写入 .local/credentials.json)
704
- loginModal: false,
705
- loginName: '',
706
- loginPassword: '',
707
- loginSubmitting: false,
708
- loginError: '',
709
- // 二次确认弹窗(版本推送 / 当前同步 / 全量同步 等写/覆盖类操作前弹出,确认后才执行 onConfirm)。
710
- // severity 三档:primary 常规 / warn 警告(橙黄+警告三角,覆盖类操作:版本推送、全量同步)/ danger 危险(红+禁止,仅明确负面结果如删除,暂无内置操作使用)
711
- confirm: {
712
- show: false,
713
- title: '',
714
- message: '',
715
- confirmText: '继续',
716
- cancelText: '取消',
717
- severity: 'primary',
718
- onConfirm: null,
719
- },
720
- // 编辑历史(撤销/重做):每个快照为 { schema, data },data 即 rt.rootScope(数据绑定),
721
- // 与 schema 一并快照,撤销时两者都还原(否则 syncDataToSchema 会用新数据覆盖回 schema)。
722
- // past=可回退的「上一版本」快照(最新在末尾),future=可重做的快照,
723
- // baseline=当前已提交版本的 { schema, data } 克隆(schema 比较时忽略 defaultValue 噪声,data 比较用深层相等)。
724
- past: [],
725
- future: [],
726
- baseline: null,
727
- _commitTimer: null, // 编辑落定防抖计时器,避免连续编辑产生过多快照
728
- viewportHeight: 700, // 手机视口实际高度(高度自适应填充后由 ResizeObserver 实测更新)
729
- // 画布背景模式:checker(棋盘格) / white(白色) / gray(浅灰) / black(近黑,深色卡片对色用)
730
- canvasBg: 'gray',
731
- // 刷新按钮进行态:图标自转 + 禁用,避免连点与「点了没反应」
732
- refreshing: false,
733
- bgOptions: [
734
- { key: 'checker', label: '棋盘' },
735
- { key: 'white', label: '白色' },
736
- { key: 'gray', label: '浅灰' },
737
- { key: 'black', label: '黑色' },
738
- ],
739
- // —— 预览区宽度可调(拖拽分隔条 / 预设尺寸,画布按实际像素宽度布局,无 transform 缩放)——
740
- _previewWidthPx: 372, // 预览列宽度(像素,持久化到 localStorage);第一次打开未主动设置时默认 372px
741
- _apiPanelWidthPx: 360, // 右侧「接口调用」面板宽度(像素,持久化到 localStorage);默认 360px
742
- presetSizes: [ // 预设尺寸下拉选项(仅宽度;高度随列高动态一致)
743
- { key: 375, value: '375' },
744
- { key: 430, value: '430' },
745
- { key: 188, value: '188' },
746
- ],
747
- stageWidth: typeof window !== 'undefined' ? window.innerWidth : 1280, // 舞台实际宽度(ResizeObserver 实测后更新)
748
- colsWidth: 0, // 中右两栏容器(.workside__cols)实测宽度:右栏宽度夹取的单一口径
749
- stageHeight: typeof window !== 'undefined' ? window.innerHeight - 52 : 748, // 舞台实际高度(contain 缩放的“高度上限”基准)
750
- dragging: false, // 是否正在拖拽分隔条
751
- apiDragging: false, // 是否正在拖拽「接口调用」面板分隔条
752
- _stageObserver: null,
753
- // —— 本地开发服务(前后端分离)配置入口 ——
754
- // devServiceForm:设置面板双向绑定的草稿;启用后前端所有 /__xxx 请求改走该地址。
755
- devServiceOpen: false,
756
- // AI 面板开合跨刷新保持:dev server 重启 / 误刷新后不必重新找入口
757
- aiChatOpen: (() => {
758
- try {
759
- return sessionStorage.getItem('cardbuddy:ai-chat-open') === '1'
760
- } catch {
761
- return false
762
- }
763
- })(),
764
- devServiceForm: loadDevServiceConfig(),
765
- // apiBaseResolved:当前生效的 API 基址;null 表示部署态未配置(必须用本地服务才能编辑)。
766
- apiBaseResolved: resolveApiBase(),
767
- // devSvcLive:本地开发服务 / 同源 dev server 的实时连接态(由 SSE / Vite HMR 生命周期驱动):
768
- // 'unknown' | 'connecting' | 'open' | 'closed'。与 apiBaseResolved(静态配置态)解耦,
769
- // 用于顶栏「连接」图标真实反映服务端是否在运行。
770
- devSvcLive: 'unknown',
771
- // devSvcDown:是否已确认「本地开发服务断开」。与 devSvcLive 区分——
772
- // EventSource 断链后会每 ~3s 自动重连并把 devSvcLive 翻回 'connecting',
773
- // 若直接用 'closed' 判定提示条会闪烁消失;故用独立标志:仅在真正重连成功(open)时清除,
774
- // 断开期间(含重连中)始终保持 true,提示条因此持续显示直到重连。
775
- devSvcDown: false,
776
- _unsubCardsChanged: null,
777
- }
778
- },
779
- computed: {
780
- currentBg() {
781
- return this.bgOptions.find((b) => b.key === this.canvasBg) || this.bgOptions[0]
782
- },
783
- /** 本地已有卡片 ID(GET /__cards 实时扫描 src/cards 的结果):新增空白卡片时做重名前置判定 */
784
- cardIds() {
785
- return this.cardList.map((c) => c && c.id).filter(Boolean)
786
- },
787
- // 撤销可用:存在可回退的上一版本;重做可用:存在被撤销后的版本
788
- canUndo() {
789
- return this.past.length > 0
790
- },
791
- canRedo() {
792
- return this.future.length > 0
793
- },
794
- // 部署态未生效的具体原因(基于设置面板草稿 devServiceForm 实时判断):
795
- // 'empty' = 未填写服务地址;
796
- // 'disabled' = 已填地址但未打开「启用」开关;
797
- // null = 已正确配置(apiBaseResolved 不应为 null)。
798
- devSvcBlockReason() {
799
- const f = this.devServiceForm || {}
800
- const hasUrl = !!String(f.baseUrl || '').trim()
801
- if (!hasUrl) return 'empty'
802
- if (!f.enabled) return 'disabled'
803
- return null
804
- },
805
- // 剪贴板是否有可粘贴节点(供结构树「粘贴」按钮 disabled 态)
806
- canPaste() {
807
- return !!getClipboardNode()
808
- },
809
- // 当前卡片是否有导入来源信息(经同步远程导入才有 .import-meta.json):
810
- // 无来源 = 本地新建卡,无远端卡片可推送版本,顶栏「版本推送」应置灰。
811
- hasCardSource() {
812
- return !!(this.cardImportMeta && this.cardImportMeta.source)
813
- },
814
- // 当前显示版本(schema 结构 + 数据绑定)是否与本地磁盘版本一致:
815
- // 不一致(撤销/重做到历史版本、或编辑后未本地保存)时,顶栏「保存到本地」按钮高亮且可点击;
816
- // 一致时按钮置灰不可用。
817
- // 注意:数据绑定比较用 localData(= buildDataFromSchema(localSchema),载入/保存时记录),
818
- // 而非 data.json 文件 —— data.json 仅为 schema 派生产物,永不读回。
819
- historyDirty() {
820
- if (!this.localSchema || !this.currentSchema) return false
821
- return (
822
- !this.structuralEqual(this.currentSchema, this.localSchema) ||
823
- !this.dataEqual(this.rt && this.rt.rootScope, this.localData)
824
- )
825
- },
826
- // 本地开发服务连接态聚合:静态配置态(apiBaseResolved) + 实时连接态(devSvcLive)。
827
- // - null = 未配置(部署态必填 / 已填但未启用)
828
- // - '' = 同源 dev 模式(页面本身由 dev server 提供)
829
- // - 其它地址 = 已连接本地服务(8787)
830
- devSvcConn() {
831
- if (this.apiBaseResolved === null) return 'unconfigured'
832
- if (this.devSvcLive === 'open') return 'open'
833
- if (this.devSvcDown) return 'closed'
834
- return 'connecting' // unknown 或 connecting 均视为「连接中 / 重连中」
835
- },
836
- // 顶栏「连接」图标 tooltip:按模式 + 实时连接态给出可读文案。
837
- devSvcTitle() {
838
- if (this.apiBaseResolved === null) {
839
- return this.devSvcBlockReason === 'disabled'
840
- ? '已填地址但未开启用本地服务'
841
- : '未配置本地开发服务(部署态必填)'
842
- }
843
- const prefix = this.apiBaseResolved === '' ? '同源开发服务' : '本地开发服务'
844
- if (this.devSvcLive === 'open') return prefix + '已连接'
845
- if (this.devSvcDown) return prefix + '已断开(连接失败或服务已停止,重连中…)'
846
- return prefix + '连接中 / 重连中…'
847
- },
848
- // 顶栏「连接」图标配色态。
849
- devSvcClass() {
850
- if (this.apiBaseResolved === null) return 'app__btn--warn'
851
- if (this.devSvcLive === 'open') return 'app__btn--connected'
852
- if (this.devSvcDown) return 'app__btn--disconnected'
853
- return 'app__btn--reconnecting'
854
- },
855
- // 底部状态条「连接」状态文案:与 devSvcClass / devSvcConn 同源,给出可朗读的简短状态(顶栏 tooltip 为完整说明)。
856
- devSvcLabel() {
857
- if (this.apiBaseResolved === null) return '未配置本地服务'
858
- if (this.devSvcLive === 'open') return '本地服务已连接'
859
- if (this.devSvcDown) return '本地服务已断开'
860
- return '本地服务连接中…'
861
- },
862
- badgeText() {
863
- const map = {
864
- idle: '待校验',
865
- pending: '校验中…',
866
- ok: '校验通过',
867
- error: '校验未通过',
868
- nopython: '未配置校验',
869
- fail: '校验服务异常',
870
- }
871
- return map[this.validateBadge.state] || '待校验'
872
- },
873
- badgeTitle() {
874
- const b = this.validateBadge
875
- if (b.state === 'ok') return '结构合法 + 渲染不抛错(不保证画得对,视觉正确性见截图)'
876
- if (b.state === 'error') {
877
- const parts = []
878
- if (b.errors) parts.push(`契约错误 ${b.errors} 个`)
879
- if (b.renderOk === false) parts.push('渲染抛出异常')
880
- return '校验未通过:' + (parts.join(';') || '详见 /__validate 与 /__render-status')
881
- }
882
- if (b.state === 'nopython') return '未找到 python3/python,无法跑契约校验(请安装 Python 或设置 DSL_PYTHON)'
883
- if (b.state === 'fail') return '校验服务异常(dev server 未启动或端点不可用)'
884
- return ''
885
- },
886
- // 预览列宽度(像素):chromeOff 时占满整屏(截图/真机模式,非“宽度调整”场景);
887
- // 否则使用 _previewWidthPx 并夹到 previewBounds(拖拽与渲染共用同一口径,杜绝两套夹取打架)
888
- previewWidth() {
889
- if (this.chromeOff) return this.stageWidth
890
- const { min, max } = this.previewBounds
891
- const w = this._previewWidthPx || PREVIEW_MIN
892
- return Math.max(min, Math.min(max, w))
893
- },
894
- /** 视口宽(响应式:随 stageWidth / colsWidth 的实测更新一起重算) */
895
- viewportWidth() {
896
- // 依赖 stageWidth 以便窗口缩放时重算;取值仍用 innerWidth(vw 口径与 CSS 一致)
897
- void this.stageWidth
898
- return typeof window !== 'undefined' ? window.innerWidth : 1280
899
- },
900
- /** 中栏软目标宽:屏宽 1/3,且不低于硬下限(下发给 CSS `.debug` 的 min-width) */
901
- debugSoftWidth() {
902
- return Math.max(DEBUG_MIN_FLOOR, Math.floor(this.viewportWidth / 3))
903
- },
904
- /** 预览列宽度区间:[188, min(舞台宽/2, 舞台宽 - 右栏下限 - 中栏软目标)]。
905
- * 只依赖舞台宽与视口宽(不反向依赖中右两栏实测),杜绝循环夹取。 */
906
- previewBounds() {
907
- const stage = this.stageWidth || 0
908
- const byHalf = Math.floor(stage / 2)
909
- const byWorkspace = stage - API_MIN - this.debugSoftWidth
910
- return { min: PREVIEW_MIN, max: Math.max(PREVIEW_MIN, Math.min(byHalf, byWorkspace)) }
911
- },
912
- /** 中栏(调试面板)实际最小宽:软目标(屏宽 1/3)优先,空间不足时退让到硬下限 */
913
- debugMinWidth() {
914
- const cols = this.colsWidth || Math.max(0, (this.stageWidth || 0) - this.previewWidth)
915
- return Math.max(DEBUG_MIN_FLOOR, Math.min(this.debugSoftWidth, Math.floor(cols) - API_MIN))
916
- },
917
- /** 右栏(接口调用面板)宽度区间:常规 [320, 两栏容器宽 - 中栏最小宽];
918
- * 容器实在放不下 320 时(窄窗口 + 预览列已占位)整体退让到剩余空间,绝不撑破容器
919
- * —— 撑破会让面板被推出视口、分隔条接缝与面板边缘脱节,表现为「拖到极限就跳」。 */
920
- apiPanelBounds() {
921
- const cols = this.colsWidth || Math.max(0, (this.stageWidth || 0) - this.previewWidth)
922
- const max = Math.max(API_ABS_MIN, Math.floor(cols) - this.debugMinWidth)
923
- return { min: Math.min(API_MIN, max), max }
924
- },
925
- // 画布实际宽度(列宽,整数):canvas-bar__size 实时显示用,拖拽分隔条时随之变化
926
- canvasW() {
927
- return Math.round(this.previewWidth || 375)
928
- },
929
- /** 当前卡片名(AI 开发助手头部展示;列表未加载完时回落 id) */
930
- selectedCardName() {
931
- const hit = this.cardList.find((c) => c.id === this.selectedId)
932
- return (hit && hit.name) || this.selectedId || ''
933
- },
934
- // 右侧「接口调用」面板宽度(像素):夹到 apiPanelBounds,保证中栏不小于屏宽 1/3、右栏不小于 320px
935
- apiPanelWidth() {
936
- const { min, max } = this.apiPanelBounds
937
- const w = this._apiPanelWidthPx || 360
938
- return Math.max(min, Math.min(max, w))
939
- },
940
- // 缩放外框占满整列(宽高交由 CSS flex 填满;画布不再做 transform 缩放,直接按实际尺寸布局)
941
- frameStyle() {
942
- return {}
943
- },
944
- },
945
- watch: {
946
- // 切到「组件结构」以外的 tab 时,取消任何节点的选中状态
947
- activeTab(val) {
948
- if (val !== 'structure') {
949
- this.selectedNodeId = ''
950
- // 离开组件结构 tab:结构树被卸载,hover 参考线不再有意义,清除避免残留
951
- clearHoverEl()
952
- }
953
- },
954
- aiChatOpen(val) {
955
- try {
956
- sessionStorage.setItem('cardbuddy:ai-chat-open', val ? '1' : '0')
957
- } catch {
958
- /* sessionStorage 不可用时忽略 */
959
- }
960
- },
961
- // 主题切换:把 'dark' / 'light' 写到 <html data-theme>,驱动 CSS 变量整体换肤
962
- theme(val) {
963
- document.documentElement.dataset.theme = val
964
- },
965
- // 「接口调用」面板展开/收起状态变化(顶栏按钮 toggleApiPanel 或面板内 ✕ 关闭)时统一持久化
966
- apiPanelOpen(val) {
967
- try {
968
- localStorage.setItem('cardbuddy:api-panel-open', val ? '1' : '0')
969
- } catch (e) {
970
- /* localStorage 不可用时忽略 */
971
- }
972
- },
973
- },
974
- beforeUnmount() {
975
- if (this.viewportObserver) this.viewportObserver.disconnect()
976
- if (this._stageObserver) this._stageObserver.disconnect()
977
- if (this.saveTimer) clearTimeout(this.saveTimer)
978
- if (this.syncTimer) clearTimeout(this.syncTimer)
979
- if (this.saveRemoteTimer) clearTimeout(this.saveRemoteTimer)
980
- window.removeEventListener('keydown', this.onKeydown)
981
- },
982
- mounted() {
983
- // 视口高度随窗口弹性填充,实测高度同步到 canvas-bar 尺寸读数
984
- this.viewportObserver = new ResizeObserver((entries) => {
985
- for (const entry of entries) {
986
- this.viewportHeight = Math.round(entry.contentRect.height)
987
- }
988
- })
989
- if (this.$refs.phoneViewport) this.viewportObserver.observe(this.$refs.phoneViewport)
990
- // 预览区宽度可调:实测舞台尺寸,驱动 previewWidth/frameStyle 自适应窗口变化
991
- this.loadPreviewWidth()
992
- this.loadApiPanelWidth()
993
- this._stageObserver = new ResizeObserver((entries) => {
994
- for (const entry of entries) {
995
- // 舞台与两栏容器分别实测:右栏夹取要用 colsWidth(不含预览列),不能拿舞台宽反推
996
- if (entry.target === this.$refs.colsEl) {
997
- this.colsWidth = Math.round(entry.contentRect.width)
998
- continue
999
- }
1000
- this.stageWidth = Math.round(entry.contentRect.width)
1001
- this.stageHeight = Math.round(entry.contentRect.height)
1002
- }
1003
- })
1004
- if (this.$refs.stageEl) this._stageObserver.observe(this.$refs.stageEl)
1005
- if (this.$refs.colsEl) this._stageObserver.observe(this.$refs.colsEl)
1006
- // 全局键盘快捷键:复制/粘贴/副本/删除/撤销/重做/取消选中(见 onKeydown)
1007
- window.addEventListener('keydown', this.onKeydown)
1008
- },
1009
- async created() {
1010
- // ynet.callJSAPI 统一桩:所有 jsapi 模拟集中在 runtime/jsapi-mock.js 分发
1011
- // (alertCard / closeAlertCard / alertDialog / showToast),不真正发请求。
1012
- installJsApiMock(cardRuntime)
1013
- // 纯净截图模式:?chrome=off 触发(隐藏调试外壳,见 template 的 v-show)
1014
- const chromeParam = new URLSearchParams(location.search).get('chrome')
1015
- this.chromeOff = chromeParam === 'off' || chromeParam === ''
1016
- // 主题:恢复本地持久化(默认深色);尽早应用到 <html>,避免首帧闪烁
1017
- try {
1018
- const t = localStorage.getItem('cardbuddy:theme')
1019
- if (t === 'light' || t === 'dark') this.theme = t
1020
- } catch (e) {
1021
- /* localStorage 不可用时忽略 */
1022
- }
1023
- document.documentElement.dataset.theme = this.theme
1024
- // 右侧「接口调用」面板:恢复本地持久化的展开/收起状态(默认展开)
1025
- try {
1026
- const v = localStorage.getItem('cardbuddy:api-panel-open')
1027
- if (v === '0') this.apiPanelOpen = false
1028
- } catch (e) {
1029
- /* localStorage 不可用时忽略 */
1030
- }
1031
- // 实时拉取卡片列表(不再依赖启动快照),再载入首张卡
1032
- await this.fetchCardList()
1033
- // 深链:若 URL 带 ?card=<id> 且命中列表,则预选该卡(供「远端预览」二维码扫码直达)
1034
- const deep = new URLSearchParams(location.search).get('card')
1035
- // 本地持久化:上次选中的卡片(无 URL 深链时优先恢复,而不是每次都回到首卡)
1036
- let persisted = null
1037
- try {
1038
- const v = localStorage.getItem('cardbuddy:selected-card')
1039
- if (v && this.cardList.some((c) => c.id === v)) persisted = v
1040
- } catch (e) {
1041
- /* localStorage 不可用时忽略 */
1042
- }
1043
- const initial =
1044
- deep && this.cardList.some((c) => c.id === deep)
1045
- ? deep
1046
- : persisted || (this.cardList[0] && this.cardList[0].id)
1047
- if (initial) {
1048
- this.selectedId = initial
1049
- await this.loadCard(this.selectedId)
1050
- }
1051
- // 数据绑定(rt.rootScope)深度监听:任意编辑实时回写 schema 的权威数据位 ——
1052
- // 已配置 mock 的 key 回写 dictionary.mocks[key],其余回写 inputParams[].defaultValue,
1053
- // 保证「保存到本地」基于最新数据绑定;预览也经 rt 响应式实时刷新。
1054
- // ⚠️ 此处【不】写历史——历史由「变量 → 配置变量 mock」视图的「应用」按钮(schema-commit)落定,
1055
- // 与节点属性/事件面板一致:实时编辑只更新预览与内存,点「应用」才记一笔撤销记录。
1056
- this.$watch(
1057
- () => this.rt.rootScope,
1058
- () => {
1059
- this.syncDataToSchema()
1060
- },
1061
- { deep: true }
1062
- )
1063
- // 监听 dev server 推送的磁盘变更事件:同步远程 / 其他窗口保存 / 手工编辑改了
1064
- // src/cards 后,预览重新拉取列表并在当前卡片受影响时即时重载,保证磁盘是唯一真相。
1065
- // 订阅磁盘变更推送:部署态经本地服务 SSE,开发期经 Vite HMR(见 @/api.js)
1066
- this.setupCardsChanged()
1067
- },
1068
- methods: {
1069
- /** 订阅磁盘变更推送:部署态经本地服务 SSE,开发期经 Vite HMR(见 @/api.js)。可重复调用以切换数据源。 */
1070
- setupCardsChanged() {
1071
- if (this._unsubCardsChanged) {
1072
- try { this._unsubCardsChanged() } catch (e) { /* ignore */ }
1073
- this._unsubCardsChanged = null
1074
- }
1075
- // 新订阅建立即进入「连接中」,待首帧 open / error 事件回写真实态
1076
- this.devSvcLive = 'connecting'
1077
- this._unsubCardsChanged = subscribeCardsChanged(
1078
- (payload) => this.onCardsChanged(payload),
1079
- (state) => this.onDevServiceState(state)
1080
- )
1081
- },
1082
- /**
1083
- * SSE / Vite HMR 连接态变化时回调,驱动顶栏「连接」图标实时反映服务端运行态。
1084
- * 断线 watchdog:若持续处于「连接中」超过 8s 仍未 open(服务端确已停止),翻为「已断开」,
1085
- * 避免用户看到永远转圈的黄灯误以为还活着;EventSource 后台仍会自动重连,恢复时 onopen 会翻回 open。
1086
- */
1087
- onDevServiceState(state) {
1088
- const wasConnecting = this.devSvcLive === 'connecting'
1089
- this.devSvcLive = state
1090
- if (state === 'open') {
1091
- // 真正重连成功:清除「已断开」标志,顶部提示条随之关闭
1092
- this.devSvcDown = false
1093
- } else if (state === 'closed') {
1094
- // 服务确已彻底关闭(EventSource readyState===2 / 显式 close):标记已断开
1095
- this.devSvcDown = true
1096
- }
1097
- // 注意:state==='connecting' 且此前已 devSvcDown===true(断开后重试)时,
1098
- // 保持 devSvcDown 为 true,使提示条在重连过程中持续显示,不闪退。
1099
- if (state === 'connecting') {
1100
- // 仅在「从其他态首次进入连接中」时启动 watchdog;
1101
- // 浏览器 EventSource 断链后会每 ~3s 自动重连并重发 onerror('connecting'),
1102
- // 若每次都重置计时器,则永远到不了 8s —— 故重复 connecting 不再重置。
1103
- if (!wasConnecting && !this._svcWatchdog) {
1104
- this._svcWatchdog = setTimeout(() => {
1105
- if (this.devSvcLive === 'connecting') {
1106
- this.devSvcLive = 'closed'
1107
- // watchdog 确认服务停止:标记已断开(即便后续重试翻回 connecting 也保持)
1108
- this.devSvcDown = true
1109
- }
1110
- this._svcWatchdog = null
1111
- }, 8000)
1112
- }
1113
- } else if (this._svcWatchdog) {
1114
- clearTimeout(this._svcWatchdog)
1115
- this._svcWatchdog = null
1116
- }
1117
- },
1118
- /** 保存「本地开发服务」配置并即时切换数据源 */
1119
- saveDevService() {
1120
- saveDevServiceConfig(this.devServiceForm)
1121
- this.apiBaseResolved = resolveApiBase()
1122
- this.setupCardsChanged()
1123
- this.devServiceOpen = false
1124
- showAppToast('本地开发服务配置已保存', 'ok')
1125
- this.fetchCardList()
1126
- },
1127
- // —— 列宽拖拽(左:预览列 / 右:接口调用面板)——
1128
- /**
1129
- * 分隔条拖拽统一入口:Pointer Events + 指针捕获。
1130
- * 曾经左条绑 mousedown、处理函数却按 pointer 写(ev.pointerId 为 undefined → 捕获失败,
1131
- * pointermove 只在 6px 命中区内触发),表现为「左侧分隔条拖不动」;两条统一走这里。
1132
- * 捕获后即使松手点在视口外,pointerup / pointercancel 仍回送本元素,不会残留拖拽态。
1133
- * @param {PointerEvent} ev
1134
- * @param {(e: PointerEvent) => void} onMove 每次移动的夹取写入
1135
- * @param {() => void} onEnd 收尾(清光标 / 持久化)
1136
- */
1137
- beginColumnDrag(ev, onMove, onEnd) {
1138
- const el = ev && ev.currentTarget
1139
- if (!el) return
1140
- const pointerId = ev.pointerId
1141
- try {
1142
- el.setPointerCapture(pointerId)
1143
- } catch {
1144
- /* 不支持捕获时退化为元素级监听 */
1145
- }
1146
- const finish = () => {
1147
- el.removeEventListener('pointermove', onMove)
1148
- el.removeEventListener('pointerup', finish)
1149
- el.removeEventListener('pointercancel', finish)
1150
- window.removeEventListener('blur', finish)
1151
- try {
1152
- if (el.hasPointerCapture?.(pointerId)) el.releasePointerCapture(pointerId)
1153
- } catch {
1154
- /* 已释放 */
1155
- }
1156
- document.body.style.cursor = ''
1157
- document.body.style.userSelect = ''
1158
- onEnd()
1159
- }
1160
- document.body.style.cursor = 'col-resize'
1161
- document.body.style.userSelect = 'none'
1162
- el.addEventListener('pointermove', onMove)
1163
- el.addEventListener('pointerup', finish)
1164
- el.addEventListener('pointercancel', finish)
1165
- window.addEventListener('blur', finish) // 窗口失焦兜底:切走 / 最小化不卡死拖拽态
1166
- },
1167
- // 左分隔条:拖动改变预览列宽度(写像素,previewWidth / canvasW 自动联动),松手持久化
1168
- startDrag(ev) {
1169
- if (this.chromeOff) return
1170
- const stage = this.$refs.stageEl
1171
- if (!stage) return
1172
- const baseLeft = stage.getBoundingClientRect().left
1173
- this.dragging = true
1174
- this.beginColumnDrag(
1175
- ev,
1176
- (e) => {
1177
- const { min, max } = this.previewBounds
1178
- this._previewWidthPx = Math.max(min, Math.min(max, Math.round(e.clientX - baseLeft)))
1179
- },
1180
- () => {
1181
- this.dragging = false
1182
- this.savePreviewWidth()
1183
- }
1184
- )
1185
- },
1186
- // 右分隔条:拖动改变「接口调用」面板宽度(向左加宽 / 向右收窄),松手持久化。
1187
- // 基准取两栏容器右缘(= 面板右缘),夹取用 apiPanelBounds,与渲染口径完全一致 → 极限处不再跳动
1188
- startApiDrag(ev) {
1189
- if (this.chromeOff || !this.apiPanelOpen) return
1190
- const cols = this.$refs.colsEl
1191
- if (!cols) return
1192
- const baseRight = cols.getBoundingClientRect().right
1193
- this.apiDragging = true
1194
- this.beginColumnDrag(
1195
- ev,
1196
- (e) => {
1197
- const { min, max } = this.apiPanelBounds
1198
- this._apiPanelWidthPx = Math.max(min, Math.min(max, Math.round(baseRight - e.clientX)))
1199
- },
1200
- () => {
1201
- this.apiDragging = false
1202
- this.saveApiPanelWidth()
1203
- }
1204
- )
1205
- },
1206
- // 读取上次保存的预览列宽度(像素);首次打开(无记录)时保持默认 372px
1207
- loadPreviewWidth() {
1208
- try {
1209
- const v = localStorage.getItem('cardbuddy:preview-width')
1210
- if (v != null) {
1211
- const px = parseInt(v, 10)
1212
- if (!Number.isNaN(px) && px >= 188) this._previewWidthPx = px
1213
- }
1214
- } catch (e) { /* localStorage 不可用时忽略 */ }
1215
- },
1216
- // 持久化预览列宽度(像素)到 localStorage
1217
- savePreviewWidth() {
1218
- try {
1219
- localStorage.setItem('cardbuddy:preview-width', String(Math.round(this._previewWidthPx)))
1220
- } catch (e) { /* 忽略 */ }
1221
- },
1222
- // 预设尺寸切换:375 / 188 一键设定预览列宽度;高度随列高动态保持一致(由 CSS flex 自动填充)
1223
- setPresetWidth(w) {
1224
- if (this.chromeOff) return
1225
- const { min, max } = this.previewBounds
1226
- this._previewWidthPx = Math.max(min, Math.min(max, w))
1227
- this.savePreviewWidth()
1228
- },
1229
- // 读取上次保存的接口调用面板宽度(像素);首次打开(无记录)时保持默认 360px
1230
- loadApiPanelWidth() {
1231
- try {
1232
- const v = localStorage.getItem('cardbuddy:api-panel-width')
1233
- if (v != null) {
1234
- const px = parseInt(v, 10)
1235
- if (!Number.isNaN(px) && px >= 320) this._apiPanelWidthPx = px
1236
- }
1237
- } catch (e) { /* localStorage 不可用时忽略 */ }
1238
- },
1239
- // 持久化接口调用面板宽度(像素,取夹取后的实际值)到 localStorage
1240
- saveApiPanelWidth() {
1241
- try {
1242
- localStorage.setItem('cardbuddy:api-panel-width', String(Math.round(this.apiPanelWidth)))
1243
- } catch (e) { /* 忽略 */ }
1244
- },
1245
- /** 背景下拉:选中某项 */
1246
- chooseBg(key) {
1247
- this.canvasBg = key
1248
- },
1249
- onSelect(id) {
1250
- this.loadingCard = true
1251
- // 乐观置位:下拉立刻跟手。持久化交给 loadCard —— 载入成功才记,
1252
- // 避免把一张打不开的卡记成「上次选中」,下次进页面直接白屏
1253
- this.selectedId = id
1254
- this.loadCard(id, { clearAll: true })
1255
- },
1256
- /**
1257
- * 置为「当前卡」并持久化(下次打开页面自动恢复,沿用 cardbuddy: 前缀约定)。
1258
- * 顶栏下拉、网格高亮、校验徽标、RenderTree 的 key、导入元数据的过期判定全读 selectedId,
1259
- * 所以由 loadCard 统一置位:任何调用点(下拉选中、网格点卡、新增空白卡、删除后切下一张)
1260
- * 都不会再出现「画布切过去了、下拉还停在旧卡」的错位。
1261
- */
1262
- selectCard(id) {
1263
- this.selectedId = id
1264
- try {
1265
- localStorage.setItem('cardbuddy:selected-card', id)
1266
- } catch (e) {
1267
- /* localStorage 不可用时忽略 */
1268
- }
1269
- },
1270
- /** 打开卡片网格预览弹窗 */
1271
- openGallery() {
1272
- if (!this.cardList.length) return
1273
- this.galleryOpen = true
1274
- },
1275
- /** 打开「新增空白卡片」弹窗:先刷一次列表,重名判定才拿得到最新本地卡片(别的窗口 / 同步远程可能刚建过卡) */
1276
- openNewCard() {
1277
- this.newCardOpen = true
1278
- this.fetchCardList()
1279
- },
1280
- /** 关闭卡片网格预览弹窗 */
1281
- closeGallery() {
1282
- this.galleryOpen = false
1283
- },
1284
- /**
1285
- * 顶栏「打开目录」按钮:请求本地开发服务在系统文件管理器打开
1286
- * 当前卡片对应文件夹(src/cards/<selectedId>)。跨平台(win/mac)由后端完成。
1287
- */
1288
- async openCardFolder() {
1289
- if (!this.selectedId) return
1290
- try {
1291
- const res = await apiFetch('/__open-folder', {
1292
- method: 'POST',
1293
- headers: { 'Content-Type': 'application/json' },
1294
- body: JSON.stringify({ id: this.selectedId }),
1295
- })
1296
- const data = (res.headers.get('content-type') || '').includes('application/json')
1297
- ? await res.json().catch(() => ({}))
1298
- : {}
1299
- if (!res.ok || !data.ok) {
1300
- showAppToast(data.error || '打开文件夹失败', 'err')
1301
- } else {
1302
- showAppToast('已打开卡片文件夹', 'ok')
1303
- }
1304
- } catch (e) {
1305
- showAppToast('无法连接本地开发服务,请确认已启动', 'err')
1306
- }
1307
- },
1308
- /**
1309
- * 底部状态条刷新按钮:重新从本地开发服务拉取当前卡片并重建预览。
1310
- */
1311
- async refreshCard() {
1312
- if (!this.selectedId || this.refreshing) return
1313
- this.refreshing = true
1314
- try {
1315
- await this.loadCard(this.selectedId)
1316
- } finally {
1317
- this.refreshing = false
1318
- }
1319
- },
1320
- // 切换深/浅主题:写入 <html data-theme> 并持久化,刷新后保持
1321
- toggleTheme() {
1322
- this.theme = this.theme === 'light' ? 'dark' : 'light'
1323
- try {
1324
- localStorage.setItem('cardbuddy:theme', this.theme)
1325
- } catch (e) {
1326
- /* localStorage 不可用时忽略 */
1327
- }
1328
- },
1329
- /** 展开/收起右侧常驻「接口调用」面板(默认展开);持久化由 apiPanelOpen 监听器统一处理 */
1330
- toggleApiPanel() {
1331
- this.apiPanelOpen = !this.apiPanelOpen
1332
- },
1333
- /**
1334
- * 整卡校验徽标(Q3:仅 ok/error,不展开 bindingErrors 明细)。
1335
- * 调 /__validate(契约硬校验)与 /__render-status(结构合法 + 渲染不抛错)两个既有端点,
1336
- * 均读磁盘,故在「载入 / 本地保存」后刷新——编辑→本地保存→徽标即时反映。
1337
- * 语义三处一致:ok = 结构合法 + 渲染不抛错,**不保证画得对**(视觉见截图)。
1338
- * bindingErrors 明细不进徽标(已有 DebugPanel 状态栏单独展示)。
1339
- */
1340
- async refreshValidateBadge() {
1341
- const id = this.selectedId
1342
- if (!id || !this.preview) return
1343
- if (this.validateTimer) clearTimeout(this.validateTimer)
1344
- this.validateBadge = { ...this.validateBadge, state: 'pending' }
1345
- // 轻量去抖:连续保存/热更新时只取最后一次结果
1346
- await new Promise((r) => {
1347
- this.validateTimer = setTimeout(r, 120)
1348
- })
1349
- if (this.selectedId !== id) return // 已切卡,丢弃过期结果
1350
- try {
1351
- const [vRes, rRes] = await Promise.all([
1352
- apiFetch(`/__validate/${encodeURIComponent(id)}`),
1353
- apiFetch(`/__render-status/${encodeURIComponent(id)}`),
1354
- ])
1355
- // 503 = 未装 python(环境缺失,非卡片问题)
1356
- if (!vRes.ok) {
1357
- this.validateBadge = { ...this.validateBadge, state: vRes.status === 503 ? 'nopython' : 'fail' }
1358
- return
1359
- }
1360
- const v = await vRes.json().catch(() => null)
1361
- const r = rRes.ok ? await rRes.json().catch(() => null) : null
1362
- if (!v) {
1363
- this.validateBadge = { ...this.validateBadge, state: 'fail' }
1364
- return
1365
- }
1366
- const contractOk = !!v.ok
1367
- // 渲染端点异常不强行判错(避免 dev server 预热期 SSR 冷启动误报)
1368
- const renderOk = r ? !!r.renderOk : true
1369
- const ok = contractOk && renderOk
1370
- const next = ok ? 'ok' : 'error'
1371
- const changed = this.validateBadge.state !== next
1372
- this.validateBadge = {
1373
- state: next,
1374
- errors: (v.errors || []).length,
1375
- warnings: (v.warnings || []).length,
1376
- renderOk,
1377
- ts: Date.now(),
1378
- }
1379
- if (changed && (next === 'ok' || next === 'error')) this.celebrateValidate(next)
1380
- } catch (e) {
1381
- // 网络/服务不可用:保底不阻断编辑,标记 fail(fail 不触发动画)
1382
- this.validateBadge = { ...this.validateBadge, state: 'fail' }
1383
- }
1384
- },
1385
- /** 状态跳变时,在徽标局部触发庆祝动画:徽标弹跳 + 碎玻璃碎屑沿轮廓放射爆开,碎屑颜色跟随目标状态主色。仅局部,不铺屏。 */
1386
- celebrateValidate(state = 'ok') {
1387
- const el = this.$refs.validateBadgeEl
1388
- if (!el) return
1389
- if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
1390
- // 徽标弹跳
1391
- el.classList.remove('is-celebrating')
1392
- void el.offsetWidth // 重排以重启动画
1393
- el.classList.add('is-celebrating')
1394
- setTimeout(() => el.classList.remove('is-celebrating'), 600)
1395
- // 碎屑颜色跟随目标状态徽标主色(--ok / --danger / --warn / --text-faint)
1396
- const r = el.getBoundingClientRect()
1397
- const colorVar = { ok: '--ok', error: '--danger', pending: '--warn', fail: '--text-faint', nopython: '--text-faint' }[state] || '--ok'
1398
- const fallback = { ok: '#52c41a', error: '#F5222D', pending: '#FAAD14', fail: '#8c8c8c', nopython: '#8c8c8c' }[state] || '#52c41a'
1399
- const badgeColor = getComputedStyle(el).getPropertyValue(colorVar).trim() || fallback
1400
- const box = document.createElement('div')
1401
- box.className = 'app__confetti'
1402
- box.style.left = r.left + r.width / 2 + 'px'
1403
- box.style.top = r.top + r.height / 2 + 'px'
1404
- // 碎玻璃碎片:从徽标矩形轮廓迸出,沿外法线方向飞散;不规则尖锐多边形 + 尺寸不一
1405
- const count = 22
1406
- const clips = [
1407
- 'polygon(50% 0%, 0% 100%, 100% 100%)',
1408
- 'polygon(62% 0%, 100% 40%, 45% 100%, 0% 52%)',
1409
- 'polygon(0% 0%, 100% 18%, 82% 100%, 12% 70%)',
1410
- 'polygon(22% 0%, 100% 8%, 88% 100%, 0% 76%)',
1411
- 'polygon(0% 32%, 100% 0%, 76% 100%, 18% 86%)',
1412
- 'polygon(8% 0%, 100% 22%, 70% 100%, 0% 64%)',
1413
- 'polygon(0% 12%, 100% 0%, 100% 78%, 30% 100%, 12% 55%)',
1414
- ]
1415
- const halfW = r.width / 2
1416
- const halfH = r.height / 2
1417
- for (let i = 0; i < count; i++) {
1418
- const bit = document.createElement('span')
1419
- bit.className = 'app__confetti-bit'
1420
- // 在徽标矩形轮廓上随机取一点作为迸发起点
1421
- const edge = (Math.random() * 4) | 0
1422
- let px, py
1423
- if (edge === 0) { px = (Math.random() * 2 - 1) * halfW; py = -halfH }
1424
- else if (edge === 1) { px = halfW; py = (Math.random() * 2 - 1) * halfH }
1425
- else if (edge === 2) { px = (Math.random() * 2 - 1) * halfW; py = halfH }
1426
- else { px = -halfW; py = (Math.random() * 2 - 1) * halfH }
1427
- // 沿轮廓点的外法线方向飞出 + 微抖动
1428
- const len = Math.hypot(px, py) || 1
1429
- const a = Math.atan2(py / len, px / len) + (Math.random() - 0.5) * 0.5
1430
- const dist = 16 + Math.random() * 26
1431
- bit.style.setProperty('--sx', px.toFixed(1) + 'px')
1432
- bit.style.setProperty('--sy', py.toFixed(1) + 'px')
1433
- bit.style.setProperty('--tx', (Math.cos(a) * dist).toFixed(1) + 'px')
1434
- bit.style.setProperty('--ty', (Math.sin(a) * dist).toFixed(1) + 'px')
1435
- bit.style.setProperty('--rot', (Math.random() * 720 - 360).toFixed(0) + 'deg')
1436
- bit.style.setProperty('--clip', clips[(Math.random() * clips.length) | 0])
1437
- bit.style.setProperty('--c', badgeColor)
1438
- // 每片独立透明度,半透明玻璃感
1439
- bit.style.setProperty('--o', (0.68 + Math.random() * 0.27).toFixed(2))
1440
- // 三层尺寸分布:长薄片 / 中等碎块 / 细微粉尘,强化碎裂参差感
1441
- const roll = Math.random()
1442
- let w, h
1443
- if (roll < 0.35) { w = 1.5 + Math.random() * 2; h = 10 + Math.random() * 10 } // 细长薄片
1444
- else if (roll < 0.75) { w = 4 + Math.random() * 5; h = w * (0.6 + Math.random() * 0.8) } // 中等碎块
1445
- else { w = 2 + Math.random() * 2.5; h = w } // 细微粉尘
1446
- bit.style.width = w.toFixed(0) + 'px'
1447
- bit.style.height = h.toFixed(0) + 'px'
1448
- box.appendChild(bit)
1449
- }
1450
- document.body.appendChild(box)
1451
- setTimeout(() => box.remove(), 800)
1452
- },
1453
- /** 实时从磁盘读取并载入指定卡片(GET /__card/<id>),不读任何缓存 */
1454
- async loadCard(id, opts = {}) {
1455
- if (!id) return
1456
- this.loadingCard = true
1457
- try {
1458
- const res = await apiFetch(`/__card/${encodeURIComponent(id)}`)
1459
- if (!res.ok) {
1460
- this.rt.pushError('loadCard', 'HTTP ' + res.status)
1461
- return
1462
- }
1463
- const fresh = await res.json()
1464
- if (!fresh.schema) {
1465
- this.rt.pushError('loadCard', 'schema 读取失败')
1466
- return
1467
- }
1468
- // 载入成功即认定这张卡为当前卡:必须在 fetchImportMeta / refreshValidateBadge 之前,
1469
- // 二者都以 selectedId 作「是否已切卡」的判据,晚置位会把新卡的结果当过期丢掉
1470
- this.selectCard(id)
1471
- // 切换卡片:清全部错误;刷新/重建预览:只清绑定错误,保留事件错误
1472
- if (opts.clearAll) {
1473
- this.rt.clearErrors()
1474
- } else {
1475
- this.rt.clearBindingErrors()
1476
- }
1477
- this.rt.clearApiCalls()
1478
- // schema 被事件编辑器改后重建时保留当前数据绑定编辑,避免被磁盘 data(由 schema 派生)覆盖。
1479
- // 数据绑定改为由 schema 单向派生,不再读回 data.json 文件。
1480
- if (!opts.keepScope) this.rt.setRootScope(buildDataFromSchema(fresh.schema, WITH_MOCKS))
1481
- this.currentCard = { schema: fresh.schema }
1482
- this.currentSchema = fresh.schema
1483
- // 重置并在载入后拉取导入来源元数据(Phase E):本地新建卡无 .import-meta.json → null,
1484
- // 顶栏「版本推送」据此置灰(无来源则无远端可推送版本)。同步远程后 onRefresh 也会回到此处刷新。
1485
- this.cardImportMeta = null
1486
- this.fetchImportMeta(id)
1487
- // 记录本地真实版本基线(磁盘当前内容),供「保存到本地」按钮判断与本地是否一致。
1488
- // localData 由 schema 派生(与渲染注入同源),保证纯数据编辑也能触发脏标记。
1489
- this.localSchema = this.cloneSchema(fresh.schema)
1490
- this.localData = this.cloneData(buildDataFromSchema(fresh.schema, WITH_MOCKS))
1491
- this.preview = buildPreview(fresh.schema)
1492
- // Task #5:把 Property 树模型森林暴露到 window,供浏览器内 AI / skill 直接读取节点可编辑结构
1493
- if (typeof window !== 'undefined') window.__cardModel = buildModelView(fresh.schema)
1494
- // 标脏后重新挂 data-card-ready:截图模式下 screenshot.js 等该属性出现再截,
1495
- // 保证截到的是已挂载的首帧而非空壳。先置 false 让切换卡片时属性短暂消失,
1496
- // 待 RenderTree 重新挂载后于 nextTick 置 true。
1497
- this.cardReady = false
1498
- this.$nextTick(() => {
1499
- this.cardReady = true
1500
- })
1501
- // 重建后立即把当前数据绑定同步进 schema(尤其 keepScope 时 currentSchema 被重置为磁盘版)
1502
- this.syncDataToSchema()
1503
- this.selectedNodeId = ''
1504
- // 以刚载入的磁盘版本作为「已提交基线」(仅 schema;数据绑定由 schema.defaultValue 派生,单向)。
1505
- // 撤销/重做历史快照也只存 schema,还原时由 buildDataFromSchema(schema) 重建数据绑定。
1506
- this.baseline = {
1507
- schema: this.cloneSchema(this.currentSchema),
1508
- }
1509
- // 递增 token 强制 RenderTree 重新挂载(重跑 init),保证新卡片 / 新 schema 的
1510
- // 初始化动作(如 assetOverview 计算 ratio)一定在 created 阶段执行。
1511
- this.reloadToken += 1
1512
- // 切换主卡片时清理弹窗与设备级模拟 UI,避免残留引用旧上下文
1513
- clearAllAlerts()
1514
- clearDeviceUI()
1515
- // 载入即刷新整卡校验徽标(端点读磁盘,载入后状态最新)
1516
- this.refreshValidateBadge()
1517
- // 载入编辑历史(落盘在 .history/):重置 past/future 并重建撤销栈
1518
- await this.loadHistory(id)
1519
- } finally {
1520
- this.loadingCard = false
1521
- }
1522
- },
1523
- /**
1524
- * 编辑历史:深拷贝 schema(剥离运行时 _schemaNode 自引用)。
1525
- */
1526
- cloneSchema(schema) {
1527
- if (!schema) return null
1528
- return JSON.parse(JSON.stringify(schema, (k, v) => (k === '_schemaNode' ? undefined : v)))
1529
- },
1530
- /**
1531
- * 归一化用于结构比较:忽略由数据绑定推导写入的 dictionary.inputParams[].defaultValue
1532
- * (这部分是数据绑定镜像,非结构性编辑,不应触发快照)。
1533
- */
1534
- normalizeForCompare(schema) {
1535
- const clone = this.cloneSchema(schema)
1536
- if (clone && clone.dictionary && Array.isArray(clone.dictionary.inputParams)) {
1537
- for (const p of clone.dictionary.inputParams) {
1538
- if (p && typeof p === 'object') p.defaultValue = undefined
1539
- }
1540
- }
1541
- return clone
1542
- },
1543
- /** 结构性相等(忽略数据绑定 defaultValue 噪声) */
1544
- structuralEqual(a, b) {
1545
- if (!a || !b) return a === b
1546
- return JSON.stringify(this.normalizeForCompare(a)) === JSON.stringify(this.normalizeForCompare(b))
1547
- },
1548
- /** 深拷贝数据绑定(rt.rootScope);同样剥离运行时 _schemaNode,避免自引用 */
1549
- cloneData(data) {
1550
- if (data == null) return data
1551
- return JSON.parse(JSON.stringify(data, (k, v) => (k === '_schemaNode' ? undefined : v)))
1552
- },
1553
- /** 数据绑定(rt.rootScope)深层相等:用于判断绑定编辑是否产生了新快照 */
1554
- dataEqual(a, b) {
1555
- return JSON.stringify(a) === JSON.stringify(b)
1556
- },
1557
- /**
1558
- * 把历史快照(某一旧版本 { schema })应用回当前编辑态:替换 currentSchema 与数据绑定,
1559
- * 重建预览/模型视图,但不产生新的历史记录(撤销/重做本身是导航,不是新修改)。
1560
- * 数据绑定由 buildDataFromSchema(schema) 从快照的 schema(含 defaultValue 镜像)重建,
1561
- * 还原后再 syncDataToSchema,保证 schema.dictionary.inputParams.defaultValue 与新数据绑定一致。
1562
- */
1563
- applySchemaSnapshot(snapshot) {
1564
- const id = this.selectedId
1565
- if (!id || !snapshot) return
1566
- const schema = snapshot.schema || snapshot
1567
- // 快照只存 schema;数据绑定由其派生。若老快照带 data 则优先沿用(历史兼容)。
1568
- const data = snapshot.data != null ? snapshot.data : buildDataFromSchema(schema, WITH_MOCKS)
1569
- this.currentSchema = this.cloneSchema(schema)
1570
- this.rt.setRootScope(this.cloneData(data))
1571
- this.preview = buildPreview(this.currentSchema)
1572
- if (typeof window !== 'undefined') window.__cardModel = buildModelView(this.currentSchema)
1573
- this.syncDataToSchema()
1574
- this.selectedNodeId = ''
1575
- this.reloadToken += 1
1576
- this.refreshValidateBadge()
1577
- },
1578
- /** 提交一次编辑:与 baseline(仅 schema)比较,schema 结构或数据绑定任一变化即算改动,
1579
- * 把「上一版本(baseline)」压入 past 并落盘(仅 schema),清空重做栈。
1580
- * 数据绑定比较用 buildDataFromSchema(baseline.schema) 派生,与 baseline 记录时一致。 */
1581
- commitHistory() {
1582
- if (!this.currentSchema || !this.baseline) return
1583
- const sameSchema = this.structuralEqual(this.currentSchema, this.baseline.schema)
1584
- const sameData = this.dataEqual(this.rt.rootScope, buildDataFromSchema(this.baseline.schema, WITH_MOCKS))
1585
- if (sameSchema && sameData) return
1586
- const previous = { schema: this.baseline.schema }
1587
- this.past.push(previous)
1588
- if (this.past.length > 30) this.past.shift()
1589
- this.future = []
1590
- this.baseline = { schema: this.cloneSchema(this.currentSchema) }
1591
- // 落盘:把「上一版本」写入卡片同目录 .history/(最多 30 个,由服务端裁剪)
1592
- this.persistSnapshot(previous)
1593
- },
1594
- /** 编辑落定防抖:连续编辑(如连续改属性)合并为一次快照 */
1595
- scheduleCommit() {
1596
- if (this._commitTimer) clearTimeout(this._commitTimer)
1597
- this._commitTimer = setTimeout(() => {
1598
- this._commitTimer = null
1599
- this.commitHistory()
1600
- }, 500)
1601
- },
1602
- /** 把上一版本 { schema } 落盘到 .history/(fire-and-forget,失败仅告警;data 由服务端从 schema 派生) */
1603
- persistSnapshot(snapshot) {
1604
- const id = this.selectedId
1605
- if (!id) return
1606
- const schema = snapshot.schema || snapshot
1607
- apiFetch(`/__history/${encodeURIComponent(id)}`, {
1608
- method: 'POST',
1609
- headers: { 'Content-Type': 'application/json' },
1610
- body: JSON.stringify({ schema }, (k, v) => (k === '_schemaNode' ? undefined : v), 2),
1611
- }).catch((e) => console.warn('[历史快照落盘失败]', e))
1612
- },
1613
- /** 载入指定卡片的编辑历史(从 .history/ 读取,旧→新),用于页面重载后重建撤销栈 */
1614
- async loadHistory(id) {
1615
- this.past = []
1616
- this.future = []
1617
- try {
1618
- const res = await apiFetch(`/__history/${encodeURIComponent(id)}`)
1619
- if (!res.ok) return
1620
- const r = await res.json().catch(() => null)
1621
- const snaps = (r && r.snapshots) || []
1622
- // 取末尾最多 30 个作为可回退的上一版本(最新在末尾;仅 schema,data 由 schema 派生)
1623
- this.past = snaps.slice(-30).map((s) => ({
1624
- schema: s.schema,
1625
- data: s.data != null ? s.data : buildDataFromSchema(s.schema, WITH_MOCKS),
1626
- }))
1627
- } catch (e) {
1628
- console.warn('[加载编辑历史失败]', e)
1629
- }
1630
- },
1631
- /** 后退一步(撤销):弹出最近一次编辑前的版本并应用 */
1632
- undo() {
1633
- if (!this.canUndo) return
1634
- const prev = this.past.pop()
1635
- this.future.push(this.baseline)
1636
- if (this.future.length > 30) this.future.shift()
1637
- this.baseline = prev
1638
- this.applySchemaSnapshot(prev)
1639
- },
1640
- /** 前进一步(重做):重放被撤销的版本 */
1641
- redo() {
1642
- if (!this.canRedo) return
1643
- const next = this.future.pop()
1644
- this.past.push(this.baseline)
1645
- if (this.past.length > 30) this.past.shift()
1646
- this.baseline = next
1647
- this.applySchemaSnapshot(next)
1648
- },
1649
- /**
1650
- * 顶栏「保存到本地」:把当前显示的历史版本(撤销/重做导航到的版本,或编辑后未保存的版本)
1651
- * 真正写回本地磁盘(src/cards/<id>/schema.json + data.json)。写盘成功后 saveCard 会刷新
1652
- * 本地基线,使按钮在「当前版本 == 本地版本」时自动置灰;下一次重新打开即加载该真实版本,
1653
- * 而非任意历史记录。
1654
- */
1655
- async applyHistoryToLocal() {
1656
- // 切卡/载入进行中:currentSchema 仍是上一卡、selectedId 已是新卡,拦截以免把上一卡误写进新卡文件
1657
- if (this.loadingCard) return
1658
- if (!this.historyDirty || !this.preview || !this.selectedId) return
1659
- const ok = await this.saveCard()
1660
- if (ok) showAppToast('已将该版本保存到本地', 'ok')
1661
- },
1662
- /**
1663
- * schema 被事件编辑器修改后,用内存中已编辑(事件编辑器直接改了 currentSchema 对象)
1664
- * 的版本重建预览,不回读磁盘以免丢失编辑。强制重挂载以重跑 init 生命周期。
1665
- */
1666
- /** 实时编辑(属性/事件/数据绑定)重建预览:仅刷新预览,不写历史。
1667
- * 历史由「应用」按钮(onSchemaCommit)落定写入。 */
1668
- onSchemaChanged() {
1669
- const id = this.selectedId
1670
- if (!id || !this.currentSchema) return
1671
- this.reloadToken += 1
1672
- this.preview = buildPreview(this.currentSchema)
1673
- if (typeof window !== 'undefined') window.__cardModel = buildModelView(this.currentSchema)
1674
- this.syncDataToSchema()
1675
- this.selectedNodeId = ''
1676
- },
1677
- /** 「应用」按钮:以最新编辑重建预览,并写入一次历史(生成一条撤销记录)。
1678
- * 区别于 onSchemaChanged:这里落定历史,实时编辑路径不写历史。 */
1679
- onSchemaCommit() {
1680
- const id = this.selectedId
1681
- if (!id || !this.currentSchema) return
1682
- this.reloadToken += 1
1683
- this.preview = buildPreview(this.currentSchema)
1684
- if (typeof window !== 'undefined') window.__cardModel = buildModelView(this.currentSchema)
1685
- this.syncDataToSchema()
1686
- this.selectedNodeId = ''
1687
- this.commitHistory()
1688
- },
1689
- /**
1690
- * 把数据绑定(rt.rootScope)的编辑实时同步回 schema 的权威数据位:
1691
- * key 已在 dictionary.mocks 中 → 回写 mocks[key](设计期覆盖仓);
1692
- * 否则回写 schema.dictionary.inputParams[i].defaultValue(随卡发布的缺省仓)。
1693
- * 这样「本地保存」时 schema.json 携带最新两仓数据,而 data.json 仍只由 defaultValue 派生,
1694
- * mock 不会泄漏进发布数据;两仓也不会互相覆盖。
1695
- */
1696
- syncDataToSchema() {
1697
- const schema = this.currentSchema
1698
- if (!schema || !schema.dictionary || !Array.isArray(schema.dictionary.inputParams)) return
1699
- const root = this.rt && this.rt.rootScope
1700
- if (!root || typeof root !== 'object') return
1701
- const params = schema.dictionary.inputParams
1702
- const mocks = readMocks(schema)
1703
- for (const key of Object.keys(root)) {
1704
- const param = params.find((p) => p && (p.key === key || p.name === key))
1705
- if (!param) continue
1706
- // 深拷贝,避免数据绑定与 schema 共享同一引用(setRootScope 重建后会失联)
1707
- const value = JSON.parse(JSON.stringify(root[key], (k, v) => (k === '_schemaNode' ? undefined : v)))
1708
- if (mocks && Object.prototype.hasOwnProperty.call(mocks, key)) mocks[key] = value
1709
- else param.defaultValue = value
1710
- }
1711
- },
1712
- /** 结构树选中节点 */
1713
- onSelectNode(id) {
1714
- this.selectedNodeId = id || ''
1715
- },
1716
- /**
1717
- * 在预览树(preview.view)中按运行时 id 递归查找对应节点。
1718
- * @param {Object} view 预览树根(或任意子树入口)
1719
- * @param {string} id 目标节点的运行时 id(即 selectedNodeId 的取值)
1720
- * @returns {Object|null}
1721
- */
1722
- findPreviewNodeById(view, id) {
1723
- if (!view || id == null) return null
1724
- const key = String(id)
1725
- const walk = (node) => {
1726
- if (!node) return null
1727
- if (String(node.id) === key) return node
1728
- const kids = []
1729
- if (node.components) kids.push(...node.components)
1730
- if (node.slots) for (const s of node.slots) if (s.components) kids.push(...s.components)
1731
- for (const k of kids) {
1732
- const r = walk(k)
1733
- if (r) return r
1734
- }
1735
- return null
1736
- }
1737
- return walk(view)
1738
- },
1739
- /**
1740
- * 复制选中节点:把其在 schema 树中的子树深拷贝进剪贴板(含子节点)。
1741
- * 根节点或空选区不处理。
1742
- */
1743
- copySelectedNode() {
1744
- const id = this.selectedNodeId
1745
- if (!id || id === '_root' || !this.currentSchema || !this.preview) return
1746
- const vn = this.findPreviewNodeById(this.preview.view, id)
1747
- if (!vn) return
1748
- const loc = this.findSchemaNodeByComponentId(this.currentSchema.view, vn.componentId)
1749
- if (!loc) return
1750
- // 记录来源卡片 id,供跨卡片粘贴标注与兼容性校验
1751
- setClipboardNode(loc.node, this.selectedId)
1752
- showAppToast('已复制节点(含子节点)', 'ok')
1753
- },
1754
- /**
1755
- * 粘贴剪贴板节点到当前选区:
1756
- * - 选区为容器 → 追加到其默认插槽末尾(作为子节点);
1757
- * - 选区为非容器 → 作为其后兄弟(同级相邻),禁止再把复制的节点插入为非容器的子节点;
1758
- * - 选区为空或根 → 追加到根组件列表末尾。
1759
- * 重复粘贴互不干扰(每次都重新克隆 + 重新生成 componentId)。
1760
- */
1761
- pasteNode() {
1762
- const clip = getClipboardNode()
1763
- if (!clip || !this.currentSchema) return
1764
- // 跨卡片兼容性校验:克隆子树引用的组件类型必须在本编辑器组件注册表中存在,
1765
- // 否则粘贴到目标卡片会因找不到组件而渲染异常。未知类型直接阻断并提示。
1766
- const classNames = collectClassNames(clip)
1767
- const unknown = [...new Set(classNames.filter((c) => !COMPONENT_META[c]))]
1768
- if (unknown.length) {
1769
- showAppToast('粘贴失败:含未知组件类型 ' + unknown.join('、'), 'err')
1770
- return
1771
- }
1772
- const clone = JSON.parse(
1773
- JSON.stringify(clip, (k, v) => (k === '_schemaNode' ? undefined : v)),
1774
- )
1775
- regenerateComponentIds(clone)
1776
- const anchor =
1777
- this.selectedNodeId && this.selectedNodeId !== '_root'
1778
- ? this.findPreviewNodeById(this.preview.view, this.selectedNodeId)
1779
- : null
1780
- let position = 'root'
1781
- // 显式约束:非容器节点禁止作为「粘贴为子节点」的目标——只有容器锚点才允许 'into',
1782
- // 非容器锚点一律降级为 'after'(粘为紧随其后的同级兄弟),避免把节点误塞进不可承载子节点的节点内。
1783
- if (anchor) position = isContainerMeta(anchor.className) ? 'into' : 'after'
1784
- this.insertClone(clone, anchor, position)
1785
- // 粘贴成功提示:区分同卡副本与跨卡片粘贴,来源卡片 id 来自剪贴板记录
1786
- const src = getClipboardSourceCardId()
1787
- if (src && src !== this.selectedId) {
1788
- showAppToast(`已从卡片「${src}」跨卡片粘贴到「${this.selectedId}」`, 'ok')
1789
- } else {
1790
- showAppToast('已粘贴节点(含子节点)', 'ok')
1791
- }
1792
- },
1793
- /**
1794
- * 副本:把选中节点就地复制为一个新节点,插入到其原位置之后(兄弟)。
1795
- * 根节点或空选区不处理。
1796
- */
1797
- duplicateNode() {
1798
- const id = this.selectedNodeId
1799
- if (!id || id === '_root' || !this.currentSchema || !this.preview) return
1800
- const vn = this.findPreviewNodeById(this.preview.view, id)
1801
- if (!vn) return
1802
- const loc = this.findSchemaNodeByComponentId(this.currentSchema.view, vn.componentId)
1803
- if (!loc) return
1804
- const clone = JSON.parse(
1805
- JSON.stringify(loc.node, (k, v) => (k === '_schemaNode' ? undefined : v)),
1806
- )
1807
- regenerateComponentIds(clone)
1808
- loc.parentArr.splice(loc.index + 1, 0, clone)
1809
- this.afterStructuralEdit(clone.componentId)
1810
- },
1811
- /**
1812
- * 把已生成的 schema 克隆节点插入指定位置并重建预览/选中/历史。
1813
- * @param {Object} clone 已重新生成 componentId 的 schema 节点
1814
- * @param {Object|null} anchor 锚点预览节点(null 表示根)
1815
- * @param {'root'|'into'|'after'} position 插入位置
1816
- */
1817
- insertClone(clone, anchor, position) {
1818
- const schema = this.currentSchema
1819
- if (position === 'root') {
1820
- if (!schema.view.components) schema.view.components = []
1821
- schema.view.components.push(clone)
1822
- } else if (position === 'into') {
1823
- const tgt = anchor && anchor._schemaNode
1824
- if (!tgt) {
1825
- if (!schema.view.components) schema.view.components = []
1826
- schema.view.components.push(clone)
1827
- this.afterStructuralEdit(clone.componentId)
1828
- return
1829
- }
1830
- const slotKey = getDefaultSlotKey(anchor.className)
1831
- let slot = (tgt.slots || []).find((s) => s.slotKey === slotKey)
1832
- if (!slot) {
1833
- if (!tgt.slots) tgt.slots = []
1834
- slot = { slotKey, slotScope: false, slotSwitch: '1', components: [] }
1835
- tgt.slots.push(slot)
1836
- }
1837
- if (!slot.components) slot.components = []
1838
- slot.components.push(clone)
1839
- } else {
1840
- // after:作为锚点的同父兄弟,插入其后
1841
- const loc = this.findSchemaNodeByComponentId(schema.view, anchor.componentId)
1842
- if (!loc) {
1843
- if (!schema.view.components) schema.view.components = []
1844
- schema.view.components.push(clone)
1845
- } else {
1846
- loc.parentArr.splice(loc.index + 1, 0, clone)
1847
- }
1848
- }
1849
- this.afterStructuralEdit(clone.componentId)
1850
- },
1851
- /**
1852
- * 结构性编辑(增/删/移动/粘贴/副本)后的统一收尾:
1853
- * 重建预览 → 暴露模型 → 同步数据绑定 → 选中新节点 → 落定历史。
1854
- * @param {string} [selectComponentId] 编辑后需选中的节点 componentId
1855
- */
1856
- afterStructuralEdit(selectComponentId) {
1857
- const schema = this.currentSchema
1858
- this.reloadToken += 1
1859
- this.preview = buildPreview(schema)
1860
- if (typeof window !== 'undefined') window.__cardModel = buildModelView(schema)
1861
- this.syncDataToSchema()
1862
- this.selectedNodeId = selectComponentId
1863
- ? this.findViewIdByComponentId(this.preview.view, selectComponentId) || ''
1864
- : ''
1865
- this.scheduleCommit()
1866
- },
1867
- /** 删除选中节点(非根):从 schema 树移除并重建;可撤销。 */
1868
- deleteSelectedNode() {
1869
- const id = this.selectedNodeId
1870
- if (!id || id === '_root' || !this.currentSchema || !this.preview) return
1871
- const vn = this.findPreviewNodeById(this.preview.view, id)
1872
- if (!vn) return
1873
- const loc = this.findSchemaNodeByComponentId(this.currentSchema.view, vn.componentId)
1874
- if (!loc) return
1875
- loc.parentArr.splice(loc.index, 1)
1876
- this.reloadToken += 1
1877
- this.preview = buildPreview(this.currentSchema)
1878
- if (typeof window !== 'undefined') window.__cardModel = buildModelView(this.currentSchema)
1879
- this.syncDataToSchema()
1880
- this.selectedNodeId = ''
1881
- clearHoverEl()
1882
- this.scheduleCommit()
1883
- },
1884
- /** 页面是否存在非空文本选区:有则视为「文本复制/粘贴」意图,交还原生行为 */
1885
- hasTextSelection() {
1886
- if (typeof window === 'undefined' || !window.getSelection) return false
1887
- const sel = window.getSelection()
1888
- return !!sel && !sel.isCollapsed && sel.toString().trim() !== ''
1889
- },
1890
- /** 是否存在可用于节点操作的选区(选中了非根节点):节点复制/粘贴/副本的前提 */
1891
- hasNodeSelection() {
1892
- return !!this.selectedNodeId && this.selectedNodeId !== '_root'
1893
- },
1894
- /** 判断按键事件焦点是否在可编辑元素内(输入框/文本域/可选文本),此时不劫持快捷键 */
1895
- isEditableTarget(e) {
1896
- const t = e.target
1897
- if (!t) return false
1898
- const tag = t.tagName
1899
- if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
1900
- if (t.isContentEditable) return true
1901
- return false
1902
- },
1903
- /**
1904
- * 全局键盘快捷键(编辑器标配):
1905
- * Ctrl/⌘+C 复制节点 · Ctrl/⌘+V 粘贴 · Ctrl/⌘+D 副本 · Delete/Backspace 删除 ·
1906
- * Ctrl/⌘+Z 撤销 · Ctrl/⌘+Shift+Z / Ctrl+Y 重做 · Esc 取消选中。
1907
- * 焦点在输入框/JSON 编辑器等可编辑元素内时,仅放行 Esc,其余交还原生行为
1908
- * (避免误删节点或劫持文本撤销/复制)。
1909
- * 节点复制/粘贴与系统文本复制/粘贴共存判据(按优先级):
1910
- * 1) 焦点可编辑 → 一律原生(文本撤销/复制/粘贴);
1911
- * 2) 存在非空文本选区 → 一律原生(用户意图是文本);
1912
- * 3) 节点类操作还要求已选中非根节点(粘贴另要求节点剪贴板非空),
1913
- * 否则不 preventDefault,把按键还给浏览器(如 ⌘D 书签)。
1914
- */
1915
- onKeydown(e) {
1916
- if (this.chromeOff || !this.preview) return
1917
- const editable = this.isEditableTarget(e)
1918
- const ctrl = e.ctrlKey || e.metaKey
1919
- const k = e.key
1920
-
1921
- if (ctrl && (k === 'c' || k === 'C')) {
1922
- if (editable) return
1923
- if (this.hasTextSelection() || !this.hasNodeSelection()) return
1924
- this.copySelectedNode()
1925
- e.preventDefault()
1926
- return
1927
- }
1928
- if (ctrl && (k === 'v' || k === 'V')) {
1929
- if (editable) return
1930
- if (this.hasTextSelection() || !this.hasNodeSelection() || !getClipboardNode()) return
1931
- this.pasteNode()
1932
- e.preventDefault()
1933
- return
1934
- }
1935
- if (ctrl && (k === 'd' || k === 'D')) {
1936
- if (editable) return
1937
- if (this.hasTextSelection() || !this.hasNodeSelection()) return
1938
- this.duplicateNode()
1939
- e.preventDefault()
1940
- return
1941
- }
1942
- if (ctrl && (k === 'z' || k === 'Z')) {
1943
- if (editable) return
1944
- e.preventDefault()
1945
- if (e.shiftKey) this.redo()
1946
- else this.undo()
1947
- return
1948
- }
1949
- if (ctrl && (k === 'y' || k === 'Y')) {
1950
- if (editable) return
1951
- e.preventDefault()
1952
- this.redo()
1953
- return
1954
- }
1955
- if (k === 'Delete' || k === 'Backspace') {
1956
- if (editable) return
1957
- if (!this.selectedNodeId || this.selectedNodeId === '_root') return
1958
- e.preventDefault()
1959
- this.deleteSelectedNode()
1960
- return
1961
- }
1962
- if (k === 'Escape') {
1963
- if (this.selectedNodeId) {
1964
- this.selectedNodeId = ''
1965
- e.preventDefault()
1966
- }
1967
- return
1968
- }
1969
- },
1970
- /**
1971
- * 结构树节点 hover:把对应节点(componentId / id)解析为画布 DOM 元素,
1972
- * 驱动参考线框出该元素;鼠标移出节点区域(payload 为 null)则清除参考线。
1973
- * 解析依据:RenderTree 在组件根元素挂载 data-cid = node.componentId || node.id,
1974
- * 与结构树节点组件标识一致,故按 data-cid 反查即可定位画布元素。
1975
- * @param {{componentId:*, id:*}|null} payload
1976
- */
1977
- onHoverNode(payload) {
1978
- if (!payload) { clearHoverEl(); return }
1979
- // 离开事件:仅当离开的正是当前高亮节点时才清除(防止「进入 B 后再离开 A」的竞态误清)
1980
- if (payload.leave) { clearHoverEl(payload.id); return }
1981
- const screen = typeof document !== 'undefined' ? document.querySelector('.phone__screen') : null
1982
- if (!screen) { clearHoverEl(payload.id); return }
1983
- const esc = (v) => (window.CSS && CSS.escape) ? CSS.escape(String(v)) : String(v).replace(/["\\\]]/g, '\\$&')
1984
- let el = null
1985
- if (payload.componentId != null) el = screen.querySelector('[data-cid="' + esc(payload.componentId) + '"]')
1986
- if (!el && payload.id != null) el = screen.querySelector('[data-cid="' + esc(payload.id) + '"]')
1987
- if (el) setHoverEl(el, payload.id)
1988
- else clearHoverEl(payload.id)
1989
- },
1990
- /**
1991
- * 画布点击定位:仅当「组件结构」tab 激活且按住 Ctrl(Windows/Linux) 或 ⌘Cmd(macOS) 时生效。
1992
- * 取点击位置最内层组件根元素([data-cid]),把其 componentId 解析回预览树节点,
1993
- * 写入 selectedNodeId(复用现有选中机制:高亮 + 结构树展开祖先 + 滚动可见)。
1994
- * 普通点击保持原行为不变(不干涉卡片自身运行期 click 事件)。
1995
- * @param {MouseEvent} e
1996
- */
1997
- onCanvasClick(e) {
1998
- if (!e.ctrlKey && !e.metaKey) return
1999
- if (this.chromeOff) return
2000
- const el = e.target && e.target.closest ? e.target.closest('[data-cid]') : null
2001
- if (!el) return
2002
- const cid = el.getAttribute('data-cid')
2003
- if (!cid) return
2004
- const node = this.findPreviewNodeByCid(cid)
2005
- if (!node) return
2006
- // 自动切到「组件结构」tab,并在节点树中聚焦选中该节点。
2007
- // 结构树挂载后会对 selectedNodeId 变化展开祖先并滚动可见;
2008
- // 当前不在结构树 tab 时,结构树是首次挂载即带选中态,故其
2009
- // selectedNodeId 监听器设为 immediate,挂载即 ensureVisible。
2010
- this.activeTab = 'structure'
2011
- this.selectedNodeId = node.id
2012
- },
2013
- /**
2014
- * 在预览树(preview.view)中按 componentId(回退 id)递归查找对应节点。
2015
- * @param {string} cid
2016
- * @returns {Object|null}
2017
- */
2018
- findPreviewNodeByCid(cid) {
2019
- const view = this.preview && this.preview.view
2020
- if (!view) return null
2021
- const key = String(cid)
2022
- const walk = (node) => {
2023
- if (!node) return null
2024
- const nodeCid = String(node.componentId != null ? node.componentId : node.id)
2025
- if (nodeCid === key) return node
2026
- const kids = []
2027
- if (node.components) kids.push(...node.components)
2028
- if (node.slots) for (const s of node.slots) if (s.components) kids.push(...s.components)
2029
- for (const k of kids) {
2030
- const r = walk(k)
2031
- if (r) return r
2032
- }
2033
- return null
2034
- }
2035
- return walk(view)
2036
- },
2037
- /**
2038
- * 结构树发起「添加」:打开组件选择面板,并记录插入锚点与模式。
2039
- * @param {{node:Object|null, mode:string}} payload
2040
- * node 为右键/选中的 viewNode(className 'Root' 或组件;null 视作根);
2041
- * mode ∈ 'child' | 'before' | 'after' | 'auto',决定 onPickComponent 的落位规则。
2042
- */
2043
- onAddNode(payload) {
2044
- if (!this.currentSchema) return
2045
- this.addNodeTarget = payload || null
2046
- this.addNodePanelOpen = true
2047
- },
2048
- /** 关闭组件选择面板(取消添加) */
2049
- cancelAddNode() {
2050
- this.addNodePanelOpen = false
2051
- this.addNodeTarget = null
2052
- },
2053
- /**
2054
- * 在 schema 树中定位某节点所在的容器数组与下标(用于非容器节点的"插入兄弟")。
2055
- * @returns {{containerArr:Array, index:number}|null}
2056
- */
2057
- findSchemaLocation(node, needle) {
2058
- const search = (containerArr) => {
2059
- for (let i = 0; i < containerArr.length; i++) {
2060
- const c = containerArr[i]
2061
- if (c === needle) return { containerArr, index: i }
2062
- const slots = c.slots || []
2063
- for (const s of slots) {
2064
- const r = search(s.components || [])
2065
- if (r) return r
2066
- }
2067
- }
2068
- return null
2069
- }
2070
- return search((node && node.components) || [])
2071
- },
2072
- /** 在预览树中按 componentId 找到对应 viewNode 的运行时 id(选中键) */
2073
- findViewIdByComponentId(viewNode, componentId) {
2074
- if (!viewNode) return null
2075
- if (viewNode.componentId === componentId) return viewNode.id
2076
- const kids = []
2077
- if (viewNode.components) kids.push(...viewNode.components)
2078
- if (viewNode.slots) for (const s of viewNode.slots) if (s.components) kids.push(...s.components)
2079
- for (const k of kids) {
2080
- const r = this.findViewIdByComponentId(k, componentId)
2081
- if (r) return r
2082
- }
2083
- return null
2084
- },
2085
- /** 在 schema 树中按 componentId 定位节点,返回 {node, parentArr, index, owner}(owner 为父节点或 'root') */
2086
- findSchemaNodeByComponentId(view, compId) {
2087
- const search = (arr, owner) => {
2088
- for (let i = 0; i < arr.length; i++) {
2089
- const c = arr[i]
2090
- if (c.componentId === compId) return { node: c, parentArr: arr, index: i, owner }
2091
- const slots = c.slots || []
2092
- for (const s of slots) {
2093
- const r = search(s.components || [], c)
2094
- if (r) return r
2095
- }
2096
- }
2097
- return null
2098
- }
2099
- return search((view && view.components) || [], 'root')
2100
- },
2101
- /** compId 是否位于 node 子树内(防环:禁止把节点拖入自身或其后代) */
2102
- isDescendantCompId(compId, node) {
2103
- if (!node || !node.componentId) return false
2104
- if (node.componentId === compId) return true
2105
- const kids = []
2106
- if (node.components) kids.push(...node.components)
2107
- if (node.slots) for (const s of node.slots) if (s.components) kids.push(...s.components)
2108
- for (const k of kids) if (this.isDescendantCompId(compId, k)) return true
2109
- return false
2110
- },
2111
- /**
2112
- * 结构性移动:把 sourceCompId 节点移动到 targetCompId 的相对位置。
2113
- * @param {string} position 'before' | 'after' | 'inside'
2114
- * @returns {Object|false} 被移动的 schema 节点;非法移动返回 false
2115
- */
2116
- moveNode(sourceCompId, targetCompId, position) {
2117
- const schema = this.currentSchema
2118
- if (!schema || !schema.view) return false
2119
- if (sourceCompId === targetCompId) return false
2120
- const src = this.findSchemaNodeByComponentId(schema.view, sourceCompId)
2121
- if (!src) return false
2122
- const sourceNode = src.node
2123
- // 防环:目标不能是源自身或其后代
2124
- if (this.isDescendantCompId(targetCompId, sourceNode)) return false
2125
-
2126
- let destArr
2127
- let insertIndex
2128
- if (targetCompId === '__root__') {
2129
- if (!schema.view.components) schema.view.components = []
2130
- destArr = schema.view.components
2131
- insertIndex = destArr.length
2132
- } else {
2133
- const tgt = this.findSchemaNodeByComponentId(schema.view, targetCompId)
2134
- if (!tgt) return false
2135
- const targetNode = tgt.node
2136
- if (position === 'inside') {
2137
- if (!isContainerMeta(targetNode.className)) return false
2138
- const slotKey = getDefaultSlotKey(targetNode.className)
2139
- let slot = (targetNode.slots || []).find((s) => s.slotKey === slotKey)
2140
- if (!slot) {
2141
- if (!targetNode.slots) targetNode.slots = []
2142
- slot = { slotKey, slotScope: false, slotSwitch: '1', components: [] }
2143
- targetNode.slots.push(slot)
2144
- }
2145
- if (!slot.components) slot.components = []
2146
- destArr = slot.components
2147
- insertIndex = destArr.length
2148
- } else {
2149
- destArr = tgt.parentArr
2150
- insertIndex = position === 'before' ? tgt.index : tgt.index + 1
2151
- }
2152
- }
2153
-
2154
- // 从原父数组移除(记录是否在同一数组且位于插入点之前,用于修正下标)
2155
- const sameArr = destArr === src.parentArr
2156
- const removeBefore = sameArr && src.index < insertIndex
2157
- src.parentArr.splice(src.index, 1)
2158
- if (removeBefore) insertIndex -= 1
2159
- destArr.splice(insertIndex, 0, sourceNode)
2160
- return sourceNode
2161
- },
2162
- /** 结构树拖拽移动:改写 currentSchema → 重建预览 → 选中移动后的节点。
2163
- * 按需求:拖拽仅实时预览(不在此提交历史),历史由后续「应用/保存到本地」落定。 */
2164
- onMoveNode({ sourceCompId, targetCompId, position }) {
2165
- if (!this.currentSchema) return
2166
- const moved = this.moveNode(sourceCompId, targetCompId, position)
2167
- if (!moved) return
2168
- this.reloadToken += 1
2169
- this.preview = buildPreview(this.currentSchema)
2170
- if (typeof window !== 'undefined') window.__cardModel = buildModelView(this.currentSchema)
2171
- this.syncDataToSchema()
2172
- const newId = this.findViewIdByComponentId(this.preview.view, sourceCompId)
2173
- this.selectedNodeId = newId || ''
2174
- // 仅当移动成功且能定位到新节点时才触发「折叠其他、仅展开祖先」行为
2175
- if (newId) this.moveCollapseToken += 1
2176
- },
2177
- /**
2178
- * 从组件选择面板选定类型后,按插入规则把新节点写进 currentSchema,再重建预览并选中新节点。
2179
- * 规则:
2180
- * - 锚点为根(或未指定)→ 追加到 view.components 末尾;
2181
- * - 锚点为容器节点 → 追加到其默认插槽 children 末尾(插槽缺失时依 meta 建立);
2182
- * - 锚点为非容器节点 → 在同父级、该节点之后插入兄弟节点;
2183
- * 找不到父级时兜底追加到根。
2184
- */
2185
- onPickComponent(className) {
2186
- const payload = this.addNodeTarget || {}
2187
- const targetNode = payload.node || null
2188
- const mode = payload.mode || 'auto'
2189
- const schema = this.currentSchema
2190
- if (!schema) {
2191
- this.cancelAddNode()
2192
- return
2193
- }
2194
- let newNode
2195
- try {
2196
- newNode = createComponentNode(className)
2197
- } catch (e) {
2198
- console.error('[创建组件节点失败]', e)
2199
- this.rt.pushError('addNode', String(e && e.message))
2200
- this.cancelAddNode()
2201
- return
2202
- }
2203
-
2204
- const targetIsRoot = !targetNode || targetNode.className === 'Root'
2205
- if ((mode === 'before' || mode === 'after') && !targetIsRoot) {
2206
- // 相邻兄弟插入:在非根节点所在的容器数组内,按 'before'/'after' 相对位置落位
2207
- const targetSchema = targetNode._schemaNode
2208
- const loc = targetSchema ? this.findSchemaLocation(schema.view, targetSchema) : null
2209
- if (!loc) {
2210
- // 兜底:找不到父数组则追加到根末尾
2211
- if (!schema.view.components) schema.view.components = []
2212
- schema.view.components.push(newNode)
2213
- } else {
2214
- const idx = mode === 'before' ? loc.index : loc.index + 1
2215
- loc.containerArr.splice(idx, 0, newNode)
2216
- }
2217
- } else if (targetIsRoot) {
2218
- // 根:添加子节点('child')或 'auto' → 追加到 view.components 末尾
2219
- if (!schema.view.components) schema.view.components = []
2220
- schema.view.components.push(newNode)
2221
- } else {
2222
- // 'child' / 'auto' 且非根:容器 → 默认插槽末尾;非容器 → 其后兄弟
2223
- const targetSchema = targetNode._schemaNode
2224
- if (!targetSchema) {
2225
- this.cancelAddNode()
2226
- return
2227
- }
2228
- if (isContainerMeta(targetNode.className)) {
2229
- const slotKey = getDefaultSlotKey(targetNode.className)
2230
- let slot = (targetSchema.slots || []).find((s) => s.slotKey === slotKey)
2231
- if (!slot) {
2232
- if (!targetSchema.slots) targetSchema.slots = []
2233
- slot = { slotKey, slotScope: false, slotSwitch: '1', components: [] }
2234
- targetSchema.slots.push(slot)
2235
- }
2236
- if (!slot.components) slot.components = []
2237
- slot.components.push(newNode)
2238
- } else {
2239
- const loc = this.findSchemaLocation(schema.view, targetSchema)
2240
- if (!loc) {
2241
- if (!schema.view.components) schema.view.components = []
2242
- schema.view.components.push(newNode)
2243
- } else {
2244
- loc.containerArr.splice(loc.index + 1, 0, newNode)
2245
- }
2246
- }
2247
- }
2248
-
2249
- this.cancelAddNode()
2250
- // 重建预览并选中新节点(与 onSchemaChanged 同款重建路径)
2251
- this.reloadToken += 1
2252
- this.preview = buildPreview(schema)
2253
- if (typeof window !== 'undefined') window.__cardModel = buildModelView(schema)
2254
- this.syncDataToSchema()
2255
- const newId = this.findViewIdByComponentId(this.preview.view, newNode.componentId)
2256
- this.selectedNodeId = newId || ''
2257
- // 结构性编辑落定后提交一次历史记录(防抖合并连续编辑)
2258
- this.scheduleCommit()
2259
- },
2260
- /** 复制 schema(合并数据绑定编辑后的数据:写入 dictionary.inputParams.defaultValue,并保留顶层 .data) */
2261
- async copySchema() {
2262
- if (!this.currentSchema) return
2263
- // 确保数据绑定已同步进 schema(实时 watcher 之外再加一道保险,避免编辑后立刻复制时落空)
2264
- this.syncDataToSchema()
2265
- let text = ''
2266
- try {
2267
- const merged = { ...this.currentSchema }
2268
- if (this.rt && this.rt.rootScope) {
2269
- merged.data = this.rt.rootScope
2270
- }
2271
- // 过滤掉 _schemaNode 引用(不可序列化)
2272
- text = JSON.stringify(merged, (key, val) => {
2273
- if (key === '_schemaNode') return undefined
2274
- return val
2275
- }, 2)
2276
- } catch (e) {
2277
- text = '(无法序列化)'
2278
- }
2279
- const ok = await this.doCopy(text)
2280
- if (ok) {
2281
- this.copiedSchema = true
2282
- if (this.copyTimer) clearTimeout(this.copyTimer)
2283
- this.copyTimer = setTimeout(() => { this.copiedSchema = false }, 1500)
2284
- }
2285
- },
2286
- async doCopy(text) {
2287
- try {
2288
- if (navigator.clipboard && navigator.clipboard.writeText) {
2289
- await navigator.clipboard.writeText(text)
2290
- return true
2291
- }
2292
- } catch (e) { /* fallback */ }
2293
- try {
2294
- const ta = document.createElement('textarea')
2295
- ta.value = text
2296
- ta.style.position = 'fixed'
2297
- ta.style.opacity = '0'
2298
- document.body.appendChild(ta)
2299
- ta.select()
2300
- const ok = document.execCommand('copy')
2301
- document.body.removeChild(ta)
2302
- return ok
2303
- } catch (e) {
2304
- return false
2305
- }
2306
- },
2307
- /**
2308
- * 从 FinMall UAT 拉取卡片最新 schema/封面并写回 src/cards。
2309
- * 经 dev-server 的 /__sync 端点触发(端点内部复用 finmall-card-sync 的 syncCards,
2310
- * 通过 connectOverCDP 连用户已开的 Chrome,在已登录的 FinMall 页内发起 API 请求)。
2311
- * 纯前端无法直接调 FinMall:跨域 + 无 token,故必须经此中转。
2312
- * @param {string} [cardCode] 传值时仅同步该卡;不传则拉取完整远程卡片列表逐张覆盖本地(全量同步)。
2313
- */
2314
- async runSync(cardCode) {
2315
- if (this.syncingRemote) return
2316
- if (cardCode && !this.selectedId) return
2317
- this.syncingRemote = true
2318
- this.syncedRemote = false
2319
- this.syncTarget = cardCode ? 'selected' : 'full'
2320
- try {
2321
- const res = await apiFetch('/__sync', {
2322
- method: 'POST',
2323
- headers: { 'Content-Type': 'application/json' },
2324
- body: JSON.stringify(cardCode ? { cardCode } : {}),
2325
- })
2326
- if (!res.ok) {
2327
- const err = await res.json().catch(() => ({}))
2328
- throw new Error(err.message || err.error || 'HTTP ' + res.status)
2329
- }
2330
- const r = await res.json()
2331
- if (r.needCredentials) {
2332
- // 凭据缺失:弹出登录表单,由用户填写账号密码后自动重试同步
2333
- this.openLoginModal()
2334
- return
2335
- }
2336
- if (!r.success) {
2337
- const detail = (r.failed || []).map((f) => `${f.id}: ${f.error}`).join('; ')
2338
- throw new Error(detail || '同步失败')
2339
- }
2340
- this.syncedRemote = true
2341
- if (this.syncTimer) clearTimeout(this.syncTimer)
2342
- this.syncTimer = setTimeout(() => { this.syncedRemote = false; this.syncTarget = null }, 1800)
2343
- // 把刚拉取的最新卡片载入预览
2344
- await this.onRefresh()
2345
- // 操作完成提示:全量 / 选中卡片区分文案
2346
- showAppToast(this.syncTarget === 'selected' ? '已同步当前卡片到本地' : '全量同步完成', 'ok')
2347
- } catch (e) {
2348
- console.error('[同步远程数据失败]', e)
2349
- // 远端操作接口报错:仅弹出错误提示,不写入事件/绑定错误记录(避免污染事件面板)
2350
- showAppToast('同步失败:' + (e && e.message), 'err')
2351
- } finally {
2352
- this.syncingRemote = false
2353
- }
2354
- },
2355
- /** 全量同步:拉取完整远程卡片列表,逐张覆盖本地同名卡片(本地独有卡片保留)。 */
2356
- syncRemote() {
2357
- return this.runSync()
2358
- },
2359
- /** 仅同步当前选中卡片。 */
2360
- syncSelected() {
2361
- return this.runSync(this.selectedId)
2362
- },
2363
- /** 打开 FinMall 登录凭据弹窗(远程同步检测到缺凭据时调用) */
2364
- openLoginModal() {
2365
- this.loginModal = true
2366
- this.loginName = ''
2367
- this.loginPassword = ''
2368
- this.loginError = ''
2369
- },
2370
- /** 关闭登录凭据弹窗(提交中不可关闭) */
2371
- closeLoginModal() {
2372
- if (this.loginSubmitting) return
2373
- this.loginModal = false
2374
- this.loginPassword = ''
2375
- this.loginError = ''
2376
- },
2377
- /** 提交账号密码:写入本地凭据后,自动重试上一次同步 */
2378
- async submitLogin() {
2379
- if (this.loginSubmitting) return
2380
- const loginName = this.loginName.trim()
2381
- const loginPassword = this.loginPassword
2382
- if (!loginName || !loginPassword) {
2383
- this.loginError = '账号与密码均不能为空'
2384
- return
2385
- }
2386
- this.loginSubmitting = true
2387
- this.loginError = ''
2388
- try {
2389
- const res = await apiFetch('/__login-credentials', {
2390
- method: 'POST',
2391
- headers: { 'Content-Type': 'application/json' },
2392
- body: JSON.stringify({ loginName, loginPassword }),
2393
- })
2394
- const r = await res.json().catch(() => ({}))
2395
- if (!res.ok || !r.ok) {
2396
- throw new Error(r.message || r.error || 'HTTP ' + res.status)
2397
- }
2398
- this.loginModal = false
2399
- this.loginPassword = ''
2400
- showAppToast('凭据已保存,正在重试同步…', 'ok')
2401
- // 沿用上一次同步模式自动重试
2402
- await this.runSync(this.syncTarget === 'selected' ? this.selectedId : undefined)
2403
- } catch (e) {
2404
- this.loginError = '保存失败:' + (e && e.message)
2405
- } finally {
2406
- this.loginSubmitting = false
2407
- }
2408
- },
2409
- /** 下拉菜单选中某项:执行对应同步(先二次确认) */
2410
- chooseSync(target) {
2411
- if (target === 'full') {
2412
- if (this.syncingRemote) return
2413
- this.requestConfirm({
2414
- title: '全量同步',
2415
- message: '即将从远端 FinMall 拉取完整卡片列表,并逐张覆盖本地同名卡片。本地独有卡片会保留,但被覆盖卡片的未保存改动将丢失,且无法撤销。是否继续?',
2416
- confirmText: '继续同步',
2417
- severity: 'warn',
2418
- onConfirm: () => this.syncRemote(),
2419
- })
2420
- return
2421
- }
2422
- if (!this.preview || !this.selectedId || this.syncingRemote) return
2423
- this.requestConfirm({
2424
- title: '当前同步',
2425
- message: `即将从远端 FinMall 拉取当前卡片「${this.selectedId}」的最新版本并覆盖本地。本地未保存的改动将丢失,且无法撤销。是否继续?`,
2426
- confirmText: '继续同步',
2427
- onConfirm: () => this.syncSelected(),
2428
- })
2429
- },
2430
- /** 「远端」分组菜单项:版本推送 / 在线预览 / 切图上传(写覆盖类操作先二次确认) */
2431
- chooseRsave(target) {
2432
- if (target === 'save') {
2433
- if (!this.preview || !this.selectedId || this.savingRemote || !this.hasCardSource) return
2434
- this.requestConfirm({
2435
- title: '版本推送',
2436
- message: `即将把本地卡片「${this.selectedId}」的当前版本推送并覆盖到远端 FinMall。线上版本会被立即更新,且该操作不可撤销。是否继续?`,
2437
- confirmText: '继续推送',
2438
- severity: 'warn',
2439
- onConfirm: () => this.saveToRemote(),
2440
- })
2441
- return
2442
- }
2443
- if (target === 'slices') {
2444
- if (!this.preview || !this.selectedId || this.uploadingSlices) return
2445
- this.requestConfirm({
2446
- title: '切图上传',
2447
- message:
2448
- `即将把当前卡片「${this.selectedId}」引用的本地切图(src/slices/)上传到 FinMall 图片库,` +
2449
- `并把卡片里的 /slices/ 引用替换为线上地址(@filestore/…)。本地切图文件保留不动;` +
2450
- `内容未变的切图会直接复用已有线上地址,不重复上传。` +
2451
- (this.historyDirty ? '\n当前卡片有未保存的改动,会先保存到本地再执行,避免被随后的重载丢弃。' : '') +
2452
- '\n是否继续?',
2453
- confirmText: '继续上传',
2454
- onConfirm: () => this.uploadCardSlices(),
2455
- })
2456
- return
2457
- }
2458
- if (!this.preview || !this.selectedId || !this.hasCardSource) return
2459
- this.remotePreviewOpen = true
2460
- },
2461
- /** 请求二次确认:统一在写/覆盖类操作前弹出,确认后执行 onConfirm 回调 */
2462
- requestConfirm({ title, message, confirmText, cancelText, severity, onConfirm }) {
2463
- this.confirm = {
2464
- show: true,
2465
- title,
2466
- message,
2467
- confirmText: confirmText || '继续',
2468
- cancelText: cancelText || '取消',
2469
- severity: ['primary', 'warn', 'danger'].includes(severity) ? severity : 'primary',
2470
- onConfirm,
2471
- }
2472
- },
2473
- /** 确认弹窗点击「确认」:执行挂起的回调 */
2474
- onConfirmDialog() {
2475
- const cb = this.confirm.onConfirm
2476
- this.confirm.show = false
2477
- this.confirm.onConfirm = null
2478
- if (typeof cb === 'function') cb()
2479
- },
2480
- /** 确认弹窗点击「取消」或遮罩:丢弃挂起操作 */
2481
- onCancelDialog() {
2482
- this.confirm.show = false
2483
- this.confirm.onConfirm = null
2484
- },
2485
- /** 把预览内编辑后的最新数据绑定 + schema 写回 src/cards/<id>/(经 dev 中间件 POST) */
2486
- async saveCard() {
2487
- if (!this.preview || !this.selectedId) return
2488
- this.savingCard = true
2489
- const id = this.selectedId
2490
- // 标记本窗口即将落盘:吸收随后 watcher 经 SSE 推回的 cards-changed 回声,避免误弹外部重载提示。
2491
- // 落盘未成功时(catch)清除,以免误吞后续真正的外部改动。
2492
- this.selfSaveId = id
2493
- this.selfSaveAt = Date.now()
2494
- try {
2495
- // 保存前从磁盘校准元数据(pageId/name/desc 由远端/磁盘权威决定,内存编辑不修改它们),
2496
- // 取磁盘最新值覆盖内存,避免滞后于同步结果把真实 cardId 写回成旧值。
2497
- try {
2498
- const fresh = await this.fetchDiskCard(this.selectedId)
2499
- if (fresh && fresh.schema) {
2500
- const disk = fresh.schema
2501
- const mem = this.currentSchema
2502
- if (mem) {
2503
- if (disk.pageId) mem.pageId = disk.pageId
2504
- if (disk.name) mem.name = disk.name
2505
- if (disk.desc !== undefined) mem.desc = disk.desc
2506
- }
2507
- }
2508
- } catch { /* 磁盘读取失败不阻塞保存 */ }
2509
- // 保存前确保数据绑定已同步进 schema.dictionary.inputParams.defaultValue
2510
- this.syncDataToSchema()
2511
- // 只发 schema:服务端写 schema.json 后会用 buildDataFromSchema(schema) 单向重算
2512
- // data.json,data 永不随请求上传、也不作为写入来源。
2513
- const payload = {
2514
- schema: this.currentSchema,
2515
- }
2516
- const body = JSON.stringify(payload, (k, v) => (k === '_schemaNode' ? undefined : v), 2)
2517
- const res = await apiFetch(`/__card/${encodeURIComponent(this.selectedId)}`, {
2518
- method: 'POST',
2519
- headers: { 'Content-Type': 'application/json' },
2520
- body,
2521
- })
2522
- if (!res.ok) {
2523
- const err = await res.json().catch(() => ({}))
2524
- throw new Error(err.message || 'HTTP ' + res.status)
2525
- }
2526
- // 写盘成功:更新内存工作副本,便于后续操作基于刚落盘的内容。磁盘变更也会经
2527
- // watcher 触发 onCardsChanged 重新载入,二者最终一致。
2528
- this.currentCard = { schema: payload.schema }
2529
- // 写盘成功后,当前显示版本即成为本地真实版本:刷新本地基线,使顶栏
2530
- // 「保存到本地」按钮在「当前版本 == 本地版本」时自动置灰。
2531
- this.localSchema = this.cloneSchema(this.currentSchema)
2532
- // 基线数据必须与载入 / 历史快照同源(含 mocks):rt.rootScope 是含 mock 覆盖的,
2533
- // 若此处漏传 WITH_MOCKS,只要存在 mock 数据,保存后 rootScope 与 localData 永远
2534
- // 不等 → historyDirty 恒真,「保存到本地」按钮保存成功也保持高亮。
2535
- this.localData = this.cloneData(buildDataFromSchema(this.currentSchema, WITH_MOCKS))
2536
- this.savedCard = true
2537
- // 本地保存后磁盘已更新,刷新整卡校验徽标(端点读磁盘)
2538
- this.refreshValidateBadge()
2539
- if (this.saveTimer) clearTimeout(this.saveTimer)
2540
- this.saveTimer = setTimeout(() => { this.savedCard = false }, 1800)
2541
- return true
2542
- } catch (e) {
2543
- console.error('[本地保存失败]', e)
2544
- this.rt.pushError('saveCard', String(e && e.message))
2545
- // 落盘未成功:清除自保存标记,避免误吞后续真正的外部改动
2546
- this.selfSaveId = ''
2547
- this.selfSaveAt = 0
2548
- return false
2549
- } finally {
2550
- this.savingCard = false
2551
- }
2552
- },
2553
- /** 从磁盘读取指定卡片最新 schema/data(供保存前校准与刷新使用) */
2554
- async fetchDiskCard(id) {
2555
- const res = await apiFetch(`/__card/${encodeURIComponent(id)}`)
2556
- if (!res.ok) throw new Error('HTTP ' + res.status)
2557
- return res.json()
2558
- },
2559
- /**
2560
- * 拉取当前卡片的导入来源元数据(Phase E):GET /__import-meta/:id。
2561
- * 本地新建卡无 .import-meta.json → 端点 404 → 置 null(顶栏「版本推送」据此置灰)。
2562
- * 与 DebugPanel「来源」tab 同源,但此处为顶栏按钮的响应式来源标志,覆盖每次切卡/同步刷新。
2563
- */
2564
- async fetchImportMeta(id) {
2565
- if (!id) { this.cardImportMeta = null; return }
2566
- try {
2567
- const res = await apiFetch(`/__import-meta/${encodeURIComponent(id)}`)
2568
- if (!res.ok) { this.cardImportMeta = null; return }
2569
- const r = await res.json().catch(() => ({}))
2570
- this.cardImportMeta = (r && r.meta) || null
2571
- } catch {
2572
- this.cardImportMeta = null
2573
- }
2574
- },
2575
- /**
2576
- * 把当前预览的卡片 schema 写回 FinMall 远端(线上编辑器「保存」接口 IDC10010.do)。
2577
- * 经 dev 中间件 /__save 触发:端点内部复用 finmall-card-sync 的 saveCardToRemote,
2578
- * 通过已登录的 FinMall 浏览器会话(与「同步」共用)中转 POST,body 含 cardId(=pageId) 与
2579
- * schemaValue(整张 schema 的 JSON 字符串)。纯前端无法直接调 FinMall(跨域 + 需 token),故须经此中转。
2580
- * 无自动保存——仅手动触发。
2581
- */
2582
- async saveToRemote() {
2583
- if (!this.preview || !this.selectedId || this.savingRemote) return
2584
- this.savingRemote = true
2585
- this.savedRemote = false
2586
- this.verifiedRemote = null
2587
- try {
2588
- // 保存前从磁盘校准 pageId:避免内存滞后于同步结果,把当前卡片误存到旧的远端 cardId。
2589
- try {
2590
- const fresh = await this.fetchDiskCard(this.selectedId)
2591
- if (fresh && fresh.schema && fresh.schema.pageId && this.currentSchema) {
2592
- this.currentSchema.pageId = fresh.schema.pageId
2593
- }
2594
- } catch { /* 磁盘读取失败不阻塞保存 */ }
2595
- // 保存前确保数据绑定已同步进 schema.dictionary.inputParams.defaultValue
2596
- this.syncDataToSchema()
2597
- // 标记本窗口即将落盘(saveCardToRemote 会写回 src/cards),吸收 watcher 推回的 cards-changed 回声
2598
- this.selfSaveId = this.selectedId
2599
- this.selfSaveAt = Date.now()
2600
- const res = await apiFetch('/__save', {
2601
- method: 'POST',
2602
- headers: { 'Content-Type': 'application/json' },
2603
- body: JSON.stringify(
2604
- { code: this.selectedId, schema: this.currentSchema },
2605
- (k, v) => (k === '_schemaNode' ? undefined : v),
2606
- 2
2607
- ),
2608
- })
2609
- if (!res.ok) {
2610
- const err = await res.json().catch(() => ({}))
2611
- throw new Error(err.message || err.error || 'HTTP ' + res.status)
2612
- }
2613
- const r = await res.json()
2614
- if (!r.ok) throw new Error(r.error || '远端保存失败')
2615
- // verified: {ok, cardIdMatched, fingerprintMatched, diff, readBackError}
2616
- // ok=true → 保存并读回一致;ok=false → 「交易成功」但内容未落库(FinMall 静默空返回)
2617
- this.verifiedRemote = r.verified ? !!r.verified.ok : null
2618
- this.savedRemote = true
2619
- if (this.verifiedRemote === false) {
2620
- const v = r.verified || {}
2621
- const detail = (v.diff && v.diff.length)
2622
- ? '差异:' + v.diff.join(';')
2623
- : (v.readBackError || '读回校验未通过')
2624
- // 远端操作结果异常:仅弹出错误提示,不写入事件/绑定错误记录
2625
- console.warn('[保存校验不符]', v)
2626
- showAppToast('已保存至远端,但读回校验未通过', 'err')
2627
- } else {
2628
- showAppToast('已保存至远端', 'ok')
2629
- }
2630
- if (this.saveRemoteTimer) clearTimeout(this.saveRemoteTimer)
2631
- this.saveRemoteTimer = setTimeout(() => { this.savedRemote = false; this.verifiedRemote = null }, 2500)
2632
- } catch (e) {
2633
- console.error('[保存至远端失败]', e)
2634
- // 远端操作接口报错:仅弹出错误提示,不写入事件/绑定错误记录(避免污染事件面板)
2635
- showAppToast('保存至远端失败:' + (e && e.message), 'err')
2636
- // 落盘未成功:清除自保存标记,避免误吞后续真正的外部改动
2637
- this.selfSaveId = ''
2638
- this.selfSaveAt = 0
2639
- } finally {
2640
- this.savingRemote = false
2641
- }
2642
- },
2643
- /**
2644
- * 把「当前卡片」引用的本地切图上传到 FinMall 图片库(CC0000.do),并把卡片里的
2645
- * `/slices/<name>` 引用改写为返回的线上地址(`@filestore/...`)。
2646
- * 经 dev-server 的 /__slice-upload 端点触发(端点内部复用 finmall-card-sync 的 uploadSlices,
2647
- * 与「同步」「版本推送」共用同一后台 FinMall 会话)。纯前端无法直接调 FinMall:
2648
- * 跨域 + 需 token(token 只在页面上下文内读取,不回传 Node),故必须经此中转。
2649
- *
2650
- * 纪律:
2651
- * - **只在用户点击「远端 → 切图上传」时执行**,保存 / 推送 / 同步都不会顺带上传切图;
2652
- * - 上传与改写范围都只限当前卡;共享切图若已被别的卡上传过(sha256 命中
2653
- * src/slices/manifest.json),直接复用线上地址,不重复上传;
2654
- * - 本地 src/slices 真源保留不动(映射清单记录 本地文件 ↔ 线上地址)。
2655
- */
2656
- async uploadCardSlices() {
2657
- if (!this.preview || !this.selectedId || this.uploadingSlices) return
2658
- const id = this.selectedId
2659
- this.uploadingSlices = true
2660
- try {
2661
- // 端点改写的是磁盘上的 schema.json;若编辑器里有未保存改动,先落盘再上传,
2662
- // 否则随后从磁盘重载会把未保存编辑静默丢掉。
2663
- if (this.historyDirty) {
2664
- const saved = await this.saveCard()
2665
- if (!saved) throw new Error('本地保存失败,已中止切图上传')
2666
- }
2667
- // 标记本窗口即将落盘(端点会改写 src/cards/<id>/schema.json),吸收 watcher 经 SSE 推回的
2668
- // cards-changed 回声,避免误弹「卡片已在编辑器外被修改」提示。
2669
- this.selfSaveId = id
2670
- this.selfSaveAt = Date.now()
2671
- const res = await apiFetch('/__slice-upload', {
2672
- method: 'POST',
2673
- headers: { 'Content-Type': 'application/json' },
2674
- body: JSON.stringify({ cardId: id }),
2675
- })
2676
- if (!res.ok) {
2677
- const err = await res.json().catch(() => ({}))
2678
- throw new Error(err.message || err.error || 'HTTP ' + res.status)
2679
- }
2680
- const r = await res.json()
2681
- if (r.needCredentials) {
2682
- // 凭据缺失:弹出登录表单,由用户填写账号密码后再点一次「切图上传」
2683
- this.openLoginModal()
2684
- return
2685
- }
2686
- // 上传可能耗时数秒(逐张传),落盘发生在响应前一刻:再刷一次自保存时间戳,
2687
- // 让 2.5s 抑制窗口覆盖到这次回声(否则慢上传会漏抑制)。
2688
- this.selfSaveAt = Date.now()
2689
- if (!r.success) {
2690
- const detail = (r.failed || []).map((f) => `${f.name}: ${f.error}`).join('; ')
2691
- throw new Error(detail || '切图上传失败')
2692
- }
2693
- // 磁盘上的引用已被改写为线上地址,重新载入让预览即时生效。
2694
- await this.onRefresh()
2695
- const replaced = (r.rewritten || []).reduce((n, item) => n + item.count, 0)
2696
- if (!r.referenced) {
2697
- showAppToast('当前卡片没有引用本地切图,无需上传', 'ok')
2698
- } else {
2699
- showAppToast(
2700
- `切图上传完成:新上传 ${r.uploaded.length} 张、复用 ${r.skipped.length} 张,替换引用 ${replaced} 处`,
2701
- 'ok'
2702
- )
2703
- }
2704
- if (r.dangling && r.dangling.length) {
2705
- showAppToast(`有 ${r.dangling.length} 处引用的切图在 src/slices 下不存在:${r.dangling.join('、')}`, 'err')
2706
- }
2707
- } catch (e) {
2708
- console.error('[切图上传失败]', e)
2709
- // 远端操作接口报错:仅弹出错误提示,不写入事件/绑定错误记录(避免污染事件面板)
2710
- showAppToast('切图上传失败:' + (e && e.message), 'err')
2711
- // 未落盘:清除自保存标记,避免误吞后续真正的外部改动
2712
- this.selfSaveId = ''
2713
- this.selfSaveAt = 0
2714
- } finally {
2715
- this.uploadingSlices = false
2716
- }
2717
- },
2718
- /** 实时拉取卡片列表(GET /__cards),替换下拉框选项,不缓存 */
2719
- async fetchCardList() {
2720
- try {
2721
- const res = await apiFetch('/__cards')
2722
- if (!res.ok) return
2723
- const r = await res.json()
2724
- const cards = (r && r.cards) || []
2725
- this.cardList = cards
2726
- this.cardCount = cards.length
2727
- } catch (e) {
2728
- console.error('[加载卡片列表失败]', e)
2729
- }
2730
- },
2731
- /**
2732
- * 网格预览删除卡片:格子删除钮 → 警告确认 → 这里真删(DELETE /__card/<id> 递归删本地目录)。
2733
- * 删完刷列表;若删的正是当前卡,切到剩余首卡,避免工作台指向已不存在的目录(保存会 404)。
2734
- */
2735
- async onGalleryDelete(id) {
2736
- try {
2737
- const res = await apiFetch(`/__card/${encodeURIComponent(id)}`, { method: 'DELETE' })
2738
- const r = await res.json().catch(() => null)
2739
- if (!res.ok) throw new Error((r && (r.message || r.error)) || `HTTP ${res.status}`)
2740
- showAppToast(`已删除本地卡片 ${id}`, 'ok')
2741
- await this.fetchCardList()
2742
- if (this.selectedId === id) {
2743
- const next = this.cardList[0]
2744
- if (next) await this.loadCard(next.id)
2745
- else {
2746
- this.selectedId = ''
2747
- this.currentSchema = null
2748
- this.preview = null
2749
- }
2750
- }
2751
- } catch (e) {
2752
- showAppToast(`删除卡片失败:${e.message}`, 'err')
2753
- }
2754
- },
2755
- /** 新增空白卡片创建成功:关弹窗 → 刷列表 → 切换到新卡(空白卡无封面,网格显示占位图) */
2756
- async onNewCardCreated(id) {
2757
- this.newCardOpen = false
2758
- showAppToast(`已创建空白卡片 ${id}`, 'ok')
2759
- await this.fetchCardList()
2760
- await this.loadCard(id)
2761
- },
2762
- /** 刷新当前卡片:从磁盘重新读取 schema/data 并强制重挂载 */
2763
- async onRefresh() {
2764
- const id = this.selectedId
2765
- if (!id || this.refreshing) return
2766
- this.refreshing = true
2767
- try {
2768
- await this.loadCard(id)
2769
- // 立即刷新卡片列表:同步远程可能新增 / 重命名卡片,
2770
- // 让顶栏下拉框即时反映,无需等待 dev server 文件监听事件。
2771
- await this.fetchCardList()
2772
- // 同步下拉框名称(若 schema.name 有改动,以内存态为准兜底)
2773
- const item = this.cardList.find((c) => c.id === id)
2774
- if (item && this.currentSchema && this.currentSchema.name) item.name = this.currentSchema.name
2775
- } catch (e) {
2776
- console.error('[刷新卡片失败]', e)
2777
- this.rt.pushError('refresh', String(e && e.message))
2778
- } finally {
2779
- this.refreshing = false
2780
- }
2781
- },
2782
- /**
2783
- * 处理 dev server 推送的磁盘变更事件(同步远程 / 其他窗口保存 / 手工编辑)。
2784
- * - 始终重新拉取卡片列表(新增 / 改名 / 删除的卡即时反映在下拉框)。
2785
- * - 若变更的是当前选中卡片的文件,重新从磁盘载入,使预览即时生效
2786
- * (这正是「连接本地开发服务后,编辑器外改文件,预览立刻刷新」的关键路径)。
2787
- * - 若当前卡片存在未保存编辑,先用既有 ConfirmDialog 二次确认,避免静默丢弃编辑。
2788
- * 用极短防抖合并同步写盘的突发多文件变更,避免重复重载。
2789
- */
2790
- onCardsChanged(payload) {
2791
- const file = (payload && payload.file) || ''
2792
- // 编辑历史快照落盘在 src/cards/<id>/.history/ 下写 .json,不属于卡片本体变更,
2793
- // 须忽略,否则会触发 loadCard 自动重载、丢弃未保存编辑并关闭属性面板。
2794
- if (/\.history[\\/]/.test(file)) return
2795
- // data.json 是 schema 的单向派生产物,其变更(写盘重算 / 外部直改)不应触发重载;
2796
- // 真正触发重载的只有 schema.json 变更(此处与 8787 watcher 形成双保险)。
2797
- if (/data\.json$/i.test(file)) return
2798
- const m = file.match(/src[\\/]cards[\\/]([^\\/]+)[\\/]/)
2799
- const changedId = m ? m[1] : null
2800
- if (this._cardsChangedTimer) return
2801
- this._cardsChangedTimer = setTimeout(() => {
2802
- this._cardsChangedTimer = null
2803
- this.fetchCardList()
2804
- if (changedId && changedId === this.selectedId) {
2805
- // 本窗口自己的「保存到本地 / 保存至远端」刚落盘,watcher 推回的 cards-changed 是回声,
2806
- // 并非外部修改——内存态已由 saveCard / saveToRemote 就地更新,不应弹外部重载提示或冲突确认框。
2807
- // 仅吸收本窗口的回声;其他窗口 / IDE / 远端同步的变更仍照常提示(其 selfSaveId 不会被设置)。
2808
- if (this.selfSaveId === changedId && Date.now() - this.selfSaveAt < SELF_SAVE_SUPPRESS_MS) {
2809
- this.selfSaveId = ''
2810
- this.selfSaveAt = 0
2811
- return
2812
- }
2813
- if (this.historyDirty) {
2814
- // 当前卡有未保存编辑,外部又改了文件 → 二次确认,避免静默丢弃编辑
2815
- this.promptExternalReload(changedId)
2816
- } else {
2817
- // 无未保存改动:外部改动直接自动重载(符合「主动刷新重载」诉求),
2818
- // 但给一条非阻塞轻提示,避免「改了却毫无感知」。
2819
- const name = (this.currentSchema && this.currentSchema.name) || changedId
2820
- this.showExternalReloadNote(`卡片「${name}」已在编辑器外被修改,已自动重新载入预览`)
2821
- this.loadCard(this.selectedId)
2822
- }
2823
- }
2824
- }, 120)
2825
- },
2826
- /**
2827
- * 外部(IDE / 其他工具 / 远端同步)改动了当前卡片文件,而平台有未保存编辑时,
2828
- * 复用既有的 ConfirmDialog 弹二次确认(与同步发布 / 版本推送同款组件):
2829
- * - 「重新载入」:放弃本地未保存改动,从磁盘重新载入(onConfirm 执行 loadCard)。
2830
- * - 「保留我的改动」:取消,保留编辑器内未保存编辑、不重载。
2831
- * 已有确认弹窗打开时跳过,避免同一波变更重复弹窗。
2832
- */
2833
- promptExternalReload(id) {
2834
- if (this.confirm.show) return
2835
- const name = (this.currentSchema && this.currentSchema.name) || id
2836
- this.requestConfirm({
2837
- title: '卡片已在编辑器外被修改',
2838
- message: `卡片「${name}」的 schema / data 在编辑器外发生了变更(例如 IDE 或其他工具覆盖了文件)。\n当前编辑器存在未保存的改动,是否放弃本地未保存改动并重新载入磁盘版本?`,
2839
- confirmText: '重新载入',
2840
- cancelText: '保留我的改动',
2841
- onConfirm: () => this.loadCard(id),
2842
- })
2843
- },
2844
- /**
2845
- * 外部改了当前卡、但平台无未保存改动时的非阻塞轻提示:顶部短暂出现、自动消失。
2846
- * 与 promptExternalReload(阻塞确认)互补——脏时弹确认、非脏时给提示。
2847
- */
2848
- showExternalReloadNote(msg) {
2849
- this.externalReloadNote = msg
2850
- if (this.externalReloadTimer) clearTimeout(this.externalReloadTimer)
2851
- this.externalReloadTimer = setTimeout(() => {
2852
- this.externalReloadNote = ''
2853
- this.externalReloadTimer = null
2854
- }, 3500)
2855
- },
2856
- },
2857
- }
2858
- </script>