art-management-fronted/src/views/art/major/index.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>