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:
LoveChengke committed 2026-09-12 15:35:01 +08:00
1 parent 0dcab6f369
commit 8b85079d81
23 files changed
+2735 -108

No files matched your search

+56
View File
@@ -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>
+21 -1
View File
@@ -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>