feat(imports): 成绩/名单表格导入 + ClassIsland 时间配置导入 + 安装版增量迁移
服务端:
- 新增 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 表与验收对照更新
This commit is contained in:
1 parent
0dcab6f369
commit
8b85079d81
23 files changed
+2735
-108
No files matched your search
@@ -21,6 +21,11 @@ import {
|
||||
type ScheduleDto,
|
||||
type ScheduleWeekView,
|
||||
type StudentDto,
|
||||
type TableImportPreview,
|
||||
type TableImportResult,
|
||||
type TimeLayoutDto,
|
||||
type TimeLayoutImportResult,
|
||||
type TimeLayoutParsePreview,
|
||||
type UpdateClassRequest,
|
||||
type UserDto,
|
||||
} from '@classhelper/shared';
|
||||
@@ -192,3 +197,54 @@ export const teacherApi = {
|
||||
/** 班级详情中的 teachers 字段即为已分配的协作教师 */
|
||||
fromClass: (classId: string): Promise<ClassDetailDto> => classApi.detail(classId),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 导入(模板 / 表格 / 课表时间配置) */
|
||||
|
||||
export const importApi = {
|
||||
/** 模板下载地址(CSV 走 JSON,XLSX 走二进制) */
|
||||
template: (
|
||||
kind: 'grades' | 'students',
|
||||
format: 'csv' | 'xlsx' = 'csv',
|
||||
): Promise<{ kind: string; format: string; fileName: string; content: string }> =>
|
||||
api.get(`${API_PATHS.imports}/template`, { kind, format }),
|
||||
|
||||
/** 上传表格并预览(解析 + 必填列校验 + 建议映射,不写库) */
|
||||
previewTable: (payload: {
|
||||
kind: 'grades' | 'students';
|
||||
fileName: string;
|
||||
contentBase64: string;
|
||||
}): Promise<TableImportPreview> => api.post(`${API_PATHS.imports}/table/preview`, payload),
|
||||
|
||||
/** 确认字段映射与写入模式后执行导入 */
|
||||
commitTable: (payload: {
|
||||
kind: 'grades' | 'students';
|
||||
classId: string;
|
||||
fileName: string;
|
||||
contentBase64: string;
|
||||
mapping: Record<string, string>;
|
||||
mode: 'append' | 'upsert';
|
||||
}): Promise<TableImportResult> => api.post(`${API_PATHS.imports}/table/commit`, payload),
|
||||
|
||||
/** ClassIsland 时间配置解析预览(不写库) */
|
||||
previewTimeLayout: (payload: {
|
||||
classId: string;
|
||||
name?: string;
|
||||
mode?: 'replace' | 'merge';
|
||||
payload: unknown;
|
||||
}): Promise<TimeLayoutParsePreview> => api.post(`${API_PATHS.imports}/time-layout/preview`, payload),
|
||||
|
||||
/** 某班已导入的时间配置 */
|
||||
listTimeLayouts: (classId: string): Promise<TimeLayoutDto[]> =>
|
||||
api.get(`${API_PATHS.imports}/time-layout`, { classId }),
|
||||
|
||||
/** 导入(覆盖或合并;解析失败返回 400 且不改动原配置) */
|
||||
importTimeLayout: (payload: {
|
||||
classId: string;
|
||||
name?: string;
|
||||
mode: 'replace' | 'merge';
|
||||
payload: unknown;
|
||||
}): Promise<TimeLayoutImportResult> => api.post(`${API_PATHS.imports}/time-layout`, payload),
|
||||
|
||||
removeTimeLayout: (id: string): Promise<{ id: string }> =>
|
||||
api.delete(`${API_PATHS.imports}/time-layout/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,407 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 表格导入弹窗(成绩 / 学生名单通用)。
|
||||
*
|
||||
* 流程:下载模板 → 选择文件(xlsx/xls/csv)→ 上传预览(列名 + 前 20 行 + 校验问题 + 建议映射)
|
||||
* → 确认字段映射与写入模式 → 提交 → 展示结果统计与错误行(行号 + 原因)。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 解析、校验、去重全部由后端完成,前端只负责收集与展示,避免前后端规则漂移;
|
||||
* - 预览失败(缺必填列 / 空文件 / 格式错误)会直接显示后端给出的原因;
|
||||
* - 错误行保留行号,方便老师在 Excel 里定位修改后重新导入。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
API_PATHS,
|
||||
STORAGE_KEYS,
|
||||
type TableImportPreview,
|
||||
type TableImportResult,
|
||||
} from '@classhelper/shared';
|
||||
import { API_BASE_URL } from '@/config';
|
||||
import { importApi } from '@/api';
|
||||
|
||||
type Kind = 'grades' | 'students';
|
||||
|
||||
interface FieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/** 与后端 IMPORT_FIELDS 保持一致(仅用于展示标签与必填提示) */
|
||||
const FIELDS: Record<Kind, FieldDef[]> = {
|
||||
grades: [
|
||||
{ key: 'username', label: '学生用户名', required: false },
|
||||
{ key: 'name', label: '学生姓名', required: false },
|
||||
{ key: 'examName', label: '考试名称', required: true },
|
||||
{ key: 'score', label: '分数', required: true },
|
||||
{ key: 'totalScore', label: '总分', required: false },
|
||||
{ key: 'courseName', label: '课程', required: false },
|
||||
],
|
||||
students: [
|
||||
{ key: 'username', label: '用户名', required: true },
|
||||
{ key: 'name', label: '姓名', required: true },
|
||||
{ key: 'password', label: '初始密码', required: false },
|
||||
],
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
kind: Kind;
|
||||
classId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void;
|
||||
(event: 'imported', result: TableImportResult): void;
|
||||
}>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const committing = ref(false);
|
||||
const fileName = ref('');
|
||||
const contentBase64 = ref('');
|
||||
const preview = ref<TableImportPreview | null>(null);
|
||||
const mapping = ref<Record<string, string>>({});
|
||||
const mode = ref<'append' | 'upsert'>('upsert');
|
||||
const result = ref<TableImportResult | null>(null);
|
||||
const fileInputRef = ref<HTMLInputElement>();
|
||||
|
||||
const fields = computed(() => FIELDS[props.kind]);
|
||||
const title = computed(() => (props.kind === 'grades' ? '导入成绩表格' : '导入学生名单'));
|
||||
|
||||
watch(visible, (value) => {
|
||||
if (value) reset();
|
||||
});
|
||||
|
||||
function reset(): void {
|
||||
preview.value = null;
|
||||
result.value = null;
|
||||
fileName.value = '';
|
||||
contentBase64.value = '';
|
||||
mapping.value = {};
|
||||
mode.value = 'upsert';
|
||||
if (fileInputRef.value) fileInputRef.value.value = '';
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
/** 模板下载:带上 JWT 直接取二进制,避免暴露匿名下载入口 */
|
||||
async function downloadTemplate(format: 'csv' | 'xlsx'): Promise<void> {
|
||||
try {
|
||||
const token = localStorage.getItem(STORAGE_KEYS.token) ?? '';
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}${API_PATHS.imports}/template?kind=${props.kind}&format=${format}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
);
|
||||
if (!response.ok) {
|
||||
ElMessage.error(`模板下载失败(${response.status})`);
|
||||
return;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `template-${props.kind}.${format}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
ElMessage.success('模板已开始下载');
|
||||
} catch (error) {
|
||||
ElMessage.error(`模板下载失败:${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const allowed = /\.(xlsx|xls|csv|tsv)$/i.test(file.name);
|
||||
if (!allowed) {
|
||||
ElMessage.warning('只支持 .xlsx / .xls / .csv 文件');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
if (file.size > 8 * 1024 * 1024) {
|
||||
ElMessage.warning('文件超过 8MB,请拆分后分批导入');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
result.value = null;
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
const base64 = btoa(binary);
|
||||
|
||||
fileName.value = file.name;
|
||||
contentBase64.value = base64;
|
||||
|
||||
const data = await importApi.previewTable({
|
||||
kind: props.kind,
|
||||
fileName: file.name,
|
||||
contentBase64: base64,
|
||||
});
|
||||
preview.value = data;
|
||||
mapping.value = { ...data.suggestedMapping };
|
||||
if ((data.errors ?? []).length > 0) {
|
||||
ElMessage.warning('表格已解析,但存在需要处理的问题(见下方提示)');
|
||||
} else {
|
||||
ElMessage.success(`解析成功:共 ${data.totalRows} 行数据`);
|
||||
}
|
||||
} catch {
|
||||
preview.value = null;
|
||||
contentBase64.value = '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const mappedRequired = computed(() =>
|
||||
fields.value.filter((field) => field.required).every((field) => Boolean(mapping.value[field.key])),
|
||||
);
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (!props.classId) {
|
||||
ElMessage.warning('请先在页面上选择班级');
|
||||
return;
|
||||
}
|
||||
if (!contentBase64.value) {
|
||||
ElMessage.warning('请先选择要导入的文件');
|
||||
return;
|
||||
}
|
||||
if (!mappedRequired.value) {
|
||||
ElMessage.warning('请先完成必填字段的列映射');
|
||||
return;
|
||||
}
|
||||
|
||||
committing.value = true;
|
||||
try {
|
||||
const data = await importApi.commitTable({
|
||||
kind: props.kind,
|
||||
classId: props.classId,
|
||||
fileName: fileName.value,
|
||||
contentBase64: contentBase64.value,
|
||||
mapping: mapping.value,
|
||||
mode: mode.value,
|
||||
});
|
||||
result.value = data;
|
||||
ElMessage.success(
|
||||
`导入完成:新增 ${data.inserted} / 更新 ${data.updated} / 跳过 ${data.skipped} / 失败 ${data.failed}`,
|
||||
);
|
||||
emit('imported', data);
|
||||
} finally {
|
||||
committing.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="title" width="760px" class="import-dialog" top="6vh">
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="支持 .xlsx / .xls / .csv;先下载模板填写可以避免列名不符"
|
||||
class="mb-12"
|
||||
/>
|
||||
|
||||
<div class="import-toolbar">
|
||||
<el-button size="small" @click="downloadTemplate('xlsx')">下载模板(Excel)</el-button>
|
||||
<el-button size="small" @click="downloadTemplate('csv')">下载模板(CSV)</el-button>
|
||||
<el-button size="small" type="primary" :loading="loading" @click="pickFile">
|
||||
{{ fileName ? '重新选择文件' : '选择文件' }}
|
||||
</el-button>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv,.tsv"
|
||||
class="hidden-input"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<span v-if="fileName" class="file-name">{{ fileName }}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="preview">
|
||||
<div class="import-meta">
|
||||
<span>解析行数:{{ preview.totalRows }}</span>
|
||||
<span v-if="preview.columns.length">列:{{ preview.columns.join(' / ') }}</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-card shadow="never" class="mt-8">
|
||||
<template #header><span>字段映射(规范字段 → 表格列)</span></template>
|
||||
<el-form label-width="110px" size="small">
|
||||
<el-form-item v-for="field in fields" :key="field.key" :label="field.label">
|
||||
<el-select v-model="mapping[field.key]" clearable placeholder="不导入该字段" style="width: 260px">
|
||||
<el-option v-for="column in preview.columns" :key="column" :label="column" :value="column" />
|
||||
</el-select>
|
||||
<span v-if="field.required" class="required-hint">必填</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-8">
|
||||
<template #header>
|
||||
<span>数据预览(前 {{ preview.rows.length }} 行)</span>
|
||||
</template>
|
||||
<el-table :data="preview.rows" size="small" max-height="220">
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column
|
||||
v-for="(column, index) in preview.columns"
|
||||
:key="column"
|
||||
:label="column"
|
||||
min-width="110"
|
||||
>
|
||||
<template #default="{ row }">{{ row[index] }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-form label-width="110px" size="small" class="mt-12">
|
||||
<el-form-item label="重复数据处理">
|
||||
<el-radio-group v-model="mode">
|
||||
<el-radio value="upsert">已存在则更新</el-radio>
|
||||
<el-radio value="append">已存在则跳过</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="mode-hint">
|
||||
{{ kind === 'grades' ? '成绩重复判定:同一学生 + 考试 + 课程' : '名单重复判定:同一用户名' }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-card v-if="result" shadow="never" class="mt-8 result-card">
|
||||
<template #header><span>导入结果</span></template>
|
||||
<div class="result-stats">
|
||||
<span>总行数:{{ result.total }}</span>
|
||||
<span class="ok">新增:{{ result.inserted }}</span>
|
||||
<span class="warn">更新:{{ result.updated }}</span>
|
||||
<span>跳过:{{ result.skipped }}</span>
|
||||
<span class="bad">失败:{{ result.failed }}</span>
|
||||
</div>
|
||||
<el-table
|
||||
v-if="result.errors.length"
|
||||
:data="result.errors"
|
||||
size="small"
|
||||
max-height="180"
|
||||
class="mt-8"
|
||||
>
|
||||
<el-table-column prop="row" label="行号" width="80" />
|
||||
<el-table-column prop="message" label="原因" />
|
||||
</el-table>
|
||||
<el-alert
|
||||
v-if="result.errors.length"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="失败的行已跳过,其余数据已成功导入;修改后可用「已存在则更新」重新导入"
|
||||
class="mt-8"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<el-empty v-else-if="!loading" description="尚未选择文件" :image-size="70" />
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="close">关闭</el-button>
|
||||
<el-button type="primary" :disabled="!preview || !contentBase64" :loading="committing" @click="submit">
|
||||
开始导入
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.import-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.file-name {
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
.import-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin: 12px 0 4px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
.required-hint {
|
||||
margin-left: 8px;
|
||||
color: #f56c6c;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mode-hint {
|
||||
width: 100%;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.result-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
.result-stats .ok {
|
||||
color: #67c23a;
|
||||
}
|
||||
.result-stats .warn {
|
||||
color: #e6a23c;
|
||||
}
|
||||
.result-stats .bad {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.mb-8 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.mt-8 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.mt-12 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,311 @@
|
||||
<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>
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type StudentDto,
|
||||
} from '@classhelper/shared';
|
||||
import { classApi, courseApi, gradeApi } from '@/api';
|
||||
import TableImportDialog from '@/components/TableImportDialog.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRealtimeStore } from '@/stores/realtime';
|
||||
|
||||
@@ -214,6 +215,22 @@ async function submitBulk(): Promise<void> {
|
||||
await loadGrades();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 表格导入 */
|
||||
|
||||
const importVisible = ref(false);
|
||||
|
||||
function openImport(): void {
|
||||
if (!filter.classId) {
|
||||
ElMessage.warning('请先选择班级');
|
||||
return;
|
||||
}
|
||||
importVisible.value = true;
|
||||
}
|
||||
|
||||
async function onImported(): Promise<void> {
|
||||
await loadGrades();
|
||||
}
|
||||
|
||||
async function removeGrade(row: GradeDto): Promise<void> {
|
||||
await ElMessageBox.confirm(`删除 ${row.student?.name ?? ''} 的「${row.examName}」成绩?`, '确认', {
|
||||
type: 'warning',
|
||||
@@ -280,6 +297,7 @@ onUnmounted(() => {
|
||||
</el-select>
|
||||
<el-button v-if="isAdmin" type="primary" :icon="'Plus'" @click="openSingle">单条录入</el-button>
|
||||
<el-button v-if="isAdmin" type="success" :icon="'Upload'" @click="openBulk">批量录入</el-button>
|
||||
<el-button v-if="isAdmin" type="warning" :icon="'Document'" @click="openImport">导入表格</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -415,6 +433,14 @@ onUnmounted(() => {
|
||||
<el-button type="primary" @click="submitBulk">提交并广播</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 表格导入(xlsx/xls/csv:模板下载 + 预览 + 字段映射 + 结果统计) -->
|
||||
<TableImportDialog
|
||||
v-model="importVisible"
|
||||
kind="grades"
|
||||
:class-id="filter.classId"
|
||||
@imported="onImported"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type ScheduleWeekView,
|
||||
} from '@classhelper/shared';
|
||||
import { classApi, courseApi, dashboardApi, scheduleApi } from '@/api';
|
||||
import TimeLayoutImportDialog from '@/components/TimeLayoutImportDialog.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRealtimeStore } from '@/stores/realtime';
|
||||
|
||||
@@ -194,6 +195,18 @@ function onScheduleEvent(): void {
|
||||
void loadSchedules();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ ClassIsland 时间配置导入 */
|
||||
|
||||
const timeLayoutVisible = ref(false);
|
||||
|
||||
function openTimeLayoutImport(): void {
|
||||
if (!query.classId) {
|
||||
ElMessage.warning('请先选择班级');
|
||||
return;
|
||||
}
|
||||
timeLayoutVisible.value = true;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadBase();
|
||||
await loadSchedules();
|
||||
@@ -228,6 +241,9 @@ onUnmounted(() => {
|
||||
<el-button v-if="canManageSchedule" type="primary" :icon="'Plus'" @click="openCreate">
|
||||
新增课表
|
||||
</el-button>
|
||||
<el-button v-if="canManageSchedule" type="warning" :icon="'Upload'" @click="openTimeLayoutImport">
|
||||
导入时间配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -313,5 +329,12 @@ onUnmounted(() => {
|
||||
<el-button type="primary" @click="submitForm">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ClassIsland 时间配置导入(覆盖 / 合并,失败不改动原配置) -->
|
||||
<TimeLayoutImportDialog
|
||||
v-model="timeLayoutVisible"
|
||||
:class-id="query.classId"
|
||||
:class-name="classes.find((item) => item.id === query.classId)?.name"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type StudentDto,
|
||||
} from '@classhelper/shared';
|
||||
import { callApi, classApi, studentApi } from '@/api';
|
||||
import TableImportDialog from '@/components/TableImportDialog.vue';
|
||||
|
||||
const loading = ref(false);
|
||||
const students = ref<StudentDto[]>([]);
|
||||
@@ -33,6 +34,16 @@ async function loadStudents(): Promise<void> {
|
||||
|
||||
/* ------------------------------------------------------------ 新建 / 编辑 */
|
||||
|
||||
/** 名单导入(表格):需要先选定班级,避免导入到错误的班级 */
|
||||
const importVisible = ref(false);
|
||||
function openImport(): void {
|
||||
if (!filter.classId) {
|
||||
ElMessage.warning('请先选择要导入的班级');
|
||||
return;
|
||||
}
|
||||
importVisible.value = true;
|
||||
}
|
||||
|
||||
const formVisible = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const editingId = ref<string | null>(null);
|
||||
@@ -180,6 +191,7 @@ async function submitCall(): Promise<void> {
|
||||
/>
|
||||
<el-button :icon="'Search'" @click="loadStudents">查询</el-button>
|
||||
<el-button type="primary" :icon="'Plus'" @click="openCreate">新建学生</el-button>
|
||||
<el-button type="warning" :icon="'Document'" @click="openImport">导入名单</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -284,6 +296,14 @@ async function submitCall(): Promise<void> {
|
||||
<el-button type="primary" @click="submitForm">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 学生名单导入(xlsx/xls/csv,仅管理员可见;后端同样强校验权限) -->
|
||||
<TableImportDialog
|
||||
v-model="importVisible"
|
||||
kind="students"
|
||||
:class-id="filter.classId"
|
||||
@imported="loadStudents"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in new issue
Block a user