first commit

This commit is contained in:
Chris
2026-03-23 20:23:58 +08:00
commit 74d612aca1
3193 changed files with 692056 additions and 0 deletions

View File

@@ -0,0 +1,705 @@
<template>
<div class="editor-canvas-frame">
<div v-if="baseUrl" class="editor-canvas-frame__viewport" :class="viewportClass">
<iframe
ref="frameRef"
class="editor-canvas-frame__iframe"
:src="baseUrl"
title="editor-canvas"
loading="lazy"
@load="handleFrameLoad"
/>
<div
class="editor-canvas-frame__status-pill"
:class="`editor-canvas-frame__status-pill--${canvasStatusTag.type}`"
>
<span class="editor-canvas-frame__status-dot" />
{{ canvasStatusTag.label }}
</div>
</div>
<div v-else class="editor-canvas-frame__empty">
<p class="editor-canvas-frame__empty-title">先填入要編輯的頁面網址</p>
<p class="editor-canvas-frame__empty-body">
畫布會直接載入目標頁面之後所有可視化操作都會從這裡發生
</p>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
import {
createStatusSummaryMessage,
createFocusSelectorMessage,
createInspectSelectorMessage,
createStartInlineEditMessage,
createMoveBlockMessage,
createToggleBlockVisibilityMessage,
createDuplicateBlockMessage,
createDeleteBlockMessage,
createInsertBlockMessage,
createRestoreCanvasStateMessage,
createPreviewOperationsMessage,
editorBridgeEventType,
normalizeCanvasSelectionPayload,
} from "@/module/editor/model/editor-bridge-protocol"
const props = defineProps({
baseUrl: {
type: String,
default: "",
},
deviceMode: {
type: String,
default: "desktop",
},
candidates: {
type: Array,
default: () => [],
},
selectedSelector: {
type: String,
default: "",
},
previewStatus: {
type: Object,
default: () => ({
title: "",
status: "idle",
}),
},
previewOperations: {
type: Array,
default: () => [],
},
feedbackSummary: {
type: Object,
default: () => ({
title: "",
selectorLabel: "",
detail: "",
helper: "",
status: "info",
}),
},
bridgeCommand: {
type: Object,
default: null,
},
})
const emit = defineEmits([
"select-candidate",
"hover-candidate",
"hover-clear",
"hotspot-action",
"inline-text-change",
"inline-attribute-change",
"inline-style-change",
"canvas-ready",
"bridge-outbound",
])
const frameRef = ref(null)
const canvasState = ref("idle")
const canvasOrigin = ref("")
const lastHoverSelector = ref("")
const lastIncomingSelector = ref("")
const lastFocusedSelector = ref("")
const lastOutboundCommand = ref("")
const viewportClass = computed(
() => `editor-canvas-frame__viewport--${props.deviceMode || "desktop"}`
)
const canvasStatusTag = computed(() => {
switch (canvasState.value) {
case "connected":
return {
type: "success",
label: "已連線",
}
case "loaded":
return {
type: "info",
label: "頁面已載入",
}
default:
return {
type: "warning",
label: props.baseUrl ? "等待編輯橋接" : "尚未啟動",
}
}
})
const handleFrameLoad = () => {
canvasState.value = "loaded"
canvasOrigin.value = ""
pushSelectedSelectorToCanvas()
}
const postBridgeMessage = (message, meta = {}) => {
if (!frameRef.value?.contentWindow || !message?.type) {
return
}
if (
[
editorBridgeEventType.FOCUS_SELECTOR,
editorBridgeEventType.INSPECT_SELECTOR,
editorBridgeEventType.START_INLINE_EDIT,
editorBridgeEventType.MOVE_BLOCK,
editorBridgeEventType.TOGGLE_BLOCK_VISIBILITY,
editorBridgeEventType.DUPLICATE_BLOCK,
editorBridgeEventType.DELETE_BLOCK,
editorBridgeEventType.INSERT_BLOCK,
].includes(message.type) &&
!message?.selector
) {
return
}
frameRef.value.contentWindow.postMessage(message, canvasOrigin.value || "*")
if (message.selector) {
lastFocusedSelector.value = message.selector
}
lastOutboundCommand.value = meta.label || message.type
emit("bridge-outbound", {
type: meta.logType || message.type,
value: message.selector,
source: meta.source || "editor-workspace",
})
}
const pushSelectedSelectorToCanvas = () => {
if (!props.selectedSelector || !frameRef.value?.contentWindow) {
return
}
postBridgeMessage(
createFocusSelectorMessage({
selector: props.selectedSelector,
}),
{
label: "已同步焦點到畫布",
logType: "focus-selector",
source: "editor-workspace",
}
)
}
const runBridgeCommand = (command) => {
if (!command?.type) {
return
}
if (
command.type !== editorBridgeEventType.RESTORE_CANVAS_STATE &&
!command?.selector
) {
return
}
if (command.type === editorBridgeEventType.INSPECT_SELECTOR) {
postBridgeMessage(
createInspectSelectorMessage({
selector: command.selector,
source: command.source,
}),
{
label: "已要求畫布檢查區塊",
logType: "inspect-selector",
source: command.source || "editor-workspace",
}
)
return
}
if (command.type === editorBridgeEventType.START_INLINE_EDIT) {
postBridgeMessage(
createStartInlineEditMessage({
selector: command.selector,
editKind: command.editKind,
attributeName: command.attributeName,
attributeValue: command.attributeValue,
styleProperty: command.styleProperty,
styleValue: command.styleValue,
source: command.source,
}),
{
label: "已要求畫布進入頁內文字編輯",
logType: "start-inline-edit",
source: command.source || "editor-workspace",
}
)
return
}
if (command.type === editorBridgeEventType.MOVE_BLOCK) {
postBridgeMessage(
createMoveBlockMessage({
selector: command.selector,
direction: command.direction,
source: command.source,
}),
{
label: command.direction === "up" ? "已要求區塊上移" : "已要求區塊下移",
logType: "move-block",
source: command.source || "editor-workspace",
}
)
return
}
if (command.type === editorBridgeEventType.TOGGLE_BLOCK_VISIBILITY) {
postBridgeMessage(
createToggleBlockVisibilityMessage({
selector: command.selector,
source: command.source,
}),
{
label: "已切換區塊顯示狀態",
logType: "toggle-block-visibility",
source: command.source || "editor-workspace",
}
)
return
}
if (command.type === editorBridgeEventType.DUPLICATE_BLOCK) {
postBridgeMessage(
createDuplicateBlockMessage({
selector: command.selector,
source: command.source,
}),
{
label: "已要求複製區塊",
logType: "duplicate-block",
source: command.source || "editor-workspace",
}
)
return
}
if (command.type === editorBridgeEventType.DELETE_BLOCK) {
postBridgeMessage(
createDeleteBlockMessage({
selector: command.selector,
source: command.source,
}),
{
label: "已要求刪除區塊",
logType: "delete-block",
source: command.source || "editor-workspace",
}
)
return
}
if (command.type === editorBridgeEventType.INSERT_BLOCK) {
postBridgeMessage(
createInsertBlockMessage({
selector: command.selector,
position: command.position,
blockType: command.blockType,
source: command.source,
}),
{
label:
command.position === "before"
? `已要求前面插入${command.blockTypeLabel || "區塊"}`
: `已要求後面插入${command.blockTypeLabel || "區塊"}`,
logType: "insert-block",
source: command.source || "editor-workspace",
}
)
return
}
if (command.type === editorBridgeEventType.RESTORE_CANVAS_STATE) {
postBridgeMessage(
createRestoreCanvasStateMessage({
pageHtml: command.pageHtml,
selector: command.selector,
source: command.source,
}),
{
label: "已還原畫布狀態",
logType: "restore-canvas-state",
source: command.source || "editor-workspace",
}
)
return
}
postBridgeMessage(
createFocusSelectorMessage({
selector: command.selector,
source: command.source,
}),
{
label: "已同步焦點到畫布",
logType: "focus-selector",
source: command.source || "editor-workspace",
}
)
}
const pushFeedbackSummaryToCanvas = () => {
if (!frameRef.value?.contentWindow || !props.feedbackSummary?.title) {
return
}
postBridgeMessage(
createStatusSummaryMessage({
mode: props.feedbackSummary.mode,
modeLabel: props.feedbackSummary.modeLabel,
status: props.feedbackSummary.status,
title: props.feedbackSummary.title,
selector: props.feedbackSummary.selectorLabel,
detail: props.feedbackSummary.detail,
helper: props.feedbackSummary.helper,
primaryActionLabel: props.feedbackSummary.primaryActionLabel,
secondaryActionLabel: props.feedbackSummary.secondaryActionLabel,
}),
{
label: "已同步畫布回饋摘要",
logType: "status-summary",
source: "editor-workspace",
}
)
}
const pushPreviewOperationsToCanvas = () => {
if (!frameRef.value?.contentWindow) {
return
}
postBridgeMessage(
createPreviewOperationsMessage({
operations: props.previewOperations,
}),
{
label: "已同步預覽結果到畫布",
logType: "preview-operations",
source: "editor-workspace",
}
)
}
const handleMessage = (event) => {
if (!props.baseUrl || !frameRef.value?.contentWindow || event.source !== frameRef.value.contentWindow) {
return
}
const payload = event.data || {}
if (payload?.type === editorBridgeEventType.READY) {
canvasState.value = "connected"
canvasOrigin.value = event.origin || ""
emit("canvas-ready", payload)
pushPreviewOperationsToCanvas()
return
}
if (payload?.type === editorBridgeEventType.HOVER && payload.selector) {
const normalizedPayload = normalizeCanvasSelectionPayload(payload)
canvasState.value = "connected"
lastHoverSelector.value = normalizedPayload.selector
emit("hover-candidate", normalizedPayload)
return
}
if (payload?.type === editorBridgeEventType.HOVER_CLEAR) {
lastHoverSelector.value = ""
emit("hover-clear")
return
}
if (payload?.type === editorBridgeEventType.HOTSPOT_ACTION) {
emit("hotspot-action", {
action: payload.action || "",
selector: payload.selector || "",
tag: payload.tag || "",
text: payload.text || "",
href: payload.href || "",
src: payload.src || "",
intentChangeType: payload.intent_change_type || "",
intentField: payload.intent_field || "",
intentAttributeName: payload.intent_attribute_name || "",
intentAttributeValue: payload.intent_attribute_value || "",
source: payload.source || "canvas-hotspot",
})
emit("bridge-outbound", {
type: payload.action || payload.type,
value: payload.selector || "",
source: payload.source || "canvas-hotspot",
})
return
}
if (payload?.type === editorBridgeEventType.INLINE_TEXT_CHANGE && payload.selector) {
emit("inline-text-change", {
selector: payload.selector || "",
text: payload.text || "",
pageHtml: payload.page_html || "",
source: payload.source || "canvas-inline-edit",
})
emit("bridge-outbound", {
type: payload.type,
value: payload.selector || "",
source: payload.source || "canvas-inline-edit",
})
return
}
if (payload?.type === editorBridgeEventType.INLINE_ATTRIBUTE_CHANGE && payload.selector) {
emit("inline-attribute-change", {
selector: payload.selector || "",
attributeName: payload.attribute_name || "",
attributeValue: payload.attribute_value || "",
pageHtml: payload.page_html || "",
source: payload.source || "canvas-inline-edit",
})
emit("bridge-outbound", {
type: payload.type,
value: payload.selector || "",
source: payload.source || "canvas-inline-edit",
})
return
}
if (payload?.type === editorBridgeEventType.INLINE_STYLE_CHANGE && payload.selector) {
emit("inline-style-change", {
selector: payload.selector || "",
styleProperty: payload.style_property || "",
styleValue: payload.style_value || "",
pageHtml: payload.page_html || "",
source: payload.source || "canvas-inline-edit",
})
emit("bridge-outbound", {
type: payload.type,
value: payload.selector || "",
source: payload.source || "canvas-inline-edit",
})
return
}
if (payload?.type === editorBridgeEventType.BLOCK_VISIBILITY_CHANGE && payload.selector) {
emit("bridge-outbound", {
type: payload.type,
value: payload.selector || "",
source: payload.source || "canvas-bridge",
hidden: Boolean(payload.hidden),
pageHtml: payload.page_html || "",
})
emit("select-candidate", normalizeCanvasSelectionPayload(payload))
return
}
if (payload?.type === editorBridgeEventType.BLOCK_STRUCTURE_CHANGE) {
emit("bridge-outbound", {
type: payload.type,
value: payload.selector || "",
source: payload.source || "canvas-bridge",
action: payload.action || "",
nextSelector: payload.next_selector || "",
blockType: payload.block_type || "",
tag: payload.tag || "",
text: payload.text || "",
href: payload.href || "",
src: payload.src || "",
hidden: Boolean(payload.hidden),
pageHtml: payload.page_html || "",
})
return
}
if (payload?.type === editorBridgeEventType.SELECTION && payload.selector) {
const normalizedPayload = normalizeCanvasSelectionPayload(payload)
canvasState.value = "connected"
lastIncomingSelector.value = normalizedPayload.selector
emit("select-candidate", normalizedPayload)
}
}
watch(
() => props.baseUrl,
(value) => {
canvasState.value = value ? "idle" : "idle"
lastHoverSelector.value = ""
lastIncomingSelector.value = ""
lastFocusedSelector.value = ""
}
)
watch(
() => props.selectedSelector,
() => {
pushSelectedSelectorToCanvas()
}
)
watch(
() => props.previewOperations,
() => {
pushPreviewOperationsToCanvas()
},
{ deep: true }
)
watch(
() => props.feedbackSummary,
() => {
pushFeedbackSummaryToCanvas()
},
{ deep: true }
)
watch(
() => props.bridgeCommand,
(value) => {
if (!value) {
return
}
runBridgeCommand(value)
},
{ deep: true }
)
onMounted(() => {
window.addEventListener("message", handleMessage)
})
onBeforeUnmount(() => {
window.removeEventListener("message", handleMessage)
})
</script>
<style scoped>
.editor-canvas-frame {
min-height: 100%;
}
.editor-canvas-frame__viewport {
position: relative;
min-height: calc(100vh - 72px);
display: flex;
justify-content: center;
align-items: flex-start;
overflow: hidden;
background-color: #e2eaf4;
background-image: radial-gradient(rgba(100, 116, 139, 0.18) 1px, transparent 1px);
background-size: 22px 22px;
}
.editor-canvas-frame__iframe {
width: 100%;
min-height: calc(100vh - 72px);
border: 0;
background: white;
}
.editor-canvas-frame__viewport--desktop .editor-canvas-frame__iframe {
width: 100%;
}
.editor-canvas-frame__viewport--tablet .editor-canvas-frame__iframe {
width: min(820px, 100%);
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.1), 0 8px 32px rgba(15, 23, 42, 0.12);
}
.editor-canvas-frame__viewport--mobile .editor-canvas-frame__iframe {
width: min(430px, 100%);
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.12), 0 12px 36px rgba(15, 23, 42, 0.14);
}
.editor-canvas-frame__empty {
display: grid;
place-items: center;
min-height: 360px;
padding: 24px;
border: 1px dashed rgba(148, 163, 184, 0.42);
border-radius: 24px;
background:
linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.98));
text-align: center;
}
.editor-canvas-frame__empty-title {
margin: 0;
color: #0f172a;
font-size: 18px;
font-weight: 700;
}
.editor-canvas-frame__empty-body {
max-width: 460px;
margin: 10px 0 0;
color: #64748b;
font-size: 14px;
line-height: 1.75;
}
.editor-canvas-frame__status-pill {
position: absolute;
bottom: 14px;
right: 14px;
z-index: 10;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px 5px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
pointer-events: none;
backdrop-filter: blur(6px);
transition: opacity 0.3s;
}
.editor-canvas-frame__status-pill--success {
background: rgba(220, 252, 231, 0.88);
color: #15803d;
border: 1px solid rgba(134, 239, 172, 0.5);
}
.editor-canvas-frame__status-pill--info {
background: rgba(224, 242, 254, 0.88);
color: #0369a1;
border: 1px solid rgba(147, 197, 253, 0.5);
}
.editor-canvas-frame__status-pill--warning {
background: rgba(254, 249, 195, 0.88);
color: #92400e;
border: 1px solid rgba(253, 224, 71, 0.5);
}
.editor-canvas-frame__status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.editor-canvas-frame__status-pill--success .editor-canvas-frame__status-dot {
background: #16a34a;
}
.editor-canvas-frame__status-pill--info .editor-canvas-frame__status-dot {
background: #0284c7;
}
.editor-canvas-frame__status-pill--warning .editor-canvas-frame__status-dot {
background: #d97706;
animation: status-pulse 1.6s ease-in-out infinite;
}
@keyframes status-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
</style>

View File

@@ -0,0 +1,335 @@
<template>
<header class="editor-top-toolbar">
<div class="editor-top-toolbar__actions">
<!-- 裝置切換 -->
<div class="editor-top-toolbar__group">
<button
v-for="dev in deviceOptions"
:key="dev.value"
class="editor-top-toolbar__icon-btn"
:class="{ 'is-active': deviceMode === dev.value }"
:title="dev.label"
@click="$emit('set-device-mode', dev.value)"
>
<component :is="dev.icon" />
</button>
</div>
<!-- 結構操作 -->
<div class="editor-top-toolbar__group">
<button
class="editor-top-toolbar__text-btn"
:disabled="!selectedSelector"
:title="selectedHidden ? '顯示區塊' : '隱藏區塊'"
@click="$emit('toggle-visibility')"
>
<EyeOffIcon v-if="!selectedHidden" />
<EyeIcon v-else />
<span>{{ selectedHidden ? "顯示" : "隱藏" }}</span>
</button>
<div class="editor-top-toolbar__insert-row">
<el-select
:model-value="insertBlockType"
class="editor-top-toolbar__insert-type"
placeholder="插入類型"
@change="$emit('update:insert-block-type', $event)"
>
<el-option
v-for="option in insertBlockTypeOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<button
class="editor-top-toolbar__text-btn"
:disabled="!selectedSelector"
title="前面插入"
@click="$emit('insert-before')"
>
<InsertBeforeIcon />
<span>前插</span>
</button>
<button
class="editor-top-toolbar__text-btn"
:disabled="!selectedSelector"
title="後面插入"
@click="$emit('insert-after')"
>
<InsertAfterIcon />
<span>後插</span>
</button>
</div>
<button
class="editor-top-toolbar__text-btn"
:disabled="!selectedSelector"
title="複製區塊"
@click="$emit('duplicate')"
>
<CopyIcon />
<span>複製</span>
</button>
<button
class="editor-top-toolbar__text-btn editor-top-toolbar__text-btn--danger"
:disabled="!selectedSelector"
title="刪除區塊"
@click="$emit('delete')"
>
<TrashIcon />
<span>刪除</span>
</button>
</div>
<!-- 歷史 -->
<div class="editor-top-toolbar__group">
<button
class="editor-top-toolbar__icon-btn"
:disabled="!canUndo"
title="上一步Ctrl+Z"
@click="$emit('undo')"
>
<UndoIcon />
</button>
<button
class="editor-top-toolbar__icon-btn"
:disabled="!canRedo"
title="下一步Ctrl+Y"
@click="$emit('redo')"
>
<RedoIcon />
</button>
</div>
</div>
<div class="editor-top-toolbar__secondary">
<button class="editor-top-toolbar__text-btn editor-top-toolbar__text-btn--ghost" title="載入示範頁" @click="$emit('load-demo')">
示範
</button>
<button class="editor-top-toolbar__text-btn editor-top-toolbar__text-btn--ghost" title="返回" @click="$emit('back')">
<BackIcon />
<span>返回</span>
</button>
</div>
</header>
</template>
<script setup>
import { defineComponent, h } from "vue"
defineProps({
deviceMode: { type: String, default: "desktop" },
selectedSelector: { type: String, default: "" },
selectedHidden: { type: Boolean, default: false },
insertBlockType: { type: String, default: "content-card" },
insertBlockTypeOptions: { type: Array, default: () => [] },
canUndo: { type: Boolean, default: false },
canRedo: { type: Boolean, default: false },
})
defineEmits([
"set-device-mode",
"toggle-visibility",
"update:insert-block-type",
"insert-before",
"insert-after",
"duplicate",
"delete",
"undo",
"redo",
"back",
"load-demo",
])
function svgIcon(pathD, vb) {
return defineComponent({
render() {
return h("svg", { width: 18, height: 18, viewBox: vb || "0 0 20 20", fill: "none", "aria-hidden": "true" },
[h("path", { d: pathD, fill: "currentColor" })]
)
}
})
}
const DesktopIcon = svgIcon("M3 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v9H3V4Zm1 11h12l1 2H3l1-2Zm3-1h6v1H7v-1Z")
const TabletIcon = svgIcon("M5 3a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H5Zm5 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z")
const MobileIcon = svgIcon("M7 2a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H7Zm3 14a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z")
const UndoIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("path", { d: "M5 7H13a4 4 0 0 1 0 8H9", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round" }), h("path", { d: "M7 4 4 7l3 3", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round", "stroke-linejoin": "round" })]) } })
const RedoIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("path", { d: "M15 7H7a4 4 0 0 0 0 8h4", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round" }), h("path", { d: "M13 4l3 3-3 3", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round", "stroke-linejoin": "round" })]) } })
const EyeIcon = svgIcon("M3 10s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5Zm7 2a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z")
const EyeOffIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("path", { d: "M3 10s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5Z", stroke: "currentColor", "stroke-width": "1.6" }), h("line", { x1: "4", y1: "4", x2: "16", y2: "16", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round" })]) } })
const CopyIcon = svgIcon("M7 4h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2ZM4 7H3a1 1 0 0 0-1 1v8a2 2 0 0 0 2 2h9a1 1 0 0 0 1-1v-1")
const TrashIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("path", { d: "M5 7h10l-1 10H6L5 7ZM3 7h14M8 7V4h4v3", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round", "stroke-linejoin": "round" })]) } })
const InsertBeforeIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("rect", { x: "3", y: "3", width: "14", height: "5", rx: "1.5", stroke: "currentColor", "stroke-width": "1.5" }), h("path", { d: "M10 11v6M7 14l3-3 3 3", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" })]) } })
const InsertAfterIcon = defineComponent({ render() { return h("svg", { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true" }, [h("rect", { x: "3", y: "12", width: "14", height: "5", rx: "1.5", stroke: "currentColor", "stroke-width": "1.5" }), h("path", { d: "M10 9V3M7 6l3 3 3-3", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" })]) } })
const BackIcon = svgIcon("M10 4 4 10l6 6M4 10h12")
const deviceOptions = [
{ value: "desktop", label: "桌機", icon: DesktopIcon },
{ value: "tablet", label: "平板", icon: TabletIcon },
{ value: "mobile", label: "手機", icon: MobileIcon },
]
</script>
<style scoped>
.editor-top-toolbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
backdrop-filter: blur(18px);
background: rgba(250, 251, 253, 0.96);
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
}
.editor-top-toolbar__actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
flex: 1;
}
.editor-top-toolbar__secondary {
display: flex;
gap: 6px;
align-items: center;
flex-shrink: 0;
}
/* 分組容器 */
.editor-top-toolbar__group {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 1px 0 rgba(255, 255, 255, 0.9) inset;
}
/* 插入列 — 把 select 和兩個按鈕放一排 */
.editor-top-toolbar__insert-row {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* icon-only 按鈕裝置切換、undo/redo */
.editor-top-toolbar__icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: 0;
border-radius: 9px;
background: transparent;
color: #64748b;
cursor: pointer;
transition: background 0.12s, color 0.12s;
flex-shrink: 0;
}
.editor-top-toolbar__icon-btn:hover:not(:disabled) {
background: #f0f5ff;
color: #1d4ed8;
}
.editor-top-toolbar__icon-btn.is-active {
background: #e8f0ff;
color: #1d4ed8;
box-shadow: 0 1px 3px rgba(37, 99, 235, 0.14);
}
.editor-top-toolbar__icon-btn:disabled {
opacity: 0.38;
cursor: default;
}
/* text + icon 按鈕 */
.editor-top-toolbar__text-btn {
display: inline-flex;
align-items: center;
gap: 5px;
height: 34px;
padding: 0 11px;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 9px;
background: #fff;
color: #334155;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.editor-top-toolbar__text-btn:hover:not(:disabled) {
background: #f0f5ff;
border-color: rgba(59, 130, 246, 0.28);
color: #1d4ed8;
}
.editor-top-toolbar__text-btn:disabled {
opacity: 0.38;
cursor: default;
background: #f8fafc;
}
/* 危險按鈕 — 刪除 */
.editor-top-toolbar__text-btn--danger:hover:not(:disabled) {
background: #fff5f5;
border-color: rgba(239, 68, 68, 0.32);
color: #dc2626;
}
/* 次要按鈕 — 返回/示範 */
.editor-top-toolbar__text-btn--ghost {
background: transparent;
border-color: transparent;
color: #64748b;
}
.editor-top-toolbar__text-btn--ghost:hover:not(:disabled) {
background: #f1f5f9;
border-color: rgba(148, 163, 184, 0.24);
color: #334155;
}
/* insert type select */
.editor-top-toolbar__insert-type {
width: 116px;
}
.editor-top-toolbar :deep(.el-select__wrapper),
.editor-top-toolbar :deep(.el-input__wrapper) {
height: 34px;
min-height: 34px;
border-radius: 9px;
box-shadow: none;
border: 1px solid rgba(148, 163, 184, 0.2);
background: #fff;
font-size: 13px;
}
@media (max-width: 1200px) {
.editor-top-toolbar {
flex-direction: column;
align-items: stretch;
}
.editor-top-toolbar__actions {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,118 @@
export const editorBridgeEventType = {
READY: "mkt-editor:ready",
HOVER: "mkt-editor:hover",
HOVER_CLEAR: "mkt-editor:hover-clear",
SELECTION: "mkt-editor:selection",
HOTSPOT_ACTION: "mkt-editor:hotspot-action",
FOCUS_SELECTOR: "mkt-editor:focus-selector",
INSPECT_SELECTOR: "mkt-editor:inspect-selector",
START_INLINE_EDIT: "mkt-editor:start-inline-edit",
MOVE_BLOCK: "mkt-editor:move-block",
TOGGLE_BLOCK_VISIBILITY: "mkt-editor:toggle-block-visibility",
DUPLICATE_BLOCK: "mkt-editor:duplicate-block",
DELETE_BLOCK: "mkt-editor:delete-block",
INSERT_BLOCK: "mkt-editor:insert-block",
RESTORE_CANVAS_STATE: "mkt-editor:restore-canvas-state",
INLINE_TEXT_CHANGE: "mkt-editor:inline-text-change",
INLINE_ATTRIBUTE_CHANGE: "mkt-editor:inline-attribute-change",
INLINE_STYLE_CHANGE: "mkt-editor:inline-style-change",
BLOCK_VISIBILITY_CHANGE: "mkt-editor:block-visibility-change",
BLOCK_STRUCTURE_CHANGE: "mkt-editor:block-structure-change",
STATUS_SUMMARY: "mkt-editor:status-summary",
PREVIEW_OPERATIONS: "mkt-editor:preview-operations",
}
export const normalizeCanvasSelectionPayload = (payload = {}) => ({
selector: payload.selector || "",
tag: payload.tag || "",
text: payload.text || "",
href: payload.href || "",
src: payload.src || "",
hidden: Boolean(payload.hidden),
source: payload.source || "canvas-bridge",
})
export const createFocusSelectorMessage = (payload = {}) => ({
type: editorBridgeEventType.FOCUS_SELECTOR,
selector: payload.selector || "",
source: payload.source || "editor-workspace",
})
export const createInspectSelectorMessage = (payload = {}) => ({
type: editorBridgeEventType.INSPECT_SELECTOR,
selector: payload.selector || "",
source: payload.source || "editor-workspace",
})
export const createStartInlineEditMessage = (payload = {}) => ({
type: editorBridgeEventType.START_INLINE_EDIT,
selector: payload.selector || "",
editKind: payload.editKind || "",
attributeName: payload.attributeName || "",
attributeValue: payload.attributeValue || "",
styleProperty: payload.styleProperty || "",
styleValue: payload.styleValue || "",
source: payload.source || "editor-workspace",
})
export const createMoveBlockMessage = (payload = {}) => ({
type: editorBridgeEventType.MOVE_BLOCK,
selector: payload.selector || "",
direction: payload.direction || "down",
source: payload.source || "editor-workspace",
})
export const createToggleBlockVisibilityMessage = (payload = {}) => ({
type: editorBridgeEventType.TOGGLE_BLOCK_VISIBILITY,
selector: payload.selector || "",
source: payload.source || "editor-workspace",
})
export const createDuplicateBlockMessage = (payload = {}) => ({
type: editorBridgeEventType.DUPLICATE_BLOCK,
selector: payload.selector || "",
source: payload.source || "editor-workspace",
})
export const createDeleteBlockMessage = (payload = {}) => ({
type: editorBridgeEventType.DELETE_BLOCK,
selector: payload.selector || "",
source: payload.source || "editor-workspace",
})
export const createInsertBlockMessage = (payload = {}) => ({
type: editorBridgeEventType.INSERT_BLOCK,
selector: payload.selector || "",
position: payload.position || "after",
blockType: payload.blockType || "content-card",
source: payload.source || "editor-workspace",
})
export const createRestoreCanvasStateMessage = (payload = {}) => ({
type: editorBridgeEventType.RESTORE_CANVAS_STATE,
pageHtml: payload.pageHtml || "",
selector: payload.selector || "",
source: payload.source || "editor-workspace",
})
export const createStatusSummaryMessage = (payload = {}) => ({
type: editorBridgeEventType.STATUS_SUMMARY,
mode: payload.mode || "idle",
modeLabel: payload.modeLabel || "",
status: payload.status || "info",
title: payload.title || "",
selector: payload.selector || "",
detail: payload.detail || "",
helper: payload.helper || "",
primaryActionLabel: payload.primaryActionLabel || "",
secondaryActionLabel: payload.secondaryActionLabel || "",
source: payload.source || "editor-workspace",
})
export const createPreviewOperationsMessage = (payload = {}) => ({
type: editorBridgeEventType.PREVIEW_OPERATIONS,
operations: Array.isArray(payload.operations)
? JSON.parse(JSON.stringify(payload.operations))
: [],
source: payload.source || "editor-workspace",
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
import fastapiClient from "@/services/api/fastapi-client";
const createEditorSession = async (payload) => {
return await fastapiClient.request("/api/editor/sessions", {
method: "POST",
body: JSON.stringify(payload),
});
};
const getVariantChanges = async (variantId) => {
const response = await fastapiClient.request(
`/api/editor/variants/${variantId}/changes`
);
return response.items || [];
};
const saveVariantChanges = async (variantId, items) => {
const response = await fastapiClient.request(
`/api/editor/variants/${variantId}/changes`,
{
method: "PUT",
body: JSON.stringify({ items }),
}
);
return response.items || [];
};
const buildPreview = async (payload) => {
return await fastapiClient.request("/api/editor/previews/build", {
method: "POST",
body: JSON.stringify(payload),
});
};
export default {
createEditorSession,
getVariantChanges,
saveVariantChanges,
buildPreview,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,140 @@
<template>
<section class="fullscreen-editor">
<EditorTopToolbar
:device-mode="deviceMode"
:selected-selector="selectedSelector"
:selected-hidden="selectedElementMeta.hidden"
:insert-block-type="insertBlockType"
:insert-block-type-options="insertBlockTypeOptions"
:can-undo="canUndo"
:can-redo="canRedo"
@set-device-mode="setDeviceMode"
@toggle-visibility="toggleSelectedBlockVisibility"
@update:insert-block-type="insertBlockType = $event"
@insert-before="insertBlockNearSelection('before')"
@insert-after="insertBlockNearSelection('after')"
@duplicate="duplicateSelectedBlock"
@delete="deleteSelectedBlock"
@undo="undoHistory"
@redo="redoHistory"
@back="goBack"
@load-demo="loadDemoPage"
/>
<el-alert
v-if="errorMessage"
type="error"
:closable="false"
:title="errorMessage"
show-icon
/>
<section class="fullscreen-editor__stage">
<div class="fullscreen-editor__canvas-shell">
<EditorCanvasFrame
:base-url="baseUrl"
:device-mode="deviceMode"
:candidates="canvasCandidates"
:selected-selector="selectedSelector"
:preview-status="selectedPreviewSummary"
:preview-operations="previewOperations"
:feedback-summary="canvasFeedbackSummary"
:bridge-command="canvasBridgeCommand"
@select-candidate="selectCanvasCandidate"
@hover-candidate="handleCanvasHover"
@hover-clear="keepCurrentSelection"
@hotspot-action="handleCanvasHotspotAction"
@inline-attribute-change="handleInlineAttributeChange"
@inline-style-change="handleInlineStyleChange"
@inline-text-change="handleInlineTextChange"
@canvas-ready="handleCanvasReady"
@bridge-outbound="handleCanvasOutbound"
/>
</div>
</section>
</section>
</template>
<script setup>
import { onBeforeUnmount, onMounted } from "vue"
import EditorCanvasFrame from "@/module/editor/components/EditorCanvasFrame.vue";
import EditorTopToolbar from "@/module/editor/components/EditorTopToolbar.vue";
import { useEditorWorkspacePage } from "@/module/editor/service/use-editor-workspace-page";
const {
errorMessage,
previewOperations,
canvasCandidates,
selectedPreviewSummary,
canvasFeedbackSummary,
selectedSelector,
selectedElementMeta,
canvasBridgeCommand,
baseUrl,
deviceMode,
setDeviceMode,
insertBlockType,
insertBlockTypeOptions,
toggleSelectedBlockVisibility,
insertBlockNearSelection,
duplicateSelectedBlock,
deleteSelectedBlock,
canUndo,
canRedo,
undoHistory,
redoHistory,
selectCanvasCandidate,
loadDemoPage,
handleCanvasReady,
handleCanvasHotspotAction,
handleInlineAttributeChange,
handleInlineStyleChange,
handleCanvasHover,
handleInlineTextChange,
keepCurrentSelection,
handleCanvasOutbound,
goBack,
} = useEditorWorkspacePage();
function onKeyDown(e) {
const tag = document.activeElement?.tagName?.toLowerCase()
if (tag === "input" || tag === "textarea" || tag === "select") return
if (document.activeElement?.isContentEditable) return
const mod = e.ctrlKey || e.metaKey
if (!mod) return
if (e.key === "z" && !e.shiftKey) {
e.preventDefault()
if (canUndo.value) undoHistory()
return
}
if ((e.key === "y") || (e.key === "z" && e.shiftKey)) {
e.preventDefault()
if (canRedo.value) redoHistory()
}
}
onMounted(() => window.addEventListener("keydown", onKeyDown))
onBeforeUnmount(() => window.removeEventListener("keydown", onKeyDown))
</script>
<style scoped>
.fullscreen-editor {
min-height: 100vh;
background: #f6f8fb;
}
.fullscreen-editor__stage {
display: grid;
min-height: calc(100vh - 72px);
}
.fullscreen-editor__canvas-shell :deep(.editor-canvas-frame) {
min-height: calc(100vh - 72px);
overflow: hidden;
border-radius: 0;
border: 0;
box-shadow: none;
}
</style>