|
|
|
@ -1,15 +1,63 @@ |
|
|
|
<template> |
|
|
|
<div class="understanding-step-content"> |
|
|
|
<!-- 翻页控件:多页时显示 --> |
|
|
|
<div v-if="currentPageCount > 1" class="pagination-bar"> |
|
|
|
<el-button size="mini" :disabled="!canPrevPage" @click="prevPage"> |
|
|
|
<i class="el-icon-arrow-left"></i> 上一页 |
|
|
|
</el-button> |
|
|
|
<span class="page-indicator">{{ currentPageIndex + 1 }} / {{ currentPageCount }}</span> |
|
|
|
<el-button size="mini" :disabled="!canNextPage" @click="nextPage"> |
|
|
|
下一页 <i class="el-icon-arrow-right"></i> |
|
|
|
</el-button> |
|
|
|
<div |
|
|
|
class="understanding-step-content" |
|
|
|
@mousemove="onPaginationAreaMouseMove" |
|
|
|
@mouseleave="onPaginationAreaMouseLeave" |
|
|
|
> |
|
|
|
<!-- 翻页控件:多页时,鼠标悬停左右边缘才显示 --> |
|
|
|
<div |
|
|
|
v-show="currentPageCount > 1 && showLeftPagination" |
|
|
|
class="pagination-left" |
|
|
|
@click="canPrevPage && prevPage()" |
|
|
|
> |
|
|
|
<div class="pagination-btn pagination-btn-circle" :class="{ disabled: !canPrevPage }"> |
|
|
|
<i class="el-icon-arrow-left"></i> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
<div |
|
|
|
v-show="currentPageCount > 1 && showRightPagination" |
|
|
|
class="pagination-right" |
|
|
|
@click="canNextPage && nextPage()" |
|
|
|
> |
|
|
|
<div class="pagination-btn pagination-btn-circle" :class="{ disabled: !canNextPage }"> |
|
|
|
<i class="el-icon-arrow-right"></i> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
<!-- 页码指示:多页时可点击预览并删除页面 --> |
|
|
|
<el-popover |
|
|
|
v-if="currentPageCount > 1" |
|
|
|
ref="pagePreviewPopover" |
|
|
|
placement="top" |
|
|
|
width="320" |
|
|
|
trigger="click" |
|
|
|
popper-class="page-preview-popover" |
|
|
|
> |
|
|
|
<div slot="reference" class="pagination-indicator pagination-indicator-clickable"> |
|
|
|
{{ currentPageIndex + 1 }} / {{ currentPageCount }} |
|
|
|
</div> |
|
|
|
<div class="page-preview-list"> |
|
|
|
<div class="page-preview-title">本小标题下的所有页面</div> |
|
|
|
<div |
|
|
|
v-for="(page, idx) in allPagesForPreview" |
|
|
|
:key="idx" |
|
|
|
class="page-preview-item" |
|
|
|
:class="{ active: currentPageIndex === idx }" |
|
|
|
> |
|
|
|
<span class="page-preview-label" @click="goToPage(idx)"> |
|
|
|
第 {{ idx + 1 }} 页 |
|
|
|
<span class="page-preview-meta">{{ (page.icons && page.icons.length) || 0 }} 图标 · {{ (page.textBoxes && page.textBoxes.length) || 0 }} 文本框</span> |
|
|
|
</span> |
|
|
|
<el-button |
|
|
|
type="text" |
|
|
|
size="mini" |
|
|
|
icon="el-icon-delete" |
|
|
|
class="page-delete-btn" |
|
|
|
:disabled="currentPageCount <= 1" |
|
|
|
@click="deletePage(idx)" |
|
|
|
>删除</el-button> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</el-popover> |
|
|
|
<input |
|
|
|
ref="bgInput" |
|
|
|
type="file" |
|
|
|
@ -132,6 +180,8 @@ |
|
|
|
</template> |
|
|
|
|
|
|
|
<script> |
|
|
|
import request from '@/utils/request' |
|
|
|
import { resolveImageUrl } from '@/utils/imageUrl' |
|
|
|
import { createRollCallTextBoxes, createSubTitleTemplate, SUB_TITLE_TEMPLATE_NAMES } from './rollCallTemplate' |
|
|
|
import { getPageContent, createEmptySubContent, ensurePagesStructure } from './subContentPages' |
|
|
|
|
|
|
|
@ -182,7 +232,10 @@ export default { |
|
|
|
drawCurrentY: 0, |
|
|
|
formatToolbarBoxId: null, |
|
|
|
formatToolbarPosition: { x: 0, y: 0 }, |
|
|
|
focusedBoxId: null |
|
|
|
focusedBoxId: null, |
|
|
|
showLeftPagination: false, |
|
|
|
showRightPagination: false, |
|
|
|
_loadingFromData: false |
|
|
|
} |
|
|
|
}, |
|
|
|
computed: { |
|
|
|
@ -221,10 +274,16 @@ export default { |
|
|
|
canNextPage() { |
|
|
|
return this.currentPageIndex < this.currentPageCount - 1 |
|
|
|
}, |
|
|
|
allPagesForPreview() { |
|
|
|
const sc = this.currentSubContent |
|
|
|
if (!sc) return [] |
|
|
|
ensurePagesStructure(sc) |
|
|
|
return sc.pages || [] |
|
|
|
}, |
|
|
|
canvasStyle() { |
|
|
|
const style = {} |
|
|
|
if (this.backgroundImage) { |
|
|
|
style.backgroundImage = `url(${this.backgroundImage})` |
|
|
|
style.backgroundImage = `url(${resolveImageUrl(this.backgroundImage)})` |
|
|
|
style.backgroundSize = '100% 100%' |
|
|
|
style.backgroundPosition = 'center' |
|
|
|
style.backgroundRepeat = 'no-repeat' |
|
|
|
@ -252,6 +311,17 @@ export default { |
|
|
|
this.ensureSubTitleTemplate() |
|
|
|
}) |
|
|
|
}, |
|
|
|
subContents: { |
|
|
|
handler() { |
|
|
|
if (this._loadingFromData) return |
|
|
|
this._saveReqTimer && clearTimeout(this._saveReqTimer) |
|
|
|
this._saveReqTimer = setTimeout(() => { |
|
|
|
this._saveReqTimer = null |
|
|
|
this.$emit('save-request') |
|
|
|
}, 300) |
|
|
|
}, |
|
|
|
deep: true |
|
|
|
}, |
|
|
|
subTitles: { |
|
|
|
handler(titles) { |
|
|
|
while (this.subContents.length < (titles?.length || 0)) { |
|
|
|
@ -283,6 +353,8 @@ export default { |
|
|
|
this.$nextTick(() => this.syncTextBoxContent()) |
|
|
|
}, |
|
|
|
beforeDestroy() { |
|
|
|
this.syncFromDomToData() |
|
|
|
this.$emit('understanding-data', { subTitles: this.subTitles, subContents: this.getDataForSave() }) |
|
|
|
document.removeEventListener('keydown', this._keydownHandler) |
|
|
|
document.removeEventListener('click', this._clickHandler) |
|
|
|
}, |
|
|
|
@ -301,16 +373,24 @@ export default { |
|
|
|
this.$refs.iconImageInput.click() |
|
|
|
} |
|
|
|
}, |
|
|
|
handleBackgroundSelect(e) { |
|
|
|
async handleBackgroundSelect(e) { |
|
|
|
const file = e.target.files?.[0] |
|
|
|
if (!file) return |
|
|
|
const reader = new FileReader() |
|
|
|
reader.onload = (ev) => { |
|
|
|
this.$emit('background-change', ev.target.result) |
|
|
|
} |
|
|
|
reader.readAsDataURL(file) |
|
|
|
this.insertMode = null |
|
|
|
e.target.value = '' |
|
|
|
try { |
|
|
|
const formData = new FormData() |
|
|
|
formData.append('file', file) |
|
|
|
const res = await request.post('/common/upload', formData) |
|
|
|
if (res && (res.code === 200 || res.fileName)) { |
|
|
|
const path = res.fileName || res.url |
|
|
|
if (path) this.$emit('background-change', path) |
|
|
|
} else { |
|
|
|
this.$message.error(res?.msg || '背景图上传失败') |
|
|
|
} |
|
|
|
} catch (err) { |
|
|
|
this.$message.error(err?.response?.data?.msg || err?.message || '背景图上传失败') |
|
|
|
} |
|
|
|
}, |
|
|
|
handleIconImageSelect(e) { |
|
|
|
const file = e.target.files?.[0] |
|
|
|
@ -627,6 +707,83 @@ export default { |
|
|
|
sub.pages.push({ icons: [], textBoxes: boxes || [] }) |
|
|
|
sub.currentPageIndex = sub.pages.length - 1 |
|
|
|
}, |
|
|
|
loadFromData(data) { |
|
|
|
if (!data) return |
|
|
|
this._loadingFromData = true |
|
|
|
const raw = typeof data === 'string' ? (() => { try { return JSON.parse(data) } catch (_) { return null } })() : data |
|
|
|
if (!raw || !Array.isArray(raw.subContents)) { |
|
|
|
this._loadingFromData = false |
|
|
|
return |
|
|
|
} |
|
|
|
const normalized = raw.subContents.map(sc => { |
|
|
|
ensurePagesStructure(sc) |
|
|
|
const pages = (sc.pages || []).map(p => ({ |
|
|
|
icons: (p.icons || []).map(ic => ({ |
|
|
|
id: ic.id || genId(), |
|
|
|
x: Number(ic.x) || 0, |
|
|
|
y: Number(ic.y) || 0, |
|
|
|
width: Number(ic.width) || 60, |
|
|
|
height: Number(ic.height) || 60, |
|
|
|
rotation: Number(ic.rotation) || 0, |
|
|
|
src: ic.src || '' |
|
|
|
})), |
|
|
|
textBoxes: (p.textBoxes || []).map(t => ({ |
|
|
|
id: t.id || genId(), |
|
|
|
x: Number(t.x) || 0, |
|
|
|
y: Number(t.y) || 0, |
|
|
|
width: Number(t.width) || 100, |
|
|
|
height: Number(t.height) || 60, |
|
|
|
text: String(t.text || ''), |
|
|
|
placeholder: t.placeholder, |
|
|
|
rotation: Number(t.rotation) || 0, |
|
|
|
fontSize: Number(t.fontSize) || DEFAULT_FONT.fontSize, |
|
|
|
fontFamily: t.fontFamily || DEFAULT_FONT.fontFamily, |
|
|
|
color: t.color || DEFAULT_FONT.color, |
|
|
|
fontWeight: t.fontWeight |
|
|
|
})) |
|
|
|
})) |
|
|
|
return { pages: pages.length > 0 ? pages : [{ icons: [], textBoxes: [] }], currentPageIndex: sc.currentPageIndex || 0 } |
|
|
|
}) |
|
|
|
this.subContents.splice(0, this.subContents.length, ...normalized) |
|
|
|
this.$nextTick(() => { |
|
|
|
this._loadingFromData = false |
|
|
|
this.ensureRollCallTextBoxes() |
|
|
|
this.ensureSubTitleTemplate() |
|
|
|
}) |
|
|
|
}, |
|
|
|
getDataForSave() { |
|
|
|
return this.subContents.map(sc => { |
|
|
|
ensurePagesStructure(sc) |
|
|
|
return { |
|
|
|
pages: (sc.pages || []).map(p => ({ |
|
|
|
icons: (p.icons || []).map(i => ({ |
|
|
|
id: i.id, |
|
|
|
x: i.x, |
|
|
|
y: i.y, |
|
|
|
width: i.width, |
|
|
|
height: i.height, |
|
|
|
rotation: i.rotation || 0, |
|
|
|
src: i.src |
|
|
|
})), |
|
|
|
textBoxes: (p.textBoxes || []).map(t => ({ |
|
|
|
id: t.id, |
|
|
|
x: t.x, |
|
|
|
y: t.y, |
|
|
|
width: t.width, |
|
|
|
height: t.height, |
|
|
|
text: t.text || '', |
|
|
|
placeholder: t.placeholder, |
|
|
|
rotation: t.rotation || 0, |
|
|
|
fontSize: t.fontSize, |
|
|
|
fontFamily: t.fontFamily, |
|
|
|
color: t.color, |
|
|
|
fontWeight: t.fontWeight |
|
|
|
})) |
|
|
|
})), |
|
|
|
currentPageIndex: sc.currentPageIndex || 0 |
|
|
|
} |
|
|
|
}) |
|
|
|
}, |
|
|
|
prevPage() { |
|
|
|
const sub = this.currentSubContent |
|
|
|
if (!sub || !this.canPrevPage) return |
|
|
|
@ -634,6 +791,20 @@ export default { |
|
|
|
sub.currentPageIndex = Math.max(0, (sub.currentPageIndex || 0) - 1) |
|
|
|
this.selectedId = null |
|
|
|
}, |
|
|
|
onPaginationAreaMouseMove(e) { |
|
|
|
if (this.currentPageCount <= 1) return |
|
|
|
const el = this.$el |
|
|
|
if (!el) return |
|
|
|
const rect = el.getBoundingClientRect() |
|
|
|
const x = e.clientX - rect.left |
|
|
|
const edgeZone = 80 |
|
|
|
this.showLeftPagination = x < edgeZone |
|
|
|
this.showRightPagination = x > rect.width - edgeZone |
|
|
|
}, |
|
|
|
onPaginationAreaMouseLeave() { |
|
|
|
this.showLeftPagination = false |
|
|
|
this.showRightPagination = false |
|
|
|
}, |
|
|
|
nextPage() { |
|
|
|
const sub = this.currentSubContent |
|
|
|
if (!sub || !this.canNextPage) return |
|
|
|
@ -641,6 +812,27 @@ export default { |
|
|
|
sub.currentPageIndex = Math.min((sub.pages?.length || 1) - 1, (sub.currentPageIndex || 0) + 1) |
|
|
|
this.selectedId = null |
|
|
|
}, |
|
|
|
goToPage(idx) { |
|
|
|
const sub = this.currentSubContent |
|
|
|
if (!sub) return |
|
|
|
ensurePagesStructure(sub) |
|
|
|
sub.currentPageIndex = Math.max(0, Math.min(idx, (sub.pages?.length || 1) - 1)) |
|
|
|
this.selectedId = null |
|
|
|
this.$refs.pagePreviewPopover && this.$refs.pagePreviewPopover.doClose() |
|
|
|
}, |
|
|
|
deletePage(idx) { |
|
|
|
const sub = this.currentSubContent |
|
|
|
if (!sub || this.currentPageCount <= 1) return |
|
|
|
ensurePagesStructure(sub) |
|
|
|
const pages = sub.pages |
|
|
|
if (!pages || idx < 0 || idx >= pages.length) return |
|
|
|
pages.splice(idx, 1) |
|
|
|
const cur = sub.currentPageIndex || 0 |
|
|
|
if (cur >= pages.length) sub.currentPageIndex = Math.max(0, pages.length - 1) |
|
|
|
else if (idx < cur) sub.currentPageIndex = cur - 1 |
|
|
|
this.selectedId = null |
|
|
|
this.$refs.pagePreviewPopover && this.$refs.pagePreviewPopover.doClose() |
|
|
|
}, |
|
|
|
deleteIcon(id) { |
|
|
|
const page = this.currentPage |
|
|
|
if (!page) return |
|
|
|
@ -659,6 +851,15 @@ export default { |
|
|
|
} |
|
|
|
}) |
|
|
|
}, |
|
|
|
syncFromDomToData() { |
|
|
|
if (!this.$refs.canvas) return |
|
|
|
const inputs = this.$refs.canvas.querySelectorAll('.textbox-input') |
|
|
|
const boxes = this.currentTextBoxes |
|
|
|
boxes.forEach((box, idx) => { |
|
|
|
const el = inputs[idx] |
|
|
|
if (el) box.text = (el.innerText || '').trim() |
|
|
|
}) |
|
|
|
}, |
|
|
|
deleteTextBox(id) { |
|
|
|
const page = this.currentPage |
|
|
|
if (!page) return |
|
|
|
@ -701,26 +902,88 @@ export default { |
|
|
|
</script> |
|
|
|
|
|
|
|
<style scoped> |
|
|
|
.pagination-bar { |
|
|
|
.pagination-left, |
|
|
|
.pagination-right { |
|
|
|
position: absolute; |
|
|
|
top: 0; |
|
|
|
bottom: 0; |
|
|
|
width: 48px; |
|
|
|
display: flex; |
|
|
|
align-items: center; |
|
|
|
justify-content: center; |
|
|
|
z-index: 100; |
|
|
|
cursor: pointer; |
|
|
|
pointer-events: auto; |
|
|
|
} |
|
|
|
.pagination-left { |
|
|
|
left: 0; |
|
|
|
} |
|
|
|
.pagination-right { |
|
|
|
right: 0; |
|
|
|
} |
|
|
|
.pagination-btn { |
|
|
|
display: flex; |
|
|
|
flex-direction: column; |
|
|
|
align-items: center; |
|
|
|
gap: 4px; |
|
|
|
padding: 12px 8px; |
|
|
|
background: rgba(255, 255, 255, 0.9); |
|
|
|
border: none; |
|
|
|
border-radius: 8px; |
|
|
|
font-size: 12px; |
|
|
|
color: #008aff; |
|
|
|
transition: all 0.2s; |
|
|
|
} |
|
|
|
.pagination-btn:hover:not(.disabled) { |
|
|
|
background: rgba(0, 138, 255, 0.1); |
|
|
|
border-color: rgba(0, 138, 255, 0.3); |
|
|
|
} |
|
|
|
.pagination-btn.disabled { |
|
|
|
color: #cbd5e1; |
|
|
|
cursor: not-allowed; |
|
|
|
opacity: 0.6; |
|
|
|
} |
|
|
|
.pagination-btn i { |
|
|
|
font-size: 18px; |
|
|
|
} |
|
|
|
.pagination-btn-circle { |
|
|
|
width: 36px; |
|
|
|
height: 36px; |
|
|
|
padding: 0; |
|
|
|
border-radius: 50%; |
|
|
|
flex-direction: row; |
|
|
|
justify-content: center; |
|
|
|
gap: 16px; |
|
|
|
padding: 8px 0; |
|
|
|
background: rgba(0, 138, 255, 0.06); |
|
|
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06); |
|
|
|
flex-shrink: 0; |
|
|
|
} |
|
|
|
.pagination-bar .page-indicator { |
|
|
|
font-size: 13px; |
|
|
|
color: #64748b; |
|
|
|
min-width: 60px; |
|
|
|
text-align: center; |
|
|
|
} |
|
|
|
.pagination-btn-circle i { |
|
|
|
font-size: 16px; |
|
|
|
} |
|
|
|
.pagination-indicator, |
|
|
|
.pagination-indicator-clickable { |
|
|
|
position: absolute; |
|
|
|
bottom: 12px; |
|
|
|
left: 50%; |
|
|
|
transform: translateX(-50%); |
|
|
|
padding: 4px 12px; |
|
|
|
background: rgba(255, 255, 255, 0.9); |
|
|
|
border: 1px solid rgba(0, 0, 0, 0.08); |
|
|
|
border-radius: 12px; |
|
|
|
font-size: 12px; |
|
|
|
color: #666; |
|
|
|
z-index: 99; |
|
|
|
} |
|
|
|
.pagination-indicator-clickable { |
|
|
|
cursor: pointer; |
|
|
|
} |
|
|
|
.pagination-indicator-clickable:hover { |
|
|
|
background: rgba(255, 255, 255, 1); |
|
|
|
border-color: rgba(0, 138, 255, 0.3); |
|
|
|
color: #008aff; |
|
|
|
} |
|
|
|
.understanding-step-content { |
|
|
|
display: flex; |
|
|
|
flex-direction: column; |
|
|
|
height: 100%; |
|
|
|
position: relative; |
|
|
|
} |
|
|
|
|
|
|
|
.task-canvas { |
|
|
|
@ -980,3 +1243,61 @@ export default { |
|
|
|
pointer-events: none; |
|
|
|
} |
|
|
|
</style> |
|
|
|
|
|
|
|
<style lang="scss"> |
|
|
|
/* 页码预览弹窗(popper 在 body,需非 scoped) */ |
|
|
|
.page-preview-popover { |
|
|
|
.page-preview-list { |
|
|
|
max-height: 280px; |
|
|
|
overflow-y: auto; |
|
|
|
} |
|
|
|
.page-preview-title { |
|
|
|
font-size: 13px; |
|
|
|
color: #64748b; |
|
|
|
margin-bottom: 10px; |
|
|
|
padding-bottom: 8px; |
|
|
|
border-bottom: 1px solid #eee; |
|
|
|
} |
|
|
|
.page-preview-item { |
|
|
|
display: flex; |
|
|
|
align-items: center; |
|
|
|
justify-content: space-between; |
|
|
|
padding: 8px 10px; |
|
|
|
border-radius: 6px; |
|
|
|
margin-bottom: 4px; |
|
|
|
transition: background 0.2s; |
|
|
|
} |
|
|
|
.page-preview-item:hover { |
|
|
|
background: #f1f5f9; |
|
|
|
} |
|
|
|
.page-preview-item.active { |
|
|
|
background: rgba(0, 138, 255, 0.1); |
|
|
|
color: #008aff; |
|
|
|
} |
|
|
|
.page-preview-label { |
|
|
|
flex: 1; |
|
|
|
cursor: pointer; |
|
|
|
font-size: 13px; |
|
|
|
} |
|
|
|
.page-preview-meta { |
|
|
|
margin-left: 8px; |
|
|
|
font-size: 11px; |
|
|
|
color: #94a3b8; |
|
|
|
} |
|
|
|
.page-preview-item.active .page-preview-meta { |
|
|
|
color: rgba(0, 138, 255, 0.7); |
|
|
|
} |
|
|
|
.page-delete-btn { |
|
|
|
padding: 4px 8px; |
|
|
|
color: #f56c6c; |
|
|
|
} |
|
|
|
.page-delete-btn:hover:not(:disabled) { |
|
|
|
color: #f56c6c; |
|
|
|
background: rgba(245, 108, 108, 0.1); |
|
|
|
} |
|
|
|
.page-delete-btn:disabled { |
|
|
|
color: #cbd5e1; |
|
|
|
cursor: not-allowed; |
|
|
|
} |
|
|
|
} |
|
|
|
</style> |
|
|
|
|