Browse Source

六步法前端

ctw
cuitw 4 weeks ago
parent
commit
9032009bea
  1. 389
      ruoyi-ui/src/views/childRoom/SixStepsOverlay.vue
  2. 252
      ruoyi-ui/src/views/childRoom/TaskPageContent.vue
  3. 313
      ruoyi-ui/src/views/childRoom/UnderstandingStepContent.vue

389
ruoyi-ui/src/views/childRoom/SixStepsOverlay.vue

@ -16,6 +16,7 @@
class="header-sub-title"
:class="{ active: activeUnderstandingSubIndex === idx }"
@click="activeUnderstandingSubIndex = idx"
@contextmenu.prevent="onSubTitleContextMenu('understanding', idx, $event)"
>
{{ sub }}
</div>
@ -24,9 +25,15 @@
<i class="el-icon-plus"></i> 插入
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="subTitle">
<i class="el-icon-menu"></i> 小标题
</el-dropdown-item>
<el-dropdown-item command="background">
<i class="el-icon-picture-outline"></i> 背景
</el-dropdown-item>
<el-dropdown-item command="removeBackground" :disabled="!sixStepsSharedBackground">
<i class="el-icon-delete"></i> 删除背景
</el-dropdown-item>
<el-dropdown-item command="icon">
<i class="el-icon-s-opportunity"></i> 图标
</el-dropdown-item>
@ -36,15 +43,32 @@
</el-dropdown-menu>
</el-dropdown>
</template>
<!-- 任务页插入按钮 -->
<el-dropdown v-else-if="overrideTitle === '任务'" trigger="click" @command="handleInsertCommand" class="header-insert">
<!-- 后五步判断规划准备执行评估小标题 + 插入按钮 -->
<template v-else-if="currentStepIndex >= 1 && currentStepIndex <= 5">
<div
v-for="(sub, idx) in getStepContent(currentStepIndex).subTitles"
:key="idx"
class="header-sub-title"
:class="{ active: activeStepSubIndex === idx }"
@click="activeStepSubIndex = idx"
@contextmenu.prevent="onSubTitleContextMenu('step', idx, $event)"
>
{{ sub }}
</div>
<el-dropdown trigger="click" @command="handleStepInsertCommand" class="header-insert">
<el-button size="small" type="primary" plain>
<i class="el-icon-plus"></i> 插入
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="subTitle">
<i class="el-icon-menu"></i> 小标题
</el-dropdown-item>
<el-dropdown-item command="background">
<i class="el-icon-picture-outline"></i> 背景
</el-dropdown-item>
<el-dropdown-item command="removeBackground" :disabled="!sixStepsSharedBackground">
<i class="el-icon-delete"></i> 删除背景
</el-dropdown-item>
<el-dropdown-item command="icon">
<i class="el-icon-s-opportunity"></i> 图标
</el-dropdown-item>
@ -53,17 +77,59 @@
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
<!-- 任务页小标题 + 插入按钮 -->
<template v-else-if="overrideTitle === '任务'">
<div
v-for="(sub, idx) in taskSubTitles"
:key="idx"
class="header-sub-title"
:class="{ active: activeTaskSubIndex === idx }"
@click="activeTaskSubIndex = idx"
@contextmenu.prevent="onSubTitleContextMenu('task', idx, $event)"
>
{{ sub }}
</div>
<el-dropdown trigger="click" @command="handleInsertCommand" class="header-insert">
<el-button size="small" type="primary" plain>
<i class="el-icon-plus"></i> 插入
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="subTitle">
<i class="el-icon-menu"></i> 小标题
</el-dropdown-item>
<el-dropdown-item command="background">
<i class="el-icon-picture-outline"></i> 背景
</el-dropdown-item>
<el-dropdown-item command="removeBackground" :disabled="!taskPageBackground">
<i class="el-icon-delete"></i> 删除背景
</el-dropdown-item>
<el-dropdown-item command="icon">
<i class="el-icon-s-opportunity"></i> 图标
</el-dropdown-item>
<el-dropdown-item command="textbox">
<i class="el-icon-edit-outline"></i> 文本框
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
</div>
</div>
<div class="overlay-body" :style="overlayBodyStyle">
<div class="overlay-content" :class="{ 'task-page': overrideTitle === '任务', 'understanding-page': currentStepIndex === 0 && !overrideTitle }">
<div class="overlay-content" :class="{
'task-page': overrideTitle === '任务',
'understanding-page': currentStepIndex === 0 && !overrideTitle,
'step-page': currentStepIndex >= 1 && currentStepIndex <= 5
}">
<!-- 任务页插入工具栏 + 可编辑画布 -->
<task-page-content
v-if="overrideTitle === '任务'"
ref="taskPage"
:room-id="roomId"
:background-image="taskPageBackground"
:task-sub-titles="taskSubTitles"
@background-change="taskPageBackground = $event"
@task-sub-titles-change="taskSubTitles = $event"
class="task-page-body"
/>
<!-- 理解步骤4 子标题 + 可编辑画布 -->
@ -72,10 +138,21 @@
ref="understandingStep"
:background-image="sixStepsSharedBackground"
:active-sub-index="activeUnderstandingSubIndex"
:sub-titles="understandingSubTitles"
@background-change="sixStepsSharedBackground = $event"
class="understanding-page-body"
/>
<!-- 判断规划准备执行评估使用共享背景 -->
<!-- 判断规划准备执行评估可编辑画布六步共享背景 -->
<step-canvas-content
v-else-if="currentStepIndex >= 1 && currentStepIndex <= 5"
ref="stepCanvas"
:key="currentStepIndex"
:content="getStepContent(currentStepIndex)"
:active-sub-index="activeStepSubIndex"
:background-image="sixStepsSharedBackground"
@background-change="sixStepsSharedBackground = $event"
class="step-canvas-body"
/>
<div v-else class="blank-placeholder">
<i class="el-icon-document"></i>
<p>{{ currentStepTitle }} - 内容区域</p>
@ -84,6 +161,27 @@
</div>
</div>
</div>
<!-- 小标题右键菜单 -->
<div
v-if="subTitleContextMenu.visible"
ref="subTitleContextMenuRef"
class="sub-title-context-menu"
:style="{ left: subTitleContextMenu.x + 'px', top: subTitleContextMenu.y + 'px' }"
@click.stop
>
<div class="context-menu-item" @click="editSubTitle">
<i class="el-icon-edit"></i>
<span>编辑</span>
</div>
<div class="context-menu-item" @click="insertNewPage">
<i class="el-icon-document-add"></i>
<span>插入新的一页</span>
</div>
<div class="context-menu-item context-menu-item-danger" @click="deleteSubTitle">
<i class="el-icon-delete"></i>
<span>删除</span>
</div>
</div>
<!-- 右侧栏任务 + 1-6 垂直排列 -->
<div class="overlay-sidebar">
<div class="sidebar-steps">
@ -112,10 +210,21 @@
<script>
import TaskPageContent from './TaskPageContent.vue'
import UnderstandingStepContent from './UnderstandingStepContent.vue'
import StepCanvasContent from './StepCanvasContent.vue'
import {
createRollCallTextBoxes,
createSubTitleTemplate,
createIntentBriefingTemplate,
createTaskPlanningTemplate,
createSimpleTitleTemplate,
SUB_TITLE_TEMPLATE_NAMES,
SIMPLE_TITLE_NAMES
} from './rollCallTemplate'
import { ensurePagesStructure, createEmptySubContent } from './subContentPages'
export default {
name: 'SixStepsOverlay',
components: { TaskPageContent, UnderstandingStepContent },
components: { TaskPageContent, UnderstandingStepContent, StepCanvasContent },
props: {
roomId: {
type: [Number, String],
@ -155,10 +264,35 @@ export default {
return {
taskPageBackground: null,
sixStepsSharedBackground: null,
understandingSubTitles: ['点名', '接收解析任务', 'XXXX', 'XXXX'],
activeUnderstandingSubIndex: 0
understandingSubTitles: ['点名', '任务目标', '自身任务', '对接任务'],
activeUnderstandingSubIndex: 0,
taskSubTitles: [],
activeTaskSubIndex: 0,
activeStepSubIndex: 0,
stepContents: {},
subTitleContextMenu: {
visible: false,
x: 0,
y: 0,
target: null,
index: -1
}
}
},
watch: {
currentStepIndex() {
this.activeStepSubIndex = 0
},
overrideTitle() {
this.activeTaskSubIndex = 0
}
},
mounted() {
document.addEventListener('click', this.onDocumentClickForSubTitleMenu)
},
beforeDestroy() {
document.removeEventListener('click', this.onDocumentClickForSubTitleMenu)
},
computed: {
currentStepTitle() {
if (this.overrideTitle) return this.overrideTitle
@ -166,14 +300,7 @@ export default {
},
overlayBodyStyle() {
if (this.overrideTitle === '任务') return {}
if (this.currentStepIndex >= 1 && this.sixStepsSharedBackground) {
return {
backgroundImage: `url(${this.sixStepsSharedBackground})`,
backgroundSize: '100% 100%',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
}
}
if (this.currentStepIndex >= 1 && this.currentStepIndex <= 5) return {}
return {}
}
},
@ -181,15 +308,209 @@ export default {
close() {
this.$emit('close')
},
getStepContent(stepIndex) {
let step = this.stepContents[stepIndex]
if (step) {
if (!step.subContents && step.subTitles) {
step.subContents = step.subTitles.map(() => createEmptySubContent())
if (step.icons?.length || step.textBoxes?.length) {
step.subContents[0] = createEmptySubContent()
step.subContents[0].pages[0] = { icons: step.icons || [], textBoxes: step.textBoxes || [] }
}
}
return step
}
{
const defaultSubTitles = stepIndex === 1
? ['相关规定', '敌情', '意图通报', '威胁判断']
: stepIndex === 2
? ['任务规划', '职责分工', '点名', '第一次进度检查', '点名', '第二次进度检查', '点名', '产品生成']
: stepIndex === 3
? ['点名', '集体协同']
: stepIndex === 4
? ['任务执行']
: stepIndex === 5
? ['评估']
: []
this.$set(this.stepContents, stepIndex, {
subTitles: defaultSubTitles,
subContents: defaultSubTitles.map(() => createEmptySubContent())
})
return this.stepContents[stepIndex]
}
},
async addSubTitle(target) {
try {
const { value } = await this.$prompt('请输入小标题名称', '插入小标题', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: '新标题',
inputPattern: /\S+/,
inputErrorMessage: '请输入小标题名称'
})
if (value && value.trim()) {
if (target === 'understanding') {
this.understandingSubTitles.push(value.trim())
} else if (target === 'task') {
this.taskSubTitles.push(value.trim())
} else if (target === 'step') {
const step = this.getStepContent(this.currentStepIndex)
step.subTitles.push(value.trim())
step.subContents.push(createEmptySubContent())
}
}
} catch (_) {}
},
handleInsertCommand(cmd) {
if (cmd === 'subTitle') {
this.addSubTitle('task')
return
}
if (this.$refs.taskPage) {
this.$refs.taskPage.handleInsertCommand(cmd)
}
},
handleUnderstandingInsertCommand(cmd) {
if (cmd === 'subTitle') {
this.addSubTitle('understanding')
return
}
if (this.$refs.understandingStep) {
this.$refs.understandingStep.handleInsertCommand(cmd)
}
},
handleStepInsertCommand(cmd) {
if (cmd === 'subTitle') {
this.addSubTitle('step')
return
}
if (this.$refs.stepCanvas) {
this.$refs.stepCanvas.handleInsertCommand(cmd)
}
},
onSubTitleContextMenu(target, index, event) {
const arr = target === 'understanding' ? this.understandingSubTitles
: target === 'task' ? this.taskSubTitles
: this.getStepContent(this.currentStepIndex).subTitles
const sourceName = arr && arr[index] ? arr[index] : ''
this.subTitleContextMenu = {
visible: true,
x: event.clientX,
y: event.clientY,
target,
index,
sourceName
}
},
closeSubTitleContextMenu() {
this.subTitleContextMenu.visible = false
},
onDocumentClickForSubTitleMenu(e) {
if (!this.subTitleContextMenu.visible) return
const el = this.$refs.subTitleContextMenuRef
if (el && el.contains(e.target)) return
this.closeSubTitleContextMenu()
},
getSubTitleArray(target) {
const t = target ?? this.subTitleContextMenu?.target
if (t === 'understanding') return this.understandingSubTitles
if (t === 'task') return this.taskSubTitles
if (t === 'step') return this.getStepContent(this.currentStepIndex).subTitles
return []
},
async editSubTitle() {
const { target, index } = this.subTitleContextMenu
const arr = this.getSubTitleArray(target)
this.closeSubTitleContextMenu()
if (index < 0 || index >= arr.length) return
try {
const { value } = await this.$prompt('请输入小标题名称', '编辑小标题', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: arr[index],
inputPattern: /\S+/,
inputErrorMessage: '请输入小标题名称'
})
if (value && value.trim()) {
this.$set(arr, index, value.trim())
}
} catch (_) {}
},
getTemplateBoxes(sourceName, newName, canvas) {
const w = canvas ? canvas.offsetWidth : 800
const h = canvas ? canvas.offsetHeight : 500
if (sourceName === '点名') return createRollCallTextBoxes(w, h)
if (sourceName === '意图通报') return createIntentBriefingTemplate(w, h)
if (sourceName === '任务规划') return createTaskPlanningTemplate(w, h)
if (SIMPLE_TITLE_NAMES.includes(sourceName)) return createSimpleTitleTemplate(newName, w, h)
if (SUB_TITLE_TEMPLATE_NAMES.includes(sourceName)) return createSubTitleTemplate(newName, w, h)
return []
},
async insertNewPage() {
const { target, index, sourceName } = this.subTitleContextMenu
this.closeSubTitleContextMenu()
if (!sourceName) return
const subIdx = index
if (target === 'task') {
this.$message.info('任务页暂不支持多页')
return
}
if (target === 'understanding') {
this.$nextTick(() => {
const canvas = this.$refs.understandingStep?.$refs?.canvas
const boxes = this.getTemplateBoxes(sourceName, sourceName, canvas)
if (this.$refs.understandingStep) {
this.$refs.understandingStep.addPageToSubIndex(subIdx, boxes)
}
})
} else if (target === 'step') {
const step = this.getStepContent(this.currentStepIndex)
const sub = step.subContents[subIdx]
if (!sub) return
ensurePagesStructure(sub)
const canvas = this.$refs.stepCanvas?.$refs?.canvas
const boxes = this.getTemplateBoxes(sourceName, sourceName, canvas)
sub.pages.push({ icons: [], textBoxes: [...boxes] })
sub.currentPageIndex = sub.pages.length - 1
}
},
deleteSubTitle() {
const { target, index } = { ...this.subTitleContextMenu }
this.closeSubTitleContextMenu()
const arr = this.getSubTitleArray(target)
if (index < 0 || index >= arr.length) return
arr.splice(index, 1)
if (target === 'step') {
const step = this.getStepContent(this.currentStepIndex)
if (step.subContents && step.subContents.length > index) {
step.subContents.splice(index, 1)
}
}
if (target === 'understanding') {
if (this.activeUnderstandingSubIndex >= arr.length && arr.length > 0) {
this.activeUnderstandingSubIndex = arr.length - 1
} else if (arr.length === 0) {
this.activeUnderstandingSubIndex = 0
} else if (this.activeUnderstandingSubIndex > index) {
this.activeUnderstandingSubIndex--
}
} else if (target === 'task') {
if (this.activeTaskSubIndex >= arr.length && arr.length > 0) {
this.activeTaskSubIndex = arr.length - 1
} else if (arr.length === 0) {
this.activeTaskSubIndex = 0
} else if (this.activeTaskSubIndex > index) {
this.activeTaskSubIndex--
}
} else if (target === 'step') {
if (this.activeStepSubIndex >= arr.length && arr.length > 0) {
this.activeStepSubIndex = arr.length - 1
} else if (arr.length === 0) {
this.activeStepSubIndex = 0
} else if (this.activeStepSubIndex > index) {
this.activeStepSubIndex--
}
}
}
}
}
@ -401,13 +722,15 @@ export default {
height: 100%;
}
.overlay-content.understanding-page {
.overlay-content.understanding-page,
.overlay-content.step-page {
padding: 0;
height: 100%;
}
.task-page-body,
.understanding-page-body {
.understanding-page-body,
.step-canvas-body {
height: 100%;
}
@ -436,4 +759,36 @@ export default {
font-size: 12px;
color: #cbd5e1;
}
.sub-title-context-menu {
position: fixed;
z-index: 10000;
min-width: 120px;
padding: 4px 0;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
border: 1px solid rgba(0, 0, 0, 0.08);
}
.sub-title-context-menu .context-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: 14px;
color: #1e293b;
cursor: pointer;
transition: background 0.2s;
}
.sub-title-context-menu .context-menu-item:hover {
background: rgba(0, 138, 255, 0.08);
color: #008aff;
}
.sub-title-context-menu .context-menu-item-danger:hover {
background: rgba(239, 68, 68, 0.08);
color: #ef4444;
}
</style>

252
ruoyi-ui/src/views/childRoom/TaskPageContent.vue

@ -64,23 +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 class="textbox-input-wrapper">
<div
v-if="box.placeholder && !box.text && focusedBoxId !== box.id"
class="textbox-placeholder"
:style="getTextBoxPlaceholderStyle(box)"
@mousedown="focusTextBox(box, $event)"
>{{ box.placeholder }}</div>
<div
class="textbox-input"
contenteditable="true"
:style="getTextBoxInputStyle(box)"
@focus="onTextBoxFocus(box)"
@blur="onTextBoxBlur(box, $event)"
@mousedown.stop="selectedId = box.id"
@contextmenu="onTextBoxContextMenu(box, $event)"
></div>
</div>
<div
class="textbox-input"
contenteditable="true"
:style="getTextBoxInputStyle(box)"
@blur="box.text = $event.target.innerText"
@mousedown.stop="selectedId = box.id"
></div>
<div class="textbox-resize-handle" v-if="selectedId === box.id">
<div
v-for="pos in resizeHandles"
@ -103,6 +103,21 @@
:style="drawingTextBoxStyle"
></div>
</div>
<!-- 格式工具栏仅选中文字后右键时显示固定定位在右键位置 -->
<div
v-if="formatToolbarBox"
class="textbox-format-toolbar-fixed"
:style="{ left: formatToolbarPosition.x + 'px', top: formatToolbarPosition.y + 'px' }"
@mousedown.stop
>
<el-select v-model="formatToolbarBox.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="formatToolbarBox.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="formatToolbarBox.color" class="format-color" @change="debouncedSave" />
</div>
</div>
</template>
@ -117,6 +132,8 @@ function genId() {
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' }
const MAIN_TITLE_ID = 'task_default_main_title'
const SUB_TITLE_ID = 'task_default_sub_title'
export default {
name: 'TaskPageContent',
@ -128,6 +145,10 @@ export default {
backgroundImage: {
type: String,
default: null
},
taskSubTitles: {
type: Array,
default: () => []
}
},
data() {
@ -147,25 +168,40 @@ export default {
drawStartX: 0,
drawStartY: 0,
drawCurrentX: 0,
drawCurrentY: 0
drawCurrentY: 0,
formatToolbarBoxId: null,
formatToolbarPosition: { x: 0, y: 0 },
focusedBoxId: null
}
},
watch: {
backgroundImage: { handler() { this.debouncedSave() }, immediate: false },
icons: { handler() { this.debouncedSave() }, deep: true },
textBoxes: { handler() { this.debouncedSave() }, deep: true }
textBoxes: { handler() { this.debouncedSave() }, deep: true },
taskSubTitles: { handler() { this.debouncedSave() }, deep: true }
},
mounted() {
this._keydownHandler = (e) => this.onKeydown(e)
this._clickHandler = (e) => {
if (this.formatToolbarBoxId && !e.target.closest('.textbox-format-toolbar-fixed')) {
this.formatToolbarBoxId = null
}
}
document.addEventListener('keydown', this._keydownHandler)
this.loadFromRedis()
this.$nextTick(() => this.syncTextBoxContent())
document.addEventListener('click', this._clickHandler)
this.loadFromRedis().then(() => {
this.$nextTick(() => {
this.ensureDefaultTitleBoxes()
this.syncTextBoxContent()
})
})
},
updated() {
this.$nextTick(() => this.syncTextBoxContent())
},
beforeDestroy() {
document.removeEventListener('keydown', this._keydownHandler)
document.removeEventListener('click', this._clickHandler)
if (this._saveTimer) {
clearTimeout(this._saveTimer)
this._saveTimer = null
@ -173,6 +209,10 @@ export default {
this.saveToRedis()
},
computed: {
formatToolbarBox() {
if (!this.formatToolbarBoxId) return null
return this.textBoxes.find(t => t.id === this.formatToolbarBoxId) || null
},
canvasStyle() {
const style = {}
if (this.backgroundImage) {
@ -198,6 +238,10 @@ export default {
},
methods: {
handleInsertCommand(cmd) {
if (cmd === 'removeBackground') {
this.$emit('background-change', null)
return
}
this.insertMode = cmd
if (cmd === 'background') {
this.$refs.bgInput.value = ''
@ -301,6 +345,32 @@ export default {
if (e.target.closest('.textbox-input')) return
this.selectElement(box.id, e)
},
focusTextBox(box, e) {
e.preventDefault()
e.stopPropagation()
this.focusedBoxId = box.id
this.selectedId = box.id
this.$nextTick(() => {
const wrapper = e.target.closest('.textbox-input-wrapper')
const input = wrapper && wrapper.querySelector('.textbox-input')
if (input) input.focus()
})
},
onTextBoxFocus(box) {
this.focusedBoxId = box.id
},
onTextBoxBlur(box, e) {
box.text = (e.target.innerText || '').trim()
this.focusedBoxId = null
},
onTextBoxContextMenu(box, e) {
const sel = window.getSelection()
const hasSelection = sel && sel.toString().trim().length > 0
if (!hasSelection) return
e.preventDefault()
this.formatToolbarBoxId = box.id
this.formatToolbarPosition = { x: e.clientX, y: e.clientY }
},
selectElement(id, e) {
this.selectedId = id
const icon = this.icons.find(i => i.id === id)
@ -467,6 +537,13 @@ export default {
color: box.color || DEFAULT_FONT.color
}
},
getTextBoxPlaceholderStyle(box) {
return {
fontSize: (box.fontSize || DEFAULT_FONT.fontSize) + 'px',
fontFamily: box.fontFamily || DEFAULT_FONT.fontFamily,
color: '#999'
}
},
deleteIcon(id) {
const idx = this.icons.findIndex(i => i.id === id)
if (idx >= 0) this.icons.splice(idx, 1)
@ -482,6 +559,45 @@ export default {
}
})
},
ensureDefaultTitleBoxes() {
const canvas = this.$refs.canvas
const w = canvas ? canvas.offsetWidth : 800
const h = canvas ? canvas.offsetHeight : 500
const hasMain = this.textBoxes.some(t => t.id === MAIN_TITLE_ID)
const hasSub = this.textBoxes.some(t => t.id === SUB_TITLE_ID)
if (!hasMain) {
this.textBoxes.unshift({
id: MAIN_TITLE_ID,
x: Math.max(0, (w - 600) / 2),
y: Math.max(0, (h - 180) / 2 - 55),
width: 600,
height: 90,
text: '',
placeholder: '在此输入大标题',
rotation: 0,
fontSize: 56,
fontFamily: DEFAULT_FONT.fontFamily,
color: DEFAULT_FONT.color
})
}
if (!hasSub) {
const insertIdx = this.textBoxes.findIndex(t => t.id === MAIN_TITLE_ID)
const at = insertIdx >= 0 ? insertIdx + 1 : 0
this.textBoxes.splice(at, 0, {
id: SUB_TITLE_ID,
x: Math.max(0, (w - 500) / 2),
y: Math.max(0, (h - 180) / 2 + 55),
width: 500,
height: 52,
text: '',
placeholder: '在此输入副标题',
rotation: 0,
fontSize: 20,
fontFamily: DEFAULT_FONT.fontFamily,
color: DEFAULT_FONT.color
})
}
},
async loadFromRedis() {
if (this.roomId == null) return
try {
@ -508,22 +624,31 @@ export default {
}))
}
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
}))
this.textBoxes = raw.textBoxes.map(t => {
const id = t.id || genId()
const isMain = id === MAIN_TITLE_ID
const isSub = id === SUB_TITLE_ID
return {
id,
x: Number(t.x) || 0,
y: Number(t.y) || 0,
width: Number(t.width) || (isMain ? 600 : isSub ? 500 : 100),
height: Number(t.height) || (isMain ? 90 : isSub ? 52 : 60),
text: String(t.text || ''),
placeholder: isMain ? '在此输入大标题' : isSub ? '在此输入副标题' : undefined,
rotation: Number(t.rotation) || 0,
fontSize: Number(t.fontSize) || (isMain ? 56 : isSub ? 20 : DEFAULT_FONT.fontSize),
fontFamily: t.fontFamily || DEFAULT_FONT.fontFamily,
color: t.color || DEFAULT_FONT.color
}
})
}
if (raw.background) {
this.$emit('background-change', raw.background)
}
if (raw.taskSubTitles && Array.isArray(raw.taskSubTitles)) {
this.$emit('task-sub-titles-change', raw.taskSubTitles)
}
} catch (e) {
console.warn('TaskPage loadFromRedis failed:', e)
}
@ -548,11 +673,13 @@ export default {
width: t.width,
height: t.height,
text: t.text || '',
placeholder: t.placeholder || undefined,
rotation: t.rotation || 0,
fontSize: t.fontSize || DEFAULT_FONT.fontSize,
fontFamily: t.fontFamily || DEFAULT_FONT.fontFamily,
color: t.color || DEFAULT_FONT.color
}))
})),
taskSubTitles: this.taskSubTitles || []
}
saveTaskPageData({
roomId: this.roomId,
@ -793,32 +920,24 @@ export default {
}
/* Office 风格格式工具栏:选中文本框时显示在上方 */
.textbox-format-toolbar {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 4px;
/* Office 风格格式工具栏:仅选中文字右键时显示,固定定位在右键位置 */
.textbox-format-toolbar-fixed {
position: fixed;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
padding: 6px 10px;
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;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
margin-top: 4px;
}
.format-size {
width: 70px;
}
.format-color {
vertical-align: middle;
}
.format-color ::v-deep .el-color-picker__trigger {
.textbox-format-toolbar-fixed .format-font { width: 120px; }
.textbox-format-toolbar-fixed .format-size { width: 70px; }
.textbox-format-toolbar-fixed .format-color { vertical-align: middle; }
.textbox-format-toolbar-fixed .format-color ::v-deep .el-color-picker__trigger {
width: 24px;
height: 24px;
padding: 2px;
@ -834,12 +953,35 @@ export default {
background: transparent;
}
.textbox-input {
.textbox-input-wrapper {
position: absolute;
top: 8px;
left: 0;
right: 0;
bottom: 0;
}
.textbox-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: 8px;
outline: none;
overflow: hidden;
background: transparent;
color: #999;
cursor: text;
pointer-events: auto;
}
.textbox-input {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: 8px;
font-size: 14px;
outline: none;
@ -847,6 +989,10 @@ export default {
background: transparent;
color: #333;
}
.textbox-input::selection {
background: #c8c8c8;
color: inherit;
}
.drawing-textbox {
position: absolute;

313
ruoyi-ui/src/views/childRoom/UnderstandingStepContent.vue

@ -1,5 +1,15 @@
<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>
<input
ref="bgInput"
type="file"
@ -64,23 +74,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">
<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">
<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" />
<div class="textbox-input-wrapper">
<div
v-if="box.placeholder && !box.text && focusedBoxId !== box.id"
class="textbox-placeholder"
:style="getTextBoxPlaceholderStyle(box)"
@mousedown="focusTextBox(box, $event)"
>{{ box.placeholder }}</div>
<div
class="textbox-input"
contenteditable="true"
:style="getTextBoxInputStyle(box)"
@focus="onTextBoxFocus(box)"
@blur="onTextBoxBlur(box, $event)"
@mousedown.stop="selectedId = box.id"
@contextmenu="onTextBoxContextMenu(box, $event)"
></div>
</div>
<div
class="textbox-input"
contenteditable="true"
:style="getTextBoxInputStyle(box)"
@blur="box.text = $event.target.innerText"
@mousedown.stop="selectedId = box.id"
></div>
<div class="textbox-resize-handle" v-if="selectedId === box.id">
<div
v-for="pos in resizeHandles"
@ -103,10 +113,28 @@
:style="drawingTextBoxStyle"
></div>
</div>
<!-- 格式工具栏仅选中文字右键时显示 -->
<div
v-if="formatToolbarBox"
class="textbox-format-toolbar-fixed"
:style="{ left: formatToolbarPosition.x + 'px', top: formatToolbarPosition.y + 'px' }"
@mousedown.stop
>
<el-select v-model="formatToolbarBox.fontFamily" size="mini" placeholder="字体" class="format-font">
<el-option v-for="f in fontOptions" :key="f" :label="f" :value="f" />
</el-select>
<el-select v-model="formatToolbarBox.fontSize" size="mini" placeholder="字号" class="format-size">
<el-option v-for="s in fontSizeOptions" :key="s" :label="String(s)" :value="s" />
</el-select>
<el-color-picker v-model="formatToolbarBox.color" class="format-color" />
</div>
</div>
</template>
<script>
import { createRollCallTextBoxes, createSubTitleTemplate, SUB_TITLE_TEMPLATE_NAMES } from './rollCallTemplate'
import { getPageContent, createEmptySubContent, ensurePagesStructure } from './subContentPages'
let idCounter = 0
function genId() {
return 'el_' + (++idCounter) + '_' + Date.now()
@ -126,6 +154,10 @@ export default {
activeSubIndex: {
type: Number,
default: 0
},
subTitles: {
type: Array,
default: () => ['点名', '任务目标', '自身任务', '对接任务']
}
},
data() {
@ -133,10 +165,10 @@ export default {
fontOptions: FONT_OPTIONS,
fontSizeOptions: FONT_SIZE_OPTIONS,
subContents: [
{ icons: [], textBoxes: [] },
{ icons: [], textBoxes: [] },
{ icons: [], textBoxes: [] },
{ icons: [], textBoxes: [] }
createEmptySubContent(),
createEmptySubContent(),
createEmptySubContent(),
createEmptySubContent()
],
insertMode: null,
pendingIconImage: null,
@ -147,15 +179,47 @@ export default {
drawStartX: 0,
drawStartY: 0,
drawCurrentX: 0,
drawCurrentY: 0
drawCurrentY: 0,
formatToolbarBoxId: null,
formatToolbarPosition: { x: 0, y: 0 },
focusedBoxId: null
}
},
computed: {
formatToolbarBox() {
if (!this.formatToolbarBoxId) return null
const boxes = this.currentTextBoxes
return boxes.find(t => t.id === this.formatToolbarBoxId) || null
},
currentSubContent() {
return this.subContents[this.activeSubIndex]
},
currentPage() {
return getPageContent(this.currentSubContent)
},
currentIcons() {
return this.subContents[this.activeSubIndex]?.icons || []
return this.currentPage?.icons || []
},
currentTextBoxes() {
return this.subContents[this.activeSubIndex]?.textBoxes || []
return this.currentPage?.textBoxes || []
},
currentPageCount() {
const sc = this.currentSubContent
if (!sc) return 0
ensurePagesStructure(sc)
return sc.pages?.length || 0
},
currentPageIndex() {
const sc = this.currentSubContent
if (!sc) return 0
ensurePagesStructure(sc)
return sc.currentPageIndex || 0
},
canPrevPage() {
return this.currentPageIndex > 0
},
canNextPage() {
return this.currentPageIndex < this.currentPageCount - 1
},
canvasStyle() {
const style = {}
@ -183,21 +247,51 @@ export default {
watch: {
activeSubIndex() {
this.selectedId = null
this.$nextTick(() => {
this.ensureRollCallTextBoxes()
this.ensureSubTitleTemplate()
})
},
subTitles: {
handler(titles) {
while (this.subContents.length < (titles?.length || 0)) {
this.subContents.push(createEmptySubContent())
}
while (this.subContents.length > (titles?.length || 0)) {
this.subContents.pop()
}
},
immediate: true
}
},
mounted() {
this._keydownHandler = (e) => this.onKeydown(e)
this._clickHandler = (e) => {
if (this.formatToolbarBoxId && !e.target.closest('.textbox-format-toolbar-fixed')) {
this.formatToolbarBoxId = null
}
}
document.addEventListener('keydown', this._keydownHandler)
this.$nextTick(() => this.syncTextBoxContent())
document.addEventListener('click', this._clickHandler)
this.$nextTick(() => {
this.ensureRollCallTextBoxes()
this.ensureSubTitleTemplate()
this.syncTextBoxContent()
})
},
updated() {
this.$nextTick(() => this.syncTextBoxContent())
},
beforeDestroy() {
document.removeEventListener('keydown', this._keydownHandler)
document.removeEventListener('click', this._clickHandler)
},
methods: {
handleInsertCommand(cmd) {
if (cmd === 'removeBackground') {
this.$emit('background-change', null)
return
}
this.insertMode = cmd
if (cmd === 'background') {
this.$refs.bgInput.value = ''
@ -234,8 +328,8 @@ export default {
const rect = this.$refs.canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const sub = this.subContents[this.activeSubIndex]
sub.icons.push({
const page = this.currentPage
if (page) page.icons.push({
id: genId(),
x,
y,
@ -280,8 +374,8 @@ export default {
const y = Math.min(this.drawStartY, this.drawCurrentY)
const w = Math.max(Math.abs(this.drawCurrentX - this.drawStartX), 20)
const h = Math.max(Math.abs(this.drawCurrentY - this.drawStartY), 20)
const sub = this.subContents[this.activeSubIndex]
sub.textBoxes.push({
const page = this.currentPage
if (page) page.textBoxes.push({
id: genId(),
x,
y,
@ -303,6 +397,14 @@ export default {
if (e.target.closest('.textbox-input')) return
this.selectElement(box.id, e)
},
onTextBoxContextMenu(box, e) {
const sel = window.getSelection()
const hasSelection = sel && sel.toString().trim().length > 0
if (!hasSelection) return
e.preventDefault()
this.formatToolbarBoxId = box.id
this.formatToolbarPosition = { x: e.clientX, y: e.clientY }
},
selectElement(id, e) {
this.selectedId = id
const icon = this.currentIcons.find(i => i.id === id)
@ -463,16 +565,87 @@ export default {
}
},
getTextBoxInputStyle(box) {
return {
const style = {
fontSize: (box.fontSize || DEFAULT_FONT.fontSize) + 'px',
fontFamily: box.fontFamily || DEFAULT_FONT.fontFamily,
color: box.color || DEFAULT_FONT.color
}
if (box.fontWeight) style.fontWeight = box.fontWeight
return style
},
getTextBoxPlaceholderStyle(box) {
const style = {
fontSize: (box.fontSize || DEFAULT_FONT.fontSize) + 'px',
fontFamily: box.fontFamily || DEFAULT_FONT.fontFamily,
color: '#999'
}
if (box.fontWeight) style.fontWeight = box.fontWeight
return style
},
focusTextBox(box, e) {
e.preventDefault()
e.stopPropagation()
this.focusedBoxId = box.id
this.selectedId = box.id
this.$nextTick(() => {
const wrapper = e.target.closest('.textbox-input-wrapper')
const input = wrapper && wrapper.querySelector('.textbox-input')
if (input) input.focus()
})
},
onTextBoxFocus(box) {
this.focusedBoxId = box.id
},
onTextBoxBlur(box, e) {
box.text = (e.target.innerText || '').trim()
this.focusedBoxId = null
},
ensureRollCallTextBoxes() {
const subTitle = this.subTitles[this.activeSubIndex]
if (subTitle !== '点名') return
const page = this.currentPage
if (!page || !page.textBoxes || page.textBoxes.length > 0) return
const canvas = this.$refs.canvas
const w = canvas ? canvas.offsetWidth : 800
const h = canvas ? canvas.offsetHeight : 500
page.textBoxes.push(...createRollCallTextBoxes(w, h))
},
ensureSubTitleTemplate() {
const subTitle = this.subTitles[this.activeSubIndex]
if (!subTitle || subTitle === '点名' || !SUB_TITLE_TEMPLATE_NAMES.includes(subTitle)) return
const page = this.currentPage
if (!page || !page.textBoxes || page.textBoxes.length > 0) return
const canvas = this.$refs.canvas
const w = canvas ? canvas.offsetWidth : 800
const h = canvas ? canvas.offsetHeight : 500
page.textBoxes.push(...createSubTitleTemplate(subTitle, w, h))
},
addPageToSubIndex(index, boxes) {
const sub = this.subContents[index]
if (!sub) return
ensurePagesStructure(sub)
sub.pages.push({ icons: [], textBoxes: boxes || [] })
sub.currentPageIndex = sub.pages.length - 1
},
prevPage() {
const sub = this.currentSubContent
if (!sub || !this.canPrevPage) return
ensurePagesStructure(sub)
sub.currentPageIndex = Math.max(0, (sub.currentPageIndex || 0) - 1)
this.selectedId = null
},
nextPage() {
const sub = this.currentSubContent
if (!sub || !this.canNextPage) return
ensurePagesStructure(sub)
sub.currentPageIndex = Math.min((sub.pages?.length || 1) - 1, (sub.currentPageIndex || 0) + 1)
this.selectedId = null
},
deleteIcon(id) {
const sub = this.subContents[this.activeSubIndex]
const idx = sub.icons.findIndex(i => i.id === id)
if (idx >= 0) sub.icons.splice(idx, 1)
const page = this.currentPage
if (!page) return
const idx = page.icons.findIndex(i => i.id === id)
if (idx >= 0) page.icons.splice(idx, 1)
if (this.selectedId === id) this.selectedId = null
},
syncTextBoxContent() {
@ -487,9 +660,10 @@ export default {
})
},
deleteTextBox(id) {
const sub = this.subContents[this.activeSubIndex]
const idx = sub.textBoxes.findIndex(t => t.id === id)
if (idx >= 0) sub.textBoxes.splice(idx, 1)
const page = this.currentPage
if (!page) return
const idx = page.textBoxes.findIndex(t => t.id === id)
if (idx >= 0) page.textBoxes.splice(idx, 1)
if (this.selectedId === id) this.selectedId = null
},
onKeydown(e) {
@ -527,6 +701,22 @@ export default {
</script>
<style scoped>
.pagination-bar {
display: flex;
align-items: center;
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;
}
.understanding-step-content {
display: flex;
flex-direction: column;
@ -709,26 +899,24 @@ export default {
pointer-events: auto;
}
/* Office 风格格式工具栏 */
.textbox-format-toolbar {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 4px;
/* 格式工具栏:仅选中文字右键时显示 */
.textbox-format-toolbar-fixed {
position: fixed;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
padding: 6px 10px;
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 20;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
margin-top: 4px;
}
.format-font { width: 120px; }
.format-size { width: 70px; }
.format-color { vertical-align: middle; }
.format-color ::v-deep .el-color-picker__trigger {
.textbox-format-toolbar-fixed .format-font { width: 120px; }
.textbox-format-toolbar-fixed .format-size { width: 70px; }
.textbox-format-toolbar-fixed .format-color { vertical-align: middle; }
.textbox-format-toolbar-fixed .format-color ::v-deep .el-color-picker__trigger {
width: 24px;
height: 24px;
padding: 2px;
@ -744,12 +932,35 @@ export default {
background: transparent;
}
.textbox-input {
.textbox-input-wrapper {
position: absolute;
top: 8px;
left: 0;
right: 0;
bottom: 0;
}
.textbox-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: 8px;
outline: none;
overflow: hidden;
background: transparent;
color: #999;
cursor: text;
pointer-events: auto;
}
.textbox-input {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: 8px;
font-size: 14px;
outline: none;
@ -757,6 +968,10 @@ export default {
background: transparent;
color: #333;
}
.textbox-input::selection {
background: #c8c8c8;
color: inherit;
}
.drawing-textbox {
position: absolute;

Loading…
Cancel
Save