服务端:
- 新增 imports 模块: 模板下载(csv/xlsx)、表格解析预览、字段映射导入(append/upsert)、
错误行号与结果统计; ClassIsland 时间配置容错解析 + replace/merge 导入 + 失败回滚(400 不写库)
- TimeLayout 模型 + 迁移; 修复 contentBase64 空值被 422 拦截, 改由 service 返回 IMPORT_EMPTY_FILE
- merge 语义改为按开始时间槽位覆盖, 同开始时间的旧节次不再被静默丢弃
- db-bootstrap 增加 _ch_migrations 账本: 覆盖安装升级也能自动补跑新增迁移(旧库实测通过)
Web 管理端:
- TableImportDialog(成绩/名单): 模板下载、预览、字段映射、重复处理模式、结果统计与错误行
- TimeLayoutImportDialog: 粘贴/选 .json、解析预览、覆盖/合并、已导入列表与删除
- 成绩录入/学生管理/课表管理接入入口(按权限矩阵显示)
验证: verify:e2e 107/107(新增导入 22 项) verify:web 18/18(新增时间配置导入弹窗真实点击)
verify:desktop 40/40
文档: README 新增导入章节、增量迁移说明、API 表与验收对照更新
312 lines
10 KiB
Vue
312 lines
10 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* ClassIsland 课表时间配置导入弹窗。
|
||
*
|
||
* 流程:粘贴 JSON / 选择 .json 文件 → 预览(后端容错解析,秒与 HH:mm 两种写法都支持)
|
||
* → 查看节次列表与校验结果 → 选择覆盖(replace) / 合并(merge) → 导入。
|
||
*
|
||
* 安全与一致性:
|
||
* - 解析失败(JSON 语法错误、时间无法识别、结束早于开始)后端返回 400,
|
||
* 教务端会提示"原有配置保持不变",不会出现"导入一半"的脏状态;
|
||
* - 权限与课表管理一致(管理员或本班班主任),科任老师看不到入口,后端亦会二次校验。
|
||
*/
|
||
import { computed, ref, watch } from 'vue';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import { formatDate, type TimeLayoutDto, type TimeLayoutParsePreview } from '@classhelper/shared';
|
||
import { importApi } from '@/api';
|
||
|
||
const props = defineProps<{
|
||
modelValue: boolean;
|
||
classId: string;
|
||
className?: string;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
(event: 'update:modelValue', value: boolean): void;
|
||
(event: 'imported'): void;
|
||
}>();
|
||
|
||
const visible = computed({
|
||
get: () => props.modelValue,
|
||
set: (value: boolean) => emit('update:modelValue', value),
|
||
});
|
||
|
||
const LAYOUT_SAMPLE = `{
|
||
"TimeLayouts": [
|
||
{ "Name": "第 1 节", "StartTime": "8:0:0", "EndTime": "8:45:0", "TimeType": 0 },
|
||
{ "Name": "课间休息", "StartTime": "8:45:0", "EndTime": "8:55:0", "TimeType": 1 }
|
||
]
|
||
}`;
|
||
|
||
/** 输入框提示(用常量避免模板里的引号破坏 lint 规则) */
|
||
const TEXTAREA_PLACEHOLDER =
|
||
'粘贴 ClassIsland 导出的 JSON,例如 {"TimeLayouts":[{"Name":"第 1 节","StartTime":"8:0:0","EndTime":"8:45:0"}]}';
|
||
|
||
const jsonText = ref('');
|
||
const layoutName = ref('默认时间表');
|
||
const mode = ref<'replace' | 'merge'>('replace');
|
||
const preview = ref<TimeLayoutParsePreview | null>(null);
|
||
const existing = ref<TimeLayoutDto[]>([]);
|
||
const parsing = ref(false);
|
||
const importing = ref(false);
|
||
const fileInputRef = ref<HTMLInputElement>();
|
||
|
||
watch(visible, async (value) => {
|
||
if (value) {
|
||
jsonText.value = '';
|
||
preview.value = null;
|
||
layoutName.value = '默认时间表';
|
||
mode.value = 'replace';
|
||
await loadExisting();
|
||
}
|
||
});
|
||
|
||
async function loadExisting(): Promise<void> {
|
||
if (!props.classId) {
|
||
existing.value = [];
|
||
return;
|
||
}
|
||
try {
|
||
existing.value = await importApi.listTimeLayouts(props.classId);
|
||
} catch {
|
||
existing.value = [];
|
||
}
|
||
}
|
||
|
||
function pickFile(): void {
|
||
fileInputRef.value?.click();
|
||
}
|
||
|
||
async function onFileChange(event: Event): Promise<void> {
|
||
const input = event.target as HTMLInputElement;
|
||
const file = input.files?.[0];
|
||
if (!file) return;
|
||
if (!/\.json$/i.test(file.name)) {
|
||
ElMessage.warning('请选择 ClassIsland 导出的 .json 文件');
|
||
input.value = '';
|
||
return;
|
||
}
|
||
jsonText.value = await file.text();
|
||
preview.value = null;
|
||
ElMessage.success(`已读取 ${file.name}(${jsonText.value.length} 字符),请点击「解析预览」`);
|
||
}
|
||
|
||
/** 解析预览:把 JSON 文本或对象交给后端容错解析 */
|
||
async function doPreview(): Promise<void> {
|
||
if (!jsonText.value.trim()) {
|
||
ElMessage.warning('请先粘贴 JSON 或选择 .json 文件');
|
||
return;
|
||
}
|
||
if (!props.classId) {
|
||
ElMessage.warning('请先在页面上选择班级');
|
||
return;
|
||
}
|
||
|
||
parsing.value = true;
|
||
try {
|
||
const payload = parseJsonOrText(jsonText.value);
|
||
preview.value = await importApi.previewTimeLayout({
|
||
classId: props.classId,
|
||
name: layoutName.value,
|
||
mode: mode.value,
|
||
payload,
|
||
});
|
||
if (preview.value.errors.length > 0) {
|
||
ElMessage.warning(`解析发现 ${preview.value.errors.length} 个问题,请修正后再导入`);
|
||
} else {
|
||
ElMessage.success(`解析成功:共 ${preview.value.items.length} 节`);
|
||
}
|
||
} catch {
|
||
preview.value = null;
|
||
} finally {
|
||
parsing.value = false;
|
||
}
|
||
}
|
||
|
||
/** JSON 语法错误由后端统一报错,这里保留原文让后端给出精确原因 */
|
||
function parseJsonOrText(text: string): unknown {
|
||
try {
|
||
return JSON.parse(text);
|
||
} catch {
|
||
return text;
|
||
}
|
||
}
|
||
|
||
async function doImport(): Promise<void> {
|
||
if (!preview.value || preview.value.items.length === 0) {
|
||
ElMessage.warning('请先解析预览并确认节次');
|
||
return;
|
||
}
|
||
if (preview.value.errors.length > 0) {
|
||
ElMessage.warning('存在解析错误,已阻止导入(原配置不会被修改)');
|
||
return;
|
||
}
|
||
|
||
importing.value = true;
|
||
try {
|
||
const result = await importApi.importTimeLayout({
|
||
classId: props.classId,
|
||
name: layoutName.value,
|
||
mode: mode.value,
|
||
payload: parseJsonOrText(jsonText.value),
|
||
});
|
||
ElMessage.success(
|
||
mode.value === 'merge'
|
||
? `合并导入成功:覆盖 ${result.merged} 节 / 新增 ${result.replaced} 节(共 ${result.layout.items.length} 节)`
|
||
: `覆盖导入成功:共 ${result.layout.items.length} 节`,
|
||
);
|
||
await loadExisting();
|
||
emit('imported');
|
||
} finally {
|
||
importing.value = false;
|
||
}
|
||
}
|
||
|
||
/** 删除已导入的时间配置(同一班级可保留多份,按名称区分) */
|
||
async function removeLayout(item: TimeLayoutDto): Promise<void> {
|
||
await ElMessageBox.confirm(`删除「${item.name}」(${item.items.length} 节)?`, '确认', {
|
||
type: 'warning',
|
||
});
|
||
await importApi.removeTimeLayout(item.id);
|
||
ElMessage.success('已删除');
|
||
await loadExisting();
|
||
}
|
||
|
||
function fillSample(): void {
|
||
jsonText.value = LAYOUT_SAMPLE;
|
||
preview.value = null;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<el-dialog v-model="visible" title="导入 ClassIsland 课表时间配置" width="820px" top="5vh">
|
||
<el-alert type="info" :closable="false" show-icon class="mb-12">
|
||
<template #title>
|
||
支持 ClassIsland 导出的时间表 JSON(数组,或含 TimeLayouts / TimeLayoutItems 字段的对象); 时间可写成
|
||
HH:mm:ss,也可用 StartSecond 秒数。解析失败不会修改原有配置。
|
||
</template>
|
||
</el-alert>
|
||
|
||
<el-form label-width="110px" size="small">
|
||
<el-form-item label="目标班级">
|
||
<el-input :model-value="className || classId" disabled style="width: 260px" />
|
||
</el-form-item>
|
||
<el-form-item label="配置名称">
|
||
<el-input v-model="layoutName" placeholder="默认时间表" style="width: 260px" />
|
||
</el-form-item>
|
||
<el-form-item label="导入方式">
|
||
<el-radio-group v-model="mode">
|
||
<el-radio value="replace">覆盖:整体替换现有时间表</el-radio>
|
||
<el-radio value="merge">合并:按开始时间覆盖同节次并保留其它节次</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<div class="toolbar mb-8">
|
||
<el-button size="small" :loading="parsing" type="primary" @click="doPreview">解析预览</el-button>
|
||
<el-button size="small" @click="pickFile">选择 .json 文件</el-button>
|
||
<el-button size="small" @click="fillSample">填入示例</el-button>
|
||
<input ref="fileInputRef" type="file" accept=".json" class="hidden-input" @change="onFileChange" />
|
||
</div>
|
||
|
||
<el-input v-model="jsonText" type="textarea" :rows="7" :placeholder="TEXTAREA_PLACEHOLDER" />
|
||
|
||
<template v-if="preview">
|
||
<div class="preview-meta">
|
||
<span>识别结构:{{ preview.shape }}</span>
|
||
<span>解析节次:{{ preview.items.length }}</span>
|
||
</div>
|
||
|
||
<el-alert
|
||
v-for="(item, index) in preview.errors"
|
||
:key="`e-${index}`"
|
||
type="error"
|
||
:closable="false"
|
||
show-icon
|
||
:title="item"
|
||
class="mb-8"
|
||
/>
|
||
<el-alert
|
||
v-for="(item, index) in preview.warnings"
|
||
:key="`w-${index}`"
|
||
type="warning"
|
||
:closable="false"
|
||
show-icon
|
||
:title="item"
|
||
class="mb-8"
|
||
/>
|
||
|
||
<el-table v-if="preview.items.length" :data="preview.items" size="small" max-height="240" class="mt-8">
|
||
<el-table-column prop="index" label="#" width="50" />
|
||
<el-table-column prop="name" label="节次" min-width="140" />
|
||
<el-table-column prop="startTime" label="开始" width="90" />
|
||
<el-table-column prop="endTime" label="结束" width="90" />
|
||
<el-table-column label="类型" width="100">
|
||
<template #default="{ row }">
|
||
{{ row.type === 'break' ? '课间' : row.type === 'divider' ? '分割线' : '上课' }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="跳过" width="80">
|
||
<template #default="{ row }">{{ row.skipped ? '是' : '否' }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</template>
|
||
|
||
<el-card v-if="existing.length" shadow="never" class="mt-12">
|
||
<template #header><span>该班级已导入的时间配置</span></template>
|
||
<el-table :data="existing" size="small">
|
||
<el-table-column prop="name" label="名称" min-width="140" />
|
||
<el-table-column label="节次" width="80">
|
||
<template #default="{ row }">{{ row.items.length }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="source" label="来源" width="120" />
|
||
<el-table-column label="更新时间" width="170">
|
||
<template #default="{ row }">{{ formatDate(row.updatedAt, true) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="90">
|
||
<template #default="{ row }">
|
||
<el-button link type="danger" size="small" @click="removeLayout(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-card>
|
||
|
||
<template #footer>
|
||
<el-button @click="visible = false">关闭</el-button>
|
||
<el-button
|
||
type="primary"
|
||
:disabled="!preview || preview.items.length === 0 || preview.errors.length > 0"
|
||
:loading="importing"
|
||
@click="doImport"
|
||
>
|
||
确认导入
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.hidden-input {
|
||
display: none;
|
||
}
|
||
.preview-meta {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-top: 10px;
|
||
color: #606266;
|
||
font-size: 13px;
|
||
}
|
||
.mb-8 {
|
||
margin-bottom: 8px;
|
||
}
|
||
.mb-12 {
|
||
margin-bottom: 12px;
|
||
}
|
||
.mt-8 {
|
||
margin-top: 8px;
|
||
}
|
||
.mt-12 {
|
||
margin-top: 12px;
|
||
}
|
||
</style>
|