466 lines
13 KiB
Vue
466 lines
13 KiB
Vue
<script setup lang="tsx">
|
|
import { computed, ref } from 'vue';
|
|
import { NButton, NDivider, NInput, NSpace } from 'naive-ui';
|
|
import { jsonClone } from '@sa/utils';
|
|
import { fetchBatchDeleteMajor, fetchCreateMajor, fetchGetMajorList, fetchUpdateMajor } from '@/service/api/art/major';
|
|
import { useAppStore } from '@/store/modules/app';
|
|
import { useAuth } from '@/hooks/business/auth';
|
|
import { useDownload } from '@/hooks/business/download';
|
|
import { defaultTransform, useNaivePaginatedTable } from '@/hooks/common/table';
|
|
import { $t } from '@/locales';
|
|
import ButtonIcon from '@/components/custom/button-icon.vue';
|
|
import InlineExpandTextarea from '@/components/custom/inline-expand-textarea.vue';
|
|
import MajorSearch from './modules/major-search.vue';
|
|
|
|
defineOptions({
|
|
name: 'MajorList'
|
|
});
|
|
|
|
const appStore = useAppStore();
|
|
const { download } = useDownload();
|
|
const { hasAuth } = useAuth();
|
|
|
|
const searchParams = ref<Api.Art.MajorSearchParams>({
|
|
pageNum: 1,
|
|
pageSize: 10,
|
|
majorName: null,
|
|
educationLevel: null,
|
|
majorIcon: null,
|
|
schoolingYears: null,
|
|
disciplinePrimary: null,
|
|
disciplineSecondary: null,
|
|
degreeAwarded: null,
|
|
summary: null,
|
|
trainingDirection: null,
|
|
coreCourses: null,
|
|
params: {}
|
|
});
|
|
|
|
type TableRow = Api.Art.Major & { tempKey?: string };
|
|
type Model = Api.Art.MajorOperateParams;
|
|
type EditableField = Extract<keyof Model, keyof TableRow>;
|
|
|
|
const editingModel = ref<Model>(createDefaultModel());
|
|
const editingSnapshot = ref<TableRow | null>(null);
|
|
const editingMode = ref<NaiveUI.TableOperateType | null>(null);
|
|
const editingRowKey = ref<string | null>(null);
|
|
const savingRowKey = ref<string | null>(null);
|
|
const tempRow = ref<TableRow | null>(null);
|
|
const checkedRowKeys = ref<CommonType.IdType[]>([]);
|
|
|
|
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination, scrollX } =
|
|
useNaivePaginatedTable({
|
|
api: () => fetchGetMajorList(searchParams.value),
|
|
transform: response => defaultTransform(response),
|
|
onPaginationParamsChange: params => {
|
|
searchParams.value.pageNum = params.page;
|
|
searchParams.value.pageSize = params.pageSize;
|
|
},
|
|
columns: () => createColumns()
|
|
});
|
|
|
|
const tableData = computed<TableRow[]>(() => {
|
|
const rows = data.value as TableRow[];
|
|
return tempRow.value ? [tempRow.value, ...rows] : rows;
|
|
});
|
|
|
|
function createColumns(): NaiveUI.TableColumn<TableRow>[] {
|
|
return [
|
|
{
|
|
type: 'selection',
|
|
align: 'center',
|
|
width: 48
|
|
},
|
|
{
|
|
key: 'index',
|
|
title: $t('common.index'),
|
|
align: 'center',
|
|
width: 64,
|
|
render: (_, index) => index + 1
|
|
},
|
|
{
|
|
key: 'majorId',
|
|
title: '专业主键ID',
|
|
align: 'center',
|
|
minWidth: 120
|
|
},
|
|
createEditableColumn('majorName', '专业名称'),
|
|
createEditableColumn('educationLevel', '学历层次'),
|
|
createEditableColumn('majorIcon', '专业图标'),
|
|
createEditableColumn('schoolingYears', '学制(年)'),
|
|
createEditableColumn('disciplinePrimary', '所属一级学科'),
|
|
createEditableColumn('disciplineSecondary', '所属二级学科'),
|
|
createEditableColumn('degreeAwarded', '授予学士学位'),
|
|
createEditableColumn('summary', '专业概括', { textarea: true }),
|
|
createEditableColumn('trainingDirection', '培养方向', { textarea: true }),
|
|
createEditableColumn('coreCourses', '主要课程', { textarea: true }),
|
|
createEditableColumn('remark', '备注', { textarea: true }),
|
|
createOperateColumn()
|
|
];
|
|
}
|
|
|
|
function createEditableColumn(key: EditableField, title: string, options?: { textarea?: boolean; minWidth?: number }) {
|
|
return {
|
|
key,
|
|
title,
|
|
align: 'center',
|
|
minWidth: options?.minWidth ?? 120,
|
|
render: (row: TableRow) => renderEditableCell(row, key, options)
|
|
} satisfies NaiveUI.TableColumn<TableRow>;
|
|
}
|
|
|
|
function createOperateColumn(): NaiveUI.TableColumn<TableRow> {
|
|
return {
|
|
key: 'operate',
|
|
title: $t('common.operate'),
|
|
align: 'center',
|
|
fixed: 'right',
|
|
width: 100,
|
|
render: (row: TableRow) => {
|
|
const rowKey = resolveRowKey(row);
|
|
const editing = editingRowKey.value === rowKey;
|
|
const saving = savingRowKey.value === rowKey;
|
|
|
|
if (editing) {
|
|
return (
|
|
<NSpace size={8} justify="center">
|
|
<NButton size="tiny" quaternary disabled={saving} onClick={handleCancelEdit}>
|
|
{$t('common.cancel')}
|
|
</NButton>
|
|
<NButton size="tiny" type="primary" loading={saving} onClick={handleSaveRow}>
|
|
{$t('common.save')}
|
|
</NButton>
|
|
</NSpace>
|
|
);
|
|
}
|
|
|
|
const showEdit = hasAuth('art:major:edit');
|
|
const showDelete = hasAuth('art:major:remove');
|
|
|
|
return (
|
|
<div class="flex-center gap-8px">
|
|
{showEdit ? (
|
|
<NButton size="tiny" text type="primary" onClick={() => handleEditRow(row)}>
|
|
{$t('common.edit')}
|
|
</NButton>
|
|
) : null}
|
|
{showEdit && showDelete ? <NDivider vertical /> : null}
|
|
{showDelete ? (
|
|
<ButtonIcon
|
|
text
|
|
type="error"
|
|
icon="material-symbols:delete-outline"
|
|
tooltipContent={$t('common.delete')}
|
|
popconfirmContent={$t('common.confirmDelete')}
|
|
onPositiveClick={() => handleDelete(row.majorId)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
}
|
|
|
|
function getRowFieldValue(row: TableRow, field: EditableField) {
|
|
return row[field as keyof TableRow];
|
|
}
|
|
|
|
function formatDisplayValue(value: unknown) {
|
|
if (value === null || value === undefined || value === '') {
|
|
return '-';
|
|
}
|
|
return typeof value === 'number' ? value : String(value);
|
|
}
|
|
|
|
function renderEditableCell(row: TableRow, field: EditableField, options?: { textarea?: boolean }) {
|
|
if (!isEditingRow(row)) {
|
|
return formatDisplayValue(getRowFieldValue(row, field));
|
|
}
|
|
|
|
const inputValue = editingModel.value[field];
|
|
const resolvedValue = inputValue === null || inputValue === undefined ? '' : String(inputValue);
|
|
|
|
if (options?.textarea) {
|
|
return renderTextareaEditor(row, field, resolvedValue);
|
|
}
|
|
|
|
return (
|
|
<NInput
|
|
size="small"
|
|
type={options?.textarea ? 'textarea' : 'text'}
|
|
autosize={options?.textarea ? { minRows: 1, maxRows: 4 } : undefined}
|
|
value={resolvedValue}
|
|
onUpdateValue={value => updateEditingField(field, value)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function renderTextareaEditor(row: TableRow, field: EditableField, value: string) {
|
|
return <InlineExpandTextarea value={value} onUpdateValue={val => updateEditingField(field, val)} />;
|
|
}
|
|
|
|
function updateEditingField(field: EditableField, value: string | number | null) {
|
|
(editingModel.value as Record<string, string | number | null>)[field] = value;
|
|
}
|
|
|
|
function resolveRowKey(row: TableRow) {
|
|
if (row.majorId !== null && row.majorId !== undefined && row.majorId !== '') {
|
|
return String(row.majorId);
|
|
}
|
|
return row.tempKey ?? '';
|
|
}
|
|
|
|
function isEditingRow(row: TableRow) {
|
|
return editingRowKey.value !== null && editingRowKey.value === resolveRowKey(row);
|
|
}
|
|
|
|
function createDefaultModel(): Model {
|
|
return {
|
|
majorId: null,
|
|
majorName: '',
|
|
educationLevel: '',
|
|
majorIcon: '',
|
|
schoolingYears: null,
|
|
disciplinePrimary: '',
|
|
disciplineSecondary: '',
|
|
degreeAwarded: '',
|
|
summary: '',
|
|
trainingDirection: '',
|
|
coreCourses: '',
|
|
remark: ''
|
|
};
|
|
}
|
|
|
|
function createTempKey() {
|
|
return `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
}
|
|
|
|
function ensureEditingGuard(action: () => void | Promise<void>) {
|
|
const executeAction = () => Promise.resolve(action());
|
|
|
|
if (!editingRowKey.value) {
|
|
return executeAction();
|
|
}
|
|
|
|
if (!window.$dialog) {
|
|
resetEditingState();
|
|
return executeAction();
|
|
}
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
window.$dialog?.warning({
|
|
title: '提示',
|
|
content: '当前行尚未保存,确定放弃修改吗?',
|
|
positiveText: '放弃',
|
|
negativeText: '继续编辑',
|
|
onPositiveClick: async () => {
|
|
resetEditingState();
|
|
try {
|
|
await executeAction();
|
|
resolve();
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
},
|
|
onNegativeClick: () => resolve()
|
|
});
|
|
});
|
|
}
|
|
|
|
function resetEditingState() {
|
|
editingModel.value = createDefaultModel();
|
|
editingSnapshot.value = null;
|
|
editingMode.value = null;
|
|
editingRowKey.value = null;
|
|
savingRowKey.value = null;
|
|
tempRow.value = null;
|
|
}
|
|
|
|
async function handleAddRow() {
|
|
await ensureEditingGuard(() => {
|
|
editingMode.value = 'add';
|
|
editingModel.value = createDefaultModel();
|
|
editingSnapshot.value = null;
|
|
const key = createTempKey();
|
|
tempRow.value = { ...(editingModel.value as TableRow), tempKey: key };
|
|
editingRowKey.value = key;
|
|
checkedRowKeys.value = [];
|
|
});
|
|
}
|
|
|
|
async function handleEditRow(row: TableRow) {
|
|
await ensureEditingGuard(() => {
|
|
editingMode.value = 'edit';
|
|
tempRow.value = null;
|
|
editingRowKey.value = resolveRowKey(row);
|
|
editingSnapshot.value = jsonClone(row);
|
|
editingModel.value = Object.assign(createDefaultModel(), jsonClone(row));
|
|
});
|
|
}
|
|
|
|
function handleCancelEdit() {
|
|
resetEditingState();
|
|
}
|
|
|
|
const requiredFields: Array<{ key: EditableField; label: string }> = [
|
|
{ key: 'majorName', label: '专业名称' },
|
|
{ key: 'educationLevel', label: '学历层次' }
|
|
];
|
|
|
|
function validateModel() {
|
|
for (const item of requiredFields) {
|
|
const value = editingModel.value[item.key];
|
|
if (value === null || value === undefined || value === '') {
|
|
window.$message?.warning(`${item.label}不能为空`);
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function handleSaveRow() {
|
|
if (!editingMode.value) return;
|
|
if (!validateModel()) return;
|
|
|
|
const {
|
|
majorId,
|
|
majorName,
|
|
educationLevel,
|
|
majorIcon,
|
|
schoolingYears,
|
|
disciplinePrimary,
|
|
disciplineSecondary,
|
|
degreeAwarded,
|
|
summary,
|
|
trainingDirection,
|
|
coreCourses,
|
|
remark
|
|
} = editingModel.value;
|
|
|
|
savingRowKey.value = editingRowKey.value;
|
|
|
|
const requestResult =
|
|
editingMode.value === 'add'
|
|
? await fetchCreateMajor({
|
|
majorName,
|
|
educationLevel,
|
|
majorIcon,
|
|
schoolingYears,
|
|
disciplinePrimary,
|
|
disciplineSecondary,
|
|
degreeAwarded,
|
|
summary,
|
|
trainingDirection,
|
|
coreCourses,
|
|
remark
|
|
})
|
|
: await fetchUpdateMajor({
|
|
majorId,
|
|
majorName,
|
|
educationLevel,
|
|
majorIcon,
|
|
schoolingYears,
|
|
disciplinePrimary,
|
|
disciplineSecondary,
|
|
degreeAwarded,
|
|
summary,
|
|
trainingDirection,
|
|
coreCourses,
|
|
remark
|
|
});
|
|
|
|
savingRowKey.value = null;
|
|
|
|
if (requestResult.error) {
|
|
window.$message?.error(requestResult.error.message || '保存失败');
|
|
if (editingMode.value === 'edit' && editingSnapshot.value) {
|
|
editingModel.value = Object.assign(createDefaultModel(), jsonClone(editingSnapshot.value));
|
|
}
|
|
return;
|
|
}
|
|
|
|
window.$message?.success($t('common.updateSuccess'));
|
|
resetEditingState();
|
|
await getData();
|
|
}
|
|
|
|
async function handleBatchDelete() {
|
|
await ensureEditingGuard(async () => {
|
|
const { error } = await fetchBatchDeleteMajor(checkedRowKeys.value);
|
|
if (error) return;
|
|
window.$message?.success($t('common.deleteSuccess'));
|
|
checkedRowKeys.value = [];
|
|
await getData();
|
|
});
|
|
}
|
|
|
|
async function handleDelete(majorId: CommonType.IdType) {
|
|
await ensureEditingGuard(async () => {
|
|
const { error } = await fetchBatchDeleteMajor([majorId]);
|
|
if (error) return;
|
|
window.$message?.success($t('common.deleteSuccess'));
|
|
await getData();
|
|
});
|
|
}
|
|
|
|
function handleExport() {
|
|
download('/art/major/export', searchParams.value, `艺术专业库_${new Date().getTime()}.xlsx`);
|
|
}
|
|
|
|
async function handleSearch() {
|
|
await ensureEditingGuard(async () => {
|
|
await getDataByPage();
|
|
});
|
|
}
|
|
|
|
async function handleRefresh() {
|
|
await ensureEditingGuard(getData);
|
|
}
|
|
|
|
function rowClassName(row: TableRow) {
|
|
return isEditingRow(row) ? 'inline-edit-row' : '';
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
|
<MajorSearch v-model:model="searchParams" @search="handleSearch" />
|
|
<NCard title="艺术专业库列表" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
|
|
<template #header-extra>
|
|
<TableHeaderOperation
|
|
v-model:columns="columnChecks"
|
|
:disabled-delete="checkedRowKeys.length === 0"
|
|
:loading="loading"
|
|
:show-add="hasAuth('art:major:add')"
|
|
:show-delete="hasAuth('art:major:remove')"
|
|
:show-export="hasAuth('art:major:export')"
|
|
@add="handleAddRow"
|
|
@delete="handleBatchDelete"
|
|
@export="handleExport"
|
|
@refresh="handleRefresh"
|
|
/>
|
|
</template>
|
|
<NDataTable
|
|
v-model:checked-row-keys="checkedRowKeys"
|
|
:columns="columns"
|
|
:data="tableData"
|
|
size="small"
|
|
:flex-height="!appStore.isMobile"
|
|
:scroll-x="scrollX"
|
|
:loading="loading"
|
|
remote
|
|
:row-key="resolveRowKey"
|
|
:row-class-name="rowClassName"
|
|
:pagination="mobilePagination"
|
|
class="sm:h-full"
|
|
/>
|
|
</NCard>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
:deep(.inline-edit-row .n-data-table-td) {
|
|
background-color: rgba(24, 160, 88, 0.08);
|
|
}
|
|
</style>
|