新增后台「数据同步」与「学生名册」页

数据同步页 /gxmu/dataSync:
- 分段手动同步(部门/教师/班级/学生)+ 全部同步
- 同步为异步:提交后立即返回批次号,每 2 秒轮询 /gxmu/sync/status 展示分段进度
  (待执行/进行中/成功/失败 + 成功/失败/停用计数 + 错误摘要)
- 页面挂载时先查一次状态,可接管定时任务或别处触发的同步
- 同步记录列表,支持按类型筛选

学生名册页 /gxmu/studentRoster:
- 按学院(取组织树 parentId=24)/班级/学号/姓名/培养层次/状态/认领状态筛选
- 手机号与身份证由后端脱敏后返回
- 导出用 gxmu:student:export 单独授权,前端生成 CSV
- 支持手动认领与解除认领

对应 api 模块 src/api/gxmu/sync 与 src/api/gxmu/student。
This commit is contained in:
2026-09-15 15:55:00 +08:00
parent 019ed28d37
commit a592e66f78
6 changed files with 1014 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { StudentRoster, StudentRosterParam } from './model';
/** 学生名册分页查询 */
export async function pageStudentRoster(params: StudentRosterParam) {
const res = await request.get<ApiResult<PageResult<StudentRoster>>>(
'/gxmu/student/page',
{ params }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 学生名册详情 */
export async function getStudentRoster(id: number) {
const res = await request.get<ApiResult<StudentRoster>>(
'/gxmu/student/' + id
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 导出名册(返回已脱敏列表,由前端生成文件) */
export async function exportStudentRoster(params: StudentRosterParam) {
const res = await request.post<ApiResult<StudentRoster[]>>(
'/gxmu/student/export',
null,
{ params }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 自助认领(学号 + 姓名) */
export async function claimStudent(data: { xh: string; xm: string }) {
const res = await request.post<ApiResult<unknown>>(
'/gxmu/student/claim',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 手动认领/改绑 */
export async function bindStudent(
id: number,
data?: { userId?: number; phone?: string }
) {
const res = await request.put<ApiResult<unknown>>(
`/gxmu/student/${id}/claim`,
data || {}
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 解除认领 */
export async function unbindStudent(id: number) {
const res = await request.put<ApiResult<unknown>>(
`/gxmu/student/${id}/unclaim`
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
+60
View File
@@ -0,0 +1,60 @@
/**
* 学生名册(开放平台同步)
*/
/** 学生名册记录 */
export interface StudentRoster {
id: number;
/** 学号 */
xh: string;
/** 姓名 */
xm?: string;
/** 性别码 */
xbm?: string;
/** 性别 */
sex?: string;
/** 民族码 */
mzm?: string;
/** 民族 */
nation?: string;
/** 政治面貌码 */
zzmmm?: string;
/** 政治面貌 */
politics?: string;
/** 身份证件号(后端已脱敏) */
sfzjh?: string;
/** 手机号(后端已脱敏) */
sjh?: string;
/** 平台班级码 */
bjm?: string;
/** 平台班号 */
bh?: string;
classId?: number;
className?: string;
collegeId?: number;
collegeName?: string;
dwh?: string;
zyh?: string;
zymc?: string;
/** 培养层次 */
pyccmc?: string;
/** 状态:1在校 0毕业 */
status?: number;
/** 已认领的账号ID */
userId?: number;
claimTime?: string;
syncTime?: string;
}
/** 名册查询参数 */
export interface StudentRosterParam {
page?: number;
limit?: number;
collegeId?: number;
classId?: number;
xh?: string;
xm?: string;
pyccmc?: string;
status?: number;
claimed?: boolean;
}
+66
View File
@@ -0,0 +1,66 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type {
SyncBatchStatus,
SyncRecord,
SyncRecordParam
} from './model';
/** 提交类接口统一返回批次号 */
async function submit(url: string) {
const res = await request.post<ApiResult<string>>(url);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 提交全部同步,返回批次号 */
export function syncAll() {
return submit('/gxmu/sync/all');
}
/** 提交部门同步,返回批次号 */
export function syncDept() {
return submit('/gxmu/sync/dept');
}
/** 提交教师同步,返回批次号 */
export function syncTeacher() {
return submit('/gxmu/sync/teacher');
}
/** 提交班级同步,返回批次号 */
export function syncClass() {
return submit('/gxmu/sync/class');
}
/** 提交学生同步,返回批次号 */
export function syncStudent() {
return submit('/gxmu/sync/student');
}
/**
* 查询当前(或最近一次)批次进度。
*
* 同步是异步的:学生全量约 9.8 万条、耗时 75~90 秒,提交后需轮询本接口看进度。
*/
export async function syncStatus() {
const res = await request.get<ApiResult<SyncBatchStatus>>('/gxmu/sync/status');
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 同步记录分页查询 */
export async function pageSyncRecord(params: SyncRecordParam) {
const res = await request.get<ApiResult<PageResult<SyncRecord>>>(
'/gxmu/sync/record/page',
{ params }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
+79
View File
@@ -0,0 +1,79 @@
/**
* 开放平台数据同步
*/
/** 单段同步结果(同步返回,异步改动后仅用于记录展示) */
export interface SyncResult {
/** 同步类型:dept/teacher/class/student */
syncType: string;
/** 成功条数 */
successCount: number;
/** 失败条数 */
failCount: number;
/** 跳过/停用条数 */
skipCount: number;
/** 是否成功 */
success: boolean;
/** 错误信息 */
errorMsg?: string;
}
/** 某一段的进度 */
export interface SyncSectionStatus {
/** 同步类型:dept/teacher/class/student */
syncType: string;
/** pending 待执行 / running 进行中 / success 成功 / fail 失败 */
state: 'pending' | 'running' | 'success' | 'fail';
successCount?: number;
failCount?: number;
skipCount?: number;
startTime?: string;
endTime?: string;
errorMsg?: string;
}
/** 批次进度 */
export interface SyncBatchStatus {
/** 是否有同步任务在执行 */
running: boolean;
batchId?: string;
triggerType?: string;
triggerUser?: string;
startTime?: string;
/** 正在执行的段 */
currentSection?: string;
/** 本次批次的各段状态 */
sections?: SyncSectionStatus[];
}
/** 同步记录 */
export interface SyncRecord {
id: number;
/** 批次号 */
batchId?: string;
/** 同步类型 */
syncType: string;
/** 触发方式:schedule/manual */
triggerType: string;
/** 触发人 */
triggerUser?: string;
/** 开始时间 */
startTime?: string;
/** 结束时间 */
endTime?: string;
successCount: number;
failCount: number;
skipCount: number;
/** 状态:0成功 1失败 2进行中 */
status: number;
/** 错误摘要 */
errorMsg?: string;
}
/** 同步记录查询参数 */
export interface SyncRecordParam {
page?: number;
limit?: number;
syncType?: string;
batchId?: string;
}
+370
View File
@@ -0,0 +1,370 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<a-alert
type="info"
show-icon
style="margin-bottom: 16px"
message="同步为异步执行:提交后立即返回,下方可实时查看进度。学生全量约 9.8 万条,预计 1~2 分钟。"
/>
<a-space style="flex-wrap: wrap; margin-bottom: 16px">
<a-button
v-if="canOperate"
type="primary"
:disabled="running"
@click="doSync('all')"
>
全部同步
</a-button>
<a-button
v-if="canOperate"
:disabled="running"
@click="doSync('dept')"
>
同步部门
</a-button>
<a-button
v-if="canOperate"
:disabled="running"
@click="doSync('teacher')"
>
同步教师
</a-button>
<a-button
v-if="canOperate"
:disabled="running"
@click="doSync('class')"
>
同步班级
</a-button>
<a-button
v-if="canOperate"
:disabled="running"
@click="doSync('student')"
>
同步学生
</a-button>
<a-button :disabled="running" @click="reload">刷新记录</a-button>
</a-space>
<div v-if="sections.length" style="margin-bottom: 16px">
<a-space style="margin-bottom: 8px">
<a-spin v-if="running" size="small" />
<span>
{{ running ? '正在同步' : '最近批次' }}
<a-typography-text v-if="batchId" type="secondary">
批次 {{ batchId }}
</a-typography-text>
</span>
</a-space>
<a-table
row-key="syncType"
size="small"
:columns="progressColumns"
:data-source="sections"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'syncType'">
{{ sectionLabel(record.syncType) }}
</template>
<template v-else-if="column.key === 'state'">
<a-tag :color="stateColor(record.state)">
{{ stateText(record.state) }}
</a-tag>
</template>
<template v-else-if="column.key === 'counts'">
<span v-if="record.state === 'pending'">-</span>
<span v-else>
成功 {{ record.successCount ?? 0 }} / 失败
{{ record.failCount ?? 0 }} / 停用
{{ record.skipCount ?? 0 }}
</span>
</template>
<template v-else-if="column.key === 'errorMsg'">
<a-typography-text type="danger">
{{ record.errorMsg || '-' }}
</a-typography-text>
</template>
</template>
</a-table>
</div>
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-select
v-model:value="syncType"
allow-clear
style="width: 160px"
placeholder="同步类型"
:options="typeOptions"
/>
<a-button type="primary" @click="reload">查询</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="recordStatusColor(record.status)">
{{ recordStatusText(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'triggerType'">
{{ record.triggerType === 'schedule' ? '定时' : '手动' }}
</template>
</template>
</ele-pro-table>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import { hasPermission } from '@/utils/permission';
import {
pageSyncRecord,
syncAll,
syncClass,
syncDept,
syncStatus,
syncStudent,
syncTeacher
} from '@/api/gxmu/sync';
import type {
SyncRecord,
SyncSectionStatus
} from '@/api/gxmu/sync/model';
type SyncKind = 'all' | 'dept' | 'teacher' | 'class' | 'student';
const POLL_INTERVAL = 2000;
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const running = ref(false);
const batchId = ref<string>('');
const sections = ref<SyncSectionStatus[]>([]);
const syncType = ref<string | undefined>(undefined);
let timer: ReturnType<typeof setInterval> | undefined;
const canOperate = computed(() => hasPermission('gxmu:sync:manual'));
const typeOptions = [
{ label: '部门', value: 'dept' },
{ label: '教师', value: 'teacher' },
{ label: '班级', value: 'class' },
{ label: '学生', value: 'student' }
];
const progressColumns: ColumnItem[] = [
{ title: '同步段', dataIndex: 'syncType', key: 'syncType', width: 120 },
{ title: '状态', dataIndex: 'state', key: 'state', width: 110 },
{ title: '数量', key: 'counts', width: 260 },
{ title: '错误', dataIndex: 'errorMsg', key: 'errorMsg' }
];
const columns = ref<ColumnItem[]>([
{ title: '批次', dataIndex: 'batchId', key: 'batchId', width: 200 },
{ title: '类型', dataIndex: 'syncType', key: 'syncType', width: 100 },
{
title: '触发方式',
dataIndex: 'triggerType',
key: 'triggerType',
width: 110
},
{ title: '触发人', dataIndex: 'triggerUser', key: 'triggerUser', width: 140 },
{
title: '开始时间',
dataIndex: 'startTime',
key: 'startTime',
width: 180,
customRender: ({ text }) => (text ? toDateString(text) : '-')
},
{
title: '结束时间',
dataIndex: 'endTime',
key: 'endTime',
width: 180,
customRender: ({ text }) => (text ? toDateString(text) : '-')
},
{
title: '成功',
dataIndex: 'successCount',
key: 'successCount',
align: 'center',
width: 90
},
{
title: '失败',
dataIndex: 'failCount',
key: 'failCount',
align: 'center',
width: 90
},
{
title: '停用/跳过',
dataIndex: 'skipCount',
key: 'skipCount',
align: 'center',
width: 110
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center',
width: 90
},
{ title: '错误摘要', dataIndex: 'errorMsg', key: 'errorMsg', width: 260 }
]);
const sectionLabel = (type: string) =>
typeOptions.find((item) => item.value === type)?.label || type;
const stateText = (state: string) =>
({ pending: '待执行', running: '进行中', success: '成功', fail: '失败' }[
state
] || state);
const stateColor = (state: string) =>
({ pending: 'default', running: 'processing', success: 'green', fail: 'red' }[
state
] || 'default');
const recordStatusText = (status: number) =>
({ 0: '成功', 1: '失败', 2: '进行中' }[status] ?? '未知');
const recordStatusColor = (status: number) =>
({ 0: 'green', 1: 'red', 2: 'processing' }[status] ?? 'default');
const datasource: DatasourceFunction = ({ page, limit }) => {
return pageSyncRecord({
page,
limit,
syncType: syncType.value
}).catch((e) => {
message.error(e.message);
return { list: [] as SyncRecord[], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const stopPolling = () => {
if (timer) {
clearInterval(timer);
timer = undefined;
}
};
const applyStatus = (status: {
running: boolean;
batchId?: string;
sections?: SyncSectionStatus[];
}) => {
running.value = status.running;
batchId.value = status.batchId || '';
sections.value = status.sections || [];
};
const poll = async () => {
try {
const status = await syncStatus();
const wasRunning = running.value;
applyStatus(status);
if (!status.running) {
stopPolling();
if (wasRunning) {
const failed = (status.sections || []).filter(
(item) => item.state === 'fail'
);
if (failed.length) {
message.warning(
`同步结束,${failed.length} 个段失败:${failed
.map((item) => `${sectionLabel(item.syncType)}(${item.errorMsg || '未知错误'})`)
.join('')}`
);
} else {
const total = (status.sections || []).reduce(
(sum, item) => sum + (item.successCount || 0),
0
);
message.success(`同步完成,共写入 ${total}`);
}
reload();
}
}
} catch (e: any) {
stopPolling();
running.value = false;
message.error(e?.message || '获取同步进度失败');
}
};
const startPolling = () => {
stopPolling();
timer = setInterval(poll, POLL_INTERVAL);
};
const doSync = (kind: SyncKind) => {
const task =
kind === 'all'
? syncAll()
: kind === 'dept'
? syncDept()
: kind === 'teacher'
? syncTeacher()
: kind === 'class'
? syncClass()
: syncStudent();
task
.then((id) => {
// 提交成功即认为在跑,先占位再等第一次轮询回填
running.value = true;
batchId.value = id;
sections.value = [];
message.success('已提交同步任务');
startPolling();
poll();
})
.catch((e) => {
message.error(e.message || '提交同步任务失败');
});
};
onMounted(() => {
// 页面打开时可能已有定时任务或别处触发的同步在跑,接管进度显示
poll().then(() => {
if (running.value) {
startPolling();
}
});
});
onUnmounted(stopPolling);
</script>
<script lang="ts">
export default {
name: 'DataSync'
};
</script>
+362
View File
@@ -0,0 +1,362 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1600 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space style="flex-wrap: wrap">
<a-select
v-model:value="collegeId"
allow-clear
show-search
option-filter-prop="label"
style="width: 220px"
placeholder="学院"
:options="collegeOptions"
@change="onCollegeChange"
/>
<a-select
v-model:value="classId"
allow-clear
show-search
option-filter-prop="label"
style="width: 220px"
placeholder="班级"
:options="classOptions"
/>
<a-input
v-model:value="xh"
allow-clear
style="width: 160px"
placeholder="学号"
@pressEnter="reload"
/>
<a-input
v-model:value="xm"
allow-clear
style="width: 140px"
placeholder="姓名"
@pressEnter="reload"
/>
<a-select
v-model:value="status"
style="width: 130px"
placeholder="状态"
:options="statusOptions"
/>
<a-select
v-model:value="claimed"
allow-clear
style="width: 130px"
placeholder="认领状态"
:options="claimedOptions"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
<a-button
v-if="hasPermission('gxmu:student:export')"
:loading="exporting"
@click="doExport"
>
导出
</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'default'">
{{ record.status === 1 ? '在校' : '毕业' }}
</a-tag>
</template>
<template v-else-if="column.key === 'claimed'">
<a-tag :color="record.userId ? 'blue' : 'default'">
{{ record.userId ? '已认领' : '未认领' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a
v-if="hasPermission('gxmu:student:list') && !record.userId"
@click="doBind(record)"
>
手动认领
</a>
<a-popconfirm
v-if="hasPermission('gxmu:student:list') && record.userId"
title="确定解除该学生的账号关联吗?"
@confirm="doUnbind(record)"
>
<a class="ele-text-danger">解除认领</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import { hasPermission } from '@/utils/permission';
import { listOrganizations } from '@/api/system/organization';
import { listClassInfo } from '@/api/gxmu/class';
import {
bindStudent,
exportStudentRoster,
pageStudentRoster,
unbindStudent
} from '@/api/gxmu/student';
import type {
StudentRoster,
StudentRosterParam
} from '@/api/gxmu/student/model';
/** 学院挂载的父节点:广西医科大学 */
const COLLEGE_PARENT_ID = 24;
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const collegeId = ref<number | undefined>(undefined);
const classId = ref<number | undefined>(undefined);
const xh = ref<string>('');
const xm = ref<string>('');
const status = ref<number>(1);
const claimed = ref<boolean | undefined>(undefined);
const exporting = ref(false);
const collegeOptions = ref<{ label: string; value: number }[]>([]);
const classOptions = ref<{ label: string; value: number }[]>([]);
const statusOptions = [
{ label: '在校', value: 1 },
{ label: '毕业', value: 0 }
];
const claimedOptions = [
{ label: '已认领', value: true },
{ label: '未认领', value: false }
];
const columns = ref<ColumnItem[]>([
{ title: '学号', dataIndex: 'xh', key: 'xh', width: 140 },
{ title: '姓名', dataIndex: 'xm', key: 'xm', width: 110 },
{ title: '性别', dataIndex: 'sex', key: 'sex', width: 80 },
{ title: '学院', dataIndex: 'collegeName', key: 'collegeName', width: 200 },
{
title: '班级',
dataIndex: 'className',
key: 'className',
width: 170,
customRender: ({ text }) => text || '-'
},
{ title: '专业', dataIndex: 'zymc', key: 'zymc', width: 170 },
{
title: '培养层次',
dataIndex: 'pyccmc',
key: 'pyccmc',
width: 110
},
{ title: '手机号', dataIndex: 'sjh', key: 'sjh', width: 140 },
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center',
width: 90
},
{
title: '认领',
dataIndex: 'userId',
key: 'claimed',
align: 'center',
width: 100
},
{
title: '同步时间',
dataIndex: 'syncTime',
key: 'syncTime',
width: 170,
customRender: ({ text }) => (text ? toDateString(text) : '-')
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 150
}
]);
const currentParam = (): StudentRosterParam => ({
collegeId: collegeId.value,
classId: classId.value,
xh: xh.value.trim() || undefined,
xm: xm.value.trim() || undefined,
status: status.value,
claimed: claimed.value
});
const datasource: DatasourceFunction = ({ page, limit }) => {
return pageStudentRoster({ ...currentParam(), page, limit }).catch((e) => {
message.error(e.message);
return { list: [] as StudentRoster[], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const loadColleges = () => {
// 学院取组织树中「广西医科大学」的直接子节点,不再走已废弃的 gxmu_college
listOrganizations({ parentId: COLLEGE_PARENT_ID })
.then((list) => {
collegeOptions.value = list.map((item) => ({
label: item.organizationFullName || item.organizationName || '',
value: item.organizationId || 0
}));
})
.catch((e) => {
message.error(e.message);
});
};
const loadClasses = (id?: number) => {
if (!id) {
classOptions.value = [];
return;
}
listClassInfo({ collegeId: id })
.then((list) => {
classOptions.value = list.map((item) => ({
label: item.className || '',
value: item.id || 0
}));
})
.catch(() => {
classOptions.value = [];
});
};
const onCollegeChange = (value?: number) => {
classId.value = undefined;
loadClasses(value);
};
const resetSearch = () => {
collegeId.value = undefined;
classId.value = undefined;
xh.value = '';
xm.value = '';
status.value = 1;
claimed.value = undefined;
classOptions.value = [];
reload();
};
const doBind = (record: StudentRoster) => {
const hide = message.loading('请求中..', 0);
bindStudent(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
const doUnbind = (record: StudentRoster) => {
const hide = message.loading('请求中..', 0);
unbindStudent(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
const doExport = () => {
exporting.value = true;
exportStudentRoster(currentParam())
.then((list) => {
if (!list.length) {
message.warning('没有可导出的数据');
return;
}
const header = [
'学号',
'姓名',
'性别',
'学院',
'班级',
'专业',
'培养层次',
'手机号',
'状态'
];
const lines = list.map((item) =>
[
item.xh,
item.xm,
item.sex,
item.collegeName,
item.className || '',
item.zymc,
item.pyccmc,
item.sjh,
item.status === 1 ? '在校' : '毕业'
]
.map((v) => `"${String(v ?? '').replace(/"/g, '""')}"`)
.join(',')
);
const csv = '\uFEFF' + [header.join(','), ...lines].join('\r\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `学生名册_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
message.success(`已导出 ${list.length}`);
})
.catch((e) => {
message.error(e.message || '导出失败');
})
.finally(() => {
exporting.value = false;
});
};
onMounted(() => {
loadColleges();
});
defineExpose({ reload });
</script>
<script lang="ts">
export default {
name: 'StudentRoster'
};
</script>