1
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
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
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
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
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
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
This commit is contained in:
904
apps/web-antd/src/components/classroom/SeatLayoutEditor.vue
Normal file
904
apps/web-antd/src/components/classroom/SeatLayoutEditor.vue
Normal file
@@ -0,0 +1,904 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { Button, Input, Select, 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];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const cols = ref(10);
|
||||
const rows = ref(8);
|
||||
const selectedCellType = ref<ClassroomApi.ClassroomLayoutCell['type']>('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 lastDrawnCell = ref<{ col: number; row: number } | null>(null);
|
||||
const hasChangesInDrawing = ref(false); // 标记当前绘制操作是否有变化
|
||||
|
||||
// 初始化布局数据
|
||||
const cells = ref<ClassroomApi.ClassroomLayoutCell[]>([]);
|
||||
|
||||
// 历史记录(用于撤销)
|
||||
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));
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 从现有布局加载
|
||||
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));
|
||||
} else {
|
||||
initLayout();
|
||||
}
|
||||
} else {
|
||||
initLayout();
|
||||
}
|
||||
};
|
||||
|
||||
// 监听布局变化
|
||||
watch(
|
||||
() => props.layout,
|
||||
() => {
|
||||
loadLayout();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 在左侧添加列
|
||||
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',
|
||||
});
|
||||
}
|
||||
cols.value += 1;
|
||||
message.success('已在左侧添加一列');
|
||||
};
|
||||
|
||||
// 在右侧添加列
|
||||
const addColumnRight = () => {
|
||||
saveHistory();
|
||||
// 添加新列(列号为当前最大列号+1)
|
||||
for (let row = 0; row < rows.value; row++) {
|
||||
cells.value.push({
|
||||
col: cols.value,
|
||||
row,
|
||||
type: 'empty',
|
||||
});
|
||||
}
|
||||
cols.value += 1;
|
||||
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',
|
||||
});
|
||||
}
|
||||
rows.value += 1;
|
||||
message.success('已在上方添加一行');
|
||||
};
|
||||
|
||||
// 在下方添加行
|
||||
const addRowBottom = () => {
|
||||
saveHistory();
|
||||
// 添加新行(行号为当前最大行号+1)
|
||||
for (let col = 0; col < cols.value; col++) {
|
||||
cells.value.push({
|
||||
col,
|
||||
row: rows.value,
|
||||
type: 'empty',
|
||||
});
|
||||
}
|
||||
rows.value += 1;
|
||||
message.success('已在下方添加一行');
|
||||
};
|
||||
|
||||
// 获取单元格
|
||||
const getCell = (col: number, row: number) => {
|
||||
return cells.value.find((c) => c.col === col && c.row === row);
|
||||
};
|
||||
|
||||
// 获取下一个座位编号
|
||||
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) return;
|
||||
|
||||
const wasSeat = cell.type === 'seat';
|
||||
const oldType = cell.type;
|
||||
|
||||
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') {
|
||||
cell.name = type === 'pillar' ? '柱子' : type === 'aisle' ? '过道' : '门';
|
||||
} else if (type === 'seat') {
|
||||
// 如果是座位类型
|
||||
if (autoNumbering.value) {
|
||||
// 自动编号:只有从非座位类型变为座位类型时才分配新编号
|
||||
if (!wasSeat || !cell.number) {
|
||||
cell.number = getNextSeatNumber();
|
||||
cell.status = 1;
|
||||
}
|
||||
// 如果已经是座位类型,保持原有编号不变
|
||||
} else if (!cell.number) {
|
||||
// 手动输入模式,如果没有编号,设置为空
|
||||
cell.status = 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理单元格设置(用于点击和拖动)
|
||||
const applyCellType = (col: number, row: number, skipSameType = false) => {
|
||||
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 (lastDrawnCell.value && lastDrawnCell.value.col === col && lastDrawnCell.value.row === row) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCellType.value === 'seat' && !autoNumbering.value && !isDrawing.value) {
|
||||
// 如果是座位且不自动编号,且不是拖动模式,需要输入座位号
|
||||
editingCell.value = { col, row };
|
||||
seatNumber.value = cell?.number || '';
|
||||
} else {
|
||||
// 其他情况直接设置
|
||||
setCellType(col, row, selectedCellType.value);
|
||||
lastDrawnCell.value = { col, row };
|
||||
// 标记有变化
|
||||
if (oldType !== selectedCellType.value) {
|
||||
hasChangesInDrawing.value = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 点击单元格
|
||||
const handleCellClick = (col: number, row: number) => {
|
||||
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) => {
|
||||
event.preventDefault();
|
||||
isDrawing.value = true;
|
||||
hasChangesInDrawing.value = false; // 重置变化标记
|
||||
lastDrawnCell.value = null; // 重置最后绘制的单元格
|
||||
// 拖动操作开始前保存历史记录
|
||||
saveHistory();
|
||||
applyCellType(col, row, false);
|
||||
};
|
||||
|
||||
// 鼠标移动绘制
|
||||
const handleCellMouseEnter = (col: number, row: number) => {
|
||||
if (isDrawing.value) {
|
||||
applyCellType(col, row, false);
|
||||
}
|
||||
};
|
||||
|
||||
// 鼠标释放停止绘制
|
||||
const handleCellMouseUp = () => {
|
||||
if (isDrawing.value) {
|
||||
isDrawing.value = false;
|
||||
lastDrawnCell.value = null;
|
||||
// 如果拖动过程中没有实际变化,撤销刚才保存的历史记录
|
||||
if (!hasChangesInDrawing.value && history.value.length > 0) {
|
||||
history.value.pop();
|
||||
}
|
||||
hasChangesInDrawing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 全局鼠标事件处理(确保即使鼠标移出单元格也能停止绘制)
|
||||
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; // 默认可用
|
||||
}
|
||||
editingCell.value = null;
|
||||
seatNumber.value = '';
|
||||
};
|
||||
|
||||
// 批量填充
|
||||
const handleFillAll = () => {
|
||||
if (selectedCellType.value === 'empty') {
|
||||
message.warning('不能批量填充为空白类型');
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认批量填充',
|
||||
content: `确定要将所有空白单元格填充为"${getTypeName(selectedCellType.value)}"吗?`,
|
||||
onOk: () => {
|
||||
// 如果是座位类型且自动编号,先获取当前最大编号
|
||||
const emptyCells = cells.value.filter((cell) => cell.type === 'empty');
|
||||
emptyCells.forEach((cell) => {
|
||||
setCellType(cell.col, cell.row, selectedCellType.value);
|
||||
});
|
||||
message.success(`批量填充完成,共填充 ${emptyCells.length} 个单元格`);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 获取类型名称
|
||||
const getTypeName = (type: ClassroomApi.ClassroomLayoutCell['type']) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
empty: '空白',
|
||||
seat: '座位',
|
||||
pillar: '柱子',
|
||||
aisle: '过道',
|
||||
door: '门',
|
||||
};
|
||||
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';
|
||||
|
||||
const classes = ['cell', `cell-${cell.type}`];
|
||||
if (editingCell.value && editingCell.value.col === col && editingCell.value.row === row) {
|
||||
classes.push('cell-editing');
|
||||
}
|
||||
return classes.join(' ');
|
||||
};
|
||||
|
||||
// 获取单元格显示文本
|
||||
const getCellText = (col: number, row: number) => {
|
||||
const cell = getCell(col, row);
|
||||
if (!cell) return '';
|
||||
|
||||
if (cell.type === 'seat' && cell.number) {
|
||||
return cell.number;
|
||||
}
|
||||
if (cell.name) {
|
||||
return cell.name;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// 保存布局
|
||||
const handleSave = async () => {
|
||||
if (!props.classroomId) {
|
||||
message.error('教室ID不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const layout: ClassroomApi.ClassroomLayout = {
|
||||
cols: cols.value,
|
||||
rows: rows.value,
|
||||
cells: cells.value,
|
||||
};
|
||||
|
||||
await saveClassroomLayoutApi({
|
||||
id: props.classroomId,
|
||||
layout,
|
||||
});
|
||||
|
||||
message.success('座位布局保存成功');
|
||||
emit('saved', layout);
|
||||
} catch (error) {
|
||||
console.error('保存座位布局失败:', error);
|
||||
message.error('保存座位布局失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 清空布局
|
||||
const handleClear = () => {
|
||||
Modal.confirm({
|
||||
title: '确认清空',
|
||||
content: '确定要清空所有座位布局吗?',
|
||||
onOk: () => {
|
||||
saveHistory(); // 保存清空前的状态,可以撤销
|
||||
initLayout();
|
||||
message.success('已清空布局');
|
||||
},
|
||||
});
|
||||
};
|
||||
</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.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>单元格即可设置类型。<strong>按住鼠标左键拖动</strong>可以连续绘制多个单元格。
|
||||
{{ autoNumbering ? '座位将自动编号。' : '选择"座位"时需要手动输入座位号。' }}
|
||||
<span v-if="isDrawing" class="drawing-indicator">正在绘制中...</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="layout-container">
|
||||
<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">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="`row-${row}`"
|
||||
class="layout-row"
|
||||
>
|
||||
<div class="header-cell header-row">{{ row }}</div>
|
||||
<div
|
||||
v-for="col in cols"
|
||||
:key="`cell-${col}-${row}`"
|
||||
:class="getCellClass(col - 1, row - 1)"
|
||||
@click="handleCellClick(col - 1, row - 1)"
|
||||
@mousedown="(e) => handleCellMouseDown(col - 1, row - 1, e)"
|
||||
@mouseenter="handleCellMouseEnter(col - 1, row - 1)"
|
||||
>
|
||||
{{ getCellText(col - 1, row - 1) }}
|
||||
</div>
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@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: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layout-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
width: 45px;
|
||||
min-width: 45px;
|
||||
min-height: 45px;
|
||||
height: 45px;
|
||||
flex-shrink: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
.cell:hover {
|
||||
border-color: #40a9ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||
transform: scale(1.05);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.cell-seat:hover {
|
||||
background: #73d13d;
|
||||
border-color: #52c41a;
|
||||
box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.2);
|
||||
}
|
||||
|
||||
.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-editing {
|
||||
border-color: #ff4d4f !important;
|
||||
box-shadow: 0 0 0 3px rgba(255, 77, 79, 0.2) !important;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user