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

数据同步页 /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
+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>