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. 216
      ruoyi-ui/src/views/childRoom/SixStepsOverlay.vue
  10. 290
      ruoyi-ui/src/views/childRoom/StepCanvasContent.vue
  11. 79
      ruoyi-ui/src/views/childRoom/TaskPageContent.vue
  12. 379
      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 * 获取导弹发射参数列表Redis房间+航线+平台为 key值为数组每项含 angle/distance/launchTimeMinutesFromK/startLng/startLat/platformHeadingDeg
*/ */
@PreAuthorize("@ss.hasPermi('system:routes:query')") @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) // 获取导弹发射参数(Redis:房间+航线+平台为 key)
export function getMissileParams(params) { export function getMissileParams(params) {
return request({ 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 return config
}, error => { }, error => {
console.log(error) console.log(error)

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

@ -173,7 +173,7 @@
</div> </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-title">位置</div>
<div class="menu-item" @click="handleAdjustPosition"> <div class="menu-item" @click="handleAdjustPosition">
<span class="menu-icon">📍</span> <span class="menu-icon">📍</span>
@ -275,6 +275,46 @@
</div> </div>
</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-section" v-if="entityData.type === 'arrow'">
<div class="menu-title">箭头属性</div> <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: 'rectangle', name: '矩形空域', icon: 'jx' },
{ id: 'circle', name: '圆形空域', icon: 'circle' }, { id: 'circle', name: '圆形空域', icon: 'circle' },
{ id: 'sector', name: '扇形空域', icon: 'sx' }, { id: 'sector', name: '扇形空域', icon: 'sx' },
{ id: 'auxiliaryLine', name: '辅助线', icon: 'el-icon-minus' },
{ id: 'arrow', name: '箭头', icon: 'el-icon-right' }, { id: 'arrow', name: '箭头', icon: 'el-icon-right' },
{ id: 'text', name: '文本', icon: 'el-icon-document' }, { id: 'text', name: '文本', icon: 'el-icon-document' },
{ id: 'image', name: '图片', icon: 'el-icon-picture-outline' }, { 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 }, circle: { color: '#800080', opacity: 0, width: 2 },
sector: { color: '#FF6347', opacity: 0, width: 2 }, sector: { color: '#FF6347', opacity: 0, width: 2 },
arrow: { color: '#FF0000', width: 6 }, 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)' }, 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 } image: { width: 150, height: 150 }
}, },
@ -4839,6 +4840,9 @@ export default {
case 'sector': case 'sector':
this.startSectorDrawing() this.startSectorDrawing()
break break
case 'auxiliaryLine':
this.startAuxiliaryLineDrawing()
break
case 'arrow': case 'arrow':
this.startArrowDrawing() this.startArrowDrawing()
break break
@ -5882,6 +5886,97 @@ export default {
calculateDistance(point1, point2) { calculateDistance(point1, point2) {
return Cesium.Cartesian3.distance(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() { startArrowDrawing() {
this.drawingPoints = []; // this.drawingPoints = []; //
@ -6725,6 +6820,12 @@ export default {
entity.polyline.width = data.width entity.polyline.width = data.width
} }
break break
case 'auxiliaryLine':
if (entity.polyline) {
entity.polyline.material = Cesium.Color.fromCssColorString(data.color)
entity.polyline.width = data.width
}
break
case 'text': case 'text':
if (entity.label) { if (entity.label) {
entity.label.fillColor = Cesium.Color.fromCssColorString(data.color) entity.label.fillColor = Cesium.Color.fromCssColorString(data.color)
@ -6928,7 +7029,7 @@ export default {
/** 开始空域位置调整模式:右键菜单点击「调整位置」后进入,移动鼠标预览,左键确认、右键取消 */ /** 开始空域位置调整模式:右键菜单点击「调整位置」后进入,移动鼠标预览,左键确认、右键取消 */
startAirspacePositionEdit() { startAirspacePositionEdit() {
const ed = this.contextMenu.entityData; 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; this.contextMenu.visible = false;
return; return;
} }
@ -7124,6 +7225,30 @@ export default {
}); });
break; 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; break;
case 'arrow': case 'arrow':
case 'auxiliaryLine':
if (entityData.positions && entityData.positions.length >= 2) { 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()); 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()); return Cesium.Cartesian3.divideByScalar(sum, entityData.positions.length, new Cesium.Cartesian3());
@ -7271,6 +7397,36 @@ export default {
}; };
} }
break; 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; entityData.entity.show = true;
this.notifyDrawingEntitiesChanged(); this.notifyDrawingEntitiesChanged();
@ -8575,7 +8731,7 @@ export default {
// ================== / ================== // ================== / ==================
/** 需要持久化到方案的空域图形类型(含测距;不含平台图标、航线) */ /** 需要持久化到方案的空域图形类型(含测距;不含平台图标、航线) */
getDrawingEntityTypes() { getDrawingEntityTypes() {
return ['line', 'polygon', 'rectangle', 'circle', 'sector', 'arrow', 'text', 'image', 'powerZone'] return ['line', 'polygon', 'rectangle', 'circle', 'sector', 'auxiliaryLine', 'arrow', 'text', 'image', 'powerZone']
}, },
/** 空域/威力区图形增删时通知父组件,用于自动保存到房间(从房间加载时不触发) */ /** 空域/威力区图形增删时通知父组件,用于自动保存到房间(从房间加载时不触发) */
notifyDrawingEntitiesChanged() { notifyDrawingEntitiesChanged() {
@ -8655,6 +8811,11 @@ export default {
borderColor: entity.borderColor || entity.color, borderColor: entity.borderColor || entity.color,
name: entity.name || '' name: entity.name || ''
}; break }; break
case 'auxiliaryLine':
data = {
points: entity.points || [],
width: entity.width != null ? entity.width : this.defaultStyles.auxiliaryLine.width
}; break
case 'arrow': case 'arrow':
data = { points: entity.points || [] }; break data = { points: entity.points || [] }; break
case 'text': case 'text':
@ -8755,6 +8916,7 @@ export default {
polygon: '面', polygon: '面',
rectangle: '矩形', rectangle: '矩形',
circle: '圆形', circle: '圆形',
auxiliaryLine: '辅助线',
ellipse: '椭圆', ellipse: '椭圆',
hold_circle: '圆形盘旋', hold_circle: '圆形盘旋',
hold_ellipse: '椭圆盘旋', hold_ellipse: '椭圆盘旋',
@ -9135,6 +9297,38 @@ export default {
this.notifyDrawingEntitiesChanged() this.notifyDrawingEntitiesChanged()
return 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': { case 'arrow': {
const pts = entityData.data.points const pts = entityData.data.points
if (!pts || pts.length < 2) break if (!pts || pts.length < 2) break

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

@ -28,6 +28,7 @@
<!-- 六步法弹窗覆盖地图右侧栏为任务+1-6点击可切换 --> <!-- 六步法弹窗覆盖地图右侧栏为任务+1-6点击可切换 -->
<six-steps-overlay <six-steps-overlay
v-if="showSixStepsBar" v-if="showSixStepsBar"
ref="sixStepsOverlay"
:visible="showSixStepsOverlay" :visible="showSixStepsOverlay"
:room-id="roomId" :room-id="roomId"
:current-step-index="activeStepIndex" :current-step-index="activeStepIndex"
@ -35,7 +36,10 @@
:override-title="taskBlockActive ? '任务' : null" :override-title="taskBlockActive ? '任务' : null"
:six-steps-data="sixStepsData" :six-steps-data="sixStepsData"
:task-block-active="taskBlockActive" :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-task="selectTask"
@select-step="selectStep" @select-step="selectStep"
/> />
@ -61,6 +65,9 @@ export default {
computed: { computed: {
showPanel() { showPanel() {
return !this.showTimelineBar && !this.showSixStepsBar return !this.showTimelineBar && !this.showSixStepsBar
},
progressStorageKey() {
return this.roomId != null ? `six-steps-progress-${this.roomId}` : null
} }
}, },
watch: { 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 }, { title: '执行', desc: '实时监控执行过程', active: false, completed: false },
{ title: '评估', desc: '评估任务完成效果', active: false, completed: false } { title: '评估', desc: '评估任务完成效果', active: false, completed: false }
] ],
savedProgress: {}
} }
}, },
methods: { methods: {
@ -105,11 +113,8 @@ export default {
toggleSixSteps() { toggleSixSteps() {
this.showSixStepsBar = !this.showSixStepsBar this.showSixStepsBar = !this.showSixStepsBar
if (this.showSixStepsBar) { if (this.showSixStepsBar) {
this.restoreProgress()
this.showSixStepsOverlay = true this.showSixStepsOverlay = true
this.taskBlockActive = true
this.sixStepsData.forEach(s => { s.active = false; s.completed = false })
this.activeStepIndex = 0
//
this.showTimelineBar = true this.showTimelineBar = true
if (this.$refs.timeline) { if (this.$refs.timeline) {
this.$refs.timeline.isVisible = true this.$refs.timeline.isVisible = true
@ -120,6 +125,63 @@ export default {
this.isExpanded = false this.isExpanded = false
this.updateBottomPanelVisible() 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() { updateBottomPanelVisible() {
this.$emit('bottom-panel-visible', this.showTimelineBar || this.showSixStepsBar) this.$emit('bottom-panel-visible', this.showTimelineBar || this.showSixStepsBar)
}, },
@ -127,9 +189,17 @@ export default {
this.closeBoth() this.closeBoth()
}, },
hideSixStepsBar() { hideSixStepsBar() {
const overlay = this.$refs.sixStepsOverlay
if (overlay && overlay.getProgress) {
this.saveProgress(overlay.getProgress())
}
this.closeBoth() this.closeBoth()
}, },
closeBoth() { closeBoth() {
const overlay = this.$refs.sixStepsOverlay
if (overlay && overlay.getProgress) {
this.saveProgress(overlay.getProgress())
}
this.showTimelineBar = false this.showTimelineBar = false
this.showSixStepsBar = false this.showSixStepsBar = false
this.showSixStepsOverlay = false this.showSixStepsOverlay = false

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

@ -130,6 +130,8 @@
:task-sub-titles="taskSubTitles" :task-sub-titles="taskSubTitles"
@background-change="taskPageBackground = $event" @background-change="taskPageBackground = $event"
@task-sub-titles-change="taskSubTitles = $event" @task-sub-titles-change="taskSubTitles = $event"
@save-request="debouncedSave"
@task-page-data="lastTaskPageData = $event; saveToRedis()"
class="task-page-body" class="task-page-body"
/> />
<!-- 理解步骤4 子标题 + 可编辑画布 --> <!-- 理解步骤4 子标题 + 可编辑画布 -->
@ -140,6 +142,8 @@
:active-sub-index="activeUnderstandingSubIndex" :active-sub-index="activeUnderstandingSubIndex"
:sub-titles="understandingSubTitles" :sub-titles="understandingSubTitles"
@background-change="sixStepsSharedBackground = $event" @background-change="sixStepsSharedBackground = $event"
@save-request="debouncedSave"
@understanding-data="lastUnderstandingData = $event; saveToRedis()"
class="understanding-page-body" class="understanding-page-body"
/> />
<!-- 判断规划准备执行评估可编辑画布六步共享背景 --> <!-- 判断规划准备执行评估可编辑画布六步共享背景 -->
@ -221,6 +225,7 @@ import {
SIMPLE_TITLE_NAMES SIMPLE_TITLE_NAMES
} from './rollCallTemplate' } from './rollCallTemplate'
import { ensurePagesStructure, createEmptySubContent } from './subContentPages' import { ensurePagesStructure, createEmptySubContent } from './subContentPages'
import { getSixStepsData, saveSixStepsData, getTaskPageData } from '@/api/system/routes'
export default { export default {
name: 'SixStepsOverlay', name: 'SixStepsOverlay',
@ -255,6 +260,9 @@ export default {
type: Boolean, type: Boolean,
default: false default: false
}, },
initialActiveUnderstandingSubIndex: { type: Number, default: 0 },
initialActiveTaskSubIndex: { type: Number, default: 0 },
initialActiveStepSubIndex: { type: Number, default: 0 },
draggable: { draggable: {
type: Boolean, type: Boolean,
default: true default: true
@ -265,10 +273,10 @@ export default {
taskPageBackground: null, taskPageBackground: null,
sixStepsSharedBackground: null, sixStepsSharedBackground: null,
understandingSubTitles: ['点名', '任务目标', '自身任务', '对接任务'], understandingSubTitles: ['点名', '任务目标', '自身任务', '对接任务'],
activeUnderstandingSubIndex: 0, activeUnderstandingSubIndex: this.initialActiveUnderstandingSubIndex,
taskSubTitles: [], taskSubTitles: [],
activeTaskSubIndex: 0, activeTaskSubIndex: this.initialActiveTaskSubIndex,
activeStepSubIndex: 0, activeStepSubIndex: this.initialActiveStepSubIndex,
stepContents: {}, stepContents: {},
subTitleContextMenu: { subTitleContextMenu: {
visible: false, visible: false,
@ -276,22 +284,95 @@ export default {
y: 0, y: 0,
target: null, target: null,
index: -1 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: { watch: {
currentStepIndex() { currentStepIndex(val) {
if (this._isRestoring) {
this.$nextTick(() => { this._isRestoring = false })
return
}
this.activeStepSubIndex = 0 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 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() { mounted() {
document.addEventListener('click', this.onDocumentClickForSubTitleMenu) document.addEventListener('click', this.onDocumentClickForSubTitleMenu)
if (this.roomId != null && this.visible) {
if (this.overrideTitle === '任务') this.taskPageLoadComplete = false
this.loadFromRedis()
}
}, },
beforeDestroy() { beforeDestroy() {
document.removeEventListener('click', this.onDocumentClickForSubTitleMenu) document.removeEventListener('click', this.onDocumentClickForSubTitleMenu)
if (this._saveTimer) {
clearTimeout(this._saveTimer)
this._saveTimer = null
}
this.saveToRedis()
}, },
computed: { computed: {
currentStepTitle() { currentStepTitle() {
@ -305,8 +386,128 @@ export default {
} }
}, },
methods: { methods: {
getProgress() {
return {
activeUnderstandingSubIndex: this.activeUnderstandingSubIndex,
activeTaskSubIndex: this.activeTaskSubIndex,
activeStepSubIndex: this.activeStepSubIndex
}
},
close() { 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) { getStepContent(stepIndex) {
let step = this.stepContents[stepIndex] let step = this.stepContents[stepIndex]
@ -674,6 +875,7 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
margin-left: 2px;
} }
.header-insert { .header-insert {

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

@ -1,15 +1,63 @@
<template> <template>
<div class="step-canvas-content"> <div
<!-- 翻页控件多页时显示 --> class="step-canvas-content"
<div v-if="currentPageCount > 1" class="pagination-bar"> @mousemove="onPaginationAreaMouseMove"
<el-button size="mini" :disabled="!canPrevPage" @click="prevPage"> @mouseleave="onPaginationAreaMouseLeave"
<i class="el-icon-arrow-left"></i> 上一页 >
</el-button> <!-- 翻页控件多页时鼠标悬停左右边缘才显示 -->
<span class="page-indicator">{{ currentPageIndex + 1 }} / {{ currentPageCount }}</span> <div
<el-button size="mini" :disabled="!canNextPage" @click="nextPage"> v-show="currentPageCount > 1 && showLeftPagination"
下一页 <i class="el-icon-arrow-right"></i> class="pagination-left"
</el-button> @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> </div>
</el-popover>
<input <input
ref="bgInput" ref="bgInput"
type="file" type="file"
@ -125,6 +173,8 @@
</template> </template>
<script> <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 { createRollCallTextBoxes, createSubTitleTemplate, createIntentBriefingTemplate, createTaskPlanningTemplate, createSimpleTitleTemplate, SUB_TITLE_TEMPLATE_NAMES, SIMPLE_TITLE_NAMES } from './rollCallTemplate'
import { getPageContent, ensurePagesStructure } from './subContentPages' import { getPageContent, ensurePagesStructure } from './subContentPages'
@ -169,7 +219,9 @@ export default {
drawCurrentY: 0, drawCurrentY: 0,
formatToolbarBoxId: null, formatToolbarBoxId: null,
formatToolbarPosition: { x: 0, y: 0 }, formatToolbarPosition: { x: 0, y: 0 },
focusedBoxId: null focusedBoxId: null,
showLeftPagination: false,
showRightPagination: false
} }
}, },
computed: { computed: {
@ -198,6 +250,12 @@ export default {
canNextPage() { canNextPage() {
return this.currentPageIndex < this.currentPageCount - 1 return this.currentPageIndex < this.currentPageCount - 1
}, },
allPagesForPreview() {
const sc = this.rawSubContent
if (!sc) return []
ensurePagesStructure(sc)
return sc.pages || []
},
formatToolbarBox() { formatToolbarBox() {
if (!this.formatToolbarBoxId) return null if (!this.formatToolbarBoxId) return null
return this.currentSubContent.textBoxes.find(t => t.id === this.formatToolbarBoxId) || null return this.currentSubContent.textBoxes.find(t => t.id === this.formatToolbarBoxId) || null
@ -205,7 +263,7 @@ export default {
canvasStyle() { canvasStyle() {
const style = {} const style = {}
if (this.backgroundImage) { if (this.backgroundImage) {
style.backgroundImage = `url(${this.backgroundImage})` style.backgroundImage = `url(${resolveImageUrl(this.backgroundImage)})`
style.backgroundSize = '100% 100%' style.backgroundSize = '100% 100%'
style.backgroundPosition = 'center' style.backgroundPosition = 'center'
style.backgroundRepeat = 'no-repeat' style.backgroundRepeat = 'no-repeat'
@ -265,6 +323,7 @@ export default {
this.$nextTick(() => this.syncTextBoxContent()) this.$nextTick(() => this.syncTextBoxContent())
}, },
beforeDestroy() { beforeDestroy() {
this.syncFromDomToData()
document.removeEventListener('keydown', this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler)
document.removeEventListener('click', this._clickHandler) document.removeEventListener('click', this._clickHandler)
}, },
@ -283,16 +342,24 @@ export default {
this.$refs.iconImageInput.click() this.$refs.iconImageInput.click()
} }
}, },
handleBackgroundSelect(e) { async handleBackgroundSelect(e) {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
this.$emit('background-change', ev.target.result)
}
reader.readAsDataURL(file)
this.insertMode = null this.insertMode = null
e.target.value = '' 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) { handleIconImageSelect(e) {
const file = e.target.files?.[0] const file = e.target.files?.[0]
@ -582,6 +649,20 @@ export default {
const h = canvas ? canvas.offsetHeight : 500 const h = canvas ? canvas.offsetHeight : 500
sub.textBoxes.push(...createSimpleTitleTemplate(subTitle, w, h)) 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() { prevPage() {
const sc = this.rawSubContent const sc = this.rawSubContent
if (!sc || !this.canPrevPage) return if (!sc || !this.canPrevPage) return
@ -596,6 +677,27 @@ export default {
sc.currentPageIndex = Math.min((sc.pages?.length || 1) - 1, (sc.currentPageIndex || 0) + 1) sc.currentPageIndex = Math.min((sc.pages?.length || 1) - 1, (sc.currentPageIndex || 0) + 1)
this.selectedId = null 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() { ensureSubTitleTemplate() {
const subTitle = this.content.subTitles && this.content.subTitles[this.activeSubIndex] 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 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) { onKeydown(e) {
if (!this.selectedId) return if (!this.selectedId) return
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
@ -661,26 +774,87 @@ export default {
</script> </script>
<style scoped> <style scoped>
.pagination-bar { .pagination-left,
.pagination-right {
position: absolute;
top: 0;
bottom: 0;
width: 48px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 16px; z-index: 100;
padding: 8px 0; cursor: pointer;
background: rgba(0, 138, 255, 0.06);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
flex-shrink: 0;
} }
.pagination-bar .page-indicator { .pagination-left {
font-size: 13px; left: 0;
color: #64748b; }
min-width: 60px; .pagination-right {
text-align: center; 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 { .step-canvas-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
position: relative;
} }
.task-canvas { .task-canvas {
@ -936,3 +1110,61 @@ export default {
pointer-events: none; pointer-events: none;
} }
</style> </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>

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

@ -122,7 +122,8 @@
</template> </template>
<script> <script>
import { getTaskPageData, saveTaskPageData } from '@/api/system/routes' import request from '@/utils/request'
import { resolveImageUrl } from '@/utils/imageUrl'
let idCounter = 0 let idCounter = 0
function genId() { function genId() {
@ -189,24 +190,24 @@ export default {
} }
document.addEventListener('keydown', this._keydownHandler) document.addEventListener('keydown', this._keydownHandler)
document.addEventListener('click', this._clickHandler) document.addEventListener('click', this._clickHandler)
this.loadFromRedis().then(() => {
this.$nextTick(() => { this.$nextTick(() => {
this.ensureDefaultTitleBoxes() this.ensureDefaultTitleBoxes()
this.syncTextBoxContent() this.syncTextBoxContent()
}) })
})
}, },
updated() { updated() {
this.$nextTick(() => this.syncTextBoxContent()) this.$nextTick(() => this.syncTextBoxContent())
}, },
beforeDestroy() { beforeDestroy() {
this.syncFromDomToData()
this.$emit('task-page-data', this.getDataForSave())
document.removeEventListener('keydown', this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler)
document.removeEventListener('click', this._clickHandler) document.removeEventListener('click', this._clickHandler)
if (this._saveTimer) { if (this._saveTimer) {
clearTimeout(this._saveTimer) clearTimeout(this._saveTimer)
this._saveTimer = null this._saveTimer = null
} }
this.saveToRedis() this.$emit('save-request')
}, },
computed: { computed: {
formatToolbarBox() { formatToolbarBox() {
@ -216,7 +217,7 @@ export default {
canvasStyle() { canvasStyle() {
const style = {} const style = {}
if (this.backgroundImage) { if (this.backgroundImage) {
style.backgroundImage = `url(${this.backgroundImage})` style.backgroundImage = `url(${resolveImageUrl(this.backgroundImage)})`
style.backgroundSize = '100% 100%' style.backgroundSize = '100% 100%'
style.backgroundPosition = 'center' style.backgroundPosition = 'center'
style.backgroundRepeat = 'no-repeat' style.backgroundRepeat = 'no-repeat'
@ -251,16 +252,24 @@ export default {
this.$refs.iconImageInput.click() this.$refs.iconImageInput.click()
} }
}, },
handleBackgroundSelect(e) { async handleBackgroundSelect(e) {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
this.$emit('background-change', ev.target.result)
}
reader.readAsDataURL(file)
this.insertMode = null this.insertMode = null
e.target.value = '' 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) { handleIconImageSelect(e) {
const file = e.target.files?.[0] 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() { ensureDefaultTitleBoxes() {
const canvas = this.$refs.canvas const canvas = this.$refs.canvas
const w = canvas ? canvas.offsetWidth : 800 const w = canvas ? canvas.offsetWidth : 800
@ -568,7 +585,7 @@ export default {
if (!hasMain) { if (!hasMain) {
this.textBoxes.unshift({ this.textBoxes.unshift({
id: MAIN_TITLE_ID, 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), y: Math.max(0, (h - 180) / 2 - 55),
width: 600, width: 600,
height: 90, height: 90,
@ -585,7 +602,7 @@ export default {
const at = insertIdx >= 0 ? insertIdx + 1 : 0 const at = insertIdx >= 0 ? insertIdx + 1 : 0
this.textBoxes.splice(at, 0, { this.textBoxes.splice(at, 0, {
id: SUB_TITLE_ID, 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), y: Math.max(0, (h - 180) / 2 + 55),
width: 500, width: 500,
height: 52, height: 52,
@ -598,20 +615,10 @@ export default {
}) })
} }
}, },
async loadFromRedis() { loadFromData(data) {
if (this.roomId == null) return
try {
const res = await getTaskPageData({ roomId: this.roomId })
let data = res && res.data
if (!data) return if (!data) return
if (typeof data === 'string') { const raw = typeof data === 'string' ? (() => { try { return JSON.parse(data) } catch (_) { return null } })() : data
try { if (!raw) return
data = JSON.parse(data)
} catch (_) {
return
}
}
const raw = data
if (raw.icons && Array.isArray(raw.icons)) { if (raw.icons && Array.isArray(raw.icons)) {
this.icons = raw.icons.map(i => ({ this.icons = raw.icons.map(i => ({
id: i.id || genId(), id: i.id || genId(),
@ -649,13 +656,13 @@ export default {
if (raw.taskSubTitles && Array.isArray(raw.taskSubTitles)) { if (raw.taskSubTitles && Array.isArray(raw.taskSubTitles)) {
this.$emit('task-sub-titles-change', raw.taskSubTitles) this.$emit('task-sub-titles-change', raw.taskSubTitles)
} }
} catch (e) { this.$nextTick(() => {
console.warn('TaskPage loadFromRedis failed:', e) this.ensureDefaultTitleBoxes()
} this.syncTextBoxContent()
})
}, },
saveToRedis() { getDataForSave() {
if (this.roomId == null) return return {
const payload = {
background: this.backgroundImage || null, background: this.backgroundImage || null,
icons: this.icons.map(i => ({ icons: this.icons.map(i => ({
id: i.id, id: i.id,
@ -681,18 +688,12 @@ export default {
})), })),
taskSubTitles: this.taskSubTitles || [] taskSubTitles: this.taskSubTitles || []
} }
saveTaskPageData({
roomId: this.roomId,
data: JSON.stringify(payload)
}).catch(e => {
console.warn('TaskPage saveToRedis failed:', e)
})
}, },
debouncedSave() { debouncedSave() {
if (this._saveTimer) clearTimeout(this._saveTimer) if (this._saveTimer) clearTimeout(this._saveTimer)
this._saveTimer = setTimeout(() => { this._saveTimer = setTimeout(() => {
this._saveTimer = null this._saveTimer = null
this.saveToRedis() this.$emit('save-request')
}, 300) }, 300)
}, },
deleteTextBox(id) { deleteTextBox(id) {

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

@ -1,15 +1,63 @@
<template> <template>
<div class="understanding-step-content"> <div
<!-- 翻页控件多页时显示 --> class="understanding-step-content"
<div v-if="currentPageCount > 1" class="pagination-bar"> @mousemove="onPaginationAreaMouseMove"
<el-button size="mini" :disabled="!canPrevPage" @click="prevPage"> @mouseleave="onPaginationAreaMouseLeave"
<i class="el-icon-arrow-left"></i> 上一页 >
</el-button> <!-- 翻页控件多页时鼠标悬停左右边缘才显示 -->
<span class="page-indicator">{{ currentPageIndex + 1 }} / {{ currentPageCount }}</span> <div
<el-button size="mini" :disabled="!canNextPage" @click="nextPage"> v-show="currentPageCount > 1 && showLeftPagination"
下一页 <i class="el-icon-arrow-right"></i> class="pagination-left"
</el-button> @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> </div>
</el-popover>
<input <input
ref="bgInput" ref="bgInput"
type="file" type="file"
@ -132,6 +180,8 @@
</template> </template>
<script> <script>
import request from '@/utils/request'
import { resolveImageUrl } from '@/utils/imageUrl'
import { createRollCallTextBoxes, createSubTitleTemplate, SUB_TITLE_TEMPLATE_NAMES } from './rollCallTemplate' import { createRollCallTextBoxes, createSubTitleTemplate, SUB_TITLE_TEMPLATE_NAMES } from './rollCallTemplate'
import { getPageContent, createEmptySubContent, ensurePagesStructure } from './subContentPages' import { getPageContent, createEmptySubContent, ensurePagesStructure } from './subContentPages'
@ -182,7 +232,10 @@ export default {
drawCurrentY: 0, drawCurrentY: 0,
formatToolbarBoxId: null, formatToolbarBoxId: null,
formatToolbarPosition: { x: 0, y: 0 }, formatToolbarPosition: { x: 0, y: 0 },
focusedBoxId: null focusedBoxId: null,
showLeftPagination: false,
showRightPagination: false,
_loadingFromData: false
} }
}, },
computed: { computed: {
@ -221,10 +274,16 @@ export default {
canNextPage() { canNextPage() {
return this.currentPageIndex < this.currentPageCount - 1 return this.currentPageIndex < this.currentPageCount - 1
}, },
allPagesForPreview() {
const sc = this.currentSubContent
if (!sc) return []
ensurePagesStructure(sc)
return sc.pages || []
},
canvasStyle() { canvasStyle() {
const style = {} const style = {}
if (this.backgroundImage) { if (this.backgroundImage) {
style.backgroundImage = `url(${this.backgroundImage})` style.backgroundImage = `url(${resolveImageUrl(this.backgroundImage)})`
style.backgroundSize = '100% 100%' style.backgroundSize = '100% 100%'
style.backgroundPosition = 'center' style.backgroundPosition = 'center'
style.backgroundRepeat = 'no-repeat' style.backgroundRepeat = 'no-repeat'
@ -252,6 +311,17 @@ export default {
this.ensureSubTitleTemplate() 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: { subTitles: {
handler(titles) { handler(titles) {
while (this.subContents.length < (titles?.length || 0)) { while (this.subContents.length < (titles?.length || 0)) {
@ -283,6 +353,8 @@ export default {
this.$nextTick(() => this.syncTextBoxContent()) this.$nextTick(() => this.syncTextBoxContent())
}, },
beforeDestroy() { beforeDestroy() {
this.syncFromDomToData()
this.$emit('understanding-data', { subTitles: this.subTitles, subContents: this.getDataForSave() })
document.removeEventListener('keydown', this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler)
document.removeEventListener('click', this._clickHandler) document.removeEventListener('click', this._clickHandler)
}, },
@ -301,16 +373,24 @@ export default {
this.$refs.iconImageInput.click() this.$refs.iconImageInput.click()
} }
}, },
handleBackgroundSelect(e) { async handleBackgroundSelect(e) {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
this.$emit('background-change', ev.target.result)
}
reader.readAsDataURL(file)
this.insertMode = null this.insertMode = null
e.target.value = '' 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) { handleIconImageSelect(e) {
const file = e.target.files?.[0] const file = e.target.files?.[0]
@ -627,6 +707,83 @@ export default {
sub.pages.push({ icons: [], textBoxes: boxes || [] }) sub.pages.push({ icons: [], textBoxes: boxes || [] })
sub.currentPageIndex = sub.pages.length - 1 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() { prevPage() {
const sub = this.currentSubContent const sub = this.currentSubContent
if (!sub || !this.canPrevPage) return if (!sub || !this.canPrevPage) return
@ -634,6 +791,20 @@ export default {
sub.currentPageIndex = Math.max(0, (sub.currentPageIndex || 0) - 1) sub.currentPageIndex = Math.max(0, (sub.currentPageIndex || 0) - 1)
this.selectedId = null 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() { nextPage() {
const sub = this.currentSubContent const sub = this.currentSubContent
if (!sub || !this.canNextPage) return if (!sub || !this.canNextPage) return
@ -641,6 +812,27 @@ export default {
sub.currentPageIndex = Math.min((sub.pages?.length || 1) - 1, (sub.currentPageIndex || 0) + 1) sub.currentPageIndex = Math.min((sub.pages?.length || 1) - 1, (sub.currentPageIndex || 0) + 1)
this.selectedId = null 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) { deleteIcon(id) {
const page = this.currentPage const page = this.currentPage
if (!page) return 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) { deleteTextBox(id) {
const page = this.currentPage const page = this.currentPage
if (!page) return if (!page) return
@ -701,26 +902,88 @@ export default {
</script> </script>
<style scoped> <style scoped>
.pagination-bar { .pagination-left,
.pagination-right {
position: absolute;
top: 0;
bottom: 0;
width: 48px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 16px; z-index: 100;
padding: 8px 0; cursor: pointer;
background: rgba(0, 138, 255, 0.06); pointer-events: auto;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
flex-shrink: 0;
} }
.pagination-bar .page-indicator { .pagination-left {
font-size: 13px; left: 0;
color: #64748b; }
min-width: 60px; .pagination-right {
text-align: center; 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;
} }
.understanding-step-content { .understanding-step-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
position: relative;
} }
.task-canvas { .task-canvas {
@ -980,3 +1243,61 @@ export default {
pointer-events: none; pointer-events: none;
} }
</style> </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 DEFAULT_FONT = { fontFamily: '微软雅黑', color: '#333333' }
const TITLE_TOP = 16 const TITLE_TOP = 16
const TITLE_FONT_SIZE = 32 const TITLE_FONT_SIZE = 32
const OFFSET_RIGHT = 2
const CONTENT_WIDTH_REDUCE = 4
const TITLE_WIDTH = 160 const TITLE_WIDTH = 160
const TITLE_HEIGHT = 72 const TITLE_HEIGHT = 72
const CONTENT_FONT_SIZE = 22 const CONTENT_FONT_SIZE = 22
@ -20,13 +22,13 @@ export function createRollCallTextBoxes(canvasWidth, canvasHeight) {
const h = canvasHeight || 500 const h = canvasHeight || 500
const cols = 2 const cols = 2
const rows = 3 const rows = 3
const boxWidth = 240 const boxWidth = 240 - CONTENT_WIDTH_REDUCE
const boxHeight = 52 const boxHeight = 52
const gapX = 80 const gapX = 80
const gapY = 48 const gapY = 48
const groupWidth = cols * boxWidth + (cols - 1) * gapX const groupWidth = cols * boxWidth + (cols - 1) * gapX
const groupHeight = rows * boxHeight + (rows - 1) * gapY 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 startY = Math.max(0, (h - groupHeight) / 2)
const boxes = [] const boxes = []
@ -60,13 +62,13 @@ export function createSubTitleTemplate(subTitleName, canvasWidth, canvasHeight)
const padding = 40 const padding = 40
const gap = 12 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 contentHeight = 600
const titleWidth = ['第一次进度检查', '第二次进度检查'].includes(subTitleName) ? TITLE_WIDTH * 2 : TITLE_WIDTH const titleWidth = ['第一次进度检查', '第二次进度检查'].includes(subTitleName) ? TITLE_WIDTH * 2 : TITLE_WIDTH
const titleX = padding const titleX = padding + OFFSET_RIGHT
const titleY = TITLE_TOP const titleY = TITLE_TOP
const contentX = padding const contentX = padding + OFFSET_RIGHT
const contentY = titleY + TITLE_HEIGHT + gap const contentY = titleY + TITLE_HEIGHT + gap
return [ return [
@ -118,7 +120,7 @@ export function createSimpleTitleTemplate(subTitleName, canvasWidth, canvasHeigh
return [ return [
{ {
id: genId(), id: genId(),
x: padding, x: padding + OFFSET_RIGHT,
y: TITLE_TOP, y: TITLE_TOP,
width: TITLE_WIDTH, width: TITLE_WIDTH,
height: TITLE_HEIGHT, height: TITLE_HEIGHT,
@ -143,14 +145,14 @@ export function createIntentBriefingTemplate(canvasWidth, canvasHeight) {
const padding = 40 const padding = 40
const gap = 12 const gap = 12
const middleHeight = 62 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 contentHeight = 500
const titleX = padding const titleX = padding + OFFSET_RIGHT
const titleY = TITLE_TOP const titleY = TITLE_TOP
const middleX = padding const middleX = padding + OFFSET_RIGHT
const middleY = titleY + TITLE_HEIGHT + gap const middleY = titleY + TITLE_HEIGHT + gap
const contentX = padding const contentX = padding + OFFSET_RIGHT
const contentY = middleY + middleHeight + gap const contentY = middleY + middleHeight + gap
return [ return [
@ -172,7 +174,7 @@ export function createIntentBriefingTemplate(canvasWidth, canvasHeight) {
id: genId(), id: genId(),
x: middleX, x: middleX,
y: middleY, y: middleY,
width: 420, width: 420 - CONTENT_WIDTH_REDUCE,
height: middleHeight, height: middleHeight,
text: '明确每日任务目标,风险等级', text: '明确每日任务目标,风险等级',
placeholder: undefined, placeholder: undefined,
@ -207,14 +209,14 @@ export function createTaskPlanningTemplate(canvasWidth, canvasHeight) {
const padding = 40 const padding = 40
const gap = 12 const gap = 12
const middleHeight = 62 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 contentHeight = 500
const titleX = padding const titleX = padding + OFFSET_RIGHT
const titleY = TITLE_TOP const titleY = TITLE_TOP
const middleX = padding const middleX = padding + OFFSET_RIGHT
const middleY = titleY + TITLE_HEIGHT + gap const middleY = titleY + TITLE_HEIGHT + gap
const contentX = padding const contentX = padding + OFFSET_RIGHT
const contentY = middleY + middleHeight + gap const contentY = middleY + middleHeight + gap
return [ return [
@ -236,7 +238,7 @@ export function createTaskPlanningTemplate(canvasWidth, canvasHeight) {
id: genId(), id: genId(),
x: middleX, x: middleX,
y: middleY, y: middleY,
width: 240, width: 240 - CONTENT_WIDTH_REDUCE,
height: middleHeight, height: middleHeight,
text: 'XX规划:XXXXX', text: 'XX规划:XXXXX',
placeholder: undefined, placeholder: undefined,

Loading…
Cancel
Save