Files
booking_admin/apps/web-antd/src/components/classroom/SeatLayoutEditor.vue
杨志 8e308a75f6
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
修改布局,修复BUG
2025-12-08 11:49:09 +08:00

1527 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts" setup>
import { ref, computed, watch, onMounted, onUnmounted, shallowRef } from 'vue';
import { Button, Input, Space, message, Modal, Radio, Divider, Card } from 'ant-design-vue';
import type { ClassroomApi } from '#/api';
import { saveClassroomLayoutApi } from '#/api';
defineOptions({ name: 'SeatLayoutEditor' });
interface Props {
classroomId: number;
layout?: ClassroomApi.ClassroomLayout | null;
}
const props = withDefaults(defineProps<Props>(), {
layout: null,
});
const emit = defineEmits<{
saved: [layout: ClassroomApi.ClassroomLayout];
}>();
type CellSelectableType = ClassroomApi.ClassroomLayoutCell['type'] | 'merge';
const loading = ref(false);
const cols = ref(10);
const rows = ref(8);
const selectedCellType = ref<CellSelectableType>('seat');
const editingCell = ref<{ col: number; row: number } | null>(null);
const seatNumber = ref('');
const isDrawing = ref(false);
const autoNumbering = ref(true);
const seatNumberStart = ref(1);
const drawStartCell = ref<{ col: number; row: number } | null>(null); // 绘制起始单元格
const drawCurrentCell = ref<{ col: number; row: number } | null>(null); // 绘制当前单元格
const drawnCells = ref<Set<string>>(new Set()); // 记录本次绘制操作中已绘制的单元格(用于避免重复)
const hasChangesInDrawing = ref(false); // 标记当前绘制操作是否有变化
const mergeStartCell = ref<{ col: number; row: number } | null>(null); // 合并起始单元格
const mergeCurrentCell = ref<{ col: number; row: number } | null>(null); // 合并当前滑动到的单元格
const editingSeatStatus = ref<{ col: number; row: number } | null>(null); // 正在编辑座位状态的单元格
// 初始化布局数据 - 使用 shallowRef 优化性能
const cells = shallowRef<ClassroomApi.ClassroomLayoutCell[]>([]);
// 单元格查找缓存 Map: key = "col,row", value = cell
const cellMapCache = ref<Map<string, ClassroomApi.ClassroomLayoutCell>>(new Map());
// 合并单元格主单元格映射: key = "col,row" (被合并的单元格), value = "col,row" (主单元格)
const mergeMasterMapCache = ref<Map<string, string>>(new Map());
// 更新单元格缓存
const updateCellMapCache = () => {
const map = new Map<string, ClassroomApi.ClassroomLayoutCell>();
const mergeMap = new Map<string, string>();
// 先建立单元格映射
cells.value.forEach((cell) => {
const key = `${cell.col},${cell.row}`;
map.set(key, cell);
});
// 建立合并单元格映射
cells.value.forEach((cell) => {
if (cell.merged) return;
const colspan = cell.colspan || 1;
const rowspan = cell.rowspan || 1;
if (colspan > 1 || rowspan > 1) {
const masterKey = `${cell.col},${cell.row}`;
for (let r = cell.row; r < cell.row + rowspan; r++) {
for (let c = cell.col; c < cell.col + colspan; c++) {
if (c === cell.col && r === cell.row) continue; // 跳过主单元格
const key = `${c},${r}`;
mergeMap.set(key, masterKey);
}
}
}
});
cellMapCache.value = map;
mergeMasterMapCache.value = mergeMap;
};
// 历史记录(用于撤销)
interface HistoryState {
cols: number;
rows: number;
cells: ClassroomApi.ClassroomLayoutCell[];
}
const history = ref<HistoryState[]>([]);
const canUndo = computed(() => history.value.length > 0);
// 保存当前状态到历史记录
const saveHistory = () => {
history.value.push({
cols: cols.value,
rows: rows.value,
cells: JSON.parse(JSON.stringify(cells.value)),
});
// 限制历史记录数量,避免内存占用过大
if (history.value.length > 50) {
history.value.shift();
}
};
// 撤销操作
const handleUndo = () => {
if (history.value.length === 0) {
message.warning('没有可撤销的操作');
return;
}
const lastState = history.value.pop()!;
cols.value = lastState.cols;
rows.value = lastState.rows;
cells.value = JSON.parse(JSON.stringify(lastState.cells));
updateCellMapCache();
message.success('已撤销');
};
// 初始化布局
const initLayout = () => {
cells.value = [];
for (let row = 0; row < rows.value; row++) {
for (let col = 0; col < cols.value; col++) {
cells.value.push({
col,
row,
type: 'empty',
colspan: 1,
rowspan: 1,
merged: false,
});
}
}
updateCellMapCache();
};
// 从现有布局加载
const loadLayout = () => {
if (props.layout) {
cols.value = props.layout.cols || 10;
rows.value = props.layout.rows || 8;
if (props.layout.cells && props.layout.cells.length > 0) {
cells.value = JSON.parse(JSON.stringify(props.layout.cells));
// 确保所有单元格都有默认的合并属性,并修复类型字段
cells.value.forEach((cell) => {
if (cell.colspan === undefined) cell.colspan = 1;
if (cell.rowspan === undefined) cell.rowspan = 1;
if (cell.merged === undefined) cell.merged = false;
// 将 monitor 类型转换为 projector兼容后端数据
if ((cell.type as any) === 'monitor') {
cell.type = 'projector';
}
});
// 确保所有位置都有单元格数据(补充缺失的空白单元格)
const cellMap = new Map<string, ClassroomApi.ClassroomLayoutCell>();
cells.value.forEach((cell) => {
const key = `${cell.col},${cell.row}`;
cellMap.set(key, cell);
});
// 检查并补充缺失的单元格
for (let row = 0; row < rows.value; row++) {
for (let col = 0; col < cols.value; col++) {
const key = `${col},${row}`;
if (!cellMap.has(key)) {
cells.value.push({
col,
row,
type: 'empty',
colspan: 1,
rowspan: 1,
merged: false,
});
}
}
}
} else {
initLayout();
}
} else {
initLayout();
}
updateCellMapCache();
};
// 监听布局变化
watch(
() => props.layout,
() => {
loadLayout();
},
{ immediate: true },
);
// 切换类型时,如果离开合并模式,重置合并选区与绘制状态
watch(
() => selectedCellType.value,
(val) => {
if (val !== 'merge') {
mergeStartCell.value = null;
mergeCurrentCell.value = null;
isDrawing.value = false;
}
},
);
// 在左侧添加列
const addColumnLeft = () => {
saveHistory();
// 所有现有单元格的列号加1
cells.value.forEach((cell) => {
cell.col += 1;
});
// 添加新列列号为0
for (let row = 0; row < rows.value; row++) {
cells.value.push({
col: 0,
row,
type: 'empty',
colspan: 1,
rowspan: 1,
merged: false,
});
}
cols.value += 1;
updateCellMapCache();
message.success('已在左侧添加一列');
};
// 在右侧添加列
const addColumnRight = () => {
saveHistory();
// 添加新列(列号为当前最大列号+1
for (let row = 0; row < rows.value; row++) {
cells.value.push({
col: cols.value,
row,
type: 'empty',
colspan: 1,
rowspan: 1,
merged: false,
});
}
cols.value += 1;
updateCellMapCache();
message.success('已在右侧添加一列');
};
// 在上方添加行
const addRowTop = () => {
saveHistory();
// 所有现有单元格的行号加1
cells.value.forEach((cell) => {
cell.row += 1;
});
// 添加新行行号为0
for (let col = 0; col < cols.value; col++) {
cells.value.push({
col,
row: 0,
type: 'empty',
colspan: 1,
rowspan: 1,
merged: false,
});
}
rows.value += 1;
updateCellMapCache();
message.success('已在上方添加一行');
};
// 在下方添加行
const addRowBottom = () => {
saveHistory();
// 添加新行(行号为当前最大行号+1
for (let col = 0; col < cols.value; col++) {
cells.value.push({
col,
row: rows.value,
type: 'empty',
colspan: 1,
rowspan: 1,
merged: false,
});
}
rows.value += 1;
updateCellMapCache();
message.success('已在下方添加一行');
};
// 获取单元格 - 使用缓存优化性能
const getCell = (col: number, row: number) => {
const key = `${col},${row}`;
return cellMapCache.value.get(key);
};
// 获取下一个座位编号
const getNextSeatNumber = () => {
const seats = cells.value.filter((c) => c.type === 'seat' && c.number);
if (seats.length === 0) {
return String(seatNumberStart.value);
}
// 找到最大的座位编号
const maxNumber = Math.max(
...seats.map((c) => {
const num = parseInt(c.number || '0', 10);
return isNaN(num) ? 0 : num;
}),
);
return String(maxNumber + 1);
};
// 设置单元格类型
const setCellType = (col: number, row: number, type: ClassroomApi.ClassroomLayoutCell['type']) => {
const cell = getCell(col, row);
if (!cell || cell.merged) return; // 被合并的单元格不能直接设置类型
const wasSeat = cell.type === 'seat';
cell.type = type;
if (type !== 'seat') {
delete cell.number;
delete cell.status;
}
if (type === 'empty') {
delete cell.name;
} else if (type === 'pillar' || type === 'aisle' || type === 'door' || type === 'projector') {
const nameMap: Record<ClassroomApi.ClassroomLayoutCell['type'], string> = {
pillar: '柱子',
aisle: '过道',
door: '门',
projector: '投影仪',
seat: '座位',
empty: '',
};
cell.name = nameMap[type];
} else if (type === 'seat') {
// 如果是座位类型
if (autoNumbering.value) {
// 自动编号:只有从非座位类型变为座位类型时才分配新编号
if (!wasSeat || !cell.number) {
cell.number = getNextSeatNumber();
cell.status = 1; // 默认可用
}
// 如果已经是座位类型,保持原有编号和状态不变
} else if (!cell.number) {
// 手动输入模式,如果没有编号,设置为空
cell.status = 1; // 默认可用
}
}
// 更新缓存(单元格对象引用未变,但内容变了,需要更新 Map
const key = `${col},${row}`;
cellMapCache.value.set(key, cell);
};
// 处理单元格设置(用于点击和拖动)
const applyCellType = (col: number, row: number, skipSameType = false) => {
// 合并模式下不做类型绘制,交给合并逻辑处理
if (selectedCellType.value === 'merge') {
return;
}
const cell = getCell(col, row);
if (!cell) return;
const oldType = cell.type;
// 如果跳过相同类型,且当前类型已经是目标类型,则不处理
if (skipSameType && cell.type === selectedCellType.value) {
// 如果是座位类型但没有编号,需要输入
if (selectedCellType.value === 'seat' && !autoNumbering.value && !cell.number) {
editingCell.value = { col, row };
seatNumber.value = '';
}
return;
}
// 在绘制模式下,检查是否已经绘制过(避免重复)
if (isDrawing.value) {
const cellKey = `${col},${row}`;
if (drawnCells.value.has(cellKey)) {
return;
}
drawnCells.value.add(cellKey);
}
if (selectedCellType.value === 'seat' && !autoNumbering.value && !isDrawing.value) {
// 如果是座位且不自动编号,且不是拖动模式,需要输入座位号
editingCell.value = { col, row };
seatNumber.value = cell?.number || '';
} else {
// 其他情况直接设置
setCellType(col, row, selectedCellType.value);
// 标记有变化
if (oldType !== selectedCellType.value) {
hasChangesInDrawing.value = true;
}
}
};
// 填充从起点到终点之间的所有单元格
const fillCellsBetween = (startCol: number, startRow: number, endCol: number, endRow: number) => {
const minCol = Math.min(startCol, endCol);
const maxCol = Math.max(startCol, endCol);
const minRow = Math.min(startRow, endRow);
const maxRow = Math.max(startRow, endRow);
// 合并模式下不填充
if (selectedCellType.value === 'merge') {
return;
}
// 按顺序填充所有单元格
for (let row = minRow; row <= maxRow; row++) {
for (let col = minCol; col <= maxCol; col++) {
const cellKey = `${col},${row}`;
if (!drawnCells.value.has(cellKey)) {
drawnCells.value.add(cellKey);
const cell = getCell(col, row);
if (cell) {
const oldType = cell.type;
setCellType(col, row, selectedCellType.value as ClassroomApi.ClassroomLayoutCell['type']);
if (oldType !== selectedCellType.value) {
hasChangesInDrawing.value = true;
}
}
}
}
}
};
// 点击单元格
const handleCellClick = (col: number, row: number) => {
// 合并模式下,点击交给拖拽流程处理,这里不做处理
if (selectedCellType.value === 'merge') return;
if (!isDrawing.value) {
// 点击操作前保存历史记录
const cell = getCell(col, row);
if (cell && cell.type !== selectedCellType.value) {
saveHistory();
}
applyCellType(col, row, true);
}
};
// 鼠标按下开始绘制/合并
const handleCellMouseDown = (col: number, row: number, event: MouseEvent) => {
// 右键点击不触发绘制
if (event.button === 2) {
return;
}
event.preventDefault();
isDrawing.value = true;
hasChangesInDrawing.value = false; // 重置变化标记
drawnCells.value.clear(); // 清空已绘制单元格记录
if (selectedCellType.value === 'merge') {
mergeStartCell.value = { col, row };
mergeCurrentCell.value = { col, row };
return;
}
// 记录绘制起始位置
drawStartCell.value = { col, row };
drawCurrentCell.value = { col, row };
// 拖动操作开始前保存历史记录
saveHistory();
applyCellType(col, row, false);
};
// 鼠标移动绘制(使用 requestAnimationFrame 优化性能)
let rafId: number | null = null;
const handleCellMouseEnter = (col: number, row: number) => {
if (!isDrawing.value) return;
if (selectedCellType.value === 'merge') {
mergeCurrentCell.value = { col, row };
return;
}
// 更新当前绘制位置
if (drawStartCell.value) {
drawCurrentCell.value = { col, row };
// 使用 requestAnimationFrame 优化性能,避免过于频繁的更新
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
// 填充从起点到当前点的所有单元格
if (drawStartCell.value && drawCurrentCell.value) {
fillCellsBetween(
drawStartCell.value.col,
drawStartCell.value.row,
drawCurrentCell.value.col,
drawCurrentCell.value.row
);
}
rafId = null;
});
} else {
// 如果没有起始点,直接应用(兼容旧逻辑)
applyCellType(col, row, false);
}
};
// 鼠标释放停止绘制/合并
const handleCellMouseUp = () => {
if (!isDrawing.value) return;
// 清除 requestAnimationFrame
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (selectedCellType.value === 'merge') {
const start = mergeStartCell.value;
const end = mergeCurrentCell.value;
if (start && end) {
const colspan = Math.abs(end.col - start.col) + 1;
const rowspan = Math.abs(end.row - start.row) + 1;
if (colspan === 1 && rowspan === 1) {
message.warning('请选择至少两个单元格进行合并');
} else {
saveHistory();
mergeCells(start.col, start.row, end.col, end.row);
}
}
mergeStartCell.value = null;
mergeCurrentCell.value = null;
isDrawing.value = false;
return;
}
// 确保填充到最终位置
if (drawStartCell.value && drawCurrentCell.value) {
fillCellsBetween(
drawStartCell.value.col,
drawStartCell.value.row,
drawCurrentCell.value.col,
drawCurrentCell.value.row
);
}
isDrawing.value = false;
drawStartCell.value = null;
drawCurrentCell.value = null;
drawnCells.value.clear();
// 如果拖动过程中没有实际变化,撤销刚才保存的历史记录
if (!hasChangesInDrawing.value && history.value.length > 0) {
history.value.pop();
}
hasChangesInDrawing.value = false;
updateCellMapCache();
};
// 全局鼠标事件处理(确保即使鼠标移出单元格也能停止绘制)
const handleGlobalMouseUp = () => {
handleCellMouseUp();
};
// 组件挂载时添加全局事件监听
onMounted(() => {
document.addEventListener('mouseup', handleGlobalMouseUp);
document.addEventListener('mouseleave', handleGlobalMouseUp);
});
onUnmounted(() => {
document.removeEventListener('mouseup', handleGlobalMouseUp);
document.removeEventListener('mouseleave', handleGlobalMouseUp);
});
// 确认座位号
const confirmSeatNumber = () => {
if (!editingCell.value) return;
const cell = getCell(editingCell.value.col, editingCell.value.row);
if (cell) {
// 如果类型或编号发生变化,保存历史记录
if (cell.type !== 'seat' || cell.number !== seatNumber.value) {
saveHistory();
}
cell.type = 'seat';
cell.number = seatNumber.value;
cell.status = 1; // 默认可用
// 更新缓存
const key = `${editingCell.value.col},${editingCell.value.row}`;
cellMapCache.value.set(key, cell);
}
editingCell.value = null;
seatNumber.value = '';
};
// 批量填充
const handleFillAll = () => {
if (selectedCellType.value === 'merge') {
message.warning('合并模式下无法批量填充');
return;
}
if (selectedCellType.value === 'empty') {
message.warning('不能批量填充为空白类型');
return;
}
Modal.confirm({
title: '确认批量填充',
content: `确定要将所有空白单元格填充为"${getTypeName(selectedCellType.value)}"吗?`,
onOk: () => {
saveHistory();
// 如果是座位类型且自动编号,先获取当前最大编号
const emptyCells = cells.value.filter((cell) => cell.type === 'empty' && !cell.merged);
const cellType = selectedCellType.value as ClassroomApi.ClassroomLayoutCell['type'];
emptyCells.forEach((cell) => {
setCellType(cell.col, cell.row, cellType);
});
updateCellMapCache();
message.success(`批量填充完成,共填充 ${emptyCells.length} 个单元格`);
},
});
};
// 获取类型名称
const getTypeName = (type: CellSelectableType) => {
const typeMap: Record<string, string> = {
empty: '空白',
seat: '座位',
pillar: '柱子',
aisle: '过道',
door: '门',
projector: '投影仪',
merge: '合并',
};
return typeMap[type] || type;
};
// 统计座位数量
const seatCount = computed(() => {
return cells.value.filter((c) => c.type === 'seat').length;
});
// 取消编辑
const cancelEdit = () => {
editingCell.value = null;
seatNumber.value = '';
};
// 获取单元格样式 - 优化性能,使用缓存
const getCellClass = (col: number, row: number) => {
const cell = getCell(col, row);
if (!cell) return 'cell-empty';
// 如果是被合并的单元格,不显示
if (cell.merged) {
return 'cell cell-merged';
}
// 使用缓存检查是否被其他合并单元格覆盖
const key = `${col},${row}`;
if (mergeMasterMapCache.value.has(key)) {
return 'cell cell-merged';
}
const classes = ['cell', `cell-${cell.type}`];
// 如果是座位且禁坐,添加禁坐样式
if (cell.type === 'seat' && cell.status === 0) {
classes.push('cell-disabled');
}
if (editingCell.value && editingCell.value.col === col && editingCell.value.row === row) {
classes.push('cell-editing');
}
// 合并拖动预览:当前选择合并类型,且在选择矩形范围内
if (selectedCellType.value === 'merge' && mergeStartCell.value && mergeCurrentCell.value) {
const minCol = Math.min(mergeStartCell.value.col, mergeCurrentCell.value.col);
const maxCol = Math.max(mergeStartCell.value.col, mergeCurrentCell.value.col);
const minRow = Math.min(mergeStartCell.value.row, mergeCurrentCell.value.row);
const maxRow = Math.max(mergeStartCell.value.row, mergeCurrentCell.value.row);
if (col >= minCol && col <= maxCol && row >= minRow && row <= maxRow) {
classes.push('cell-merge-selected');
}
}
return classes.join(' ');
};
// 获取单元格显示文本
const getCellText = (col: number, row: number) => {
const cell = getCell(col, row);
if (!cell || cell.merged) return '';
if (cell.type === 'seat' && cell.number) {
return cell.number;
}
if (cell.name) {
return cell.name;
}
return '';
};
// 获取单元格的 Grid 样式属性(用于合并单元格)
const getCellGridStyle = (col: number, row: number) => {
const cell = getCell(col, row);
// 使用 CSS Grid 的 grid-column 和 grid-row 来实现合并
// grid 布局中列从1开始第1列是行号行从1开始
// 传入的 col 和 row 是数组索引从0开始需要转换为Grid位置从1开始
const gridColumn = col + 2; // +2 因为第1列是行号数据列从第2列开始
const gridRow = row + 1; // row 是数组索引0开始Grid行号从1开始所以+1
const style: Record<string, string> = {};
// 如果单元格不存在或被合并,仍然需要设置基本位置(用于空白单元格显示)
if (!cell || cell.merged) {
// 被合并的单元格不设置 grid 位置,由主单元格占据
// 但如果单元格不存在(空白),需要设置位置以便显示
if (!cell) {
style.gridColumn = `${gridColumn} / span 1`;
style.gridRow = `${gridRow} / span 1`;
}
return style;
}
const colspan = cell.colspan || 1;
const rowspan = cell.rowspan || 1;
if (colspan > 1 || rowspan > 1) {
// 合并单元格:使用 span 语法
style.gridColumn = `${gridColumn} / span ${colspan}`;
style.gridRow = `${gridRow} / span ${rowspan}`;
} else {
// 普通单元格只设置位置也可以显式设置span 1
style.gridColumn = `${gridColumn} / span 1`;
style.gridRow = `${gridRow} / span 1`;
}
return style;
};
// 检查单元格是否应该渲染用于合并单元格Grid布局中只渲染主单元格- 优化性能
const shouldRenderCell = (col: number, row: number) => {
// 首先检查是否在有效范围内
if (col < 0 || col >= cols.value || row < 0 || row >= rows.value) {
return false;
}
const cell = getCell(col, row);
// 如果单元格不存在,应该渲染(作为空白单元格)
if (!cell) {
return true;
}
// 被合并的单元格不渲染
if (cell.merged) {
return false;
}
// 使用缓存检查是否被其他合并单元格覆盖
const key = `${col},${row}`;
if (mergeMasterMapCache.value.has(key)) {
return false;
}
return true;
};
// 保存布局
const handleSave = async () => {
if (!props.classroomId) {
message.error('教室ID不能为空');
return;
}
loading.value = true;
try {
// 保存前将 projector 转换为 monitor兼容后端
const cellsToSave = cells.value.map((cell) => {
const cellCopy = { ...cell };
if (cellCopy.type === 'projector') {
(cellCopy as any).type = 'monitor';
}
return cellCopy;
});
const layout: ClassroomApi.ClassroomLayout = {
cols: cols.value,
rows: rows.value,
cells: cellsToSave as any,
};
await saveClassroomLayoutApi({
id: props.classroomId,
layout,
});
message.success('座位布局保存成功');
// 发送保存后的布局(使用前端类型)
emit('saved', {
cols: cols.value,
rows: rows.value,
cells: cells.value,
});
} catch (error) {
console.error('保存座位布局失败:', error);
message.error('保存座位布局失败');
} finally {
loading.value = false;
}
};
// 清空布局
const handleClear = () => {
Modal.confirm({
title: '确认清空',
content: '确定要清空所有座位布局吗?',
onOk: () => {
saveHistory(); // 保存清空前的状态,可以撤销
initLayout();
message.success('已清空布局');
},
});
};
// 检查单元格是否是合并的主单元格
const isMergeMaster = (col: number, row: number) => {
const cell = getCell(col, row);
return cell && (cell.colspan && cell.colspan > 1 || cell.rowspan && cell.rowspan > 1);
};
// 获取合并的主单元格
const getMergeMaster = (col: number, row: number) => {
// 遍历所有单元格,找到包含该单元格的合并主单元格
for (const cell of cells.value) {
if (cell.merged) continue;
const colspan = cell.colspan || 1;
const rowspan = cell.rowspan || 1;
if (
col >= cell.col &&
col < cell.col + colspan &&
row >= cell.row &&
row < cell.row + rowspan
) {
return cell;
}
}
return null;
};
// 取消合并单元格
const unmergeCell = (col: number, row: number) => {
const cell = getCell(col, row);
if (!cell) return;
const colspan = cell.colspan || 1;
const rowspan = cell.rowspan || 1;
// 清除被合并的单元格标记
for (let r = cell.row; r < cell.row + rowspan; r++) {
for (let c = cell.col; c < cell.col + colspan; c++) {
if (c === col && r === row) continue; // 跳过主单元格
const mergedCell = getCell(c, r);
if (mergedCell) {
mergedCell.merged = false;
// 恢复为空白类型
if (mergedCell.type === 'empty') {
mergedCell.colspan = 1;
mergedCell.rowspan = 1;
}
}
}
}
// 重置主单元格的合并属性
cell.colspan = 1;
cell.rowspan = 1;
updateCellMapCache();
};
// 合并单元格
const mergeCells = (startCol: number, startRow: number, endCol: number, endRow: number) => {
// 确保起始位置在左上角
const minCol = Math.min(startCol, endCol);
const maxCol = Math.max(startCol, endCol);
const minRow = Math.min(startRow, endRow);
const maxRow = Math.max(startRow, endRow);
const colspan = maxCol - minCol + 1;
const rowspan = maxRow - minRow + 1;
// 如果只选择一个单元格,不进行合并
if (colspan === 1 && rowspan === 1) {
message.warning('请选择至少两个单元格进行合并');
return;
}
// 检查范围内是否有已合并的单元格
for (let r = minRow; r <= maxRow; r++) {
for (let c = minCol; c <= maxCol; c++) {
const cell = getCell(c, r);
if (!cell) continue;
// 如果单元格是合并的主单元格,先取消合并
if (isMergeMaster(c, r)) {
unmergeCell(c, r);
}
// 如果单元格是被合并的单元格,需要找到主单元格并取消合并
if (cell.merged) {
const master = getMergeMaster(c, r);
if (master) {
unmergeCell(master.col, master.row);
}
}
}
}
// 获取主单元格(左上角的单元格)
const masterCell = getCell(minCol, minRow);
if (!masterCell) return;
// 设置主单元格的合并属性
masterCell.colspan = colspan;
masterCell.rowspan = rowspan;
// 标记其他单元格为被合并
for (let r = minRow; r <= maxRow; r++) {
for (let c = minCol; c <= maxCol; c++) {
if (c === minCol && r === minRow) continue; // 跳过主单元格
const cell = getCell(c, r);
if (cell) {
cell.merged = true;
// 保持单元格的类型和属性,但标记为被合并
}
}
}
updateCellMapCache();
message.success(`已合并 ${colspan}×${rowspan} 的单元格`);
};
// 设置座位禁坐状态
const setSeatDisabled = (col: number, row: number) => {
const cell = getCell(col, row);
if (!cell || cell.type !== 'seat') {
message.warning('只能对座位设置禁坐状态');
return;
}
saveHistory();
cell.status = cell.status === 0 ? 1 : 0; // 切换状态
// 更新缓存
const key = `${col},${row}`;
cellMapCache.value.set(key, cell);
message.success(cell.status === 0 ? '座位已设置为禁坐' : '座位已设置为可用');
};
// 右键菜单处理(用于设置座位禁坐)
const handleCellContextMenu = (col: number, row: number, event: MouseEvent) => {
event.preventDefault();
const cell = getCell(col, row);
if (cell && cell.type === 'seat') {
editingSeatStatus.value = { col, row };
Modal.confirm({
title: '设置座位状态',
content: `当前状态:${cell.status === 0 ? '禁坐' : '可用'}`,
okText: cell.status === 0 ? '设为可用' : '设为禁坐',
cancelText: '取消',
onOk: () => {
setSeatDisabled(col, row);
editingSeatStatus.value = null;
},
onCancel: () => {
editingSeatStatus.value = null;
},
});
}
};
</script>
<template>
<div class="seat-layout-editor" :class="{ drawing: isDrawing }">
<Card>
<div class="editor-toolbar">
<div class="toolbar-section">
<div class="toolbar-item">
<label class="toolbar-label">当前列数</label>
<span class="toolbar-value">{{ cols }}</span>
<Button size="small" @click="addColumnLeft">左侧添加</Button>
<Button size="small" @click="addColumnRight">右侧添加</Button>
</div>
<Divider type="vertical" />
<div class="toolbar-item">
<label class="toolbar-label">当前行数</label>
<span class="toolbar-value">{{ rows }}</span>
<Button size="small" @click="addRowTop">上方添加</Button>
<Button size="small" @click="addRowBottom">下方添加</Button>
</div>
<Divider type="vertical" />
<div class="toolbar-item">
<label class="toolbar-label">选择类型</label>
<Radio.Group v-model:value="selectedCellType" button-style="solid" size="large">
<Radio.Button value="empty">
<span class="type-icon type-empty"></span>
空白
</Radio.Button>
<Radio.Button value="seat">
<span class="type-icon type-seat"></span>
座位
</Radio.Button>
<Radio.Button value="pillar">
<span class="type-icon type-pillar"></span>
柱子
</Radio.Button>
<Radio.Button value="aisle">
<span class="type-icon type-aisle"></span>
过道
</Radio.Button>
<Radio.Button value="door">
<span class="type-icon type-door"></span>
</Radio.Button>
<Radio.Button value="projector">
<span class="type-icon type-projector"></span>
投影仪
</Radio.Button>
<Radio.Button value="merge">
<span class="type-icon type-merge"></span>
合并
</Radio.Button>
</Radio.Group>
</div>
</div>
<Divider />
<div class="toolbar-section">
<div class="toolbar-item">
<label class="toolbar-label">座位编号</label>
<Radio.Group v-model:value="autoNumbering">
<Radio :value="true">自动编号</Radio>
<Radio :value="false">手动输入</Radio>
</Radio.Group>
<Input
v-if="autoNumbering"
:value="String(seatNumberStart)"
type="number"
:min="1"
:style="{ width: '100px', marginLeft: '8px' }"
placeholder="起始编号"
@change="(e: any) => { const val = parseInt(e.target.value); if (!isNaN(val) && val > 0) seatNumberStart = val; }"
/>
</div>
<Divider type="vertical" />
<Space>
<Button :disabled="!canUndo" @click="handleUndo">
<template #icon>
<span></span>
</template>
撤销
</Button>
<Button @click="handleFillAll">批量填充空白</Button>
<Button danger @click="handleClear">清空布局</Button>
<Button type="primary" size="large" :loading="loading" @click="handleSave">
保存布局
</Button>
</Space>
</div>
<div class="toolbar-stats">
<span>座位总数<strong>{{ seatCount }}</strong></span>
</div>
</div>
</Card>
<Card class="layout-card">
<div class="editor-hint">
<p>
<strong>操作提示</strong>
<strong>按住鼠标左键拖动</strong>可以连续绘制多个单元格
{{ autoNumbering ? '座位将自动编号。' : '选择"座位"时需要手动输入座位号。' }}
<strong>右键点击座位</strong>可以设置禁坐状态
<span v-if="isDrawing" class="drawing-indicator">正在绘制中...</span>
</p>
</div>
<div class="layout-container" :style="{ '--cols': cols, '--rows': rows }">
<div class="layout-header">
<div class="header-cell"></div>
<div
v-for="col in cols"
:key="`header-col-${col}`"
class="header-cell header-col"
>
{{ col }}
</div>
</div>
<div class="layout-body">
<template v-for="row in rows" :key="`row-${row}`">
<div class="header-cell header-row" :style="{ gridColumn: '1', gridRow: String(row) }">{{ row }}</div>
<template v-for="col in cols" :key="`cell-${row}-${col}`">
<div
v-if="shouldRenderCell(col - 1, row - 1)"
:class="getCellClass(col - 1, row - 1)"
:style="getCellGridStyle(col - 1, row - 1)"
@click="handleCellClick(col - 1, row - 1)"
@mousedown="(e) => handleCellMouseDown(col - 1, row - 1, e)"
@mouseenter="handleCellMouseEnter(col - 1, row - 1)"
@contextmenu="(e) => handleCellContextMenu(col - 1, row - 1, e)"
>
{{ getCellText(col - 1, row - 1) }}
<span v-if="getCell(col - 1, row - 1)?.type === 'seat' && getCell(col - 1, row - 1)?.status === 0" class="disabled-icon">🚫</span>
</div>
</template>
</template>
</div>
</div>
</Card>
<Modal
:open="!!editingCell"
title="输入座位号"
@ok="confirmSeatNumber"
@cancel="cancelEdit"
>
<Input
v-model:value="seatNumber"
placeholder="请输入座位号"
@press-enter="confirmSeatNumber"
/>
</Modal>
</div>
</template>
<style scoped>
.seat-layout-editor {
padding: 0;
}
.editor-toolbar {
padding: 0;
}
.toolbar-section {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 12px;
}
.toolbar-item {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-label {
font-size: 15px;
font-weight: 600;
color: var(--ant-color-text);
white-space: nowrap;
}
.toolbar-value {
display: inline-block;
min-width: 30px;
padding: 0 8px;
font-size: 15px;
font-weight: 700;
color: var(--ant-color-primary);
text-align: center;
}
.toolbar-item :deep(.ant-radio-wrapper),
.toolbar-item :deep(.ant-radio-button-wrapper) {
color: var(--ant-color-text);
font-weight: 500;
}
.toolbar-item :deep(.ant-radio-button-wrapper-checked) {
color: var(--ant-color-text-inverse);
}
.toolbar-stats {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--ant-color-border-secondary);
font-size: 15px;
color: var(--ant-color-text-secondary);
font-weight: 500;
}
.toolbar-stats strong {
color: var(--ant-color-primary);
font-size: 18px;
font-weight: 700;
}
.type-icon {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: 4px;
vertical-align: middle;
}
.type-empty {
background: #fff;
border: 1px solid #d9d9d9;
}
.type-seat {
background: #52c41a;
}
.type-pillar {
background: #8c8c8c;
}
.type-aisle {
background: #faad14;
}
.type-door {
background: #1890ff;
}
.type-projector {
background: #13c2c2;
}
.type-merge {
background: #722ed1;
}
.layout-card {
margin-top: 16px;
}
.editor-hint {
margin-bottom: 16px;
padding: 12px 16px;
background: var(--ant-color-info-bg, #e6f7ff);
border: 1px solid var(--ant-color-info-border, #91d5ff);
border-radius: 4px;
}
.editor-hint p {
margin: 0;
font-size: 14px;
color: var(--ant-color-info, #1890ff);
line-height: 1.6;
}
.drawing-indicator {
display: inline-block;
margin-left: 12px;
padding: 2px 8px;
background: #ff4d4f;
color: #fff;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
animation: blink 1s infinite;
}
.merge-indicator {
display: inline-block;
margin-left: 12px;
padding: 2px 8px;
background: #1890ff;
color: #fff;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.layout-container {
overflow-x: auto;
padding: 10px;
background: var(--ant-color-fill-tertiary, #fafafa);
border-radius: 4px;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.layout-header {
display: flex;
margin-bottom: 4px;
gap: 4px;
}
.layout-body {
display: grid;
grid-template-columns: 45px repeat(var(--cols, 10), 45px);
grid-template-rows: repeat(var(--rows, 8), 45px);
gap: 4px;
grid-auto-flow: row;
align-items: stretch;
justify-items: stretch;
}
.layout-row {
display: contents; /* 让行容器不参与布局,直接使用 grid */
}
.header-cell {
width: 45px;
min-width: 45px;
height: 45px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: var(--ant-color-text-secondary, #595959);
background: var(--ant-color-fill-secondary, #f5f5f5);
border: 2px solid var(--ant-color-border-secondary, #e8e8e8);
border-radius: 2px;
user-select: none;
box-sizing: border-box;
}
.header-col {
background: var(--ant-color-primary-bg, #e6f7ff);
color: var(--ant-color-primary, #1890ff);
font-weight: 700;
}
.header-row {
background: var(--ant-color-success-bg, #f6ffed);
color: var(--ant-color-success, #52c41a);
font-weight: 700;
}
.cell {
/* Grid布局中宽度和高度由grid自动控制不设置固定值 */
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 2px solid #d9d9d9;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
transition: all 0.15s;
user-select: none;
position: relative;
line-height: 1;
box-sizing: border-box;
min-width: 0; /* 允许grid控制宽度 */
min-height: 0; /* 允许grid控制高度 */
}
.cell:hover {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
z-index: 1;
/* 移除 transform: scale避免影响合并单元格的布局 */
}
.seat-layout-editor.drawing .cell {
cursor: crosshair;
}
.cell-empty {
background: var(--ant-color-bg-container, #ffffff);
color: var(--ant-color-text-disabled, #bfbfbf);
border-color: var(--ant-color-border, #d9d9d9);
}
.cell-seat {
background: #52c41a;
color: #ffffff;
border-color: #389e0d;
font-weight: 700;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
position: relative;
}
.cell-seat:hover {
background: #73d13d;
border-color: #52c41a;
box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.2);
}
.cell-seat.cell-disabled {
background: #ff7875;
border-color: #ff4d4f;
opacity: 0.7;
position: relative;
}
.cell-seat.cell-disabled:hover {
background: #ff9c9a;
border-color: #ff7875;
box-shadow: 0 0 0 3px rgba(255, 77, 79, 0.2);
}
.cell-seat.cell-disabled::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
45deg,
transparent,
transparent 5px,
rgba(0, 0, 0, 0.1) 5px,
rgba(0, 0, 0, 0.1) 10px
);
pointer-events: none;
}
.disabled-icon {
position: absolute;
top: 2px;
right: 2px;
font-size: 10px;
line-height: 1;
}
.cell-pillar {
background: #595959;
color: #ffffff;
border-color: #434343;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.cell-pillar:hover {
background: #8c8c8c;
box-shadow: 0 0 0 3px rgba(89, 89, 89, 0.2);
}
.cell-aisle {
background: #fa8c16;
color: #ffffff;
border-color: #d46b08;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.cell-aisle:hover {
background: #ffa940;
box-shadow: 0 0 0 3px rgba(250, 140, 22, 0.2);
}
.cell-door {
background: #1890ff;
color: #ffffff;
border-color: #096dd9;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.cell-door:hover {
background: #40a9ff;
box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.2);
}
.cell-projector {
background: #13c2c2;
color: #ffffff;
border-color: #08979c;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.cell-projector:hover {
background: #36cfc9;
border-color: #13c2c2;
box-shadow: 0 0 0 3px rgba(19, 194, 194, 0.2);
}
.cell-editing {
border-color: #ff4d4f !important;
box-shadow: 0 0 0 3px rgba(255, 77, 79, 0.2) !important;
animation: pulse 1s infinite;
}
.cell-merged {
display: none !important; /* Grid 布局中,被合并的单元格完全隐藏,由主单元格占据空间 */
}
.cell-merge-selected {
border-color: #1890ff !important;
box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.3) !important;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
</style>