新增后台「数据同步」与「学生名册」页
数据同步页 /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:
@@ -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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user