Files
kaidi/frontend/src/pages/forms/FormCenterPage.vue
T
2026-08-17 14:08:40 +08:00

677 lines
22 KiB
Vue

<template>
<div class="form-center-page">
<h1 class="page-title-sr-only">表单中心</h1>
<header class="page-header page-header--actions-only" aria-label="页面操作">
<t-button variant="outline" :loading="loading" @click="reloadAll">
<template #icon><refresh-icon /></template>
刷新
</t-button>
</header>
<t-alert v-if="errorMessage" theme="error" :close-btn="false">
<div class="alert-content">
<span>{{ errorMessage }}</span>
<t-link theme="primary" @click="reloadAll">重新加载</t-link>
</div>
</t-alert>
<section class="template-section" aria-label="来源表单模板">
<div class="section-heading">
<div>
<h2>业务模板</h2>
<span>当前启用 {{ templates.length }} 类表单</span>
</div>
</div>
<t-tabs v-model="activeGroup" theme="card" @change="groupChanged">
<t-tab-panel v-for="group in groups" :key="group.code" :value="group.code" :label="group.name">
<div class="template-grid">
<t-card v-for="template in groupedTemplates[group.code] || []" :key="template.publicId" :bordered="true">
<template #title>
<div class="template-title">
<span>{{ template.formType }}</span>
<t-tag theme="primary" variant="light">V{{ template.templateVersion }}</t-tag>
</div>
</template>
<div class="template-body">
<h3>{{ template.name }}</h3>
<p>{{ template.purpose }}</p>
<div class="template-roles" aria-label="适用角色">
<span>适用角色</span>
<t-tag v-for="role in template.initiatorRoles" :key="role" variant="light">
{{ roleLabel(role) }}
</t-tag>
</div>
<div class="template-metrics">
<span
>我的草稿 <strong>{{ template.myDraftCount }}</strong></span
>
<span
>待我处理 <strong>{{ template.myPendingCount }}</strong></span
>
</div>
<div class="template-actions">
<t-space size="small" break-line>
<t-button v-if="template.canCreate" size="small" theme="primary" @click="openCreate(template)">
<template #icon><add-icon /></template>
在线填写
</t-button>
<t-button v-if="template.canCreate" size="small" variant="outline" @click="openImport(template)">
<template #icon><upload-icon /></template>
导入
</t-button>
<t-button size="small" variant="text" @click="downloadTemplate(template)">
<template #icon><download-icon /></template>
模板
</t-button>
<t-button size="small" variant="text" @click="showTemplateHistory(template)">历史</t-button>
</t-space>
</div>
</div>
</t-card>
</div>
</t-tab-panel>
</t-tabs>
</section>
<section class="history-section" aria-label="来源表单记录">
<div class="section-heading">
<div>
<h2>表单记录</h2>
<span>共 {{ pagination.total }} 条</span>
</div>
</div>
<t-form :data="filters" label-align="top" @submit="search">
<div class="filter-grid">
<t-form-item label="关键词" name="keyword">
<t-input v-model="filters.keyword" clearable placeholder="业务编号、来源单号或项目" @enter="search" />
</t-form-item>
<t-form-item label="表单类型" name="formType">
<t-select v-model="filters.formType" clearable :options="templateOptions" placeholder="全部类型" />
</t-form-item>
<t-form-item label="模板版本" name="templateVersion">
<t-input-number
v-model="filters.templateVersion"
clearable
:min="1"
:decimal-places="0"
theme="normal"
placeholder="全部版本"
/>
</t-form-item>
<t-form-item label="来源方式" name="sourceSystem">
<t-select v-model="filters.sourceSystem" clearable :options="sourceOptions" placeholder="全部来源" />
</t-form-item>
<t-form-item label="状态" name="status">
<t-select v-model="filters.status" clearable :options="statusOptions" placeholder="全部状态" />
</t-form-item>
<t-form-item label="创建日期" name="createdDateRange">
<t-date-range-picker v-model="createdDateRange" clearable value-type="YYYY-MM-DD" />
</t-form-item>
<t-form-item label="排序" name="sort">
<t-select v-model="filters.sort" :options="sortOptions" placeholder="选择排序方式" />
</t-form-item>
<div class="filter-actions">
<t-checkbox v-model="filters.createdByMe">只看我发起</t-checkbox>
<t-button theme="primary" type="submit">
<template #icon><search-icon /></template>
查询
</t-button>
<t-button variant="outline" type="button" @click="resetFilters">重置</t-button>
</div>
</div>
</t-form>
<t-table
v-if="loading || rows.length"
:data="rows"
:columns="columns"
row-key="publicId"
:loading="loading"
:hover="true"
table-layout="fixed"
:horizontal-scroll-affixed-bottom="true"
>
<template #businessNo="{ row }">
<t-link theme="primary" @click="openDocument(row)">{{ row.businessNo }}</t-link>
</template>
<template #form="{ row }">
<div class="form-name-cell">
<strong>{{ row.formName }}</strong
><span>{{ row.formType }} · V{{ row.templateVersion }}</span>
</div>
</template>
<template #project="{ row }">
<t-link
v-if="row.project"
theme="primary"
role="link"
tabindex="0"
@click="openProject(row.project.publicId)"
@keydown.enter="openProject(row.project.publicId)"
>
{{ row.project.name }}
</t-link>
<span v-else>不涉及项目</span>
</template>
<template #source="{ row }">{{ sourceLabel(row.sourceSystem) }} · {{ row.sourceNo }}</template>
<template #status="{ row }">
<t-tag :theme="statusTheme(row.status)" variant="light">{{ statusLabel(row.status) }}</t-tag>
</template>
<template #updatedAt="{ row }">{{ formatTime(row.updatedAt) }}</template>
<template #createdAt="{ row }">{{ formatTime(row.createdAt) }}</template>
<template #op="{ row }">
<t-space size="small">
<t-link theme="primary" @click="openDocument(row)">{{
row.allowedActions.includes('EDIT') ? '继续填写' : '查看'
}}</t-link>
<t-link v-if="row.allowedActions.includes('VALIDATE')" theme="primary" @click="validateDocument(row)"
>校验</t-link
>
</t-space>
</template>
</t-table>
<t-empty v-else description="当前条件下暂无表单记录" />
<div v-if="pagination.total > 0" class="pagination-row">
<t-pagination
v-model="pagination.current"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-size-options="[20, 50, 100]"
show-jumper
@current-change="loadForms"
@page-size-change="pageSizeChanged"
/>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { AddIcon, DownloadIcon, RefreshIcon, SearchIcon, UploadIcon } from 'tdesign-icons-vue-next';
import type { PrimaryTableCol } from 'tdesign-vue-next';
import { MessagePlugin } from 'tdesign-vue-next';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { SourceFormQuery, SourceFormSummary, SourceTemplate } from '@/api/source';
import { getSourceForms, getSourceTemplate, getSourceTemplates, validateSourceForm } from '@/api/source';
defineOptions({ name: 'FormCenterPage' });
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const validatingId = ref('');
const errorMessage = ref('');
const templates = ref<SourceTemplate[]>([]);
const rows = ref<SourceFormSummary[]>([]);
const groups = [
{ code: 'PROJECT_PREPARATION', name: '项目准备' },
{ code: 'INCOME_EXPENSE', name: '收支执行' },
{ code: 'PROJECT_PROCESS', name: '项目过程' },
{ code: 'CLOSING_ARCHIVE', name: '结束归档' },
{ code: 'BANK_INFORMATION', name: '银行资料' },
];
const requestedGroup = typeof route.query.groupCode === 'string' ? route.query.groupCode : route.query.group;
const activeGroup = ref(
groups.some((group) => group.code === requestedGroup) ? String(requestedGroup) : groups[0].code,
);
const parsedTemplateVersion = Number(
typeof route.query.templateVersion === 'string' ? route.query.templateVersion : '',
);
const sortValues = new Set<NonNullable<SourceFormQuery['sort']>>([
'updatedAt,asc',
'updatedAt,desc',
'createdAt,asc',
'createdAt,desc',
'businessNo,asc',
'businessNo,desc',
'status,asc',
'status,desc',
]);
const parseSort = (value: unknown): NonNullable<SourceFormQuery['sort']> =>
typeof value === 'string' && sortValues.has(value as NonNullable<SourceFormQuery['sort']>)
? (value as NonNullable<SourceFormQuery['sort']>)
: 'updatedAt,desc';
const filters = reactive<{
keyword: string;
formType: string;
templateVersion?: number;
projectId: string;
sourceSystem: string;
status: string;
createdByMe: boolean;
sort: NonNullable<SourceFormQuery['sort']>;
}>({
keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '',
formType: typeof route.query.formType === 'string' ? route.query.formType : '',
templateVersion:
Number.isInteger(parsedTemplateVersion) && parsedTemplateVersion > 0 ? parsedTemplateVersion : undefined,
projectId: typeof route.query.projectId === 'string' ? route.query.projectId : '',
sourceSystem: typeof route.query.sourceSystem === 'string' ? route.query.sourceSystem : '',
status: typeof route.query.status === 'string' ? route.query.status : '',
createdByMe: route.query.createdByMe === 'true',
sort: parseSort(route.query.sort),
});
const createdDateRange = ref<string[]>(
typeof route.query.createdDateFrom === 'string' && typeof route.query.createdDateTo === 'string'
? [route.query.createdDateFrom, route.query.createdDateTo]
: [],
);
const pagination = reactive({
current: Number(route.query.page) || 1,
pageSize: Number(route.query.size) || 20,
total: 0,
});
const groupedTemplates = computed(() =>
templates.value.reduce<Record<string, SourceTemplate[]>>((result, item) => {
(result[item.groupCode] ||= []).push(item);
return result;
}, {}),
);
const templateOptions = computed(() =>
templates.value.map((item) => ({ label: `${item.formType} ${item.name}`, value: item.formType })),
);
const sourceOptions = [
{ label: '在线录入', value: 'MANUAL' },
{ label: 'OA 导出', value: 'OA_EXPORT' },
{ label: 'Excel/CSV 导入', value: 'EXCEL_IMPORT' },
];
const statusOptions = [
{ label: '草稿', value: 'DRAFT' },
{ label: '项目复核中', value: 'PROJECT_REVIEW' },
{ label: '财务复核中', value: 'FINANCE_REVIEW' },
{ label: '已退回', value: 'RETURNED' },
{ label: '已驳回', value: 'REJECTED' },
{ label: '已通过', value: 'APPROVED' },
{ label: '待归档', value: 'ARCHIVE_PENDING' },
{ label: '已归档', value: 'ARCHIVED' },
{ label: '已作废', value: 'VOID' },
];
const sortOptions: Array<{ label: string; value: NonNullable<SourceFormQuery['sort']> }> = [
{ label: '更新时间从新到旧', value: 'updatedAt,desc' },
{ label: '更新时间从旧到新', value: 'updatedAt,asc' },
{ label: '创建时间从新到旧', value: 'createdAt,desc' },
{ label: '创建时间从旧到新', value: 'createdAt,asc' },
{ label: '业务编号升序', value: 'businessNo,asc' },
{ label: '业务编号降序', value: 'businessNo,desc' },
{ label: '状态升序', value: 'status,asc' },
{ label: '状态降序', value: 'status,desc' },
];
const columns: PrimaryTableCol[] = [
{ title: '业务编号', colKey: 'businessNo', width: 175, fixed: 'left', ellipsis: true },
{ title: '表单', colKey: 'form', width: 230 },
{ title: '项目', colKey: 'project', width: 180, ellipsis: true },
{ title: '来源', colKey: 'source', width: 210, ellipsis: true },
{ title: '申请人', colKey: 'applicantName', width: 110, ellipsis: true },
{ title: '状态', colKey: 'status', width: 115 },
{ title: '当前节点', colKey: 'currentNodeName', width: 120 },
{ title: '创建时间', colKey: 'createdAt', width: 165 },
{ title: '更新时间', colKey: 'updatedAt', width: 165 },
{ title: '操作', colKey: 'op', width: 145, fixed: 'right' },
];
function openProject(projectId: string) {
void router.push({
name: 'ProjectDetail',
params: { projectId },
query: { tab: 'forms', from: route.fullPath },
});
}
function query(): SourceFormQuery {
return {
groupCode: activeGroup.value,
keyword: filters.keyword.trim() || undefined,
formType: filters.formType || undefined,
templateVersion: filters.templateVersion,
projectId: filters.projectId || undefined,
sourceSystem: filters.sourceSystem || undefined,
status: filters.status || undefined,
createdByMe: filters.createdByMe || undefined,
createdDateFrom: createdDateRange.value[0] || undefined,
createdDateTo: createdDateRange.value[1] || undefined,
sort: filters.sort,
page: pagination.current,
size: pagination.pageSize,
};
}
async function syncUrl(params: SourceFormQuery) {
const next = Object.fromEntries(Object.entries(params).filter(([, value]) => value !== undefined && value !== ''));
await router.replace({ name: 'FormCenter', query: next as Record<string, string> });
}
async function loadTemplates() {
templates.value = await getSourceTemplates();
}
async function loadForms() {
loading.value = true;
errorMessage.value = '';
try {
const params = query();
await syncUrl(params);
const result = await getSourceForms(params);
rows.value = result.items;
pagination.total = result.meta.totalElements;
} catch (error) {
errorMessage.value = errorText(error, '表单记录加载失败');
} finally {
loading.value = false;
}
}
async function reloadAll() {
loading.value = true;
errorMessage.value = '';
try {
await Promise.all([loadTemplates(), loadForms()]);
} catch (error) {
errorMessage.value = errorText(error, '表单中心加载失败');
} finally {
loading.value = false;
}
}
function search() {
pagination.current = 1;
loadForms();
}
function resetFilters() {
Object.assign(filters, {
keyword: '',
formType: '',
templateVersion: undefined,
projectId: '',
sourceSystem: '',
status: '',
createdByMe: false,
sort: 'updatedAt,desc',
});
createdDateRange.value = [];
pagination.current = 1;
loadForms();
}
function groupChanged() {
pagination.current = 1;
loadForms();
}
function pageSizeChanged() {
pagination.current = 1;
loadForms();
}
function openCreate(template: SourceTemplate) {
router.push({
name: 'FormEditor',
params: { formType: template.formType },
query: { mode: 'online', from: route.fullPath },
});
}
function openImport(template: SourceTemplate) {
router.push({
name: 'FormEditor',
params: { formType: template.formType },
query: { mode: 'import', from: route.fullPath },
});
}
function openDocument(row: SourceFormSummary) {
router.push({
name: 'FormEditor',
params: { formType: row.formType, documentId: row.publicId },
query: { from: route.fullPath },
});
}
function showTemplateHistory(template: SourceTemplate) {
filters.formType = template.formType;
activeGroup.value = template.groupCode;
pagination.current = 1;
loadForms();
document.querySelector('.history-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
async function validateDocument(row: SourceFormSummary) {
if (validatingId.value || !row.allowedActions.includes('VALIDATE')) return;
validatingId.value = row.publicId;
try {
const result = await validateSourceForm(row.publicId);
result.valid ? MessagePlugin.success('表单校验通过') : MessagePlugin.warning(`发现 ${result.errors.length} 项错误`);
await loadForms();
} catch (error) {
MessagePlugin.error(errorText(error, '表单校验失败'));
} finally {
validatingId.value = '';
}
}
async function downloadTemplate(template: SourceTemplate) {
try {
const detail = await getSourceTemplate(template.formType, template.templateVersion);
const headers = [
'来源单号',
'来源版本',
'公司编号',
'项目编号',
'申请人',
'业务日期',
...detail.fields.map((field) => field.label),
];
const csv = `\uFEFF${headers.map(csvCell).join(',')}\n`;
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
const link = document.createElement('a');
link.href = url;
link.download = `${template.formType}-${template.name}-V${template.templateVersion}.csv`;
link.click();
URL.revokeObjectURL(url);
} catch (error) {
MessagePlugin.error(errorText(error, '模板下载失败'));
}
}
function csvCell(value: string) {
return `"${value.replaceAll('"', '""')}"`;
}
function sourceLabel(value: string) {
return sourceOptions.find((item) => item.value === value)?.label || value;
}
function statusLabel(value: string) {
return statusOptions.find((item) => item.value === value)?.label || value;
}
function roleLabel(value: string) {
return (
{
PROJECT_MANAGER: '项目管理人员',
FINANCE_MANAGER: '财务管理人员',
ARCHIVE_MANAGER: '资料管理人员',
}[value] || value
);
}
function statusTheme(value: string): TagTheme {
if (value === 'APPROVED' || value === 'ARCHIVED') return 'success';
if (value === 'RETURNED' || value === 'PROJECT_REVIEW' || value === 'FINANCE_REVIEW') return 'warning';
if (value === 'REJECTED' || value === 'VOID') return 'danger';
return value === 'DRAFT' ? 'default' : 'primary';
}
function formatTime(value: string) {
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
}
function errorText(error: unknown, fallback: string) {
const responseDetail = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
return responseDetail || (error as Error)?.message || fallback;
}
onMounted(reloadAll);
</script>
<style scoped>
.form-center-page {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.page-header,
.section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.page-header h1 {
margin: 0;
font-size: 24px;
line-height: 32px;
font-weight: 600;
}
.page-header p {
margin: 6px 0 0;
color: var(--td-text-color-secondary);
}
.alert-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.template-section,
.history-section {
padding: 20px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
border-radius: var(--td-radius-default);
}
.section-heading {
margin-bottom: 16px;
}
.section-heading h2 {
margin: 0;
font-size: 18px;
line-height: 26px;
}
.section-heading span {
color: var(--td-text-color-secondary);
font-size: 13px;
}
.template-grid {
display: grid;
grid-template-columns: repeat(3, minmax(240px, 1fr));
gap: 14px;
padding-top: 16px;
}
.template-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.template-title > span:first-child {
white-space: nowrap;
}
.template-body {
min-height: 154px;
}
.template-body h3 {
margin: 0 0 8px;
font-size: 16px;
line-height: 24px;
}
.template-body p {
min-height: 44px;
margin: 0;
color: var(--td-text-color-secondary);
line-height: 22px;
}
.template-metrics {
display: flex;
gap: 20px;
margin-top: 14px;
color: var(--td-text-color-secondary);
font-size: 13px;
}
.template-metrics strong {
margin-left: 4px;
color: var(--td-text-color-primary);
font-size: 15px;
}
.template-actions {
min-height: 28px;
margin-top: 14px;
}
.template-roles {
display: flex;
min-height: 24px;
align-items: center;
gap: 6px;
margin-top: 10px;
}
.template-roles > span {
flex: 0 0 auto;
color: var(--td-text-color-secondary);
font-size: 12px;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 4px 20px;
margin-bottom: 8px;
}
.filter-actions {
display: flex;
grid-column: 1 / -1;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.form-name-cell {
display: flex;
flex-direction: column;
gap: 3px;
}
.form-name-cell span {
color: var(--td-text-color-secondary);
font-size: 12px;
}
.pagination-row {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
@media (max-width: 1280px) {
.template-grid {
grid-template-columns: repeat(2, minmax(240px, 1fr));
}
.filter-grid {
grid-template-columns: repeat(3, minmax(160px, 1fr));
}
}
@media (max-width: 760px) {
.template-grid,
.filter-grid {
grid-template-columns: 1fr;
}
.page-header {
flex-direction: column;
}
.template-section,
.history-section {
padding: 16px;
}
}
</style>