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

This commit is contained in:
杨志
2025-12-05 13:39:40 +08:00
parent 21107f02fd
commit 51a72f1f0c
1239 changed files with 107262 additions and 1 deletions

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