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
1527 lines
41 KiB
Vue
1527 lines
41 KiB
Vue
<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>
|
||
|