<script lang="ts" setup>
|
import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable';
|
|
import type { QuestionBankOption, QuestionEntity } from './types';
|
|
import { onMounted, ref } from 'vue';
|
import { useRouter } from 'vue-router';
|
|
import { useMessage } from '@jnpf/hooks';
|
import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
|
|
import { Modal, Upload } from 'ant-design-vue';
|
|
import {
|
downloadQuestionImportTemplate,
|
exportQuestions,
|
getQuestionBanks,
|
getQuestionList,
|
importQuestions,
|
invalidateQuestion,
|
setQuestionStatus,
|
} from '#/api/x/tms/question';
|
import { saveExportBlob } from '#/views/x/tms/shared/exportDownload';
|
import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic';
|
import { TMS_BTN } from '#/views/x/tms/shared/ui';
|
|
import {
|
LIFE_TYPE_OPTIONS,
|
QUESTION_TYPE_OPTIONS,
|
STATUS_OPTIONS,
|
labelOfLifeType,
|
labelOfStatus,
|
labelOfType,
|
loadQuestionDics,
|
} from './constants';
|
|
import '#/views/x/tms/shared/page.css';
|
|
defineOptions({ name: 'TmsQuestionList' });
|
|
const router = useRouter();
|
const { createMessage } = useMessage();
|
|
const banks = ref<QuestionBankOption[]>([]);
|
const exporting = ref(false);
|
const importVisible = ref(false);
|
const templateLoading = ref(false);
|
const importing = ref(false);
|
|
const columns: BasicColumn[] = [
|
{ title: '题库', dataIndex: 'bankName', minWidth: 220 },
|
{
|
title: '类型',
|
dataIndex: 'questionType',
|
width: 90,
|
customRender: ({ record }) => labelOfType((record as QuestionEntity).questionType),
|
},
|
{
|
title: '题干',
|
dataIndex: 'stem',
|
minWidth: 280,
|
customRender: ({ record }) => stripHtml((record as QuestionEntity).stem),
|
},
|
{
|
title: '时效',
|
dataIndex: 'lifeType',
|
width: 80,
|
customRender: ({ record }) => labelOfLifeType((record as QuestionEntity).lifeType),
|
},
|
{
|
title: '过期时间',
|
dataIndex: 'expireTime',
|
width: 170,
|
customRender: ({ record }) => {
|
const row = record as QuestionEntity;
|
return row.lifeType === 'temp' ? row.expireTime || '-' : '-';
|
},
|
},
|
{ title: '创建时间', dataIndex: 'creatorTime', width: 170 },
|
{ title: '管理员', dataIndex: 'adminUserName', width: 100 },
|
{
|
title: '状态',
|
dataIndex: 'bizStatus',
|
width: 90,
|
align: 'center',
|
slots: { default: 'bizStatus' },
|
},
|
];
|
|
const [registerTable, { reload, getForm, getSelectRows }] = useVxeTable({
|
api: fetchList,
|
columns,
|
immediate: false,
|
rowKey: 'id',
|
useSearchForm: true,
|
formConfig: {
|
baseColProps: { span: 6 },
|
compact: true,
|
showAdvancedButton: true,
|
alwaysShowLines: 1,
|
autoAdvancedLine: 4,
|
schemas: [
|
{
|
field: 'bankId',
|
label: '题库',
|
component: 'Select',
|
componentProps: {
|
allowClear: true,
|
showSearch: true,
|
placeholder: '请选择题库',
|
options: [],
|
},
|
},
|
{
|
field: 'questionType',
|
label: '题型',
|
component: 'Select',
|
componentProps: {
|
allowClear: true,
|
showSearch: true,
|
placeholder: '请选择题型',
|
options: QUESTION_TYPE_OPTIONS.value,
|
fieldNames: TMS_DIC_FIELD_NAMES,
|
},
|
},
|
{
|
field: 'bizStatus',
|
label: '状态',
|
component: 'Select',
|
componentProps: {
|
allowClear: true,
|
showSearch: true,
|
placeholder: '请选择状态',
|
options: STATUS_OPTIONS.value,
|
fieldNames: TMS_DIC_FIELD_NAMES,
|
},
|
},
|
{
|
field: 'lifeType',
|
label: '时效类型',
|
component: 'Select',
|
componentProps: {
|
allowClear: true,
|
showSearch: true,
|
placeholder: '请选择时效类型',
|
options: LIFE_TYPE_OPTIONS.value,
|
fieldNames: TMS_DIC_FIELD_NAMES,
|
},
|
},
|
{
|
field: 'adminUserId',
|
label: '管理员',
|
component: 'UserSelect',
|
componentProps: {
|
placeholder: '请选择管理员',
|
},
|
},
|
{
|
field: 'keyword',
|
label: '题干',
|
component: 'Input',
|
componentProps: { placeholder: '请输入题干', submitOnPressEnter: true },
|
},
|
],
|
},
|
rowSelection: { type: 'checkbox' },
|
actionColumn: {
|
width: 200,
|
title: '操作',
|
dataIndex: 'action',
|
fixed: 'right',
|
},
|
});
|
|
onMounted(async () => {
|
await loadQuestionDics();
|
try {
|
const list = await getQuestionBanks();
|
banks.value = Array.isArray(list) ? list : [];
|
} catch {
|
banks.value = [];
|
}
|
getForm()?.updateSchema?.([
|
{
|
field: 'bankId',
|
componentProps: {
|
allowClear: true,
|
showSearch: true,
|
placeholder: '请选择题库',
|
options: banks.value,
|
},
|
},
|
{
|
field: 'questionType',
|
componentProps: {
|
allowClear: true,
|
showSearch: true,
|
placeholder: '请选择题型',
|
options: QUESTION_TYPE_OPTIONS.value,
|
fieldNames: TMS_DIC_FIELD_NAMES,
|
},
|
},
|
{
|
field: 'bizStatus',
|
componentProps: {
|
allowClear: true,
|
showSearch: true,
|
placeholder: '请选择状态',
|
options: STATUS_OPTIONS.value,
|
fieldNames: TMS_DIC_FIELD_NAMES,
|
},
|
},
|
{
|
field: 'lifeType',
|
componentProps: {
|
allowClear: true,
|
showSearch: true,
|
placeholder: '请选择时效类型',
|
options: LIFE_TYPE_OPTIONS.value,
|
fieldNames: TMS_DIC_FIELD_NAMES,
|
},
|
},
|
]);
|
reload();
|
});
|
|
async function fetchList(params: Record<string, any>) {
|
const page = await getQuestionList(params);
|
return {
|
data: {
|
list: Array.isArray(page?.list) ? page.list : [],
|
pagination: page?.pagination || { total: 0 },
|
},
|
};
|
}
|
|
function stripHtml(html?: string) {
|
if (!html) return '';
|
const text = html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim();
|
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
}
|
|
function statusColor(status?: string) {
|
if (status === 'open') return 'success';
|
if (status === 'closed') return 'warning';
|
if (status === 'invalid') return 'error';
|
return 'default';
|
}
|
|
function handleCreate() {
|
router.push('/tms/question/create');
|
}
|
|
function handleManage() {
|
const rows = (getSelectRows?.() || []) as QuestionEntity[];
|
if (!rows.length) {
|
createMessage.warning('请先勾选要管理的试题');
|
return;
|
}
|
if (rows.length === 1) {
|
handleEdit(rows[0]!);
|
return;
|
}
|
const openRows = rows.filter((r) => r.bizStatus === 'open');
|
if (openRows.length) {
|
createMessage.warning('勾选中含已开放试题,请先设为不开放后再批量废弃');
|
return;
|
}
|
Modal.confirm({
|
title: '批量废弃',
|
content: `已勾选 ${rows.length} 道试题,确定全部废弃吗?`,
|
onOk: async () => {
|
for (const row of rows) {
|
if (row.id) await invalidateQuestion(row.id);
|
}
|
createMessage.success('废弃成功');
|
reload();
|
},
|
});
|
}
|
|
function handleDetail(record: QuestionEntity) {
|
router.push(`/tms/question/detail/${record.id}`);
|
}
|
|
function handleEdit(record: QuestionEntity) {
|
if (record.bizStatus === 'invalid') {
|
createMessage.warning('已废弃试题不可编辑');
|
return;
|
}
|
router.push(`/tms/question/edit/${record.id}`);
|
}
|
|
async function handleInvalidate(record: QuestionEntity) {
|
await invalidateQuestion(record.id!);
|
createMessage.success('废弃成功');
|
reload();
|
}
|
|
async function handleEnable(record: QuestionEntity) {
|
await setQuestionStatus(record.id!, 'open');
|
createMessage.success('已开放');
|
reload();
|
}
|
|
async function handleDisable(record: QuestionEntity) {
|
await setQuestionStatus(record.id!, 'closed');
|
createMessage.success('已不开放');
|
reload();
|
}
|
|
async function handleExport() {
|
if (exporting.value) return;
|
const selected = (getSelectRows?.() || []) as QuestionEntity[];
|
const filters = (getForm()?.getFieldsValue?.() || {}) as Record<string, any>;
|
exporting.value = true;
|
try {
|
const payload =
|
selected.length > 0
|
? { ids: selected.map((x) => x.id!).filter(Boolean) }
|
: {
|
bankId: filters.bankId,
|
questionType: filters.questionType,
|
bizStatus: filters.bizStatus,
|
lifeType: filters.lifeType,
|
keyword: filters.keyword,
|
};
|
const res = await exportQuestions(payload);
|
await saveExportBlob(res, `试题导出.xls`);
|
createMessage.success(selected.length ? '导出成功' : '已按当前筛选条件导出');
|
} catch (e: any) {
|
createMessage.error(e?.message || '导出失败');
|
} finally {
|
exporting.value = false;
|
}
|
}
|
|
function openImportModal() {
|
importVisible.value = true;
|
}
|
|
async function handleDownloadTemplate() {
|
if (templateLoading.value) return;
|
templateLoading.value = true;
|
try {
|
const res = await downloadQuestionImportTemplate();
|
await saveExportBlob(res, '试题导入模板.xls');
|
createMessage.success('模板已下载');
|
} catch (e: any) {
|
createMessage.error(e?.message || '下载模板失败');
|
} finally {
|
templateLoading.value = false;
|
}
|
}
|
|
async function handleImport(file: File) {
|
if (importing.value) return false;
|
importing.value = true;
|
try {
|
const result = await importQuestions(file);
|
const ok = result?.successCount || 0;
|
const fail = result?.failCount || 0;
|
if (fail > 0) {
|
const detail = (result?.errors || []).slice(0, 8).join('\n');
|
Modal.warning({
|
title: `导入完成:成功 ${ok},失败 ${fail}`,
|
content: detail || '部分行导入失败,请检查题库名称与题型',
|
width: 520,
|
});
|
} else {
|
createMessage.success(`成功导入 ${ok} 道试题`);
|
}
|
if (ok > 0) {
|
importVisible.value = false;
|
reload();
|
}
|
} catch (e: any) {
|
createMessage.error(e?.message || '导入失败');
|
} finally {
|
importing.value = false;
|
}
|
return false;
|
}
|
|
function getTableActions(record: QuestionEntity): ActionItem[] {
|
const title = stripHtml(record.stem) || record.questionNo || '';
|
if (record.bizStatus === 'invalid') {
|
return [{ label: '详情', onClick: handleDetail.bind(null, record) }];
|
}
|
const actions: ActionItem[] = [
|
{ label: '编辑', onClick: handleEdit.bind(null, record) },
|
{ label: '详情', onClick: handleDetail.bind(null, record) },
|
];
|
if (record.bizStatus === 'open') {
|
actions.unshift({
|
label: '不开放',
|
modelConfirm: {
|
content: `确定将试题「${title}」设为不开放吗?不开放后学生不可模拟抽题。`,
|
onOk: handleDisable.bind(null, record),
|
},
|
});
|
} else {
|
actions.unshift({
|
label: '开放',
|
modelConfirm: {
|
content: `确定开放试题「${title}」吗?开放后学生可模拟抽题。`,
|
onOk: handleEnable.bind(null, record),
|
},
|
});
|
actions.push({
|
label: '废弃',
|
color: 'error',
|
modelConfirm: {
|
content: `确定废弃试题「${title}」吗?废弃后仍可在列表按状态查看,但不可再组卷/模拟。`,
|
onOk: handleInvalidate.bind(null, record),
|
},
|
});
|
}
|
return actions;
|
}
|
</script>
|
|
<template>
|
<div class="jnpf-content-wrapper">
|
<div class="jnpf-content-wrapper-center">
|
<div class="jnpf-content-wrapper-content">
|
<BasicVxeTable @register="registerTable">
|
<template #tableTitle>
|
<a-space>
|
<a-button type="primary" pre-icon="icon-ym icon-ym-btn-add" @click="handleCreate">创建试题</a-button>
|
<a-button @click="handleManage">管理试题</a-button>
|
<a-button @click="openImportModal">{{ TMS_BTN.import }}</a-button>
|
<a-button :loading="exporting" @click="handleExport">{{ TMS_BTN.export }}</a-button>
|
<span class="tms-toolbar-hint">导出优先勾选,未勾选则按筛选条件</span>
|
</a-space>
|
</template>
|
<template #bizStatus="{ record }">
|
<a-tag :color="statusColor(record.bizStatus)">{{ labelOfStatus(record.bizStatus) }}</a-tag>
|
</template>
|
<template #action="{ record }">
|
<TableAction :actions="getTableActions(record)" />
|
</template>
|
</BasicVxeTable>
|
</div>
|
</div>
|
|
<Modal
|
v-model:open="importVisible"
|
title="导入试题"
|
:mask-closable="!importing"
|
:destroy-on-close="true"
|
:confirm-loading="importing"
|
centered
|
width="560"
|
wrap-class-name="tms-import-dialog"
|
>
|
<div class="tms-import-modal">
|
<a-alert
|
class="tms-import-modal__alert"
|
type="info"
|
show-icon
|
message="请先下载模板并按示例填写;题库名称须与系统已有题库完全一致。支持 xls / xlsx / csv。"
|
/>
|
|
<div class="tms-import-step">
|
<div class="tms-import-step__index">1</div>
|
<div class="tms-import-step__body">
|
<div class="tms-import-step__title">下载导入模板</div>
|
<div class="tms-import-step__desc">按模板列填写题库、题型、题干与答案</div>
|
<a-button
|
class="tms-import-step__action"
|
:loading="templateLoading"
|
pre-icon="icon-ym icon-ym-btn-download"
|
@click="handleDownloadTemplate"
|
>
|
{{ TMS_BTN.downloadTemplate }}
|
</a-button>
|
</div>
|
</div>
|
|
<div class="tms-import-step">
|
<div class="tms-import-step__index">2</div>
|
<div class="tms-import-step__body">
|
<div class="tms-import-step__title">上传填写后的文件</div>
|
<div class="tms-import-step__desc">选择或拖拽文件后将立即开始导入</div>
|
<Upload.Dragger
|
class="tms-import-modal__dragger"
|
:show-upload-list="false"
|
:disabled="importing"
|
accept=".xls,.xlsx,.csv"
|
:before-upload="handleImport"
|
>
|
<p class="tms-import-modal__drag-icon">
|
<i class="icon-ym icon-ym-generator-upload"></i>
|
</p>
|
<p class="tms-import-modal__drag-title">
|
{{ importing ? '正在导入…' : '点击或拖拽文件到此处' }}
|
</p>
|
<p class="tms-import-modal__drag-sub">支持 .xls / .xlsx / .csv</p>
|
</Upload.Dragger>
|
</div>
|
</div>
|
</div>
|
<template #footer>
|
<a-button @click="importVisible = false">{{ TMS_BTN.close }}</a-button>
|
</template>
|
</Modal>
|
</div>
|
</template>
|
|
<style scoped>
|
.tms-import-modal {
|
padding: 8px 8px 4px;
|
}
|
|
.tms-import-modal__alert {
|
margin-bottom: 20px;
|
}
|
|
.tms-import-step {
|
display: flex;
|
gap: 14px;
|
margin-bottom: 20px;
|
}
|
|
.tms-import-step:last-child {
|
margin-bottom: 0;
|
}
|
|
.tms-import-step__index {
|
flex: 0 0 28px;
|
width: 28px;
|
height: 28px;
|
margin-top: 2px;
|
border-radius: 50%;
|
background: #1677ff;
|
color: #fff;
|
font-size: 13px;
|
font-weight: 600;
|
line-height: 28px;
|
text-align: center;
|
}
|
|
.tms-import-step__body {
|
flex: 1;
|
min-width: 0;
|
}
|
|
.tms-import-step__title {
|
font-size: 14px;
|
font-weight: 600;
|
color: rgba(0, 0, 0, 0.88);
|
line-height: 22px;
|
}
|
|
.tms-import-step__desc {
|
margin-top: 2px;
|
margin-bottom: 12px;
|
font-size: 13px;
|
color: rgba(0, 0, 0, 0.45);
|
line-height: 20px;
|
}
|
|
.tms-import-step__action {
|
min-width: 120px;
|
}
|
|
.tms-import-modal__dragger {
|
display: block;
|
}
|
|
.tms-import-modal__dragger :deep(.ant-upload-drag) {
|
padding: 28px 16px;
|
background: #fafafa;
|
border-radius: 8px;
|
}
|
|
.tms-import-modal__drag-icon {
|
margin: 0 0 8px;
|
font-size: 36px;
|
line-height: 1;
|
color: #1677ff;
|
}
|
|
.tms-import-modal__drag-title {
|
margin: 0;
|
font-size: 14px;
|
color: rgba(0, 0, 0, 0.88);
|
}
|
|
.tms-import-modal__drag-sub {
|
margin: 6px 0 0;
|
font-size: 12px;
|
color: rgba(0, 0, 0, 0.45);
|
}
|
</style>
|