Browse Source

六步法前后端,加空域辅助线绘制

ctw
cuitw 4 weeks ago
parent
commit
d67e853223
  1. 39
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java
  2. 19
      ruoyi-ui/src/api/system/routes.js
  3. 14
      ruoyi-ui/src/utils/imageUrl.js
  4. 4
      ruoyi-ui/src/utils/request.js
  5. 42
      ruoyi-ui/src/views/cesiumMap/ContextMenu.vue
  6. 1
      ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue
  7. 198
      ruoyi-ui/src/views/cesiumMap/index.vue
  8. 82
      ruoyi-ui/src/views/childRoom/BottomLeftPanel.vue
  9. 218
      ruoyi-ui/src/views/childRoom/SixStepsOverlay.vue
  10. 292
      ruoyi-ui/src/views/childRoom/StepCanvasContent.vue
  11. 157
      ruoyi-ui/src/views/childRoom/TaskPageContent.vue
  12. 381
      ruoyi-ui/src/views/childRoom/UnderstandingStepContent.vue
  13. 34
      ruoyi-ui/src/views/childRoom/rollCallTemplate.js

39
ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java

@ -163,6 +163,45 @@ public class RoutesController extends BaseController
}
/**
* 保存六步法全部数据到 Redis任务页理解后五步背景多页等
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@PostMapping("/saveSixStepsData")
public AjaxResult saveSixStepsData(@RequestBody java.util.Map<String, Object> params)
{
Object roomId = params.get("roomId");
Object data = params.get("data");
if (roomId == null || data == null) {
return AjaxResult.error("参数不完整");
}
String key = "room:" + String.valueOf(roomId) + ":six_steps";
fourTRedisTemplate.opsForValue().set(key, data.toString());
return success();
}
/**
* Redis 获取六步法全部数据
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping("/getSixStepsData")
public AjaxResult getSixStepsData(Long roomId)
{
if (roomId == null) {
return AjaxResult.error("房间ID不能为空");
}
String key = "room:" + String.valueOf(roomId) + ":six_steps";
String val = fourTRedisTemplate.opsForValue().get(key);
if (val != null && !val.isEmpty()) {
try {
return success(JSON.parseObject(val));
} catch (Exception e) {
return success(val);
}
}
return success();
}
/**
* 获取导弹发射参数列表Redis房间+航线+平台为 key值为数组每项含 angle/distance/launchTimeMinutesFromK/startLng/startLat/platformHeadingDeg
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")

19
ruoyi-ui/src/api/system/routes.js

@ -100,6 +100,25 @@ export function getTaskPageData(params) {
})
}
// 保存六步法全部数据到 Redis(任务页、理解、后五步、背景、多页等)
export function saveSixStepsData(data) {
return request({
url: '/system/routes/saveSixStepsData',
method: 'post',
data,
headers: { repeatSubmit: false }
})
}
// 从 Redis 获取六步法全部数据
export function getSixStepsData(params) {
return request({
url: '/system/routes/getSixStepsData',
method: 'get',
params
})
}
// 获取导弹发射参数(Redis:房间+航线+平台为 key)
export function getMissileParams(params) {
return request({

14
ruoyi-ui/src/utils/imageUrl.js

@ -0,0 +1,14 @@
/**
* 解析背景图/图片 URL
* - data:image/... (base64) http(s):// 开头:原样返回
* - 否则视为若依 profile 路径拼接 base API
*/
export function resolveImageUrl(img) {
if (!img) return ''
if (img.startsWith('data:') || img.startsWith('http://') || img.startsWith('https://')) {
return img
}
const base = process.env.VUE_APP_BASE_API || ''
const path = img.startsWith('/') ? img : '/' + img
return base + path
}

4
ruoyi-ui/src/utils/request.js

@ -66,6 +66,10 @@ service.interceptors.request.use(config => {
}
}
}
// FormData 上传时移除 Content-Type,让浏览器自动设置 multipart/form-data; boundary=...
if (config.data instanceof FormData) {
delete config.headers['Content-Type']
}
return config
}, error => {
console.log(error)

42
ruoyi-ui/src/views/cesiumMap/ContextMenu.vue

@ -173,7 +173,7 @@
</div>
<!-- 空域图形调整位置 -->
<div class="menu-section" v-if="entityData.type === 'polygon' || entityData.type === 'rectangle' || entityData.type === 'circle' || entityData.type === 'sector' || entityData.type === 'arrow'">
<div class="menu-section" v-if="entityData.type === 'polygon' || entityData.type === 'rectangle' || entityData.type === 'circle' || entityData.type === 'sector' || entityData.type === 'auxiliaryLine' || entityData.type === 'arrow'">
<div class="menu-title">位置</div>
<div class="menu-item" @click="handleAdjustPosition">
<span class="menu-icon">📍</span>
@ -275,6 +275,46 @@
</div>
</div>
<!-- 辅助线特有选项 -->
<div class="menu-section" v-if="entityData.type === 'auxiliaryLine'">
<div class="menu-title">辅助线属性</div>
<div class="menu-item" @click="toggleColorPicker('color')">
<span class="menu-icon">🎨</span>
<span>颜色</span>
<span class="menu-preview" :style="{backgroundColor: entityData.color}"></span>
</div>
<div class="color-picker-container" v-if="showColorPickerFor === 'color'">
<div class="color-grid">
<div
v-for="color in presetColors"
:key="color"
class="color-item"
:style="{backgroundColor: color}"
@click="selectColor('color', color)"
:class="{ active: entityData.color === color }"
></div>
</div>
</div>
<div class="menu-item" @click="toggleWidthPicker">
<span class="menu-icon">📏</span>
<span>线宽</span>
<span class="menu-value">{{ entityData.width }}px</span>
</div>
<div class="width-picker-container" v-if="showWidthPicker">
<div class="width-grid">
<div
v-for="width in presetWidths"
:key="width"
class="width-item"
@click="selectWidth(width)"
:class="{ active: entityData.width === width }"
>
{{ width }}px
</div>
</div>
</div>
</div>
<!-- 箭头特有选项 -->
<div class="menu-section" v-if="entityData.type === 'arrow'">
<div class="menu-title">箭头属性</div>

1
ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue

@ -46,6 +46,7 @@ export default {
{ id: 'rectangle', name: '矩形空域', icon: 'jx' },
{ id: 'circle', name: '圆形空域', icon: 'circle' },
{ id: 'sector', name: '扇形空域', icon: 'sx' },
{ id: 'auxiliaryLine', name: '辅助线', icon: 'el-icon-minus' },
{ id: 'arrow', name: '箭头', icon: 'el-icon-right' },
{ id: 'text', name: '文本', icon: 'el-icon-document' },
{ id: 'image', name: '图片', icon: 'el-icon-picture-outline' },

198
ruoyi-ui/src/views/cesiumMap/index.vue

@ -521,6 +521,7 @@ export default {
circle: { color: '#800080', opacity: 0, width: 2 },
sector: { color: '#FF6347', opacity: 0, width: 2 },
arrow: { color: '#FF0000', width: 6 },
auxiliaryLine: { color: '#000000', width: 2 },
text: { color: '#000000', font: '14px PingFang SC, Microsoft YaHei, Helvetica Neue, sans-serif', backgroundColor: 'rgba(255, 255, 255, 0.92)' },
image: { width: 150, height: 150 }
},
@ -4839,6 +4840,9 @@ export default {
case 'sector':
this.startSectorDrawing()
break
case 'auxiliaryLine':
this.startAuxiliaryLineDrawing()
break
case 'arrow':
this.startArrowDrawing()
break
@ -5882,6 +5886,97 @@ export default {
calculateDistance(point1, point2) {
return Cesium.Cartesian3.distance(point1, point2);
},
// 线线 1px
startAuxiliaryLineDrawing() {
this.drawingPoints = []
if (this.tempEntity) this.viewer.entities.remove(this.tempEntity)
if (this.tempPreviewEntity) this.viewer.entities.remove(this.tempPreviewEntity)
this.tempEntity = null
this.tempPreviewEntity = null
this.drawingHandler.setInputAction((movement) => {
const newPosition = this.getClickPosition(movement.endPosition)
if (newPosition) {
this.activeCursorPosition = newPosition
if (this.viewer.scene.requestRenderMode) this.viewer.scene.requestRender()
}
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
this.drawingHandler.setInputAction((click) => {
const position = this.getClickPosition(click.position)
if (position) {
this.drawingPoints.push(position)
if (this.drawingPoints.length === 1) {
this.activeCursorPosition = position
this.tempPreviewEntity = this.viewer.entities.add({
polyline: {
positions: new Cesium.CallbackProperty(() => {
if (this.drawingPoints.length > 0 && this.activeCursorPosition) {
return [this.drawingPoints[this.drawingPoints.length - 1], this.activeCursorPosition]
}
return []
}, false),
width: this.defaultStyles.auxiliaryLine.width,
material: Cesium.Color.fromCssColorString(this.defaultStyles.auxiliaryLine.color),
arcType: Cesium.ArcType.NONE
}
})
} else {
this.activeCursorPosition = null
this.finishAuxiliaryLineDrawing()
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
this.drawingHandler.setInputAction(() => {
this.cancelDrawing()
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
},
finishAuxiliaryLineDrawing() {
if (this.drawingPoints.length >= 2) {
if (this.tempPreviewEntity) {
this.viewer.entities.remove(this.tempPreviewEntity)
this.tempPreviewEntity = null
}
const entity = this.addAuxiliaryLineEntity([...this.drawingPoints])
this.drawingPoints = []
this.notifyDrawingEntitiesChanged()
return entity
} else {
this.cancelDrawing()
return null
}
},
addAuxiliaryLineEntity(positions) {
if (!positions || positions.length < 2) return null
const linePositions = positions.length === 2 ? positions : [positions[0], positions[positions.length - 1]]
this.entityCounter++
const id = `auxiliaryLine_${this.entityCounter}`
const entity = this.viewer.entities.add({
id,
name: `辅助线 ${this.entityCounter}`,
polyline: {
positions: linePositions,
width: this.defaultStyles.auxiliaryLine.width,
material: Cesium.Color.fromCssColorString(this.defaultStyles.auxiliaryLine.color),
arcType: Cesium.ArcType.NONE
}
})
const entityData = {
id,
type: 'auxiliaryLine',
points: linePositions.map(p => this.cartesianToLatLng(p)),
positions: linePositions,
entity,
color: this.defaultStyles.auxiliaryLine.color,
width: this.defaultStyles.auxiliaryLine.width,
label: `辅助线 ${this.entityCounter}`
}
this.allEntities.push(entityData)
entity.clickHandler = (e) => {
this.selectEntity(entityData)
e.stopPropagation()
}
this.notifyDrawingEntitiesChanged()
return entityData
},
//
startArrowDrawing() {
this.drawingPoints = []; //
@ -6725,6 +6820,12 @@ export default {
entity.polyline.width = data.width
}
break
case 'auxiliaryLine':
if (entity.polyline) {
entity.polyline.material = Cesium.Color.fromCssColorString(data.color)
entity.polyline.width = data.width
}
break
case 'text':
if (entity.label) {
entity.label.fillColor = Cesium.Color.fromCssColorString(data.color)
@ -6928,7 +7029,7 @@ export default {
/** 开始空域位置调整模式:右键菜单点击「调整位置」后进入,移动鼠标预览,左键确认、右键取消 */
startAirspacePositionEdit() {
const ed = this.contextMenu.entityData;
if (!ed || !['polygon', 'rectangle', 'circle', 'sector', 'arrow'].includes(ed.type)) {
if (!ed || !['polygon', 'rectangle', 'circle', 'sector', 'auxiliaryLine', 'arrow'].includes(ed.type)) {
this.contextMenu.visible = false;
return;
}
@ -7124,6 +7225,30 @@ export default {
});
break;
}
case 'auxiliaryLine': {
const auxColor = entityData.color || this.defaultStyles.auxiliaryLine.color;
const auxWidth = entityData.width != null ? entityData.width : this.defaultStyles.auxiliaryLine.width;
this.airspacePositionEditPreviewEntity = this.viewer.entities.add({
polyline: {
positions: new Cesium.CallbackProperty(() => {
const mc = that.airspacePositionEditContext?.mouseCartesian;
const oldCenter = that.getAirspaceCenter(entityData);
if (!mc || !oldCenter || !entityData.positions || entityData.positions.length < 2) return entityData.positions || [];
const delta = Cesium.Cartesian3.subtract(mc, oldCenter, new Cesium.Cartesian3());
const first = entityData.positions[0];
const last = entityData.positions[entityData.positions.length - 1];
return [
Cesium.Cartesian3.add(first, delta, new Cesium.Cartesian3()),
Cesium.Cartesian3.add(last, delta, new Cesium.Cartesian3())
];
}, false),
width: auxWidth,
material: Cesium.Color.fromCssColorString(auxColor),
arcType: Cesium.ArcType.NONE
}
});
break;
}
}
},
@ -7168,6 +7293,7 @@ export default {
}
break;
case 'arrow':
case 'auxiliaryLine':
if (entityData.positions && entityData.positions.length >= 2) {
const sum = entityData.positions.reduce((acc, p) => Cesium.Cartesian3.add(acc, p, new Cesium.Cartesian3()), new Cesium.Cartesian3());
return Cesium.Cartesian3.divideByScalar(sum, entityData.positions.length, new Cesium.Cartesian3());
@ -7271,6 +7397,36 @@ export default {
};
}
break;
case 'auxiliaryLine':
if (entityData.positions && entityData.positions.length >= 2) {
const first = entityData.positions[0];
const last = entityData.positions[entityData.positions.length - 1];
const newFirst = Cesium.Cartesian3.add(first, delta, new Cesium.Cartesian3());
const newLast = Cesium.Cartesian3.add(last, delta, new Cesium.Cartesian3());
const linePositions = [newFirst, newLast];
const auxColor = entityData.color || this.defaultStyles.auxiliaryLine.color;
const auxWidth = entityData.width != null ? entityData.width : this.defaultStyles.auxiliaryLine.width;
const oldEntity = entityData.entity;
this.viewer.entities.remove(oldEntity);
const newEntity = this.viewer.entities.add({
id: oldEntity.id,
name: oldEntity.name,
polyline: {
positions: linePositions,
width: auxWidth,
material: Cesium.Color.fromCssColorString(auxColor),
arcType: Cesium.ArcType.NONE
}
});
entityData.entity = newEntity;
entityData.positions = linePositions;
entityData.points = [this.cartesianToLatLng(newFirst), this.cartesianToLatLng(newLast)];
newEntity.clickHandler = (e) => {
this.selectEntity(entityData);
e.stopPropagation();
};
}
break;
}
entityData.entity.show = true;
this.notifyDrawingEntitiesChanged();
@ -8575,7 +8731,7 @@ export default {
// ================== / ==================
/** 需要持久化到方案的空域图形类型(含测距;不含平台图标、航线) */
getDrawingEntityTypes() {
return ['line', 'polygon', 'rectangle', 'circle', 'sector', 'arrow', 'text', 'image', 'powerZone']
return ['line', 'polygon', 'rectangle', 'circle', 'sector', 'auxiliaryLine', 'arrow', 'text', 'image', 'powerZone']
},
/** 空域/威力区图形增删时通知父组件,用于自动保存到房间(从房间加载时不触发) */
notifyDrawingEntitiesChanged() {
@ -8655,6 +8811,11 @@ export default {
borderColor: entity.borderColor || entity.color,
name: entity.name || ''
}; break
case 'auxiliaryLine':
data = {
points: entity.points || [],
width: entity.width != null ? entity.width : this.defaultStyles.auxiliaryLine.width
}; break
case 'arrow':
data = { points: entity.points || [] }; break
case 'text':
@ -8755,6 +8916,7 @@ export default {
polygon: '面',
rectangle: '矩形',
circle: '圆形',
auxiliaryLine: '辅助线',
ellipse: '椭圆',
hold_circle: '圆形盘旋',
hold_ellipse: '椭圆盘旋',
@ -9135,6 +9297,38 @@ export default {
this.notifyDrawingEntitiesChanged()
return
}
case 'auxiliaryLine': {
const auxPts = entityData.data.points
if (!auxPts || auxPts.length < 2) break
const auxPositions = auxPts.map(p => Cesium.Cartesian3.fromDegrees(p.lng, p.lat))
const auxLinePositions = auxPositions.length === 2 ? auxPositions : [auxPositions[0], auxPositions[auxPositions.length - 1]]
const auxColor = entityData.color || this.defaultStyles.auxiliaryLine.color
const auxWidth = entityData.data.width != null ? entityData.data.width : this.defaultStyles.auxiliaryLine.width
entity = this.viewer.entities.add({
polyline: {
positions: auxLinePositions,
width: auxWidth,
material: Cesium.Color.fromCssColorString(auxColor),
arcType: Cesium.ArcType.NONE
}
})
this.allEntities.push({
id: entity.id,
type: 'auxiliaryLine',
points: [auxPts[0], auxPts[auxPts.length - 1]],
positions: auxLinePositions,
entity,
color: auxColor,
width: auxWidth,
label: entityData.label || '辅助线'
})
entity.clickHandler = (e) => {
this.selectEntity(this.allEntities.find(ed => ed.entity === entity))
e.stopPropagation()
}
this.notifyDrawingEntitiesChanged()
return
}
case 'arrow': {
const pts = entityData.data.points
if (!pts || pts.length < 2) break

82
ruoyi-ui/src/views/childRoom/BottomLeftPanel.vue

@ -28,6 +28,7 @@
<!-- 六步法弹窗覆盖地图右侧栏为任务+1-6点击可切换 -->
<six-steps-overlay
v-if="showSixStepsBar"
ref="sixStepsOverlay"
:visible="showSixStepsOverlay"
:room-id="roomId"
:current-step-index="activeStepIndex"
@ -35,7 +36,10 @@
:override-title="taskBlockActive ? '任务' : null"
:six-steps-data="sixStepsData"
:task-block-active="taskBlockActive"
@close="hideSixStepsBar"
:initial-active-understanding-sub-index="savedProgress.activeUnderstandingSubIndex || 0"
:initial-active-task-sub-index="savedProgress.activeTaskSubIndex || 0"
:initial-active-step-sub-index="savedProgress.activeStepSubIndex || 0"
@close="onSixStepsClose"
@select-task="selectTask"
@select-step="selectStep"
/>
@ -61,6 +65,9 @@ export default {
computed: {
showPanel() {
return !this.showTimelineBar && !this.showSixStepsBar
},
progressStorageKey() {
return this.roomId != null ? `six-steps-progress-${this.roomId}` : null
}
},
watch: {
@ -83,7 +90,8 @@ export default {
{ title: '准备', desc: '识别和评估潜在风险', active: false, completed: false },
{ title: '执行', desc: '实时监控执行过程', active: false, completed: false },
{ title: '评估', desc: '评估任务完成效果', active: false, completed: false }
]
],
savedProgress: {}
}
},
methods: {
@ -105,11 +113,8 @@ export default {
toggleSixSteps() {
this.showSixStepsBar = !this.showSixStepsBar
if (this.showSixStepsBar) {
this.restoreProgress()
this.showSixStepsOverlay = true
this.taskBlockActive = true
this.sixStepsData.forEach(s => { s.active = false; s.completed = false })
this.activeStepIndex = 0
//
this.showTimelineBar = true
if (this.$refs.timeline) {
this.$refs.timeline.isVisible = true
@ -120,6 +125,63 @@ export default {
this.isExpanded = false
this.updateBottomPanelVisible()
},
saveProgress(overlayProgress) {
if (!this.progressStorageKey) return
try {
const progress = {
taskBlockActive: this.taskBlockActive,
activeStepIndex: this.activeStepIndex,
sixStepsData: this.sixStepsData.map(s => ({ ...s })),
...overlayProgress
}
sessionStorage.setItem(this.progressStorageKey, JSON.stringify(progress))
} catch (e) {
console.warn('SixSteps saveProgress failed:', e)
}
},
restoreProgress() {
if (!this.progressStorageKey) {
this.taskBlockActive = true
this.activeStepIndex = 0
this.sixStepsData.forEach(s => { s.active = false; s.completed = false })
return
}
try {
const raw = sessionStorage.getItem(this.progressStorageKey)
const progress = raw ? JSON.parse(raw) : null
if (progress) {
this.taskBlockActive = progress.taskBlockActive !== false
this.activeStepIndex = Math.min(progress.activeStepIndex || 0, 5)
if (Array.isArray(progress.sixStepsData) && progress.sixStepsData.length === this.sixStepsData.length) {
progress.sixStepsData.forEach((s, i) => {
if (this.sixStepsData[i]) {
this.sixStepsData[i].active = s.active
this.sixStepsData[i].completed = s.completed
}
})
}
this.savedProgress = {
activeUnderstandingSubIndex: progress.activeUnderstandingSubIndex || 0,
activeTaskSubIndex: progress.activeTaskSubIndex || 0,
activeStepSubIndex: progress.activeStepSubIndex || 0
}
} else {
this.taskBlockActive = true
this.activeStepIndex = 0
this.sixStepsData.forEach(s => { s.active = false; s.completed = false })
this.savedProgress = {}
}
} catch (e) {
console.warn('SixSteps restoreProgress failed:', e)
this.taskBlockActive = true
this.activeStepIndex = 0
this.savedProgress = {}
}
},
onSixStepsClose(overlayProgress) {
this.saveProgress(overlayProgress || {})
this.closeBoth()
},
updateBottomPanelVisible() {
this.$emit('bottom-panel-visible', this.showTimelineBar || this.showSixStepsBar)
},
@ -127,9 +189,17 @@ export default {
this.closeBoth()
},
hideSixStepsBar() {
const overlay = this.$refs.sixStepsOverlay
if (overlay && overlay.getProgress) {
this.saveProgress(overlay.getProgress())
}
this.closeBoth()
},
closeBoth() {
const overlay = this.$refs.sixStepsOverlay
if (overlay && overlay.getProgress) {
this.saveProgress(overlay.getProgress())
}
this.showTimelineBar = false
this.showSixStepsBar = false
this.showSixStepsOverlay = false

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

@ -130,6 +130,8 @@
:task-sub-titles="taskSubTitles"
@background-change="taskPageBackground = $event"
@task-sub-titles-change="taskSubTitles = $event"
@save-request="debouncedSave"
@task-page-data="lastTaskPageData = $event; saveToRedis()"
class="task-page-body"
/>
<!-- 理解步骤4 子标题 + 可编辑画布 -->
@ -140,6 +142,8 @@
:active-sub-index="activeUnderstandingSubIndex"
:sub-titles="understandingSubTitles"
@background-change="sixStepsSharedBackground = $event"
@save-request="debouncedSave"
@understanding-data="lastUnderstandingData = $event; saveToRedis()"
class="understanding-page-body"
/>
<!-- 判断规划准备执行评估可编辑画布六步共享背景 -->
@ -221,6 +225,7 @@ import {
SIMPLE_TITLE_NAMES
} from './rollCallTemplate'
import { ensurePagesStructure, createEmptySubContent } from './subContentPages'
import { getSixStepsData, saveSixStepsData, getTaskPageData } from '@/api/system/routes'
export default {
name: 'SixStepsOverlay',
@ -255,6 +260,9 @@ export default {
type: Boolean,
default: false
},
initialActiveUnderstandingSubIndex: { type: Number, default: 0 },
initialActiveTaskSubIndex: { type: Number, default: 0 },
initialActiveStepSubIndex: { type: Number, default: 0 },
draggable: {
type: Boolean,
default: true
@ -265,10 +273,10 @@ export default {
taskPageBackground: null,
sixStepsSharedBackground: null,
understandingSubTitles: ['点名', '任务目标', '自身任务', '对接任务'],
activeUnderstandingSubIndex: 0,
activeUnderstandingSubIndex: this.initialActiveUnderstandingSubIndex,
taskSubTitles: [],
activeTaskSubIndex: 0,
activeStepSubIndex: 0,
activeTaskSubIndex: this.initialActiveTaskSubIndex,
activeStepSubIndex: this.initialActiveStepSubIndex,
stepContents: {},
subTitleContextMenu: {
visible: false,
@ -276,22 +284,95 @@ export default {
y: 0,
target: null,
index: -1
}
},
_saveTimer: null,
lastTaskPageData: null,
lastUnderstandingData: null,
taskPageLoadComplete: false,
_isRestoring: false
}
},
created() {
this._isRestoring = this.initialActiveUnderstandingSubIndex !== 0 || this.initialActiveTaskSubIndex !== 0 || this.initialActiveStepSubIndex !== 0
},
watch: {
currentStepIndex() {
currentStepIndex(val) {
if (this._isRestoring) {
this.$nextTick(() => { this._isRestoring = false })
return
}
this.activeStepSubIndex = 0
if (val === 0 && !this.overrideTitle) {
this.$nextTick(() => {
if (this.$refs.understandingStep && this.lastUnderstandingData) {
this.$refs.understandingStep.loadFromData(this.lastUnderstandingData)
}
})
}
},
overrideTitle() {
overrideTitle(val) {
if (this._isRestoring) {
this.$nextTick(() => { this._isRestoring = false })
return
}
this.activeTaskSubIndex = 0
}
if (val === '任务') {
this.taskPageLoadComplete = false
this.$nextTick(() => {
if (this.$refs.taskPage) {
if (this.lastTaskPageData) {
this.$refs.taskPage.loadFromData(this.lastTaskPageData)
}
this.loadFromRedis()
}
})
}
if (!val && this.currentStepIndex === 0) {
this.$nextTick(() => {
if (this.$refs.understandingStep) {
if (this.lastUnderstandingData) {
this.$refs.understandingStep.loadFromData(this.lastUnderstandingData)
}
this.loadFromRedis()
}
})
}
},
roomId: {
handler(val) {
if (val != null && this.visible) this.loadFromRedis()
},
immediate: false
},
visible: {
handler(val) {
if (val && this.roomId != null) {
if (this.overrideTitle === '任务') this.taskPageLoadComplete = false
this.loadFromRedis()
}
},
immediate: false
},
taskPageBackground: { handler() { this.debouncedSave() }, deep: false },
sixStepsSharedBackground: { handler() { this.debouncedSave() }, deep: false },
understandingSubTitles: { handler() { this.debouncedSave() }, deep: true },
taskSubTitles: { handler() { this.debouncedSave() }, deep: true },
stepContents: { handler() { this.debouncedSave() }, deep: true }
},
mounted() {
document.addEventListener('click', this.onDocumentClickForSubTitleMenu)
if (this.roomId != null && this.visible) {
if (this.overrideTitle === '任务') this.taskPageLoadComplete = false
this.loadFromRedis()
}
},
beforeDestroy() {
document.removeEventListener('click', this.onDocumentClickForSubTitleMenu)
if (this._saveTimer) {
clearTimeout(this._saveTimer)
this._saveTimer = null
}
this.saveToRedis()
},
computed: {
currentStepTitle() {
@ -305,8 +386,128 @@ export default {
}
},
methods: {
getProgress() {
return {
activeUnderstandingSubIndex: this.activeUnderstandingSubIndex,
activeTaskSubIndex: this.activeTaskSubIndex,
activeStepSubIndex: this.activeStepSubIndex
}
},
close() {
this.$emit('close')
this.$emit('close', this.getProgress())
},
async loadFromRedis() {
if (this.roomId == null) return
try {
let res = await getSixStepsData({ roomId: this.roomId })
let data = res && res.data
if (!data) {
res = await getTaskPageData({ roomId: this.roomId })
data = res && res.data
if (data) {
const tp = typeof data === 'string' ? (() => { try { return JSON.parse(data) } catch (_) { return null } })() : data
if (tp) data = { taskPage: tp }
}
}
if (!data) return
const raw = typeof data === 'string' ? (() => { try { return JSON.parse(data) } catch (_) { return null } })() : data
if (!raw) return
if (raw.taskPage) {
this.taskPageBackground = raw.taskPage.background || null
if (Array.isArray(raw.taskPage.taskSubTitles)) this.taskSubTitles = raw.taskPage.taskSubTitles
this.lastTaskPageData = raw.taskPage
this.taskPageLoadComplete = true
this.$nextTick(() => {
if (this.$refs.taskPage) this.$refs.taskPage.loadFromData(raw.taskPage)
})
} else if (this.overrideTitle === '任务') {
this.taskPageLoadComplete = true
}
if (raw.sixStepsSharedBackground) this.sixStepsSharedBackground = raw.sixStepsSharedBackground
if (Array.isArray(raw.understanding?.subTitles)) this.understandingSubTitles = raw.understanding.subTitles
if (raw.understanding?.subContents) {
this.lastUnderstandingData = raw.understanding
this.$nextTick(() => {
if (this.$refs.understandingStep) this.$refs.understandingStep.loadFromData(raw.understanding)
})
}
if (raw.steps && typeof raw.steps === 'object') {
Object.keys(raw.steps).forEach(k => {
const step = raw.steps[k]
if (step && (step.subTitles || step.subContents)) {
this.$set(this.stepContents, parseInt(k, 10), {
subTitles: step.subTitles || [],
subContents: (step.subContents || []).map(sc => {
ensurePagesStructure(sc)
return sc
})
})
}
})
}
} catch (e) {
console.warn('SixSteps loadFromRedis failed:', e)
} finally {
if (this.overrideTitle === '任务') this.taskPageLoadComplete = true
}
},
saveToRedis() {
if (this.roomId == null) return
let taskPageData
if (this.$refs.taskPage) {
if (this.taskPageLoadComplete) {
taskPageData = this.lastTaskPageData = this.$refs.taskPage.getDataForSave()
} else {
taskPageData = this.lastTaskPageData || { background: this.taskPageBackground, icons: [], textBoxes: [], taskSubTitles: this.taskSubTitles }
}
} else {
taskPageData = this.lastTaskPageData || { background: this.taskPageBackground, icons: [], textBoxes: [], taskSubTitles: this.taskSubTitles }
}
const payload = {
taskPage: taskPageData,
sixStepsSharedBackground: this.sixStepsSharedBackground,
understanding: (() => {
if (this.$refs.understandingStep) {
this.lastUnderstandingData = { subTitles: this.understandingSubTitles, subContents: this.$refs.understandingStep.getDataForSave() }
}
return {
subTitles: this.understandingSubTitles,
subContents: this.lastUnderstandingData?.subContents || []
}
})(),
steps: {}
}
Object.keys(this.stepContents).forEach(k => {
const step = this.stepContents[k]
if (step && (step.subTitles || step.subContents)) {
payload.steps[k] = {
subTitles: step.subTitles || [],
subContents: (step.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
}
})
}
}
})
saveSixStepsData({
roomId: this.roomId,
data: JSON.stringify(payload)
}).catch(e => {
console.warn('SixSteps saveToRedis failed:', e)
})
},
debouncedSave() {
if (this._saveTimer) clearTimeout(this._saveTimer)
this._saveTimer = setTimeout(() => {
this._saveTimer = null
this.saveToRedis()
}, 300)
},
getStepContent(stepIndex) {
let step = this.stepContents[stepIndex]
@ -674,6 +875,7 @@ export default {
display: flex;
align-items: center;
gap: 16px;
margin-left: 2px;
}
.header-insert {

292
ruoyi-ui/src/views/childRoom/StepCanvasContent.vue

@ -1,15 +1,63 @@
<template>
<div class="step-canvas-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="step-canvas-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"
@ -125,6 +173,8 @@
</template>
<script>
import request from '@/utils/request'
import { resolveImageUrl } from '@/utils/imageUrl'
import { createRollCallTextBoxes, createSubTitleTemplate, createIntentBriefingTemplate, createTaskPlanningTemplate, createSimpleTitleTemplate, SUB_TITLE_TEMPLATE_NAMES, SIMPLE_TITLE_NAMES } from './rollCallTemplate'
import { getPageContent, ensurePagesStructure } from './subContentPages'
@ -169,7 +219,9 @@ export default {
drawCurrentY: 0,
formatToolbarBoxId: null,
formatToolbarPosition: { x: 0, y: 0 },
focusedBoxId: null
focusedBoxId: null,
showLeftPagination: false,
showRightPagination: false
}
},
computed: {
@ -198,6 +250,12 @@ export default {
canNextPage() {
return this.currentPageIndex < this.currentPageCount - 1
},
allPagesForPreview() {
const sc = this.rawSubContent
if (!sc) return []
ensurePagesStructure(sc)
return sc.pages || []
},
formatToolbarBox() {
if (!this.formatToolbarBoxId) return null
return this.currentSubContent.textBoxes.find(t => t.id === this.formatToolbarBoxId) || null
@ -205,7 +263,7 @@ export default {
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'
@ -265,6 +323,7 @@ export default {
this.$nextTick(() => this.syncTextBoxContent())
},
beforeDestroy() {
this.syncFromDomToData()
document.removeEventListener('keydown', this._keydownHandler)
document.removeEventListener('click', this._clickHandler)
},
@ -283,16 +342,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]
@ -582,6 +649,20 @@ export default {
const h = canvas ? canvas.offsetHeight : 500
sub.textBoxes.push(...createSimpleTitleTemplate(subTitle, w, h))
},
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
},
prevPage() {
const sc = this.rawSubContent
if (!sc || !this.canPrevPage) return
@ -596,6 +677,27 @@ export default {
sc.currentPageIndex = Math.min((sc.pages?.length || 1) - 1, (sc.currentPageIndex || 0) + 1)
this.selectedId = null
},
goToPage(idx) {
const sc = this.rawSubContent
if (!sc) return
ensurePagesStructure(sc)
sc.currentPageIndex = Math.max(0, Math.min(idx, (sc.pages?.length || 1) - 1))
this.selectedId = null
this.$refs.pagePreviewPopover && this.$refs.pagePreviewPopover.doClose()
},
deletePage(idx) {
const sc = this.rawSubContent
if (!sc || this.currentPageCount <= 1) return
ensurePagesStructure(sc)
const pages = sc.pages
if (!pages || idx < 0 || idx >= pages.length) return
pages.splice(idx, 1)
const cur = sc.currentPageIndex || 0
if (cur >= pages.length) sc.currentPageIndex = Math.max(0, pages.length - 1)
else if (idx < cur) sc.currentPageIndex = cur - 1
this.selectedId = null
this.$refs.pagePreviewPopover && this.$refs.pagePreviewPopover.doClose()
},
ensureSubTitleTemplate() {
const subTitle = this.content.subTitles && this.content.subTitles[this.activeSubIndex]
if (!subTitle || subTitle === '点名' || subTitle === '意图通报' || subTitle === '任务规划' || SIMPLE_TITLE_NAMES.includes(subTitle) || !SUB_TITLE_TEMPLATE_NAMES.includes(subTitle)) return
@ -626,6 +728,17 @@ export default {
}
})
},
syncFromDomToData() {
if (!this.$refs.canvas) return
const inputs = this.$refs.canvas.querySelectorAll('.textbox-input')
const boxes = this.currentSubContent && this.currentSubContent.textBoxes
if (boxes) {
boxes.forEach((box, idx) => {
const el = inputs[idx]
if (el) box.text = (el.innerText || '').trim()
})
}
},
onKeydown(e) {
if (!this.selectedId) return
if (e.key === 'Delete' || e.key === 'Backspace') {
@ -661,26 +774,87 @@ 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;
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;
z-index: 100;
cursor: pointer;
}
.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;
}
.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;
}
.step-canvas-content {
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}
.task-canvas {
@ -936,3 +1110,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>

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

@ -122,7 +122,8 @@
</template>
<script>
import { getTaskPageData, saveTaskPageData } from '@/api/system/routes'
import request from '@/utils/request'
import { resolveImageUrl } from '@/utils/imageUrl'
let idCounter = 0
function genId() {
@ -189,24 +190,24 @@ export default {
}
document.addEventListener('keydown', this._keydownHandler)
document.addEventListener('click', this._clickHandler)
this.loadFromRedis().then(() => {
this.$nextTick(() => {
this.ensureDefaultTitleBoxes()
this.syncTextBoxContent()
})
this.$nextTick(() => {
this.ensureDefaultTitleBoxes()
this.syncTextBoxContent()
})
},
updated() {
this.$nextTick(() => this.syncTextBoxContent())
},
beforeDestroy() {
this.syncFromDomToData()
this.$emit('task-page-data', this.getDataForSave())
document.removeEventListener('keydown', this._keydownHandler)
document.removeEventListener('click', this._clickHandler)
if (this._saveTimer) {
clearTimeout(this._saveTimer)
this._saveTimer = null
}
this.saveToRedis()
this.$emit('save-request')
},
computed: {
formatToolbarBox() {
@ -216,7 +217,7 @@ export default {
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'
@ -251,16 +252,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]
@ -559,6 +568,14 @@ export default {
}
})
},
syncFromDomToData() {
if (!this.$refs.canvas) return
const inputs = this.$refs.canvas.querySelectorAll('.textbox-input')
this.textBoxes.forEach((box, idx) => {
const el = inputs[idx]
if (el) box.text = (el.innerText || '').trim()
})
},
ensureDefaultTitleBoxes() {
const canvas = this.$refs.canvas
const w = canvas ? canvas.offsetWidth : 800
@ -568,7 +585,7 @@ export default {
if (!hasMain) {
this.textBoxes.unshift({
id: MAIN_TITLE_ID,
x: Math.max(0, (w - 600) / 2),
x: Math.max(0, (w - 600) / 2) + 2,
y: Math.max(0, (h - 180) / 2 - 55),
width: 600,
height: 90,
@ -585,7 +602,7 @@ export default {
const at = insertIdx >= 0 ? insertIdx + 1 : 0
this.textBoxes.splice(at, 0, {
id: SUB_TITLE_ID,
x: Math.max(0, (w - 500) / 2),
x: Math.max(0, (w - 500) / 2) + 2,
y: Math.max(0, (h - 180) / 2 + 55),
width: 500,
height: 52,
@ -598,64 +615,54 @@ export default {
})
}
},
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
loadFromData(data) {
if (!data) return
const raw = typeof data === 'string' ? (() => { try { return JSON.parse(data) } catch (_) { return null } })() : data
if (!raw) return
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 => {
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
}
}
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 => {
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)
})
}
if (raw.background) {
this.$emit('background-change', raw.background)
}
if (raw.taskSubTitles && Array.isArray(raw.taskSubTitles)) {
this.$emit('task-sub-titles-change', raw.taskSubTitles)
}
this.$nextTick(() => {
this.ensureDefaultTitleBoxes()
this.syncTextBoxContent()
})
},
saveToRedis() {
if (this.roomId == null) return
const payload = {
getDataForSave() {
return {
background: this.backgroundImage || null,
icons: this.icons.map(i => ({
id: i.id,
@ -681,18 +688,12 @@ export default {
})),
taskSubTitles: this.taskSubTitles || []
}
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()
this.$emit('save-request')
}, 300)
},
deleteTextBox(id) {

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

@ -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>

34
ruoyi-ui/src/views/childRoom/rollCallTemplate.js

@ -10,6 +10,8 @@ function genId() {
const DEFAULT_FONT = { fontFamily: '微软雅黑', color: '#333333' }
const TITLE_TOP = 16
const TITLE_FONT_SIZE = 32
const OFFSET_RIGHT = 2
const CONTENT_WIDTH_REDUCE = 4
const TITLE_WIDTH = 160
const TITLE_HEIGHT = 72
const CONTENT_FONT_SIZE = 22
@ -20,13 +22,13 @@ export function createRollCallTextBoxes(canvasWidth, canvasHeight) {
const h = canvasHeight || 500
const cols = 2
const rows = 3
const boxWidth = 240
const boxWidth = 240 - CONTENT_WIDTH_REDUCE
const boxHeight = 52
const gapX = 80
const gapY = 48
const groupWidth = cols * boxWidth + (cols - 1) * gapX
const groupHeight = rows * boxHeight + (rows - 1) * gapY
const startX = Math.max(0, (w - groupWidth) / 2)
const startX = Math.max(0, (w - groupWidth) / 2) + OFFSET_RIGHT
const startY = Math.max(0, (h - groupHeight) / 2)
const boxes = []
@ -60,13 +62,13 @@ export function createSubTitleTemplate(subTitleName, canvasWidth, canvasHeight)
const padding = 40
const gap = 12
const contentWidth = Math.max(300, w - (padding * 2))
const contentWidth = Math.max(300, w - (padding * 2)) - CONTENT_WIDTH_REDUCE
const contentHeight = 600
const titleWidth = ['第一次进度检查', '第二次进度检查'].includes(subTitleName) ? TITLE_WIDTH * 2 : TITLE_WIDTH
const titleX = padding
const titleX = padding + OFFSET_RIGHT
const titleY = TITLE_TOP
const contentX = padding
const contentX = padding + OFFSET_RIGHT
const contentY = titleY + TITLE_HEIGHT + gap
return [
@ -118,7 +120,7 @@ export function createSimpleTitleTemplate(subTitleName, canvasWidth, canvasHeigh
return [
{
id: genId(),
x: padding,
x: padding + OFFSET_RIGHT,
y: TITLE_TOP,
width: TITLE_WIDTH,
height: TITLE_HEIGHT,
@ -143,14 +145,14 @@ export function createIntentBriefingTemplate(canvasWidth, canvasHeight) {
const padding = 40
const gap = 12
const middleHeight = 62
const contentWidth = Math.max(300, w - (padding * 2))
const contentWidth = Math.max(300, w - (padding * 2)) - CONTENT_WIDTH_REDUCE
const contentHeight = 500
const titleX = padding
const titleX = padding + OFFSET_RIGHT
const titleY = TITLE_TOP
const middleX = padding
const middleX = padding + OFFSET_RIGHT
const middleY = titleY + TITLE_HEIGHT + gap
const contentX = padding
const contentX = padding + OFFSET_RIGHT
const contentY = middleY + middleHeight + gap
return [
@ -172,7 +174,7 @@ export function createIntentBriefingTemplate(canvasWidth, canvasHeight) {
id: genId(),
x: middleX,
y: middleY,
width: 420,
width: 420 - CONTENT_WIDTH_REDUCE,
height: middleHeight,
text: '明确每日任务目标,风险等级',
placeholder: undefined,
@ -207,14 +209,14 @@ export function createTaskPlanningTemplate(canvasWidth, canvasHeight) {
const padding = 40
const gap = 12
const middleHeight = 62
const contentWidth = Math.max(300, w - (padding * 2))
const contentWidth = Math.max(300, w - (padding * 2)) - CONTENT_WIDTH_REDUCE
const contentHeight = 500
const titleX = padding
const titleX = padding + OFFSET_RIGHT
const titleY = TITLE_TOP
const middleX = padding
const middleX = padding + OFFSET_RIGHT
const middleY = titleY + TITLE_HEIGHT + gap
const contentX = padding
const contentX = padding + OFFSET_RIGHT
const contentY = middleY + middleHeight + gap
return [
@ -236,7 +238,7 @@ export function createTaskPlanningTemplate(canvasWidth, canvasHeight) {
id: genId(),
x: middleX,
y: middleY,
width: 240,
width: 240 - CONTENT_WIDTH_REDUCE,
height: middleHeight,
text: 'XX规划:XXXXX',
placeholder: undefined,

Loading…
Cancel
Save