新增后台「数据同步」与「学生名册」页
数据同步页 /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