|
|
|
@ -64,12 +64,23 @@ |
|
|
|
@mousedown="onTextBoxMouseDown(box, $event)" |
|
|
|
> |
|
|
|
<div class="textbox-drag-bar" @mousedown.stop="selectElement(box.id, $event)"></div> |
|
|
|
<!-- Office 风格格式工具栏:选中时显示在文本框上方 --> |
|
|
|
<div v-if="selectedId === box.id" class="textbox-format-toolbar" @mousedown.stop> |
|
|
|
<el-select v-model="box.fontFamily" size="mini" placeholder="字体" class="format-font" @change="debouncedSave"> |
|
|
|
<el-option v-for="f in fontOptions" :key="f" :label="f" :value="f" /> |
|
|
|
</el-select> |
|
|
|
<el-select v-model="box.fontSize" size="mini" placeholder="字号" class="format-size" @change="debouncedSave"> |
|
|
|
<el-option v-for="s in fontSizeOptions" :key="s" :label="String(s)" :value="s" /> |
|
|
|
</el-select> |
|
|
|
<el-color-picker v-model="box.color" class="format-color" @change="debouncedSave" /> |
|
|
|
</div> |
|
|
|
<div |
|
|
|
class="textbox-input" |
|
|
|
contenteditable="true" |
|
|
|
:style="getTextBoxInputStyle(box)" |
|
|
|
@blur="box.text = $event.target.innerText" |
|
|
|
@mousedown.stop="selectedId = box.id" |
|
|
|
>{{ box.text }}</div> |
|
|
|
></div> |
|
|
|
<div class="textbox-resize-handle" v-if="selectedId === box.id"> |
|
|
|
<div |
|
|
|
v-for="pos in resizeHandles" |
|
|
|
@ -96,14 +107,24 @@ |
|
|
|
</template> |
|
|
|
|
|
|
|
<script> |
|
|
|
import { getTaskPageData, saveTaskPageData } from '@/api/system/routes' |
|
|
|
|
|
|
|
let idCounter = 0 |
|
|
|
function genId() { |
|
|
|
return 'el_' + (++idCounter) + '_' + Date.now() |
|
|
|
} |
|
|
|
|
|
|
|
const FONT_OPTIONS = ['宋体', '黑体', '微软雅黑', '楷体', '仿宋', 'Arial', 'Times New Roman', 'Verdana', 'Georgia'] |
|
|
|
const FONT_SIZE_OPTIONS = [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72] |
|
|
|
const DEFAULT_FONT = { fontSize: 14, fontFamily: '微软雅黑', color: '#333333' } |
|
|
|
|
|
|
|
export default { |
|
|
|
name: 'TaskPageContent', |
|
|
|
props: { |
|
|
|
roomId: { |
|
|
|
type: [Number, String], |
|
|
|
default: null |
|
|
|
}, |
|
|
|
backgroundImage: { |
|
|
|
type: String, |
|
|
|
default: null |
|
|
|
@ -111,6 +132,8 @@ export default { |
|
|
|
}, |
|
|
|
data() { |
|
|
|
return { |
|
|
|
fontOptions: FONT_OPTIONS, |
|
|
|
fontSizeOptions: FONT_SIZE_OPTIONS, |
|
|
|
insertMode: null, |
|
|
|
pendingIconImage: null, |
|
|
|
icons: [], |
|
|
|
@ -127,12 +150,27 @@ export default { |
|
|
|
drawCurrentY: 0 |
|
|
|
} |
|
|
|
}, |
|
|
|
watch: { |
|
|
|
backgroundImage: { handler() { this.debouncedSave() }, immediate: false }, |
|
|
|
icons: { handler() { this.debouncedSave() }, deep: true }, |
|
|
|
textBoxes: { handler() { this.debouncedSave() }, deep: true } |
|
|
|
}, |
|
|
|
mounted() { |
|
|
|
this._keydownHandler = (e) => this.onKeydown(e) |
|
|
|
document.addEventListener('keydown', this._keydownHandler) |
|
|
|
this.loadFromRedis() |
|
|
|
this.$nextTick(() => this.syncTextBoxContent()) |
|
|
|
}, |
|
|
|
updated() { |
|
|
|
this.$nextTick(() => this.syncTextBoxContent()) |
|
|
|
}, |
|
|
|
beforeDestroy() { |
|
|
|
document.removeEventListener('keydown', this._keydownHandler) |
|
|
|
if (this._saveTimer) { |
|
|
|
clearTimeout(this._saveTimer) |
|
|
|
this._saveTimer = null |
|
|
|
} |
|
|
|
this.saveToRedis() |
|
|
|
}, |
|
|
|
computed: { |
|
|
|
canvasStyle() { |
|
|
|
@ -248,7 +286,10 @@ export default { |
|
|
|
width: w, |
|
|
|
height: h, |
|
|
|
text: '', |
|
|
|
rotation: 0 |
|
|
|
rotation: 0, |
|
|
|
fontSize: DEFAULT_FONT.fontSize, |
|
|
|
fontFamily: DEFAULT_FONT.fontFamily, |
|
|
|
color: DEFAULT_FONT.color |
|
|
|
}) |
|
|
|
this.drawingTextBox = false |
|
|
|
this.insertMode = null |
|
|
|
@ -419,11 +460,114 @@ export default { |
|
|
|
transform: `rotate(${box.rotation || 0}deg)` |
|
|
|
} |
|
|
|
}, |
|
|
|
getTextBoxInputStyle(box) { |
|
|
|
return { |
|
|
|
fontSize: (box.fontSize || DEFAULT_FONT.fontSize) + 'px', |
|
|
|
fontFamily: box.fontFamily || DEFAULT_FONT.fontFamily, |
|
|
|
color: box.color || DEFAULT_FONT.color |
|
|
|
} |
|
|
|
}, |
|
|
|
deleteIcon(id) { |
|
|
|
const idx = this.icons.findIndex(i => i.id === id) |
|
|
|
if (idx >= 0) this.icons.splice(idx, 1) |
|
|
|
if (this.selectedId === id) this.selectedId = null |
|
|
|
}, |
|
|
|
syncTextBoxContent() { |
|
|
|
if (!this.$refs.canvas) return |
|
|
|
const inputs = this.$refs.canvas.querySelectorAll('.textbox-input') |
|
|
|
this.textBoxes.forEach((box, idx) => { |
|
|
|
const el = inputs[idx] |
|
|
|
if (el && document.activeElement !== el && el.innerText !== box.text) { |
|
|
|
el.innerText = box.text |
|
|
|
} |
|
|
|
}) |
|
|
|
}, |
|
|
|
async loadFromRedis() { |
|
|
|
if (this.roomId == null) return |
|
|
|
try { |
|
|
|
const res = await getTaskPageData({ roomId: this.roomId }) |
|
|
|
let data = res && res.data |
|
|
|
if (!data) return |
|
|
|
if (typeof data === 'string') { |
|
|
|
try { |
|
|
|
data = JSON.parse(data) |
|
|
|
} catch (_) { |
|
|
|
return |
|
|
|
} |
|
|
|
} |
|
|
|
const raw = data |
|
|
|
if (raw.icons && Array.isArray(raw.icons)) { |
|
|
|
this.icons = raw.icons.map(i => ({ |
|
|
|
id: i.id || genId(), |
|
|
|
x: Number(i.x) || 0, |
|
|
|
y: Number(i.y) || 0, |
|
|
|
width: Number(i.width) || 60, |
|
|
|
height: Number(i.height) || 60, |
|
|
|
rotation: Number(i.rotation) || 0, |
|
|
|
src: i.src || '' |
|
|
|
})) |
|
|
|
} |
|
|
|
if (raw.textBoxes && Array.isArray(raw.textBoxes)) { |
|
|
|
this.textBoxes = raw.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 || ''), |
|
|
|
rotation: Number(t.rotation) || 0, |
|
|
|
fontSize: Number(t.fontSize) || DEFAULT_FONT.fontSize, |
|
|
|
fontFamily: t.fontFamily || DEFAULT_FONT.fontFamily, |
|
|
|
color: t.color || DEFAULT_FONT.color |
|
|
|
})) |
|
|
|
} |
|
|
|
if (raw.background) { |
|
|
|
this.$emit('background-change', raw.background) |
|
|
|
} |
|
|
|
} catch (e) { |
|
|
|
console.warn('TaskPage loadFromRedis failed:', e) |
|
|
|
} |
|
|
|
}, |
|
|
|
saveToRedis() { |
|
|
|
if (this.roomId == null) return |
|
|
|
const payload = { |
|
|
|
background: this.backgroundImage || null, |
|
|
|
icons: this.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: this.textBoxes.map(t => ({ |
|
|
|
id: t.id, |
|
|
|
x: t.x, |
|
|
|
y: t.y, |
|
|
|
width: t.width, |
|
|
|
height: t.height, |
|
|
|
text: t.text || '', |
|
|
|
rotation: t.rotation || 0, |
|
|
|
fontSize: t.fontSize || DEFAULT_FONT.fontSize, |
|
|
|
fontFamily: t.fontFamily || DEFAULT_FONT.fontFamily, |
|
|
|
color: t.color || DEFAULT_FONT.color |
|
|
|
})) |
|
|
|
} |
|
|
|
saveTaskPageData({ |
|
|
|
roomId: this.roomId, |
|
|
|
data: JSON.stringify(payload) |
|
|
|
}).catch(e => { |
|
|
|
console.warn('TaskPage saveToRedis failed:', e) |
|
|
|
}) |
|
|
|
}, |
|
|
|
debouncedSave() { |
|
|
|
if (this._saveTimer) clearTimeout(this._saveTimer) |
|
|
|
this._saveTimer = setTimeout(() => { |
|
|
|
this._saveTimer = null |
|
|
|
this.saveToRedis() |
|
|
|
}, 300) |
|
|
|
}, |
|
|
|
deleteTextBox(id) { |
|
|
|
const idx = this.textBoxes.findIndex(t => t.id === id) |
|
|
|
if (idx >= 0) this.textBoxes.splice(idx, 1) |
|
|
|
@ -649,6 +793,37 @@ export default { |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* Office 风格格式工具栏:选中文本框时显示在上方 */ |
|
|
|
.textbox-format-toolbar { |
|
|
|
position: absolute; |
|
|
|
bottom: 100%; |
|
|
|
left: 0; |
|
|
|
margin-bottom: 4px; |
|
|
|
display: flex; |
|
|
|
align-items: center; |
|
|
|
gap: 6px; |
|
|
|
padding: 4px 8px; |
|
|
|
background: #fff; |
|
|
|
border: 1px solid #ddd; |
|
|
|
border-radius: 6px; |
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|
|
|
z-index: 20; |
|
|
|
} |
|
|
|
.format-font { |
|
|
|
width: 120px; |
|
|
|
} |
|
|
|
.format-size { |
|
|
|
width: 70px; |
|
|
|
} |
|
|
|
.format-color { |
|
|
|
vertical-align: middle; |
|
|
|
} |
|
|
|
.format-color ::v-deep .el-color-picker__trigger { |
|
|
|
width: 24px; |
|
|
|
height: 24px; |
|
|
|
padding: 2px; |
|
|
|
} |
|
|
|
|
|
|
|
.textbox-drag-bar { |
|
|
|
position: absolute; |
|
|
|
top: 0; |
|
|
|
|