feat: deliver first finance preview with setup wizard
Release / release (push) Failing after 18s

This commit is contained in:
Qiufeng
2026-08-17 14:08:40 +08:00
parent 47403fd337
commit c8b0151769
558 changed files with 140774 additions and 3 deletions
+26
View File
@@ -0,0 +1,26 @@
<template>
<t-config-provider :global-config="getComponentsLocale">
<router-view :key="locale" :class="[mode]" />
</t-config-provider>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useTDesignAccessibility } from '@/composables/useTDesignAccessibility';
import { useLocale } from '@/locales/useLocale';
import { useSettingStore } from '@/store';
const store = useSettingStore();
const mode = computed(() => {
return store.displayMode;
});
const { getComponentsLocale, locale } = useLocale();
useTDesignAccessibility();
</script>
<style lang="less" scoped>
#nprogress .bar {
background: var(--td-brand-color) !important;
}
</style>
+405
View File
@@ -0,0 +1,405 @@
import type { AxiosResponse } from 'axios';
import { request } from '@/utils/request';
import { commandHeaders } from './command';
export type AccountingTab = 'pending' | 'draft' | 'review' | 'exported' | 'result' | 'completed' | 'reversed' | 'void';
export type EventType = 'RECEIPT' | 'INVOICE' | 'PAYABLE' | 'PAYMENT' | 'PAYMENT_REFUND';
export type VoucherStatus =
| 'DRAFT'
| 'REVIEWING'
| 'RETURNED'
| 'APPROVED'
| 'EXPORTED'
| 'RESULT_RECORDED'
| 'COMPLETED'
| 'RESULT_REVERSED'
| 'VOID';
export type AccountingAction =
| 'GENERATE_DRAFT'
| 'EDIT'
| 'SUBMIT'
| 'APPROVE'
| 'RETURN'
| 'EXPORT'
| 'RECORD_RESULT'
| 'VERIFY_RESULT'
| 'REVERSE_RESULT'
| 'REOPEN_RESULT'
| 'VOID';
export interface PageMeta {
page: number;
size: number;
totalElements: number;
totalPages: number;
}
export interface Reference {
publicId: string;
code?: string | null;
name: string;
}
export interface AccountReference {
publicId: string;
code: string;
name: string;
accountType: string;
auxiliaryRequired: boolean;
}
export interface AccountingEvent {
publicId: string;
businessNo: string;
eventType: EventType;
sourceType: string;
sourcePublicId: string;
sourceBusinessNo?: string | null;
sourceVersion: string;
eventSequence: number;
sourceEventKey: string;
company: Reference;
project?: Reference | null;
counterparty?: Reference | null;
businessDate: string;
period: string;
amount: string;
currency: string;
status: string;
voucherPublicId?: string | null;
voucherStatus?: VoucherStatus | null;
version: number;
createdAt: string;
allowedActions: AccountingAction[];
}
export interface VoucherEntry {
publicId: string;
lineNo: number;
direction: 'DEBIT' | 'CREDIT';
account: Reference;
amount: string;
summary: string;
auxiliaryJson?: string | null;
}
export interface Voucher {
publicId: string;
businessNo: string;
eventPublicId: string;
eventBusinessNo: string;
company: Reference;
project?: Reference | null;
period: string;
businessDate: string;
summary: string;
debitTotal: string;
creditTotal: string;
balanceStatus: 'BALANCED' | 'UNBALANCED';
status: VoucherStatus;
submittedByName?: string | null;
reviewedByName?: string | null;
exportedAt?: string | null;
exportBatchNo?: string | null;
exportSha256?: string | null;
exportFileId?: string | null;
ruleVersion: string;
externalVoucherNo?: string | null;
resultAt?: string | null;
resultCycleNo: number;
evidenceFileIds: string[];
resultRecordedByName?: string | null;
resultRecordedAt?: string | null;
resultRecordRemark?: string | null;
resultVerifiedByName?: string | null;
resultVerifiedAt?: string | null;
resultVerifyOpinion?: string | null;
voidReason?: string | null;
version: number;
createdAt: string;
updatedAt: string;
allowedActions: AccountingAction[];
}
export interface VoucherDetail {
voucher: Voucher;
event: AccountingEvent;
entries: VoucherEntry[];
evidenceFiles: ResultEvidenceFile[];
resultActions: VoucherResultAction[];
}
export interface ResultEvidenceFile {
publicId: string;
originalName: string;
mediaType: string;
sizeBytes: number;
sha256: string;
scanStatus: string;
}
export interface VoucherResultAction {
publicId: string;
sequenceNo: number;
resultCycleNo: number;
actionType: 'REVERSE' | 'REOPEN';
reason: string;
externalVoucherNo?: string | null;
resultAt?: string | null;
resultRecordedByName?: string | null;
resultRecordedAt?: string | null;
resultRecordRemark?: string | null;
resultVerifiedByName?: string | null;
resultVerifiedAt?: string | null;
resultVerifyOpinion?: string | null;
evidenceFiles: ResultEvidenceFile[];
operatedByName: string;
operatedAt: string;
versionBefore: number;
versionAfter: number;
}
export interface VoucherExport {
voucher: Voucher;
batchNo: string;
sha256: string;
fileId: string;
rowCount: number;
}
export interface RecordAccountingResultPayload {
version: number;
externalVoucherNo: string;
resultAt: string;
evidenceFileIds: string[];
remark?: string;
}
export interface AccountingEventQuery {
period?: string;
projectId?: string;
eventType?: EventType;
keyword?: string;
sort: string;
page: number;
size: number;
}
export interface VoucherQuery extends AccountingEventQuery {
tab: Exclude<AccountingTab, 'pending'>;
status?: VoucherStatus;
externalVoucherNo?: string;
}
export interface VoucherEntryPayload {
lineNo: number;
direction: 'DEBIT' | 'CREDIT';
accountId: string;
amount: string;
summary: string;
auxiliaryJson?: string;
}
export interface VoucherUpdatePayload {
version: number;
period: string;
businessDate: string;
summary: string;
entries: VoucherEntryPayload[];
changeReason: string;
}
interface PageEnvelope<T> {
data: T[];
meta: PageMeta;
requestId: string;
}
async function page<T>(url: string, params: AccountingEventQuery | VoucherQuery) {
const envelope = await request.get<PageEnvelope<T>>({ url, params }, { isTransformResponse: false });
return {
items: Array.isArray(envelope?.data) ? envelope.data : [],
meta: envelope?.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
requestId: envelope?.requestId || '',
};
}
function normalizeEvent(event: AccountingEvent): AccountingEvent {
return { ...event, allowedActions: Array.isArray(event.allowedActions) ? event.allowedActions : [] };
}
function normalizeVoucher(voucher: Voucher): Voucher {
return {
...voucher,
allowedActions: Array.isArray(voucher.allowedActions) ? voucher.allowedActions : [],
evidenceFileIds: Array.isArray(voucher.evidenceFileIds) ? voucher.evidenceFileIds : [],
};
}
function normalizeVoucherDetail(detail: VoucherDetail): VoucherDetail {
return {
...detail,
voucher: normalizeVoucher(detail.voucher),
entries: Array.isArray(detail?.entries) ? detail.entries : [],
evidenceFiles: Array.isArray(detail?.evidenceFiles) ? detail.evidenceFiles : [],
resultActions: Array.isArray(detail?.resultActions)
? detail.resultActions.map((action) => ({
...action,
evidenceFiles: Array.isArray(action.evidenceFiles) ? action.evidenceFiles : [],
}))
: [],
event: normalizeEvent(detail.event),
};
}
export function getAccountingAccounts() {
return request
.get<AccountReference[]>({ url: '/accounting/accounts' })
.then((rows) => (Array.isArray(rows) ? rows : []));
}
export async function getAccountingEvents(params: AccountingEventQuery) {
const result = await page<AccountingEvent>('/accounting/events', params);
return { ...result, items: result.items.map(normalizeEvent) };
}
export async function getAccountingVouchers(params: VoucherQuery) {
const result = await page<Voucher>('/accounting/vouchers', params);
return { ...result, items: result.items.map(normalizeVoucher) };
}
export async function getAccountingVoucher(publicId: string) {
const detail = await request.get<VoucherDetail>({ url: `/accounting/vouchers/${publicId}` });
return normalizeVoucherDetail(detail);
}
export function generateVoucherDraft(publicId: string, version: number, idempotencyKey?: string) {
return request
.post<VoucherDetail>({
url: `/accounting/events/${publicId}/generate-draft`,
data: { version, ruleVersion: 'ACCOUNTING-RULE-V1' },
...commandHeaders('accounting-generate-draft', idempotencyKey),
})
.then(normalizeVoucherDetail);
}
export function updateAccountingVoucher(publicId: string, payload: VoucherUpdatePayload, idempotencyKey?: string) {
return request
.patch<VoucherDetail>({
url: `/accounting/vouchers/${publicId}`,
data: payload,
...commandHeaders('accounting-update', idempotencyKey),
})
.then(normalizeVoucherDetail);
}
export function submitAccountingVoucher(publicId: string, version: number, idempotencyKey?: string) {
return request
.post<VoucherDetail>({
url: `/accounting/vouchers/${publicId}/submit`,
data: { version },
...commandHeaders('accounting-submit', idempotencyKey),
})
.then(normalizeVoucherDetail);
}
export function approveAccountingVoucher(publicId: string, version: number, idempotencyKey?: string) {
return request
.post<VoucherDetail>({
url: `/accounting/vouchers/${publicId}/approve`,
data: { version },
...commandHeaders('accounting-approve', idempotencyKey),
})
.then(normalizeVoucherDetail);
}
export function returnAccountingVoucher(publicId: string, version: number, opinion: string, idempotencyKey?: string) {
return request
.post<VoucherDetail>({
url: `/accounting/vouchers/${publicId}/return`,
data: { version, opinion },
...commandHeaders('accounting-return', idempotencyKey),
})
.then(normalizeVoucherDetail);
}
export function exportAccountingVoucher(publicId: string, version: number, idempotencyKey?: string) {
return request.post<VoucherExport>({
url: `/accounting/vouchers/${publicId}/export`,
data: { version },
...commandHeaders('accounting-export', idempotencyKey),
});
}
export function downloadAccountingVoucherExport(publicId: string) {
return request.get<AxiosResponse<Blob>>(
{ url: `/accounting/vouchers/${publicId}/export-file`, responseType: 'blob' },
{ isReturnNativeResponse: true },
);
}
export function downloadAccountingVoucherResultEvidence(voucherPublicId: string, filePublicId: string) {
return request.get<AxiosResponse<Blob>>(
{
url: `/accounting/vouchers/${voucherPublicId}/result-files/${filePublicId}/content`,
responseType: 'blob',
},
{ isReturnNativeResponse: true },
);
}
export function recordAccountingResult(
publicId: string,
payload: RecordAccountingResultPayload,
idempotencyKey?: string,
) {
return request
.post<VoucherDetail>({
url: `/accounting/vouchers/${publicId}/record-result`,
data: payload,
...commandHeaders('accounting-record-result', idempotencyKey),
})
.then(normalizeVoucherDetail);
}
export function verifyAccountingResult(publicId: string, version: number, opinion: string, idempotencyKey?: string) {
return request
.post<VoucherDetail>({
url: `/accounting/vouchers/${publicId}/verify-result`,
data: { version, opinion },
...commandHeaders('accounting-verify-result', idempotencyKey),
})
.then(normalizeVoucherDetail);
}
export function reverseAccountingResult(publicId: string, version: number, reason: string, idempotencyKey?: string) {
return request
.post<VoucherDetail>({
url: `/accounting/vouchers/${publicId}/reverse-result`,
data: { version, reason },
...commandHeaders('accounting-reverse-result', idempotencyKey),
})
.then(normalizeVoucherDetail);
}
export function reopenAccountingResult(publicId: string, version: number, reason: string, idempotencyKey?: string) {
return request
.post<VoucherDetail>({
url: `/accounting/vouchers/${publicId}/reopen-result`,
data: { version, reason },
...commandHeaders('accounting-reopen-result', idempotencyKey),
})
.then(normalizeVoucherDetail);
}
export function voidAccountingVoucher(publicId: string, version: number, reason: string, idempotencyKey?: string) {
return request
.post<VoucherDetail>({
url: `/accounting/vouchers/${publicId}/void`,
data: { version, reason },
...commandHeaders('accounting-void', idempotencyKey),
})
.then(normalizeVoucherDetail);
}
+555
View File
@@ -0,0 +1,555 @@
import type { AxiosResponse } from 'axios';
import { request } from '@/utils/request';
import { commandHeaders } from './command';
export const ARCHIVE_API_BASE = '/archives';
const array = <T>(value: T[] | null | undefined): T[] => (Array.isArray(value) ? value : []);
export interface ArchivePageMeta {
page: number;
size: number;
totalElements: number;
totalPages: number;
}
export interface ArchiveReference {
publicId: string;
businessNo: string;
name: string;
}
export interface ArchivePackageItem {
publicId: string;
itemType: string;
itemName: string;
objectType?: string | null;
objectPublicId?: string | null;
filePublicId?: string | null;
fileName?: string | null;
fileSha256?: string | null;
required: boolean;
status: string;
missingReason?: string | null;
returnReason?: string | null;
notApplicableReason?: string | null;
notApplicableRequestedByName?: string | null;
notApplicableRequestedAt?: string | null;
notApplicableReviewedByName?: string | null;
notApplicableReviewedAt?: string | null;
notApplicableReviewOpinion?: string | null;
allowedActions: string[];
}
export interface ArchivePackageAction {
sequenceNo: number;
actionCode: string;
fromStatus?: string | null;
toStatus?: string | null;
opinion?: string | null;
actorName?: string | null;
occurredAt: string;
manifestSha256?: string | null;
}
export interface ArchivePackageObjectSnapshot {
publicId: string;
itemType: string;
objectType: string;
objectPublicId: string;
objectBusinessNo?: string | null;
objectName?: string | null;
objectStatus: string;
objectVersion?: number | null;
objectFormType?: string | null;
sourceVersionPublicId?: string | null;
snapshotSha256: string;
capturedAt: string;
}
export interface ArchivePackage {
publicId: string;
businessNo: string;
company: ArchiveReference;
project: ArchiveReference;
packageVersion: number;
rootPackagePublicId?: string | null;
supersedesPackagePublicId?: string | null;
revisionReason?: string | null;
ruleVersion: string;
completeness: string;
missingCount: number;
retentionYears: number;
retentionExpiresOn?: string | null;
retentionStatus: 'PENDING' | 'ACTIVE' | 'DUE_SOON' | 'EXPIRED' | 'FROZEN';
physicalLocation?: string | null;
frozen: boolean;
status: string;
manifestSha256?: string | null;
submittedByName?: string | null;
reviewedByName?: string | null;
submittedAt?: string | null;
archivedAt?: string | null;
updatedAt: string;
version: number;
allowedActions: string[];
items: ArchivePackageItem[];
objectSnapshots: ArchivePackageObjectSnapshot[];
actions: ArchivePackageAction[];
}
export interface ArchiveFile {
publicId: string;
displayName: string;
mediaType: string;
originalType?: string | null;
sizeBytes: number;
sha256: string;
scanStatus: string;
companyPublicId: string;
companyName: string;
projectPublicId: string;
projectBusinessNo: string;
projectName: string;
contractPublicId?: string | null;
counterpartyPublicId?: string | null;
formType?: string | null;
documentType: string;
seriesPublicId: string;
versionNo: number;
archiveStatus: string;
borrowStatus?: string | null;
packagePublicId?: string | null;
packageStatus?: string | null;
uploadedBy: string;
uploadedAt: string;
sensitive: boolean;
allowedActions: string[];
}
export interface ArchiveFileDetail {
file: ArchiveFile;
versions: ArchiveFile[];
borrows: ArchiveBorrow[];
}
export interface ArchiveBorrow {
publicId: string;
businessNo: string;
filePublicId: string;
fileName: string;
projectPublicId: string;
projectName: string;
purpose: string;
dueAt: string;
status: string;
applicantName: string;
approvedByName?: string | null;
approvedAt?: string | null;
reviewOpinion?: string | null;
returnCondition?: string | null;
returnedByName?: string | null;
returnedAt?: string | null;
version: number;
allowedActions: string[];
}
export interface ArchivePackageQuery {
view?: 'prepare' | 'check' | 'returned' | 'archived';
companyId?: string;
projectId?: string;
status?: string;
completeness?: string;
frozen?: boolean;
keyword?: string;
sort?: string;
page: number;
size: number;
}
export interface ArchiveFileQuery {
companyId?: string;
projectId?: string;
contractId?: string;
counterpartyId?: string;
formType?: string;
fileName?: string;
uploadedBy?: string;
uploadedDateFrom?: string;
uploadedDateTo?: string;
originalType?: string;
archiveStatus?: string;
scanStatus?: string;
sort?: string;
page: number;
size: number;
}
export interface ArchiveBorrowQuery {
status?: string;
keyword?: string;
page: number;
size: number;
}
export interface ArchivePackageCreatePayload {
projectId: string;
retentionYears?: number;
physicalLocation?: string;
}
export interface ArchivePackageCommandPayload {
version: number;
opinion?: string;
returnItemIds?: string[];
}
export interface ArchiveFreezePayload {
version: number;
reason: string;
}
export interface ArchivePackageRevisionPayload {
version: number;
reason: string;
}
export interface ArchiveNotApplicablePayload {
version: number;
reason: string;
}
export interface ArchiveNotApplicableReviewPayload {
version: number;
approved: boolean;
opinion: string;
}
export interface ArchiveFileMetadata {
projectId: string;
packageId?: string;
packageItemId?: string;
contractId?: string;
counterpartyId?: string;
formType?: string;
fileType: string;
originalType?: string;
replacesFileId?: string;
replacementReason?: string;
notApplicableReason?: string;
}
export interface ArchiveBorrowCreatePayload {
fileId: string;
purpose: string;
dueAt: string;
}
export interface ArchiveBorrowCommandPayload {
version: number;
opinion?: string;
returnCondition?: string;
}
interface PageEnvelope<T> {
data: T[];
meta: ArchivePageMeta;
requestId: string;
}
export function normalizeArchivePackageItem(item: Partial<ArchivePackageItem> | null | undefined): ArchivePackageItem {
return { ...(item || {}), allowedActions: array(item?.allowedActions) } as ArchivePackageItem;
}
export function normalizeArchivePackage(value: Partial<ArchivePackage> | null | undefined): ArchivePackage {
const item = value || {};
return {
...item,
allowedActions: array(item.allowedActions),
items: array(item.items).map(normalizeArchivePackageItem),
objectSnapshots: array(item.objectSnapshots),
actions: array(item.actions),
} as ArchivePackage;
}
export function normalizeArchiveFile(value: Partial<ArchiveFile> | null | undefined): ArchiveFile {
return { ...(value || {}), allowedActions: array(value?.allowedActions) } as ArchiveFile;
}
export function normalizeArchiveBorrow(value: Partial<ArchiveBorrow> | null | undefined): ArchiveBorrow {
return { ...(value || {}), allowedActions: array(value?.allowedActions) } as ArchiveBorrow;
}
export function normalizeArchiveFileDetail(value: Partial<ArchiveFileDetail> | null | undefined): ArchiveFileDetail {
return {
...(value || {}),
file: normalizeArchiveFile(value?.file),
versions: array(value?.versions).map(normalizeArchiveFile),
borrows: array(value?.borrows).map(normalizeArchiveBorrow),
} as ArchiveFileDetail;
}
export function archiveErrorText(error: unknown, fallback: string): string {
const value = error as Error & { status?: number; code?: string; fieldErrors?: Record<string, string> };
const fields = value.fieldErrors ? Object.values(value.fieldErrors).join(';') : '';
if (value.status === 401) return '登录会话已失效,请重新登录后重试。';
if (value.status === 403) return '当前身份没有访问该数据或执行该操作的权限。';
if (value.status === 404) return '记录不存在、已被删除或当前身份无权查看。';
if (value.code === 'VERSION_CONFLICT' || value.status === 409)
return value.message || '数据已发生变化,请刷新后重试。';
if (value.status === 422) {
return fields
? `${value.message || '当前条件不满足业务规则'}:${fields}`
: value.message || '当前条件不满足业务规则,请检查后重试。';
}
if (value.status && value.status >= 500) return value.message || '服务处理失败,请稍后重试。';
return fields ? `${value.message || fallback}:${fields}` : value.message || fallback;
}
async function page<T>(url: string, params: Record<string, unknown>) {
const envelope = await request.get<PageEnvelope<T>>({ url, params }, { isTransformResponse: false });
return {
items: array(envelope.data),
meta: envelope.meta || {
page: Number(params.page) || 1,
size: Number(params.size) || 20,
totalElements: 0,
totalPages: 0,
},
requestId: envelope.requestId,
};
}
export function getArchivePackages(params: ArchivePackageQuery) {
return page<ArchivePackage>(`${ARCHIVE_API_BASE}/packages`, params as unknown as Record<string, unknown>).then(
(result) => ({
...result,
items: result.items.map(normalizeArchivePackage),
}),
);
}
export function getArchivePackage(publicId: string) {
return request.get<ArchivePackage>({ url: `${ARCHIVE_API_BASE}/packages/${publicId}` }).then(normalizeArchivePackage);
}
export function createArchivePackage(payload: ArchivePackageCreatePayload, idempotencyKey?: string) {
return request
.post<ArchivePackage>({
url: `${ARCHIVE_API_BASE}/packages`,
data: payload,
...commandHeaders('archive-package-create', idempotencyKey),
})
.then(normalizeArchivePackage);
}
export function checkArchivePackage(publicId: string, payload: ArchivePackageCommandPayload, idempotencyKey?: string) {
return request
.post<ArchivePackage>({
url: `${ARCHIVE_API_BASE}/packages/${publicId}/check`,
data: payload,
...commandHeaders('archive-package-check', idempotencyKey),
})
.then(normalizeArchivePackage);
}
export function submitArchivePackage(publicId: string, payload: ArchivePackageCommandPayload, idempotencyKey?: string) {
return request
.post<ArchivePackage>({
url: `${ARCHIVE_API_BASE}/packages/${publicId}/submit`,
data: payload,
...commandHeaders('archive-package-submit', idempotencyKey),
})
.then(normalizeArchivePackage);
}
export function returnArchivePackage(publicId: string, payload: ArchivePackageCommandPayload, idempotencyKey?: string) {
return request
.post<ArchivePackage>({
url: `${ARCHIVE_API_BASE}/packages/${publicId}/return`,
data: payload,
...commandHeaders('archive-package-return', idempotencyKey),
})
.then(normalizeArchivePackage);
}
export function reviseArchivePackage(
publicId: string,
payload: ArchivePackageRevisionPayload,
idempotencyKey?: string,
) {
return request
.post<ArchivePackage>({
url: `${ARCHIVE_API_BASE}/packages/${publicId}/revise`,
data: payload,
...commandHeaders('archive-package-revise', idempotencyKey),
})
.then(normalizeArchivePackage);
}
export function archiveArchivePackage(
publicId: string,
payload: ArchivePackageCommandPayload,
idempotencyKey?: string,
) {
return request
.post<ArchivePackage>({
url: `${ARCHIVE_API_BASE}/packages/${publicId}/archive`,
data: payload,
...commandHeaders('archive-package-archive', idempotencyKey),
})
.then(normalizeArchivePackage);
}
export function freezeArchivePackage(publicId: string, payload: ArchiveFreezePayload, idempotencyKey?: string) {
return request
.post<ArchivePackage>({
url: `${ARCHIVE_API_BASE}/packages/${publicId}/freeze`,
data: payload,
...commandHeaders('archive-package-freeze', idempotencyKey),
})
.then(normalizeArchivePackage);
}
export function unfreezeArchivePackage(
publicId: string,
payload: ArchivePackageCommandPayload,
idempotencyKey?: string,
) {
return request
.post<ArchivePackage>({
url: `${ARCHIVE_API_BASE}/packages/${publicId}/unfreeze`,
data: payload,
...commandHeaders('archive-package-unfreeze', idempotencyKey),
})
.then(normalizeArchivePackage);
}
export function markArchiveItemNotApplicable(
packageId: string,
itemId: string,
payload: ArchiveNotApplicablePayload,
idempotencyKey?: string,
) {
return request
.post<ArchivePackage>({
url: `${ARCHIVE_API_BASE}/packages/${packageId}/items/${itemId}/not-applicable`,
data: payload,
...commandHeaders('archive-item-not-applicable', idempotencyKey),
})
.then(normalizeArchivePackage);
}
export function reviewArchiveItemNotApplicable(
packageId: string,
itemId: string,
payload: ArchiveNotApplicableReviewPayload,
idempotencyKey?: string,
) {
return request
.post<ArchivePackage>({
url: `${ARCHIVE_API_BASE}/packages/${packageId}/items/${itemId}/not-applicable/review`,
data: payload,
...commandHeaders('archive-item-not-applicable-review', idempotencyKey),
})
.then(normalizeArchivePackage);
}
export function getArchiveFiles(params: ArchiveFileQuery) {
return page<ArchiveFile>(`${ARCHIVE_API_BASE}/files`, params as unknown as Record<string, unknown>).then(
(result) => ({
...result,
items: result.items.map(normalizeArchiveFile),
}),
);
}
export function getArchiveFile(publicId: string) {
return request
.get<ArchiveFileDetail>({ url: `${ARCHIVE_API_BASE}/files/${publicId}` })
.then(normalizeArchiveFileDetail);
}
export function rescanArchiveFile(publicId: string, idempotencyKey?: string) {
return request
.post<ArchiveFile>({
url: `${ARCHIVE_API_BASE}/files/${publicId}/rescan`,
data: {},
...commandHeaders('archive-file-rescan', idempotencyKey),
})
.then(normalizeArchiveFile);
}
export function getArchiveFileContent(publicId: string, preview = false) {
return request.get<AxiosResponse<Blob>>(
{ url: `${ARCHIVE_API_BASE}/files/${publicId}/content`, params: { preview }, responseType: 'blob' },
{ isReturnNativeResponse: true },
);
}
export function uploadArchiveFile(metadata: ArchiveFileMetadata, file: File, idempotencyKey?: string) {
const formData = new FormData();
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
formData.append('file', file, file.name);
return request
.post<ArchiveFile>({
url: `${ARCHIVE_API_BASE}/files`,
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
...commandHeaders('archive-file-upload', idempotencyKey).headers,
},
})
.then(normalizeArchiveFile);
}
export function getArchiveBorrows(params: ArchiveBorrowQuery) {
return page<ArchiveBorrow>(`${ARCHIVE_API_BASE}/borrows`, params as unknown as Record<string, unknown>).then(
(result) => ({
...result,
items: result.items.map(normalizeArchiveBorrow),
}),
);
}
export function createArchiveBorrow(payload: ArchiveBorrowCreatePayload, idempotencyKey?: string) {
return request
.post<ArchiveBorrow>({
url: `${ARCHIVE_API_BASE}/borrows`,
data: payload,
...commandHeaders('archive-borrow-create', idempotencyKey),
})
.then(normalizeArchiveBorrow);
}
export function approveArchiveBorrow(publicId: string, payload: ArchiveBorrowCommandPayload, idempotencyKey?: string) {
return request
.post<ArchiveBorrow>({
url: `${ARCHIVE_API_BASE}/borrows/${publicId}/approve`,
data: payload,
...commandHeaders('archive-borrow-approve', idempotencyKey),
})
.then(normalizeArchiveBorrow);
}
export function rejectArchiveBorrow(publicId: string, payload: ArchiveBorrowCommandPayload, idempotencyKey?: string) {
return request
.post<ArchiveBorrow>({
url: `${ARCHIVE_API_BASE}/borrows/${publicId}/reject`,
data: payload,
...commandHeaders('archive-borrow-reject', idempotencyKey),
})
.then(normalizeArchiveBorrow);
}
export function returnArchiveBorrow(publicId: string, payload: ArchiveBorrowCommandPayload, idempotencyKey?: string) {
return request
.post<ArchiveBorrow>({
url: `${ARCHIVE_API_BASE}/borrows/${publicId}/return`,
data: payload,
...commandHeaders('archive-borrow-return', idempotencyKey),
})
.then(normalizeArchiveBorrow);
}
+108
View File
@@ -0,0 +1,108 @@
import { request } from '@/utils/request';
export interface AuditPageMeta {
page: number;
size: number;
totalElements: number;
totalPages: number;
}
export type AuditSort = 'occurredAt,asc' | 'occurredAt,desc' | 'eventSequence,asc' | 'eventSequence,desc';
export interface AuditQuery {
occurredFrom?: string;
occurredTo?: string;
actorId?: string;
identityCode?: string;
objectType?: string;
objectId?: string;
action?: string;
requestId?: string;
result?: string;
keyword?: string;
sort: AuditSort;
page: number;
size: number;
}
export interface AuditLog {
publicId: string;
eventSequence: number;
requestId: string;
userPublicId?: string | null;
username: string;
activeRole?: string | null;
companyPublicId?: string | null;
projectPublicId?: string | null;
actionCode: string;
objectType: string;
objectPublicId?: string | null;
resultCode: string;
reason?: string | null;
beforeJson?: string | null;
afterJson?: string | null;
occurredAt: string;
allowedActions: string[];
}
export interface AuditExport {
exportId: string;
rowCount: number;
sha256: string;
fileName: string;
content: string;
}
function requiredString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) throw new Error(`审计导出响应缺少${field}`);
return value;
}
function nonNegativeInteger(value: unknown, field: string): number {
if (!Number.isInteger(value) || Number(value) < 0) throw new Error(`审计导出响应中的${field}无效`);
return Number(value);
}
export function normalizeAuditExport(value: AuditExport): AuditExport {
return {
exportId: requiredString(value?.exportId, '导出编号'),
rowCount: nonNegativeInteger(value?.rowCount, '导出行数'),
sha256: requiredString(value?.sha256, '文件摘要'),
fileName: requiredString(value?.fileName, '文件名'),
content: requiredString(value?.content, '文件内容'),
};
}
interface AuditEnvelope {
data: AuditLog[];
meta: AuditPageMeta;
requestId: string;
}
export async function getAuditLogs(params: AuditQuery) {
const envelope = await request.get<AuditEnvelope>(
{ url: '/audit/logs', params },
{ isTransformResponse: false, joinTime: false },
);
return {
items: Array.isArray(envelope?.data)
? envelope.data.map((row) => ({
...row,
allowedActions: Array.isArray(row?.allowedActions) ? row.allowedActions : [],
}))
: [],
meta: envelope?.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
requestId: envelope?.requestId || '',
};
}
export function getAuditLog(publicId: string) {
return request.get<AuditLog>({ url: `/audit/logs/${publicId}` }, { joinTime: false }).then((row) => ({
...row,
allowedActions: Array.isArray(row?.allowedActions) ? row.allowedActions : [],
}));
}
export function exportAuditLogs(payload: Omit<AuditQuery, 'page' | 'size'>) {
return request.post<AuditExport>({ url: '/audit/exports', data: payload }).then(normalizeAuditExport);
}
+177
View File
@@ -0,0 +1,177 @@
import { request } from '@/utils/request';
export interface UserView {
publicId: string;
username: string;
displayName: string;
departmentName?: string;
mustChangePassword: boolean;
authorizationScopes?: AuthorizationScopeView[];
recentLogins?: RecentLoginView[];
}
function normalizeUser(user: UserView | null | undefined): UserView {
return {
publicId: user?.publicId || '',
username: user?.username || '',
displayName: user?.displayName || '',
departmentName: user?.departmentName || '',
mustChangePassword: Boolean(user?.mustChangePassword),
authorizationScopes: Array.isArray(user?.authorizationScopes) ? user.authorizationScopes : [],
recentLogins: Array.isArray(user?.recentLogins) ? user.recentLogins : [],
};
}
function normalizeRole(role: RoleView): RoleView {
return {
code: String(role?.code || ''),
name: role?.name || role?.code || '未命名身份',
description: role?.description,
workbenchRoute: role?.workbenchRoute,
};
}
function normalizeSession(session: SessionView): SessionView {
return {
...session,
authenticated: Boolean(session?.authenticated),
user: normalizeUser(session?.user),
roles: Array.isArray(session?.roles) ? session.roles.map(normalizeRole) : [],
permissions: Array.isArray(session?.permissions) ? session.permissions : [],
};
}
function normalizeSessionSummary(session: SessionSummaryView): SessionSummaryView {
return {
sessionId: String(session?.sessionId || ''),
createdAt: session?.createdAt || '',
lastAccessedAt: session?.lastAccessedAt || '',
expiresAt: session?.expiresAt || '',
current: Boolean(session?.current),
deviceSummary: session?.deviceSummary,
maskedIp: session?.maskedIp,
};
}
export interface AuthorizationScopeView {
rowKey?: string;
roleCode: string;
roleName: string;
scopeType: 'GLOBAL' | 'COMPANY' | 'PROJECT' | string;
companyPublicId?: string;
companyCode?: string;
companyName?: string;
projectPublicId?: string;
projectCode?: string;
projectName?: string;
permissionCount: number;
amountLimit?: number;
}
export interface RecentLoginView {
occurredAt: string;
deviceSummary: string;
maskedIp: string;
}
export interface RoleView {
code: string;
name: string;
description?: string;
workbenchRoute?: string;
}
export interface SessionView {
authenticated: boolean;
user: UserView;
roles: RoleView[];
activeRole?: string | null;
permissions: string[];
permissionVersion?: string;
sessionExpiresAt?: string;
absoluteSessionExpiresAt?: string;
}
export interface SessionSummaryView {
sessionId: string;
createdAt: string;
lastAccessedAt: string;
expiresAt: string;
current: boolean;
deviceSummary?: string;
maskedIp?: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface PasswordChangeRequest {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
export interface CsrfView {
headerName: string;
parameterName: string;
token: string;
}
export function getCsrf() {
return request.get<CsrfView>({ url: '/auth/csrf' }, { isTransformResponse: true, retry: { count: 0, delay: 0 } });
}
export function login(payload: LoginRequest) {
return request
.post<SessionView>({ url: '/auth/login', data: payload }, { retry: { count: 0, delay: 0 } })
.then(normalizeSession);
}
export function getSession() {
return request.get<SessionView>({ url: '/auth/session' }).then(normalizeSession);
}
export function getRoles() {
return request
.get<RoleView[]>({ url: '/auth/roles' })
.then((roles) => (Array.isArray(roles) ? roles.map(normalizeRole) : []));
}
export function getProfile() {
return request.get<UserView>({ url: '/auth/profile' }).then(normalizeUser);
}
export function selectRole(roleCode: string) {
return request
.post<SessionView>({ url: '/auth/select-role', data: { roleCode } }, { retry: { count: 0, delay: 0 } })
.then(normalizeSession);
}
export function changePassword(payload: PasswordChangeRequest) {
return request
.patch<SessionView>({ url: '/auth/password', data: payload }, { retry: { count: 0, delay: 0 } })
.then(normalizeSession);
}
export function listSessions() {
return request
.get<SessionSummaryView[]>({ url: '/auth/sessions' })
.then((sessions) => (Array.isArray(sessions) ? sessions.map(normalizeSessionSummary) : []));
}
export function deleteSession(sessionId: string) {
return request.delete<void>(
{ url: `/auth/sessions/${encodeURIComponent(sessionId)}` },
{ retry: { count: 0, delay: 0 } },
);
}
export function deleteAllSessions() {
return request.delete<void>({ url: '/auth/sessions' }, { retry: { count: 0, delay: 0 } });
}
export function logout() {
return request.post<void>({ url: '/auth/logout' }, { retry: { count: 0, delay: 0 } });
}
+11
View File
@@ -0,0 +1,11 @@
function randomPart(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
/** Every mutating request gets a stable key for the lifetime of that call. */
export function commandHeaders(prefix: string, key?: string) {
return { headers: { 'Idempotency-Key': key ?? `${prefix}-${randomPart()}` } };
}
+315
View File
@@ -0,0 +1,315 @@
import type { AxiosResponse } from 'axios';
import { request } from '@/utils/request';
import { commandHeaders } from './command';
export interface ContractCostPageMeta {
page: number;
size: number;
totalElements: number;
totalPages: number;
}
interface ApiEnvelope<T> {
data: T;
meta?: ContractCostPageMeta;
requestId: string;
}
export interface ContractCostReference {
publicId: string;
code?: string | null;
name: string;
}
export interface ContractCostReferences {
companies: ContractCostReference[];
projects: ContractCostReference[];
counterparties: ContractCostReference[];
costCategories: ContractCostReference[];
}
export interface ContractCostQuery {
companyId?: string;
projectId?: string;
counterpartyId?: string;
keyword?: string;
status?: string;
costCategoryId?: string;
businessDateFrom?: string;
businessDateTo?: string;
sort: string;
page?: number;
size?: number;
}
export interface ContractCostLedgerItem {
publicId: string;
businessNo: string;
name: string;
company: ContractCostReference;
project: ContractCostReference;
counterparty: ContractCostReference;
originalAmount: string;
approvedChangeAmount: string;
settlementAmount?: string | null;
invoicedAmount: string;
pendingPayableAmount: string;
payableAmount: string;
paidAmount: string;
availableAmount: string;
currency: string;
status: string;
version: number;
updatedAt: string;
allowedActions: string[];
}
export interface ContractCostSummary {
contractCount: number;
originalAmount: string;
approvedChangeAmount: string;
settlementAmount: string;
invoicedAmount: string;
pendingPayableAmount: string;
payableAmount: string;
paidAmount: string;
availableAmount: string;
}
export interface ContractCostPage {
items: ContractCostLedgerItem[];
summary: ContractCostSummary;
}
export interface ContractPayableLine {
publicId: string;
businessNo: string;
businessDate: string;
costCategory?: ContractCostReference | null;
amount: string;
invoicedAmount: string;
paidAmount: string;
unpaidAmount: string;
status: string;
version: number;
updatedAt: string;
allowedActions: string[];
}
export interface ContractChangeLine {
publicId: string;
changeNo: string;
changeAmount: string;
effectiveDate: string;
reason: string;
status: string;
version: number;
createdByName: string;
createdAt: string;
allowedActions: string[];
}
export interface ContractSettlementLine {
publicId: string;
settlementNo: string;
settlementAmount: string;
settlementDate: string;
sourceDocumentId?: string | null;
status: string;
version: number;
createdByName: string;
createdAt: string;
allowedActions: string[];
}
export interface ContractInvoiceLine {
publicId: string;
businessNo: string;
requestedDate: string;
invoiceType: string;
amount: string;
taxRate?: string | null;
invoiceNo?: string | null;
issuedDate?: string | null;
status: string;
updatedAt: string;
}
export interface ContractPaymentLine {
publicId: string;
businessNo: string;
requestedDate: string;
requestedAmount: string;
approvedAmount?: string | null;
purpose?: string | null;
status: string;
updatedAt: string;
}
export interface ContractCostDetail {
contract: ContractCostLedgerItem;
changes: ContractChangeLine[];
settlements: ContractSettlementLine[];
payables: ContractPayableLine[];
invoices: ContractInvoiceLine[];
payments: ContractPaymentLine[];
}
function normalizeLedgerItem(item: ContractCostLedgerItem): ContractCostLedgerItem {
return { ...item, allowedActions: Array.isArray(item?.allowedActions) ? item.allowedActions : [] };
}
function normalizeContractCostDetail(detail: ContractCostDetail): ContractCostDetail {
return {
...detail,
contract: normalizeLedgerItem(detail.contract),
changes: (Array.isArray(detail?.changes) ? detail.changes : []).map((row) => ({
...row,
allowedActions: Array.isArray(row?.allowedActions) ? row.allowedActions : [],
})),
settlements: (Array.isArray(detail?.settlements) ? detail.settlements : []).map((row) => ({
...row,
allowedActions: Array.isArray(row?.allowedActions) ? row.allowedActions : [],
})),
payables: (Array.isArray(detail?.payables) ? detail.payables : []).map((row) => ({
...row,
allowedActions: Array.isArray(row?.allowedActions) ? row.allowedActions : [],
})),
invoices: Array.isArray(detail?.invoices) ? detail.invoices : [],
payments: Array.isArray(detail?.payments) ? detail.payments : [],
};
}
export async function getContractCostPage(params: ContractCostQuery) {
const envelope = await request.get<ApiEnvelope<ContractCostPage>>(
{ url: '/contract-costs/contracts', params },
{ isTransformResponse: false },
);
return {
data: {
items: Array.isArray(envelope?.data?.items) ? envelope.data.items.map(normalizeLedgerItem) : [],
summary: envelope?.data?.summary || {
contractCount: 0,
originalAmount: '0.00',
approvedChangeAmount: '0.00',
settlementAmount: '0.00',
invoicedAmount: '0.00',
pendingPayableAmount: '0.00',
payableAmount: '0.00',
paidAmount: '0.00',
availableAmount: '0.00',
},
},
meta: envelope.meta || {
page: params.page || 1,
size: params.size || 20,
totalElements: 0,
totalPages: 0,
},
requestId: envelope.requestId,
};
}
export function getContractCostReferences() {
return request.get<ContractCostReferences>({ url: '/contract-costs/references' });
}
export function getContractCostDetail(publicId: string) {
return request
.get<ContractCostDetail>({ url: `/contract-costs/contracts/${publicId}` })
.then(normalizeContractCostDetail);
}
export interface ContractChangeCreatePayload {
changeNo: string;
changeAmount: string;
effectiveDate: string;
reason: string;
contractVersion: number;
}
export interface ContractSettlementCreatePayload {
settlementNo: string;
settlementAmount: string;
settlementDate: string;
sourceDocumentId: string;
contractVersion: number;
}
export interface ContractPayableCreatePayload {
businessNo: string;
costCategoryId: string;
sourceDocumentId: string;
businessDate: string;
amount: string;
contractVersion: number;
}
export function createContractChange(contractId: string, data: ContractChangeCreatePayload, idempotencyKey?: string) {
return request.post<ContractCostDetail>({
url: `/contract-costs/contracts/${contractId}/changes`,
data,
...commandHeaders('contract-cost-change-create', idempotencyKey),
});
}
export function voidContractChange(changeId: string, version: number, reason: string, idempotencyKey?: string) {
return request.post<ContractCostDetail>({
url: `/contract-costs/changes/${changeId}/void`,
data: { version, reason },
...commandHeaders('contract-cost-change-void', idempotencyKey),
});
}
export function createContractSettlement(
contractId: string,
data: ContractSettlementCreatePayload,
idempotencyKey?: string,
) {
return request.post<ContractCostDetail>({
url: `/contract-costs/contracts/${contractId}/settlements`,
data,
...commandHeaders('contract-cost-settlement-create', idempotencyKey),
});
}
export function voidContractSettlement(settlementId: string, version: number, reason: string, idempotencyKey?: string) {
return request.post<ContractCostDetail>({
url: `/contract-costs/settlements/${settlementId}/void`,
data: { version, reason },
...commandHeaders('contract-cost-settlement-void', idempotencyKey),
});
}
export function createContractPayable(contractId: string, data: ContractPayableCreatePayload, idempotencyKey?: string) {
return request.post<ContractCostDetail>({
url: `/contract-costs/contracts/${contractId}/payables`,
data,
...commandHeaders('contract-cost-payable-create', idempotencyKey),
});
}
export function confirmContractPayable(payableId: string, version: number, idempotencyKey?: string) {
return request.post<ContractCostDetail>({
url: `/contract-costs/payables/${payableId}/confirm`,
data: { version },
...commandHeaders('contract-cost-payable-confirm', idempotencyKey),
});
}
export function voidContractPayable(payableId: string, version: number, reason: string, idempotencyKey?: string) {
return request.post<ContractCostDetail>({
url: `/contract-costs/payables/${payableId}/void`,
data: { version, reason },
...commandHeaders('contract-cost-payable-void', idempotencyKey),
});
}
export function exportContractCostCsv(params: Omit<ContractCostQuery, 'page' | 'size'>) {
return request.get<AxiosResponse<Blob>>(
{ url: '/contract-costs/contracts/export', params, responseType: 'blob' },
{ isReturnNativeResponse: true },
);
}
+151
View File
@@ -0,0 +1,151 @@
import { request } from '@/utils/request';
export type GovernanceResource = 'users' | 'roles' | 'scopes' | 'templates' | 'parameters';
export interface GovernancePageMeta {
page: number;
size: number;
totalElements: number;
totalPages: number;
}
export interface GovernanceRow {
resource: GovernanceResource;
publicId: string;
username?: string;
displayName?: string;
departmentName?: string;
enabled?: boolean;
mustChangePassword?: boolean;
roleCodes?: string[];
code?: string;
name?: string;
description?: string;
sortOrder?: number;
memberCount?: number;
userPublicId?: string;
roleCode?: string;
permissionCode?: string;
scopeType?: string;
companyPublicId?: string;
projectPublicId?: string;
amountLimit?: string;
status?: string;
formType?: string;
templateVersion?: number;
schemaVersion?: number;
parameterGroup?: string;
versionNo?: number;
version?: number;
publishedAt?: string;
effectiveAt?: string;
createdAt?: string;
updatedAt?: string;
}
export interface GovernanceResourceView {
resource: GovernanceResource;
publicId: string;
businessCode: string;
status: string;
version: number;
values: Record<string, unknown>;
allowedActions: string[];
}
export interface GovernanceCreatePayload {
code?: string;
name?: string;
description?: string;
username?: string;
displayName?: string;
departmentName?: string;
password?: string;
roleCode?: string;
permissionCode?: string;
userId?: string;
scopeType?: string;
companyId?: string;
projectId?: string;
amountLimit?: number;
formType?: string;
templateVersion?: number;
schemaVersion?: number;
parameterGroup?: string;
versionNo?: number;
valueJson?: string;
roleCodes?: string[];
permissionCodes?: string[];
}
export interface GovernanceUpdatePayload extends GovernanceCreatePayload {
version: number;
enabled?: boolean;
}
export interface GovernanceCommandPayload {
version: number;
reason?: string;
}
interface ListEnvelope {
data: GovernanceRow[];
meta: GovernancePageMeta;
requestId: string;
}
function normalizeGovernanceView(view: GovernanceResourceView): GovernanceResourceView {
return {
...view,
values: view?.values && typeof view.values === 'object' ? view.values : {},
allowedActions: Array.isArray(view?.allowedActions) ? view.allowedActions : [],
};
}
export async function getGovernanceResource(
resource: GovernanceResource,
page: number,
size: number,
keyword?: string,
) {
const envelope = await request.get<ListEnvelope>(
{ url: `/admin/${resource}`, params: { keyword: keyword || undefined, page, size } },
{ isTransformResponse: false },
);
return {
items: Array.isArray(envelope?.data) ? envelope.data : [],
meta: envelope.meta || { page, size, totalElements: 0, totalPages: 0 },
requestId: envelope.requestId,
};
}
export function getGovernanceDetail(resource: GovernanceResource, publicId: string) {
return request.get<GovernanceResourceView>({ url: `/admin/${resource}/${publicId}` }).then(normalizeGovernanceView);
}
export function createGovernanceResource(resource: GovernanceResource, payload: GovernanceCreatePayload) {
return request
.post<GovernanceResourceView>({ url: `/admin/${resource}`, data: payload })
.then(normalizeGovernanceView);
}
export function updateGovernanceResource(
resource: GovernanceResource,
publicId: string,
payload: GovernanceUpdatePayload,
) {
return request
.patch<GovernanceResourceView>({ url: `/admin/${resource}/${publicId}`, data: payload })
.then(normalizeGovernanceView);
}
export function commandGovernanceResource(
resource: GovernanceResource,
publicId: string,
command: 'enable' | 'disable' | 'publish' | 'restore',
payload: GovernanceCommandPayload,
) {
return request
.post<GovernanceResourceView>({ url: `/admin/${resource}/${publicId}/${command}`, data: payload })
.then(normalizeGovernanceView);
}
+396
View File
@@ -0,0 +1,396 @@
import { request } from '@/utils/request';
export type MasterDataResource =
| 'companies'
| 'departments'
| 'counterparties'
| 'bank-accounts'
| 'contracts'
| 'cost-categories'
| 'accounts'
| 'tax-rates';
export type MasterDataAction = 'EDIT' | 'SUBMIT' | 'REVIEW' | 'DISABLE';
export type LegacyIdentifierResource = MasterDataResource | 'projects';
export type LegacyIdentifierAction = 'CREATE' | 'DISABLE';
export interface MasterDataPageMeta {
page: number;
size: number;
totalElements: number;
totalPages: number;
}
interface ApiEnvelope<T> {
data: T;
meta?: MasterDataPageMeta;
requestId: string;
}
export interface MasterDataReference {
publicId: string;
code?: string | null;
name: string;
}
export interface MasterDataReferences {
companies: MasterDataReference[];
projects: MasterDataReference[];
counterparties: MasterDataReference[];
costCategories: MasterDataReference[];
}
export interface MasterDataBase {
publicId: string;
status: string;
version: number;
createdAt: string;
updatedAt: string;
allowedActions: MasterDataAction[];
}
export type MasterDataSnapshot = Record<string, unknown>;
export interface MasterDataVersion {
auditId: string;
version: number;
status: string;
actionCode: string;
actorName: string;
changedAt: string;
before: MasterDataSnapshot | null;
after: MasterDataSnapshot | null;
}
export interface LegacyIdentifier {
publicId: string;
resource: LegacyIdentifierResource;
sourceSystem: string;
legacyCode: string;
targetPublicId: string;
status: 'ACTIVE' | 'DISABLED';
version: number;
createdAt: string;
updatedAt: string;
allowedActions: LegacyIdentifierAction[];
}
export interface LegacyIdentifierResolution {
mappingPublicId: string;
resource: LegacyIdentifierResource;
sourceSystem: string;
legacyCode: string;
targetPublicId: string;
}
export interface CompanyMasterData extends MasterDataBase {
businessNo: string;
name: string;
taxNo?: string | null;
}
export interface CounterpartyMasterData extends MasterDataBase {
businessNo: string;
type: string;
name: string;
taxNo?: string | null;
contactName?: string | null;
contactPhone?: string | null;
}
export interface BankAccountMasterData extends MasterDataBase {
ownerType: string;
owner: MasterDataReference;
accountCategory: string;
accountName: string;
bankName: string;
maskedAccountNo: string;
versionNo: number;
validFrom: string;
validTo?: string | null;
}
export interface ContractMasterData extends MasterDataBase {
company: MasterDataReference;
project: MasterDataReference;
counterparty: MasterDataReference;
businessNo: string;
name: string;
originalAmount: string;
approvedChangeAmount: string;
settlementAmount: string;
currency: string;
}
export interface DictionaryMasterData extends MasterDataBase {
resource: 'departments' | 'cost-categories' | 'accounts' | 'tax-rates';
code: string;
name: string;
parent?: MasterDataReference | null;
accountType?: string | null;
auxiliaryRequired?: boolean | null;
rate?: string | null;
}
export type MasterDataItem =
CompanyMasterData | CounterpartyMasterData | BankAccountMasterData | ContractMasterData | DictionaryMasterData;
function normalizeMasterDataItem(item: MasterDataItem): MasterDataItem {
return {
...item,
allowedActions: Array.isArray(item?.allowedActions) ? item.allowedActions : [],
} as MasterDataItem;
}
function normalizeReferences(references: MasterDataReferences): MasterDataReferences {
return {
companies: Array.isArray(references?.companies) ? references.companies : [],
projects: Array.isArray(references?.projects) ? references.projects : [],
counterparties: Array.isArray(references?.counterparties) ? references.counterparties : [],
costCategories: Array.isArray(references?.costCategories) ? references.costCategories : [],
};
}
export interface MasterDataQuery {
keyword?: string;
status?: string;
type?: string;
page: number;
size: number;
}
export interface MasterDataVersionQuery {
page: number;
size: number;
}
export interface LegacyIdentifierQuery {
keyword?: string;
status?: 'ACTIVE' | 'DISABLED';
page: number;
size: number;
}
export interface LegacyIdentifierPageMeta extends MasterDataPageMeta {
allowedActions: LegacyIdentifierAction[];
}
export interface LegacyIdentifierCreatePayload {
sourceSystem: string;
legacyCode: string;
}
export interface CompanyCreatePayload {
businessNo: string;
name: string;
taxNo?: string;
}
export interface CompanyUpdatePayload {
name: string;
taxNo?: string;
version: number;
}
export interface CounterpartyCreatePayload {
businessNo: string;
type: string;
name: string;
taxNo?: string;
contactName?: string;
contactPhone?: string;
}
export interface CounterpartyUpdatePayload extends Omit<CounterpartyCreatePayload, 'businessNo'> {
version: number;
}
export interface BankAccountCreatePayload {
ownerType: string;
ownerId: string;
accountCategory: string;
accountName: string;
bankName: string;
accountNo: string;
validFrom: string;
validTo?: string;
}
export interface BankAccountUpdatePayload {
accountCategory: string;
accountName: string;
bankName: string;
accountNo?: string;
validFrom: string;
validTo?: string;
version: number;
}
export interface ContractCreatePayload {
companyId: string;
projectId: string;
counterpartyId: string;
businessNo: string;
name: string;
originalAmount: string;
currency: string;
}
export interface ContractUpdatePayload extends Omit<ContractCreatePayload, 'businessNo'> {
version: number;
}
export interface DictionaryCreatePayload {
code: string;
name: string;
parentId?: string;
accountType?: string;
auxiliaryRequired?: boolean;
rate?: string;
}
export interface DictionaryUpdatePayload extends Omit<DictionaryCreatePayload, 'code'> {
version: number;
}
export type MasterDataCreatePayload =
| CompanyCreatePayload
| CounterpartyCreatePayload
| BankAccountCreatePayload
| ContractCreatePayload
| DictionaryCreatePayload;
export type MasterDataUpdatePayload =
| CompanyUpdatePayload
| CounterpartyUpdatePayload
| BankAccountUpdatePayload
| ContractUpdatePayload
| DictionaryUpdatePayload;
export interface MasterDataReviewPayload {
version: number;
decision: 'APPROVE' | 'RETURN';
opinion: string;
}
export async function getMasterDataPage(resource: MasterDataResource, params: MasterDataQuery) {
const envelope = await request.get<ApiEnvelope<MasterDataItem[]>>(
{ url: `/masterdata/${resource}`, params },
{ isTransformResponse: false },
);
return {
items: Array.isArray(envelope?.data) ? envelope.data.map(normalizeMasterDataItem) : [],
meta: envelope.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
requestId: envelope.requestId,
};
}
export function getMasterDataDetail(resource: MasterDataResource, publicId: string) {
return request.get<MasterDataItem>({ url: `/masterdata/${resource}/${publicId}` }).then(normalizeMasterDataItem);
}
export async function getMasterDataVersions(
resource: MasterDataResource,
publicId: string,
params: MasterDataVersionQuery,
) {
const envelope = await request.get<ApiEnvelope<MasterDataVersion[]>>(
{ url: `/masterdata/${resource}/${publicId}/versions`, params },
{ isTransformResponse: false },
);
return {
items: Array.isArray(envelope?.data) ? envelope.data : [],
meta: envelope.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
requestId: envelope.requestId,
};
}
export async function getLegacyIdentifiers(
resource: LegacyIdentifierResource,
publicId: string,
params: LegacyIdentifierQuery,
) {
const envelope = await request.get<ApiEnvelope<LegacyIdentifier[]>>(
{ url: `/masterdata/${resource}/${publicId}/legacy-identifiers`, params },
{ isTransformResponse: false },
);
const meta = envelope.meta as LegacyIdentifierPageMeta | undefined;
return {
items: Array.isArray(envelope?.data)
? envelope.data.map((item) => ({
...item,
allowedActions: Array.isArray(item.allowedActions) ? item.allowedActions : [],
}))
: [],
meta: {
page: meta?.page || params.page,
size: meta?.size || params.size,
totalElements: meta?.totalElements || 0,
totalPages: meta?.totalPages || 0,
allowedActions: Array.isArray(meta?.allowedActions) ? meta.allowedActions : [],
} as LegacyIdentifierPageMeta,
requestId: envelope.requestId,
};
}
export function createLegacyIdentifier(
resource: LegacyIdentifierResource,
publicId: string,
payload: LegacyIdentifierCreatePayload,
) {
return request.post<LegacyIdentifier>({
url: `/masterdata/${resource}/${publicId}/legacy-identifiers`,
data: payload,
});
}
export function disableLegacyIdentifier(
resource: LegacyIdentifierResource,
targetPublicId: string,
mappingPublicId: string,
version: number,
) {
return request.post<LegacyIdentifier>({
url: `/masterdata/${resource}/${targetPublicId}/legacy-identifiers/${mappingPublicId}/disable`,
data: { version },
});
}
export function resolveLegacyIdentifier(resource: LegacyIdentifierResource, sourceSystem: string, legacyCode: string) {
return request.get<LegacyIdentifierResolution>({
url: `/masterdata/${resource}/legacy-identifiers/resolve`,
params: { sourceSystem, legacyCode },
});
}
export function getMasterDataReferences() {
return request.get<MasterDataReferences>({ url: '/masterdata/references' }).then(normalizeReferences);
}
export function createMasterData(resource: MasterDataResource, payload: MasterDataCreatePayload) {
return request.post<MasterDataItem>({ url: `/masterdata/${resource}`, data: payload }).then(normalizeMasterDataItem);
}
export function updateMasterData(resource: MasterDataResource, publicId: string, payload: MasterDataUpdatePayload) {
return request
.patch<MasterDataItem>({ url: `/masterdata/${resource}/${publicId}`, data: payload })
.then(normalizeMasterDataItem);
}
export function submitMasterData(resource: MasterDataResource, publicId: string, version: number) {
return request
.post<MasterDataItem>({ url: `/masterdata/${resource}/${publicId}/submit`, data: { version } })
.then(normalizeMasterDataItem);
}
export function reviewMasterData(resource: MasterDataResource, publicId: string, payload: MasterDataReviewPayload) {
return request
.post<MasterDataItem>({ url: `/masterdata/${resource}/${publicId}/review`, data: payload })
.then(normalizeMasterDataItem);
}
export function disableMasterData(resource: MasterDataResource, publicId: string, version: number) {
return request
.post<MasterDataItem>({ url: `/masterdata/${resource}/${publicId}/disable`, data: { version } })
.then(normalizeMasterDataItem);
}
+554
View File
@@ -0,0 +1,554 @@
import type { AxiosResponse } from 'axios';
import { request } from '@/utils/request';
import { commandHeaders } from './command';
export type PaymentTab = 'all' | 'my' | 'finance-check' | 'offline' | 'result' | 'paid' | 'failed';
export type PaymentStatus =
| 'DRAFT'
| 'REVIEWING'
| 'RETURNED'
| 'REJECTED'
| 'APPROVED'
| 'OFFLINE_PENDING'
| 'RESULT_RECORDED'
| 'RESULT_VERIFIED'
| 'PART_PAID'
| 'PAID'
| 'FAILED'
| 'REFUNDED'
| 'VOID';
export type PaymentAction =
| 'EDIT'
| 'SUBMIT'
| 'VOID'
| 'APPROVE'
| 'RETURN'
| 'REQUEST_CONTROL_EXCEPTION'
| 'RECORD_SUCCESS'
| 'RECORD_FAILURE'
| 'RECORD_REFUND'
| 'VERIFY_RESULT'
| 'RETURN_RESULT'
| 'CREATE_REPAYMENT';
export interface PageMeta {
page: number;
size: number;
totalElements: number;
totalPages: number;
}
export interface Reference {
publicId: string;
code?: string | null;
name: string;
}
export interface ContractReference extends Reference {
companyId: string;
projectId: string;
supplierId: string;
}
export interface BankAccountReference {
publicId: string;
supplierId: string;
accountName: string;
bankName: string;
maskedAccountNo: string;
versionNo: number;
}
export interface PayableReference {
publicId: string;
businessNo: string;
projectId: string;
contractId: string;
supplierId: string;
costCategoryCode?: string | null;
costCategoryName?: string | null;
amount: string;
paidAmount: string;
remainingAmount: string;
}
export interface PaymentAttachmentRequirement {
documentType: string;
name: string;
required: boolean;
allowNotApplicable: boolean;
}
export interface PaymentAttachmentRule {
parameterId: string;
versionNo: number;
costCategoryCode: string;
costCategoryName: string;
requirements: PaymentAttachmentRequirement[];
}
export interface PaymentReferenceOptions {
companies: Reference[];
projects: Reference[];
suppliers: Reference[];
contracts: ContractReference[];
payables: PayableReference[];
bankAccounts: BankAccountReference[];
attachmentRules: PaymentAttachmentRule[];
}
export interface FundControl {
projectId: string;
projectCode: string;
projectName: string;
currency: string;
confirmedReceipt: string;
confirmedDeduction: string;
approvedAdjustment: string;
frozenAmount: string;
approvedUnpaid: string;
paidAmount: string;
availableBalance: string;
version: number;
}
export interface FundLedgerEntry {
publicId: string;
entryType: string;
sourceType: string;
sourcePublicId?: string | null;
sourceBusinessNo?: string | null;
description: string;
currency: string;
eventAmount: string;
deltaAmount: string;
confirmedReceiptDelta: string;
frozenAmountDelta: string;
paidAmountDelta: string;
balanceAfter: string;
controlVersionAfter: number;
createdByName?: string | null;
occurredAt: string;
}
export interface FundLedgerPage {
control: FundControl;
items: FundLedgerEntry[];
meta: PageMeta;
}
export interface FundLedgerQuery {
projectId: string;
currency?: string;
entryType?: string;
dateFrom?: string;
dateTo?: string;
page: number;
size: number;
}
export interface PaymentQuery {
tab: PaymentTab;
companyId?: string;
projectId?: string;
supplierId?: string;
status?: PaymentStatus;
keyword?: string;
riskCode?: string;
submittedDateFrom?: string;
submittedDateTo?: string;
sort: string;
page: number;
size: number;
}
export interface Payment {
publicId: string;
businessNo: string;
company: Reference;
project: Reference;
contract: Reference;
payable?: Reference | null;
supplier: Reference;
bankAccount: BankAccountReference;
sourceDocumentId?: string | null;
originalPaymentId?: string | null;
repaymentSequence: number;
requestedAmount: string;
approvedAmount?: string | null;
frozenAmount?: string | null;
paidAmount?: string | null;
currency: string;
requestedDate: string;
purpose: string;
status: PaymentStatus;
submittedByName?: string | null;
approvedByName?: string | null;
resultRecordedByName?: string | null;
resultVerifiedByName?: string | null;
version: number;
createdAt: string;
updatedAt: string;
allowedActions: PaymentAction[];
}
export type PaymentCheckCode =
| 'PROJECT_FUNDS'
| 'CONTRACT_LIMIT'
| 'PAYABLE_LIMIT'
| 'ACCOUNT_ACTIVE'
| 'ATTACHMENTS'
| 'DUPLICATE'
| 'SOURCE_APPROVED'
| 'PROJECT_RISK';
export interface PaymentCheck {
publicId: string;
code: PaymentCheckCode;
result: 'PASS' | 'WARNING' | 'BLOCK';
message: string;
checkedAt: string;
}
export interface PaymentResult {
publicId: string;
resultNo: string;
resultType: 'SUCCESS' | 'FAILURE' | 'REFUND';
resultAt: string;
amount?: string | null;
failureReason?: string | null;
remark?: string | null;
recordedByName?: string | null;
recordedAt: string;
verifiedByName?: string | null;
verifiedAt?: string | null;
verificationOpinion?: string | null;
recordedFromStatus?: PaymentStatus | null;
returnedByName?: string | null;
returnedAt?: string | null;
returnOpinion?: string | null;
status: 'PENDING_VERIFY' | 'VERIFIED' | 'RETURNED';
version: number;
receiptFileIds: string[];
}
export interface PaymentAttachment {
publicId: string;
fileId: string;
fileName: string;
documentType: string;
scanStatus: string;
createdAt: string;
}
export interface PaymentAttachmentException {
publicId: string;
documentType: string;
documentName: string;
version: number;
reason: string;
status: 'PENDING' | 'APPROVED' | 'REJECTED';
requestedByName?: string | null;
requestedAt: string;
reviewedByName?: string | null;
reviewedAt?: string | null;
reviewOpinion?: string | null;
consumedAt?: string | null;
allowedActions: Array<'REVIEW'>;
}
export type PaymentControlCode = 'DUPLICATE' | 'PROJECT_RISK';
export interface PaymentControlException {
publicId: string;
controlCode: PaymentControlCode;
findingSummary: string;
version: number;
reason: string;
validUntil: string;
status: 'PENDING' | 'APPROVED' | 'REJECTED';
requestedByName?: string | null;
requestedAt: string;
reviewedByName?: string | null;
reviewedAt?: string | null;
reviewOpinion?: string | null;
consumedAt?: string | null;
allowedActions: Array<'REVIEW'>;
}
export interface PaymentDetail {
payment: Payment;
checks: PaymentCheck[];
results: PaymentResult[];
attachments: PaymentAttachment[];
attachmentExceptions: PaymentAttachmentException[];
controlExceptions: PaymentControlException[];
}
export interface PaymentControlExceptionRequestPayload {
paymentVersion: number;
controlCode: PaymentControlCode;
reason: string;
validUntil: string;
}
export interface PaymentControlExceptionReviewPayload {
paymentVersion: number;
version: number;
decision: 'APPROVE' | 'REJECT';
opinion: string;
}
export interface PaymentDraftPayload {
companyId: string;
projectId: string;
contractId: string;
payableId?: string;
supplierId: string;
bankAccountVersionId: string;
sourceDocumentId?: string;
requestedAmount: string;
currency: string;
requestedDate: string;
purpose: string;
attachments: Array<{ fileId: string; documentType: string }>;
attachmentExceptions: Array<{ documentType: string; reason: string }>;
}
export interface PaymentUpdatePayload extends PaymentDraftPayload {
version: number;
}
export interface PaymentResultPayload {
version: number;
resultAt: string;
amount: string;
receiptFileIds: string[];
remark?: string;
}
export interface PaymentFailurePayload {
version: number;
resultAt: string;
reason: string;
receiptFileIds?: string[];
remark?: string;
}
export interface PaymentRefundPayload extends PaymentResultPayload {
reason: string;
}
export interface RepaymentPayload {
version: number;
requestedDate: string;
requestedAmount: string;
purpose: string;
}
interface PageEnvelope<T> {
data: T[];
meta: PageMeta;
requestId: string;
}
async function page<T>(url: string, params: PaymentQuery) {
const envelope = await request.get<PageEnvelope<T>>({ url, params }, { isTransformResponse: false });
return {
items: Array.isArray(envelope?.data) ? envelope.data : [],
meta: envelope?.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
requestId: envelope?.requestId || '',
};
}
export function getPaymentReferences() {
return request.get<PaymentReferenceOptions>({ url: '/payments/references' });
}
function normalizePayment(payment: Payment): Payment {
return { ...payment, allowedActions: Array.isArray(payment?.allowedActions) ? payment.allowedActions : [] };
}
export async function getPayments(params: PaymentQuery) {
const result = await page<Payment>('/payments', params);
return { ...result, items: result.items.map(normalizePayment) };
}
export async function getPayment(publicId: string) {
const detail = await request.get<PaymentDetail>({ url: `/payments/${publicId}` });
return {
...detail,
payment: normalizePayment(detail.payment),
checks: Array.isArray(detail?.checks) ? detail.checks : [],
results: (Array.isArray(detail?.results) ? detail.results : []).map((item) => ({
...item,
receiptFileIds: Array.isArray(item?.receiptFileIds) ? item.receiptFileIds : [],
})),
attachments: Array.isArray(detail?.attachments) ? detail.attachments : [],
attachmentExceptions: (Array.isArray(detail?.attachmentExceptions) ? detail.attachmentExceptions : []).map(
(item) => ({
...item,
allowedActions: Array.isArray(item?.allowedActions) ? item.allowedActions : [],
}),
),
controlExceptions: (Array.isArray(detail?.controlExceptions) ? detail.controlExceptions : []).map((item) => ({
...item,
allowedActions: Array.isArray(item?.allowedActions) ? item.allowedActions : [],
})),
};
}
export function getPaymentChecks(publicId: string) {
return request.get<PaymentCheck[]>({ url: `/payments/${publicId}/checks` });
}
export function getFundLedger(params: FundLedgerQuery) {
return request.get<FundLedgerPage>({ url: '/funds/ledger', params });
}
export function createPayment(payload: PaymentDraftPayload, idempotencyKey?: string) {
return request.post<Payment>({
url: '/payments',
data: payload,
...commandHeaders('payment-create', idempotencyKey),
});
}
export function updatePayment(publicId: string, payload: PaymentUpdatePayload, idempotencyKey?: string) {
return request.patch<Payment>({
url: `/payments/${publicId}`,
data: payload,
...commandHeaders('payment-update', idempotencyKey),
});
}
export function reviewPaymentAttachmentException(
paymentPublicId: string,
exceptionPublicId: string,
version: number,
decision: 'APPROVE' | 'REJECT',
opinion: string,
idempotencyKey?: string,
) {
return request.post<Payment>({
url: `/payments/${paymentPublicId}/attachment-exceptions/${exceptionPublicId}/review`,
data: { version, decision, opinion },
...commandHeaders('payment-attachment-exception-review', idempotencyKey),
});
}
export function requestPaymentControlException(
paymentPublicId: string,
payload: PaymentControlExceptionRequestPayload,
idempotencyKey?: string,
) {
return request.post<Payment>({
url: `/payments/${paymentPublicId}/control-exceptions`,
data: payload,
...commandHeaders('payment-control-exception-request', idempotencyKey),
});
}
export function reviewPaymentControlException(
paymentPublicId: string,
exceptionPublicId: string,
payload: PaymentControlExceptionReviewPayload,
idempotencyKey?: string,
) {
return request.post<Payment>({
url: `/payments/${paymentPublicId}/control-exceptions/${exceptionPublicId}/review`,
data: payload,
...commandHeaders('payment-control-exception-review', idempotencyKey),
});
}
export function submitPayment(publicId: string, version: number, idempotencyKey?: string) {
return request.post<Payment>({
url: `/payments/${publicId}/submit`,
data: { version },
...commandHeaders('payment-submit', idempotencyKey),
});
}
export function approvePayment(publicId: string, version: number, idempotencyKey?: string) {
return request.post<Payment>({
url: `/payments/${publicId}/approve`,
data: { version },
...commandHeaders('payment-approve', idempotencyKey),
});
}
export function returnPayment(publicId: string, version: number, opinion: string, idempotencyKey?: string) {
return request.post<Payment>({
url: `/payments/${publicId}/return`,
data: { version, opinion },
...commandHeaders('payment-return', idempotencyKey),
});
}
export function voidPayment(publicId: string, version: number, opinion: string, idempotencyKey?: string) {
return request.post<Payment>({
url: `/payments/${publicId}/void`,
data: { version, opinion },
...commandHeaders('payment-void', idempotencyKey),
});
}
export function confirmOfflinePaymentResult(publicId: string, payload: PaymentResultPayload, idempotencyKey?: string) {
return request.post<Payment>({
url: `/payments/${publicId}/confirm-offline-result`,
data: payload,
...commandHeaders('payment-result', idempotencyKey),
});
}
export function recordPaymentFailure(publicId: string, payload: PaymentFailurePayload, idempotencyKey?: string) {
return request.post<Payment>({
url: `/payments/${publicId}/record-failure`,
data: payload,
...commandHeaders('payment-failure', idempotencyKey),
});
}
export function recordPaymentRefund(publicId: string, payload: PaymentRefundPayload, idempotencyKey?: string) {
return request.post<Payment>({
url: `/payments/${publicId}/record-refund`,
data: payload,
...commandHeaders('payment-refund', idempotencyKey),
});
}
export function verifyPaymentResult(publicId: string, version: number, opinion: string, idempotencyKey?: string) {
return request.post<Payment>({
url: `/payments/${publicId}/verify-result`,
data: { version, opinion },
...commandHeaders('payment-verify', idempotencyKey),
});
}
export function returnPaymentResult(publicId: string, version: number, opinion: string, idempotencyKey?: string) {
return request.post<Payment>({
url: `/payments/${publicId}/return-result`,
data: { version, opinion },
...commandHeaders('payment-return-result', idempotencyKey),
});
}
export function createRepayment(publicId: string, payload: RepaymentPayload, idempotencyKey?: string) {
return request.post<Payment>({
url: `/payments/${publicId}/create-repayment`,
data: payload,
...commandHeaders('payment-repayment', idempotencyKey),
});
}
export function exportPayments(params: Omit<PaymentQuery, 'page' | 'size'>) {
return request.post<AxiosResponse<Blob>>(
{ url: '/payments/exports', data: params, responseType: 'blob' },
{ isReturnNativeResponse: true },
);
}
+350
View File
@@ -0,0 +1,350 @@
import { request } from '@/utils/request';
import { commandHeaders } from './command';
export interface ApiPageMeta {
page: number;
size: number;
totalElements: number;
totalPages: number;
}
interface ApiEnvelope<T> {
data: T;
meta?: ApiPageMeta;
requestId: string;
}
export interface ProjectReference {
publicId: string;
name: string;
}
export interface ProjectReferenceOptions {
companies: ProjectReference[];
customers: ProjectReference[];
owners: ProjectReference[];
}
export interface ProjectOverview {
contractAmount: string;
receivedAmount: string;
paidAmount: string;
availableBalance: string;
sourceFormCount: number;
contractCount: number;
receiptCount: number;
invoiceCount: number;
paymentCount: number;
progressCount: number;
peopleCount: number;
accountingCount: number;
voucherCount: number;
pendingTaskCount: number;
fileCount: number;
activeRiskCount: number;
documentCompleteness: number;
}
export interface ProjectRiskFlag {
publicId: string;
riskType: string;
reason: string;
effectiveFrom: string;
effectiveUntil?: string | null;
blocking: boolean;
status: string;
version: number;
createdByName: string;
createdAt: string;
resolvedReason?: string | null;
resolvedByName?: string | null;
resolvedAt?: string | null;
allowedActions: string[];
}
export interface ProjectTimelineItem {
publicId: string;
actionCode: string;
resultCode: string;
reason?: string | null;
username: string;
activeRole?: string | null;
occurredAt: string;
}
export interface ProjectListItem {
publicId: string;
businessNo: string;
name: string;
company: ProjectReference;
customer?: ProjectReference | null;
owner: ProjectReference;
contractAmount: string;
receivedAmount: string;
paidAmount: string;
currency: string;
stage: string;
financeStatus: string;
archiveStatus: string;
status: string;
pendingTaskCount: number;
documentCompleteness: number;
startDate?: string | null;
endDate?: string | null;
version: number;
updatedAt: string;
allowedActions: string[];
}
export interface ProjectDetail extends Omit<
ProjectListItem,
'contractAmount' | 'receivedAmount' | 'paidAmount' | 'pendingTaskCount' | 'documentCompleteness'
> {
description?: string | null;
createdAt: string;
overview: ProjectOverview;
riskFlags: ProjectRiskFlag[];
timeline: ProjectTimelineItem[];
}
export interface ProjectQuery {
companyId?: string;
keyword?: string;
ownerId?: string;
customerId?: string;
stage?: string;
financeStatus?: string;
archiveStatus?: string;
status?: string;
startDateFrom?: string;
startDateTo?: string;
endDateFrom?: string;
endDateTo?: string;
sort?: string;
page: number;
size: number;
}
export interface ProjectExport {
exportId: string;
rowCount: number;
sha256: string;
filterHash: string;
columns: string[];
fileName: string;
content: string;
}
export type ProjectExportPayload = Omit<ProjectQuery, 'page' | 'size'> & { columns?: string[] };
export interface ProjectImportRow {
rowNo: number;
businessNo: string;
name: string;
description?: string;
companyId: string;
customerId?: string;
ownerId: string;
startDate?: string;
endDate?: string;
currency: string;
}
export interface ProjectImportIssue {
rowNo: number;
fieldCode?: string | null;
message: string;
}
export interface ProjectImportSummary {
publicId: string;
batchNo: string;
sourceFileId: string;
fileSha256: string;
totalRows: number;
validRows: number;
errorRows: number;
status: string;
version: number;
createdAt: string;
updatedAt: string;
issues: ProjectImportIssue[];
}
export interface ProjectImportPayload {
sourceFileId: string;
fileSha256: string;
/** Compatibility field for pre-server-parser clients; new clients omit it. */
rows?: ProjectImportRow[];
}
export interface ProjectCreatePayload {
businessNo: string;
name: string;
description?: string;
companyId: string;
customerId?: string;
ownerId: string;
startDate?: string;
endDate?: string;
currency: string;
}
export interface ProjectUpdatePayload {
name: string;
description?: string;
customerId?: string;
ownerId: string;
startDate?: string;
endDate?: string;
version: number;
}
export interface ProjectRiskFlagCreatePayload {
riskType: string;
reason: string;
effectiveFrom?: string;
effectiveUntil?: string;
blocking: boolean;
}
export type ProjectSubresource =
| 'forms'
| 'contracts'
| 'receipts'
| 'invoices'
| 'payments'
| 'progress'
| 'people'
| 'accounting'
| 'archives'
| 'timeline'
| 'risk-flags';
export interface ProjectSubresourceQuery {
page: number;
size: number;
[key: string]: string | number | boolean | undefined;
}
export interface ProjectSubresourceEnvelope<T> {
data: T;
meta?: ApiPageMeta;
requestId: string;
}
/**
* Read-only PAGE-08 drill-down query. The resource is a closed union so a
* caller cannot turn a project URL into an arbitrary backend path.
*/
export function getProjectSubresource<T>(
projectId: string,
resource: ProjectSubresource,
params: ProjectSubresourceQuery,
) {
return request
.get<ProjectSubresourceEnvelope<T>>(
{ url: `/projects/${projectId}/${resource}`, params },
{ isTransformResponse: false },
)
.then((envelope) => ({
...envelope,
data: envelope && envelope.data !== undefined ? envelope.data : ([] as T),
meta: envelope?.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
}));
}
export async function getProjects(params: ProjectQuery) {
const envelope = await request.get<ApiEnvelope<ProjectListItem[]>>(
{ url: '/projects', params },
{ isTransformResponse: false },
);
return {
items: Array.isArray(envelope?.data) ? envelope.data : [],
meta: envelope.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
requestId: envelope.requestId,
};
}
export function exportProjects(payload: ProjectExportPayload) {
return request.post<ProjectExport>({ url: '/projects/exports', data: payload });
}
export function createProjectImport(payload: ProjectImportPayload, idempotencyKey: string) {
return request.post<ProjectImportSummary>({
url: '/projects/imports',
data: payload,
headers: { 'Idempotency-Key': idempotencyKey },
});
}
export function getProjectImport(publicId: string) {
return request.get<ProjectImportSummary>({ url: `/projects/imports/${publicId}` });
}
export function confirmProjectImport(publicId: string, version: number, idempotencyKey: string) {
return request.post<ProjectDetail[]>({
url: `/projects/imports/${publicId}/confirm`,
data: { version },
headers: { 'Idempotency-Key': idempotencyKey },
});
}
export function getProject(publicId: string) {
return request.get<ProjectDetail>({ url: `/projects/${publicId}` });
}
export function getProjectReferences() {
return request.get<ProjectReferenceOptions>({ url: '/projects/references' });
}
export function createProject(payload: ProjectCreatePayload, idempotencyKey?: string) {
return request.post<ProjectDetail>({
url: '/projects',
data: payload,
...commandHeaders('project-create', idempotencyKey),
});
}
export function updateProject(publicId: string, payload: ProjectUpdatePayload, idempotencyKey?: string) {
return request.patch<ProjectDetail>({
url: `/projects/${publicId}`,
data: payload,
...commandHeaders('project-update', idempotencyKey),
});
}
export function completeProjectFinance(publicId: string, version: number, idempotencyKey?: string) {
return request.post<ProjectDetail>({
url: `/projects/${publicId}/finance-complete`,
data: { version },
...commandHeaders('project-finance-complete', idempotencyKey),
});
}
export function createProjectRiskFlag(
publicId: string,
payload: ProjectRiskFlagCreatePayload,
idempotencyKey?: string,
) {
return request.post<ProjectRiskFlag>({
url: `/projects/${publicId}/risk-flags`,
data: payload,
...commandHeaders('project-risk-create', idempotencyKey),
});
}
export function resolveProjectRiskFlag(
publicId: string,
riskPublicId: string,
version: number,
reason: string,
idempotencyKey?: string,
) {
return request.post<ProjectRiskFlag>({
url: `/projects/${publicId}/risk-flags/${riskPublicId}/resolve`,
data: { version, reason },
...commandHeaders('project-risk-resolve', idempotencyKey),
});
}
+320
View File
@@ -0,0 +1,320 @@
import type { AxiosResponse } from 'axios';
import { request } from '@/utils/request';
import { commandHeaders } from './command';
export interface PageMeta {
page: number;
size: number;
totalElements: number;
totalPages: number;
}
export interface Reference {
publicId: string;
code?: string | null;
name: string;
}
export interface ReferenceOptions {
companies: Reference[];
projects: Reference[];
counterparties: Reference[];
contracts: Reference[];
receivables: ReceivableOption[];
}
export interface ReceivableOption {
publicId: string;
businessNo: string;
project: Reference;
counterparty: Reference;
amount: string;
outstandingAmount: string;
currency: string;
}
export interface ReceivableQuery {
companyId?: string;
projectId?: string;
counterpartyId?: string;
keyword?: string;
status?: string;
dateFrom?: string;
dateTo?: string;
sort?: string;
page: number;
size: number;
}
export interface Receipt {
publicId: string;
businessNo: string;
company: Reference;
project?: Reference | null;
customer?: Reference | null;
receiptDate: string;
amount: string;
allocatedAmount: string;
unallocatedAmount: string;
currency: string;
payerName: string;
referenceNo?: string | null;
status: string;
confirmedByName?: string | null;
confirmedAt?: string | null;
voidReason?: string | null;
version: number;
createdAt: string;
updatedAt: string;
allowedActions: string[];
}
export interface ReceiptAllocation {
publicId: string;
sequenceNo: number;
receivable?: Reference | null;
project: Reference;
contract?: Reference | null;
amount: string;
receivableAmount?: string | null;
receivableOutstandingAmount?: string | null;
note?: string | null;
createdByName?: string | null;
createdAt: string;
}
export interface Receivable {
publicId: string;
businessNo: string;
company: Reference;
project: Reference;
contract?: Reference | null;
counterparty: Reference;
businessDate: string;
amount: string;
settledAmount: string;
outstandingAmount: string;
currency: string;
status: string;
sourceInvoiceId?: string | null;
version: number;
createdAt: string;
updatedAt: string;
}
export interface ReceiptDetail {
receipt: Receipt;
allocations: ReceiptAllocation[];
}
export interface Invoice {
publicId: string;
businessNo: string;
sourceDocumentId?: string | null;
sourceVersionId?: string | null;
company: Reference;
project: Reference;
contract?: Reference | null;
counterparty: Reference;
invoiceType: string;
amount: string;
currency: string;
taxRate: string;
requestedDate: string;
invoiceNo?: string | null;
issuedDate?: string | null;
resultFileId?: string | null;
originalInvoiceId?: string | null;
status: string;
submittedByName?: string | null;
reviewedByName?: string | null;
resultRecordedByName?: string | null;
submittedAt?: string | null;
reviewedAt?: string | null;
reviewOpinion?: string | null;
resultRecordedAt?: string | null;
terminationReason?: string | null;
contractLimit?: string | null;
contractCommittedAmount?: string | null;
contractAvailableAmount?: string | null;
version: number;
createdAt: string;
updatedAt: string;
allowedActions: string[];
}
interface PageEnvelope<T> {
data: T[];
meta: PageMeta;
requestId: string;
}
interface DataEnvelope<T> {
data: T;
requestId: string;
}
async function page<T>(url: string, params: ReceivableQuery) {
const envelope = await request.get<PageEnvelope<T>>({ url, params }, { isTransformResponse: false });
return {
items: Array.isArray(envelope?.data) ? envelope.data : [],
meta: envelope?.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
requestId: envelope?.requestId || '',
};
}
function normalizeReceipt(receipt: Receipt): Receipt {
return { ...receipt, allowedActions: Array.isArray(receipt?.allowedActions) ? receipt.allowedActions : [] };
}
function normalizeInvoice(invoice: Invoice): Invoice {
return { ...invoice, allowedActions: Array.isArray(invoice?.allowedActions) ? invoice.allowedActions : [] };
}
function normalizeReceiptDetail(detail: ReceiptDetail): ReceiptDetail {
return {
...detail,
receipt: normalizeReceipt(detail.receipt),
allocations: Array.isArray(detail?.allocations) ? detail.allocations : [],
};
}
export function getReceivableReferences() {
return request.get<ReferenceOptions>({ url: '/receivables/references' });
}
export async function getReceipts(params: ReceivableQuery) {
const result = await page<Receipt>('/receipts', params);
return { ...result, items: result.items.map(normalizeReceipt) };
}
export function getReceivables(params: ReceivableQuery) {
return page<Receivable>('/receivables', params);
}
export function getReceipt(publicId: string) {
return request.get<ReceiptDetail>({ url: `/receipts/${publicId}` }).then(normalizeReceiptDetail);
}
export function createReceipt(payload: Record<string, unknown>, idempotencyKey?: string) {
return request.post<Receipt>({
url: '/receipts',
data: payload,
...commandHeaders('receipt-create', idempotencyKey),
});
}
export function updateReceipt(publicId: string, payload: Record<string, unknown>, idempotencyKey?: string) {
return request.patch<Receipt>({
url: `/receipts/${publicId}`,
data: payload,
...commandHeaders('receipt-update', idempotencyKey),
});
}
export function confirmReceipt(publicId: string, version: number, idempotencyKey?: string) {
return request.post<Receipt>({
url: `/receipts/${publicId}/confirm`,
data: { version },
...commandHeaders('receipt-confirm', idempotencyKey),
});
}
export function allocateReceipt(publicId: string, payload: Record<string, unknown>, idempotencyKey?: string) {
return request.post<ReceiptDetail>({
url: `/receipts/${publicId}/allocations`,
data: payload,
...commandHeaders('receipt-allocate', idempotencyKey),
});
}
export function voidReceipt(publicId: string, payload: Record<string, unknown>, idempotencyKey?: string) {
return request.post<Receipt>({
url: `/receipts/${publicId}/void`,
data: payload,
...commandHeaders('receipt-void', idempotencyKey),
});
}
export function exportReceipts(params: Omit<ReceivableQuery, 'page' | 'size'>) {
return request.get<AxiosResponse<Blob>>(
{ url: '/receipts/export', params, responseType: 'blob' },
{ isReturnNativeResponse: true },
);
}
export async function getInvoices(params: ReceivableQuery) {
const result = await page<Invoice>('/invoices', params);
return { ...result, items: result.items.map(normalizeInvoice) };
}
export function getInvoice(publicId: string) {
return request.get<Invoice>({ url: `/invoices/${publicId}` }).then(normalizeInvoice);
}
export function createInvoice(payload: Record<string, unknown>, idempotencyKey?: string) {
return request.post<Invoice>({
url: '/invoices',
data: payload,
...commandHeaders('invoice-create', idempotencyKey),
});
}
export function updateInvoice(publicId: string, payload: Record<string, unknown>, idempotencyKey?: string) {
return request.patch<Invoice>({
url: `/invoices/${publicId}`,
data: payload,
...commandHeaders('invoice-update', idempotencyKey),
});
}
export function submitInvoice(publicId: string, version: number, idempotencyKey?: string) {
return request.post<Invoice>({
url: `/invoices/${publicId}/submit`,
data: { version },
...commandHeaders('invoice-submit', idempotencyKey),
});
}
export function reviewInvoice(publicId: string, payload: Record<string, unknown>, idempotencyKey?: string) {
return request.post<Invoice>({
url: `/invoices/${publicId}/review`,
data: payload,
...commandHeaders('invoice-review', idempotencyKey),
});
}
export function recordInvoiceResult(publicId: string, payload: Record<string, unknown>, idempotencyKey?: string) {
return request.post<Invoice>({
url: `/invoices/${publicId}/record-result`,
data: payload,
...commandHeaders('invoice-result', idempotencyKey),
});
}
export function voidInvoice(publicId: string, payload: Record<string, unknown>, idempotencyKey?: string) {
return request.post<Invoice>({
url: `/invoices/${publicId}/void`,
data: payload,
...commandHeaders('invoice-void', idempotencyKey),
});
}
export function redLetterInvoice(publicId: string, payload: Record<string, unknown>, idempotencyKey?: string) {
return request.post<Invoice>({
url: `/invoices/${publicId}/red-letter`,
data: payload,
...commandHeaders('invoice-red-letter', idempotencyKey),
});
}
export function exportInvoices(params: Omit<ReceivableQuery, 'page' | 'size'>) {
return request.get<AxiosResponse<Blob>>(
{ url: '/invoices/export', params, responseType: 'blob' },
{ isReturnNativeResponse: true },
);
}
export type ReceivableDataEnvelope<T> = DataEnvelope<T>;
+191
View File
@@ -0,0 +1,191 @@
import { request } from '@/utils/request';
export type ReportCode =
| 'project-ledger'
| 'contract-execution'
| 'receipt-invoice'
| 'payment-progress'
| 'workflow-duration'
| 'archive-completeness';
export interface ReportPageMeta {
page: number;
size: number;
totalElements: number;
totalPages: number;
reportCode: ReportCode;
definitionVersion: string;
filterHash: string;
columns: string[];
sort: string;
summary: Record<string, string>;
allowedActions: string[];
}
export interface ReportQuery {
companyId?: string;
projectId?: string;
counterpartyId?: string;
keyword?: string;
status?: string;
dateFrom?: string;
dateTo?: string;
page: number;
size: number;
}
export type ReportRow = Record<string, unknown> & { rowId?: string; publicId?: string };
export interface ReportDrilldown {
reportCode: ReportCode;
rowId: string;
definitionVersion: string;
filterHash: string;
source: Record<string, unknown>;
groups: Array<{ code: string; label: string; value: string }>;
relatedRecords: Array<{
relationType: string;
objectType: string;
publicId: string;
businessNo: string;
status: string;
amount: string;
businessDate: string;
sourceDocumentId: string;
}>;
sourceForms: Array<{
publicId: string;
businessNo: string;
formType: string;
sourceSystem: string;
sourceNo: string;
status: string;
sourceVersionId: string;
versionNo?: number;
relationType: string;
ownerType: string;
ownerPublicId: string;
exactVersion: boolean;
}>;
approvals: Array<{
publicId: string;
sourceDocumentId: string;
sourceVersionId: string;
nodeCode: string;
action: string;
actorName: string;
opinion: string;
actedAt: string;
}>;
attachments: Array<{
fileId: string;
ownerType: string;
ownerId: string;
sourceDocumentId: string;
documentType: string;
displayName: string;
scanStatus: string;
versionNo?: number;
}>;
}
export interface ReportExport {
exportId: string;
reportCode: ReportCode;
definitionVersion: string;
filterHash: string;
status: string;
estimatedRows: number;
rowCount: number;
sha256: string;
fileName: string;
content: string;
}
export interface ReportExportPreview {
exportId: string;
reportCode: ReportCode;
definitionVersion: string;
filterHash: string;
status: 'PREPARED';
estimatedRows: number;
columns: string[];
sort: string;
expiresAt: string;
maskingNotice: string;
}
export interface ReportExportSnapshot {
reportCode: ReportCode;
filters: Omit<ReportQuery, 'page' | 'size'>;
definitionVersion: string;
filterHash: string;
}
function requiredString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) throw new Error(`报表导出响应缺少${field}`);
return value;
}
function nonNegativeInteger(value: unknown, field: string): number {
if (!Number.isInteger(value) || Number(value) < 0) throw new Error(`报表导出响应中的${field}无效`);
return Number(value);
}
export function normalizeReportExport(value: ReportExport): ReportExport {
return {
exportId: requiredString(value?.exportId, '导出编号'),
reportCode: requiredString(value?.reportCode, '报表编号') as ReportCode,
definitionVersion: requiredString(value?.definitionVersion, '口径版本'),
filterHash: requiredString(value?.filterHash, '筛选快照'),
status: requiredString(value?.status, '导出状态'),
estimatedRows: nonNegativeInteger(value?.estimatedRows, '预计行数'),
rowCount: nonNegativeInteger(value?.rowCount, '导出行数'),
sha256: requiredString(value?.sha256, '文件摘要'),
fileName: requiredString(value?.fileName, '文件名'),
content: requiredString(value?.content, '文件内容'),
};
}
interface ReportEnvelope {
data: ReportRow[];
meta: ReportPageMeta;
requestId: string;
}
export async function getReport(reportCode: ReportCode, params: ReportQuery) {
const envelope = await request.get<ReportEnvelope>(
{ url: `/reports/${reportCode}`, params },
{ isTransformResponse: false },
);
return {
items: envelope.data || [],
meta: envelope.meta,
requestId: envelope.requestId,
};
}
export function getReportDrilldown(reportCode: ReportCode, rowId: string, params: Omit<ReportQuery, 'page' | 'size'>) {
return request.get<ReportDrilldown>({ url: `/reports/${reportCode}/${rowId}/drilldown`, params });
}
export function prepareReportExport(
reportCode: ReportCode,
payload: Omit<ReportQuery, 'page' | 'size'> & {
columns?: string[];
sort: string;
expectedFilterHash: string;
expectedDefinitionVersion: string;
},
) {
return request.post<ReportExportPreview>({ url: `/reports/${reportCode}/exports/prepare`, data: payload });
}
export function confirmReportExport(reportCode: ReportCode, exportId: string) {
return request
.post<ReportExport>({
url: `/reports/${reportCode}/exports/confirm`,
data: { exportId, maskingConfirmed: true },
})
.then(normalizeReportExport);
}
+61
View File
@@ -0,0 +1,61 @@
import { request } from '@/utils/request';
export interface SetupStatus {
required: boolean;
locked: boolean;
supportedDatabaseTypes: string[];
message: string;
}
export interface SetupDatabaseRequest {
setupCode: string;
databaseType: 'MYSQL' | 'POSTGRESQL';
host: string;
port: number;
database: string;
username: string;
password: string;
}
export interface SetupConnection {
successful: boolean;
databaseType: string;
serverVersion: string;
schemaReady: boolean;
message: string;
}
export interface SetupCompleteRequest extends SetupDatabaseRequest {
adminUsername: string;
adminDisplayName: string;
adminPassword: string;
adminPasswordConfirmation: string;
}
export interface SetupCompleted {
completed: boolean;
username: string;
message: string;
restartAfterSeconds: number;
}
function normalizeStatus(value: SetupStatus): SetupStatus {
return {
required: Boolean(value?.required),
locked: Boolean(value?.locked),
supportedDatabaseTypes: Array.isArray(value?.supportedDatabaseTypes) ? value.supportedDatabaseTypes : [],
message: value?.message || '',
};
}
export function getSetupStatus() {
return request.get<SetupStatus>({ url: '/setup/status' }).then(normalizeStatus);
}
export function testSetupConnection(data: SetupDatabaseRequest) {
return request.post<SetupConnection>({ url: '/setup/test-connection', data }).then((value) => value);
}
export function completeSetup(data: SetupCompleteRequest) {
return request.post<SetupCompleted>({ url: '/setup/complete', data }).then((value) => value);
}
+564
View File
@@ -0,0 +1,564 @@
import type { UploadFile } from 'tdesign-vue-next';
import { request } from '@/utils/request';
import { commandHeaders } from './command';
export interface ApiPageMeta {
page: number;
size: number;
totalElements: number;
totalPages: number;
}
interface ApiEnvelope<T> {
data: T;
meta?: ApiPageMeta;
requestId: string;
}
export interface ReferenceItem {
publicId: string;
name: string;
}
export interface ContractReferenceItem extends ReferenceItem {
businessNo: string;
companyId: string;
projectId: string;
counterpartyId: string;
counterpartyName: string;
counterpartyType: 'CUSTOMER' | 'SUPPLIER' | 'BOTH';
}
export interface FormReferenceOptions {
companies: ReferenceItem[];
projects: ReferenceItem[];
suppliers: ReferenceItem[];
customers: ReferenceItem[];
contracts: ContractReferenceItem[];
payables: PayableReferenceItem[];
costCategories: ReferenceItem[];
}
export interface PayableReferenceItem {
publicId: string;
businessNo: string;
projectId: string;
contractId: string;
supplierId: string;
amount: string;
paidAmount: string;
remainingAmount: string;
}
export interface TemplateColumnOption {
code: string;
label: string;
}
export interface TemplateColumn {
code: string;
label: string;
dataType?: 'TEXT' | 'LONG_TEXT' | 'ENUM' | 'DATE' | 'DECIMAL' | 'INTEGER' | 'BOOLEAN' | 'REFERENCE' | 'FILE';
required?: boolean;
maxLength?: number;
options?: TemplateColumnOption[];
}
export interface SourceTemplateField {
code: string;
label: string;
sectionCode: string;
sectionName: string;
dataType:
| 'TEXT'
| 'LONG_TEXT'
| 'INTEGER'
| 'DECIMAL'
| 'DATE'
| 'DATETIME'
| 'BOOLEAN'
| 'ENUM'
| 'REFERENCE'
| 'FILE'
| 'TABLE';
required: boolean;
repeatable: boolean;
maxLength?: number;
numericPrecision?: number;
numericScale?: number;
options: TemplateColumn[];
unit?: string;
sensitivity: 'NORMAL' | 'INTERNAL' | 'SENSITIVE' | 'RESTRICTED';
maskingRule?: string;
helpText?: string;
sortOrder: number;
}
export interface SourceTemplate {
publicId: string;
formType: string;
templateVersion: number;
name: string;
groupCode: string;
groupName: string;
purpose: string;
initiatorRoles: string[];
status: string;
publishedAt: string;
myDraftCount: number;
myPendingCount: number;
canCreate: boolean;
fields: SourceTemplateField[];
}
export interface SourceFormSummary {
publicId: string;
businessNo: string;
formType: string;
formName: string;
templateVersion: number;
sourceSystem: string;
sourceNo: string;
sourceVersion: string;
company: ReferenceItem;
project?: ReferenceItem | null;
applicantName: string;
businessDate: string;
status: string;
currentNodeCode?: string | null;
currentNodeName?: string | null;
version: number;
createdByName: string;
createdAt: string;
updatedAt: string;
allowedActions: string[];
}
export interface ValidationIssue {
level: string;
sectionCode: string;
fieldCode: string;
rowNo?: number | null;
message: string;
}
export interface SourceValidation {
documentId: string;
version: number;
valid: boolean;
canSubmit: boolean;
errors: ValidationIssue[];
warnings: ValidationIssue[];
validatedAt: string;
}
export interface ApprovalItem {
publicId: string;
nodeCode: string;
nodeName: string;
actorName: string;
action: string;
opinion?: string;
actedAt: string;
}
export interface SourceFormDetail {
summary: SourceFormSummary;
documentVersion: number;
snapshotSha256: string;
values: Record<string, unknown>;
fields: SourceTemplateField[];
approvals: ApprovalItem[];
validation: SourceValidation;
}
export interface SourceFormPayload {
formType: string;
templateVersion: number;
sourceSystem: string;
sourceNo: string;
sourceVersion: string;
companyId: string;
projectId?: string;
applicantName: string;
businessDate: string;
values: Record<string, unknown>;
}
export interface SourceFormUpdatePayload extends Omit<
SourceFormPayload,
'formType' | 'templateVersion' | 'sourceSystem'
> {
version: number;
}
export interface SourceFormQuery {
groupCode?: string;
formType?: string;
templateVersion?: number;
projectId?: string;
status?: string;
sourceSystem?: string;
createdByMe?: boolean;
keyword?: string;
createdDateFrom?: string;
createdDateTo?: string;
sort?:
| 'updatedAt,asc'
| 'updatedAt,desc'
| 'createdAt,asc'
| 'createdAt,desc'
| 'businessNo,asc'
| 'businessNo,desc'
| 'status,asc'
| 'status,desc';
page: number;
size: number;
}
export interface WorkflowTaskSummary {
publicId: string;
instancePublicId: string;
nodeCode: string;
nodeName: string;
sequenceNo: number;
status: string;
version: number;
assigneeName: string;
arrivedAt: string;
dueAt?: string | null;
updatedAt: string;
keyAmount?: string | null;
form: SourceFormSummary;
allowedActions: string[];
}
export interface WorkflowTaskDetail {
task: WorkflowTaskSummary;
form: SourceFormDetail;
timeline: ApprovalItem[];
}
export interface WorkflowTaskQuery {
view: 'TODO' | 'PROCESSED' | 'CREATED' | 'RETURNED' | 'COMPLETED';
formType?: string;
status?: string;
keyword?: string;
projectId?: string;
arrivedDateFrom?: string;
arrivedDateTo?: string;
overdue?: boolean;
sort?: 'arrivedAt,asc' | 'arrivedAt,desc' | 'dueAt,asc' | 'dueAt,desc' | 'updatedAt,asc' | 'updatedAt,desc';
page: number;
size: number;
}
export interface SourceImportRowPayload {
rowNo: number;
sourceNo: string;
sourceVersion: string;
companyId: string;
projectId?: string;
applicantName: string;
businessDate: string;
values: Record<string, unknown>;
}
export interface SourceImportRequest {
formType: string;
templateVersion: number;
sourceSystem: 'EXCEL_IMPORT' | 'OA_EXPORT';
sourceFileId: string;
fileSha256: string;
rows: SourceImportRowPayload[];
}
export interface SourceImportIssue {
rowNo: number;
fieldCode?: string;
message: string;
}
export interface SourceImportSummary {
publicId: string;
batchNo: string;
formType: string;
sourceSystem: string;
sourceFileId: string;
fileSha256: string;
totalRows: number;
validRows: number;
errorRows: number;
status: 'VALIDATING' | 'INVALID' | 'READY' | 'CONFIRMED' | 'CANCELLED' | string;
version: number;
createdAt: string;
updatedAt: string;
issues: SourceImportIssue[];
}
export interface FileView {
publicId: string;
originalName: string;
mediaType: string;
extension: string;
sizeBytes: number;
sha256: string;
scanStatus: string;
createdAt: string;
}
export function getSourceTemplates() {
return request
.get<SourceTemplate[]>({ url: '/source/templates' })
.then((items) => (Array.isArray(items) ? items : []).map(normalizeTemplate));
}
export function getSourceTemplate(formType: string, version: number) {
return request
.get<SourceTemplate>({ url: `/source/templates/${formType}/versions/${version}` })
.then(normalizeTemplate);
}
export function getSourceReferences() {
return request.get<FormReferenceOptions>({ url: '/source/references' }).then((references) => ({
companies: Array.isArray(references?.companies) ? references.companies : [],
projects: Array.isArray(references?.projects) ? references.projects : [],
suppliers: Array.isArray(references?.suppliers) ? references.suppliers : [],
customers: Array.isArray(references?.customers) ? references.customers : [],
contracts: Array.isArray(references?.contracts) ? references.contracts : [],
payables: Array.isArray(references?.payables) ? references.payables : [],
costCategories: Array.isArray(references?.costCategories) ? references.costCategories : [],
}));
}
export async function getSourceForms(params: SourceFormQuery) {
const envelope = await request.get<ApiEnvelope<SourceFormSummary[]>>(
{ url: '/source/forms', params },
{ isTransformResponse: false },
);
return {
items: (Array.isArray(envelope?.data) ? envelope.data : []).map(normalizeSummary),
meta: envelope.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
requestId: envelope.requestId,
};
}
export function getSourceForm(publicId: string) {
return request.get<SourceFormDetail>({ url: `/source/forms/${publicId}` }).then(normalizeDetail);
}
export function createSourceForm(payload: SourceFormPayload, idempotencyKey?: string) {
return request
.post<SourceFormDetail>({
url: '/source/forms',
data: payload,
...commandHeaders('source-form-create', idempotencyKey),
})
.then(normalizeDetail);
}
export function updateSourceForm(publicId: string, payload: SourceFormUpdatePayload, idempotencyKey?: string) {
return request
.patch<SourceFormDetail>({
url: `/source/forms/${publicId}`,
data: payload,
...commandHeaders('source-form-update', idempotencyKey),
})
.then(normalizeDetail);
}
export function validateSourceForm(publicId: string, idempotencyKey?: string) {
return request
.post<SourceValidation>({
url: `/source/forms/${publicId}/validate`,
data: {},
...commandHeaders('source-form-validate', idempotencyKey),
})
.then(normalizeValidation);
}
export function submitSourceForm(publicId: string, version: number, idempotencyKey?: string) {
return request
.post<SourceFormDetail>({
url: `/source/forms/${publicId}/submit`,
data: { version },
...commandHeaders('source-form-submit', idempotencyKey),
})
.then(normalizeDetail);
}
export function withdrawSourceForm(publicId: string, version: number, opinion: string, idempotencyKey?: string) {
return request
.post<SourceFormDetail>({
url: `/source/forms/${publicId}/withdraw`,
data: { version, opinion },
...commandHeaders('source-form-withdraw', idempotencyKey),
})
.then(normalizeDetail);
}
export function voidSourceForm(publicId: string, version: number, opinion: string, idempotencyKey?: string) {
return request
.post<SourceFormDetail>({
url: `/source/forms/${publicId}/void`,
data: { version, opinion },
...commandHeaders('source-form-void', idempotencyKey),
})
.then(normalizeDetail);
}
export async function getWorkflowTasks(params: WorkflowTaskQuery) {
const envelope = await request.get<ApiEnvelope<WorkflowTaskSummary[]>>(
{ url: '/workflow/tasks', params },
{ isTransformResponse: false },
);
return {
items: (Array.isArray(envelope?.data) ? envelope.data : []).map(normalizeTask),
meta: envelope.meta || { page: params.page, size: params.size, totalElements: 0, totalPages: 0 },
requestId: envelope.requestId,
};
}
export function getWorkflowTask(publicId: string) {
return request.get<WorkflowTaskDetail>({ url: `/workflow/tasks/${publicId}` }).then(normalizeTaskDetail);
}
export function actOnWorkflowTask(
publicId: string,
action: 'approve' | 'return' | 'reject',
payload: { version: number; opinion: string; returnPoints: string[] },
idempotencyKey?: string,
) {
return request
.post<WorkflowTaskDetail>({
url: `/workflow/tasks/${publicId}/${action}`,
data: payload,
...commandHeaders(`workflow-task-${action}`, idempotencyKey),
})
.then(normalizeTaskDetail);
}
export function withdrawWorkflowInstance(
instancePublicId: string,
version: number,
opinion: string,
idempotencyKey?: string,
) {
return request
.post<SourceFormDetail>({
url: `/workflow/instances/${instancePublicId}/withdraw`,
data: { version, opinion },
...commandHeaders('workflow-instance-withdraw', idempotencyKey),
})
.then(normalizeDetail);
}
export function voidWorkflowInstance(
instancePublicId: string,
version: number,
opinion: string,
idempotencyKey?: string,
) {
return request
.post<SourceFormDetail>({
url: `/workflow/instances/${instancePublicId}/void`,
data: { version, opinion },
...commandHeaders('workflow-instance-void', idempotencyKey),
})
.then(normalizeDetail);
}
export function createSourceImport(payload: SourceImportRequest, idempotencyKey?: string) {
return request.post<SourceImportSummary>({
url: '/source/imports',
data: payload,
...commandHeaders('source-import-create', idempotencyKey),
});
}
export function getSourceImport(publicId: string) {
return request.get<SourceImportSummary>({ url: `/source/imports/${publicId}` });
}
export function confirmSourceImport(publicId: string, version: number, idempotencyKey?: string) {
return request
.post<SourceFormDetail[]>({
url: `/source/imports/${publicId}/confirm`,
data: { version },
...commandHeaders('source-import-confirm', idempotencyKey),
})
.then((items) => (Array.isArray(items) ? items : []).map(normalizeDetail));
}
export async function uploadBusinessFile(uploadFile: UploadFile) {
if (!uploadFile.raw) throw new Error('未读取到待上传文件');
const formData = new FormData();
formData.append('file', uploadFile.raw);
return request.post<FileView>({
url: '/files',
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
...commandHeaders('business-file-upload').headers,
},
});
}
function normalizeTemplate(template: SourceTemplate): SourceTemplate {
return {
...template,
initiatorRoles: Array.isArray(template?.initiatorRoles) ? template.initiatorRoles : [],
fields: (Array.isArray(template?.fields) ? template.fields : []).map((field) => ({
...field,
options: Array.isArray(field.options) ? field.options : [],
})),
};
}
function normalizeSummary(summary: SourceFormSummary): SourceFormSummary {
return {
...summary,
allowedActions: Array.isArray(summary?.allowedActions) ? summary.allowedActions : [],
};
}
function normalizeValidation(validation: SourceValidation): SourceValidation {
return {
...validation,
errors: Array.isArray(validation?.errors) ? validation.errors : [],
warnings: Array.isArray(validation?.warnings) ? validation.warnings : [],
};
}
function normalizeDetail(detail: SourceFormDetail): SourceFormDetail {
return {
...detail,
summary: normalizeSummary(detail.summary),
values: detail?.values && typeof detail.values === 'object' ? detail.values : {},
fields: (Array.isArray(detail?.fields) ? detail.fields : []).map((field) => ({
...field,
options: Array.isArray(field.options) ? field.options : [],
})),
approvals: Array.isArray(detail?.approvals) ? detail.approvals : [],
validation: normalizeValidation(detail.validation),
};
}
function normalizeTask(task: WorkflowTaskSummary): WorkflowTaskSummary {
return {
...task,
form: normalizeSummary(task.form),
allowedActions: Array.isArray(task?.allowedActions) ? task.allowedActions : [],
};
}
function normalizeTaskDetail(detail: WorkflowTaskDetail): WorkflowTaskDetail {
return {
...detail,
task: normalizeTask(detail.task),
form: normalizeDetail(detail.form),
timeline: Array.isArray(detail?.timeline) ? detail.timeline : [],
};
}
+56
View File
@@ -0,0 +1,56 @@
import { request } from '@/utils/request';
import { commandHeaders } from './command';
export interface SystemUpdateView {
enabled: boolean;
currentVersion: string;
latestVersion?: string | null;
updateAvailable: boolean;
state: string;
message: string;
releaseNotes?: string | null;
publishedAt?: string | null;
checkedAt?: string | null;
statusUpdatedAt?: string | null;
allowedActions: string[];
}
function normalize(value: SystemUpdateView): SystemUpdateView {
return {
...value,
enabled: Boolean(value?.enabled),
updateAvailable: Boolean(value?.updateAvailable),
allowedActions: Array.isArray(value?.allowedActions) ? value.allowedActions : [],
};
}
export function getSystemUpdateStatus() {
return request.get<SystemUpdateView>({ url: '/admin/system-update' }).then(normalize);
}
export function checkSystemUpdate() {
return request
.post<SystemUpdateView>({ url: '/admin/system-update/check', ...commandHeaders('system-update-check') })
.then(normalize);
}
export function downloadSystemUpdate(version: string) {
return request
.post<SystemUpdateView>({
url: '/admin/system-update/download',
data: { version },
...commandHeaders('system-update-download'),
})
.then(normalize);
}
export function installSystemUpdate(version: string, reason?: string) {
return request
.post<SystemUpdateView>({
url: '/admin/system-update/install',
data: { version, reason },
...commandHeaders('system-update-install'),
})
.then(normalize);
}
+113
View File
@@ -0,0 +1,113 @@
import { request } from '@/utils/request';
export interface WorkbenchMetric {
code: string;
label: string;
value: number;
targetRoute: string;
}
export interface WorkbenchTodo {
publicId: string;
formType: string;
formName: string;
businessNo: string;
projectPublicId?: string;
projectName?: string;
nodeCode: string;
status: string;
arrivedAt?: string;
dueAt?: string;
targetRoute: string;
}
export interface WorkbenchProject {
publicId: string;
businessNo: string;
name: string;
companyName: string;
stage: string;
status: string;
contractAmount: string;
updatedAt?: string;
targetRoute: string;
}
export interface WorkbenchIssue {
code: string;
label: string;
severity: string;
count: number;
detail: string;
targetRoute: string;
}
export interface WorkbenchFund {
projectPublicId: string;
projectName: string;
currency: string;
confirmedReceipt: string;
approvedUnpaid: string;
paidAmount: string;
frozenAmount: string;
availableAmount: string;
targetRoute: string;
}
export interface WorkbenchArchiveItem {
kind: string;
publicId: string;
title: string;
subtitle: string;
status: string;
completeness?: string;
missingCount: number;
dueAt?: string;
updatedAt?: string;
targetRoute: string;
}
export interface WorkbenchActivity {
publicId: string;
actionCode: string;
objectType: string;
objectPublicId?: string;
title: string;
resultCode: string;
occurredAt?: string;
targetRoute: string;
}
export interface WorkbenchView {
roleCode: string;
title: string;
refreshedAt: string;
metrics: WorkbenchMetric[];
todos: WorkbenchTodo[];
projects: WorkbenchProject[];
issues: WorkbenchIssue[];
funds: WorkbenchFund[];
archiveItems: WorkbenchArchiveItem[];
activities: WorkbenchActivity[];
}
const array = <T>(value: T[] | null | undefined): T[] => (Array.isArray(value) ? value : []);
export function normalizeWorkbench(view: Partial<WorkbenchView> | null | undefined): WorkbenchView {
return {
roleCode: String(view?.roleCode || ''),
title: String(view?.title || ''),
refreshedAt: String(view?.refreshedAt || ''),
metrics: array(view?.metrics),
todos: array(view?.todos),
projects: array(view?.projects),
issues: array(view?.issues),
funds: array(view?.funds),
archiveItems: array(view?.archiveItems),
activities: array(view?.activities),
};
}
export function getWorkbench(role: 'project' | 'finance' | 'archive') {
return request.get<WorkbenchView>({ url: `/workbenches/${role}` }).then(normalizeWorkbench);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 789 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

+32
View File
@@ -0,0 +1,32 @@
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<g mask="url(#mask0_17_619)">
<path d="M30 62H118V122H30V62Z" fill="#97A3B7" />
<g filter="url(#filter0_f_17_619)">
<rect x="12" y="84" width="80" height="60" fill="#E3E6EB" />
</g>
<g filter="url(#filter1_f_17_619)">
<rect x="80" y="54" width="80" height="60" fill="#E3E6EB" />
</g>
<rect x="46" y="105" width="32" height="2" fill="white" />
<rect x="46" y="98" width="32" height="2" fill="white" />
<rect x="46" y="88" width="16" height="2" fill="white" />
</g>
<path opacity="0.9" d="M63 20H151V30H63V20Z" fill="currentcolor" />
<mask id="mask1_17_619" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="63" y="30" width="88" height="50">
<path d="M63 30H151V80H63V30Z" fill="currentcolor" />
</mask>
<g mask="url(#mask1_17_619)">
<path d="M63 30H151V80H63V30Z" fill="currentcolor" />
<g opacity="0.3" filter="url(#filter2_f_17_619)">
<path d="M30 62H118V122H30V62Z" fill="#97A3B7" />
</g>
</g>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M95.6863 40.8577L105.964 51.1345C106.295 51.0466 106.642 50.9998 107 50.9998C109.213 50.9998 111 52.7865 111 54.9998C111 55.3574 110.953 55.7038 110.866 56.0333L121.142 66.3135L118.314 69.1419L113.716 64.5448C111.653 65.423 109.384 65.9089 107 65.9089C99.7273 65.9089 93.5164 61.3853 91 54.9998C92.1785 52.0093 94.1673 49.4271 96.6961 47.5268L92.8579 43.6861L95.6863 40.8577ZM99 54.9998C99 59.4158 102.584 62.9998 107 62.9998C108.483 62.9998 109.872 62.5957 111.063 61.8917L108.034 58.8657C107.704 58.9532 107.358 58.9998 107 58.9998C104.787 58.9998 103 57.2131 103 54.9998C103 54.6423 103.047 54.2958 103.134 53.9663L100.107 50.9389C99.4037 52.1295 99 53.5178 99 54.9998ZM107 44.0907C114.273 44.0907 120.484 48.6143 123 54.9998C122.071 57.3574 120.638 59.4612 118.834 61.1773L114.729 57.0717C114.906 56.4108 115 55.7162 115 54.9998C115 50.5838 111.416 46.9998 107 46.9998C106.284 46.9998 105.589 47.0941 104.928 47.2711L102.378 44.7205C103.848 44.3101 105.398 44.0907 107 44.0907Z"
fill="white" />
<rect x="68" y="24" width="2" height="2" fill="white" />
<rect x="74" y="24" width="2" height="2" fill="white" />
<rect x="80" y="24" width="66" height="2" fill="white" />
<path d="M157 53.9998L181.249 95.9998H132.751L157 53.9998Z" fill="white" stroke="black" />
<path d="M157 88.9998L157 70.9998" stroke="black" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+36
View File
@@ -0,0 +1,36 @@
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<g mask="url(#mask0_16559_24301)">
<path d="M30 62H118V122H30V62Z" fill="#97A3B7" />
<g filter="url(#filter0_f_16559_24301)">
<rect x="12" y="84" width="80" height="60" fill="#E3E6EB" />
</g>
<g filter="url(#filter1_f_16559_24301)">
<rect x="80" y="54" width="80" height="60" fill="#E3E6EB" />
</g>
<path d="M49 93L42 100L49 107" stroke="white" stroke-width="2" />
<path d="M69 107L76 100L69 93" stroke="white" stroke-width="2" />
<path d="M62.3647 87.4431L55.6355 112.557" stroke="white" stroke-width="2" />
</g>
<path opacity="0.9" d="M63 20H151V30H63V20Z" fill="currentcolor" />
<mask id="mask1_16559_24301" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="63" y="30" width="88" height="50">
<path d="M63 30H151V80H63V30Z" fill="currentcolor" />
</mask>
<g mask="url(#mask1_16559_24301)">
<path d="M63 30H151V80H63V30Z" fill="currentcolor" />
<g opacity="0.3" filter="url(#filter2_f_16559_24301)">
<path d="M30 62H118V122H30V62Z" fill="#97A3B7" />
</g>
</g>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M105.25 41C112.015 41 117.5 46.4845 117.5 53.25C117.5 55.6827 116.791 57.9498 115.568 59.8558L121 65.2877L117.288 69L111.856 63.5681C109.95 64.7909 107.683 65.5 105.25 65.5C98.4845 65.5 93 60.0155 93 53.25C93 46.4845 98.4845 41 105.25 41ZM105.25 44.5C100.418 44.5 96.5 48.4175 96.5 53.25C96.5 58.0825 100.418 62 105.25 62C110.082 62 114 58.0825 114 53.25C114 48.4175 110.082 44.5 105.25 44.5Z"
fill="white" />
<rect x="68" y="24" width="2" height="2" fill="white" />
<rect x="74" y="24" width="2" height="2" fill="white" />
<rect x="80" y="24" width="66" height="2" fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M153 56C140.85 56 131 65.8497 131 78C131 82.6039 132.414 86.8776 134.832 90.4102L127 98.5L139.495 95.3681C143.222 98.2709 147.909 100 153 100C165.15 100 175 90.1503 175 78C175 65.8497 165.15 56 153 56Z"
fill="white" />
<path
d="M131 78L131.5 78V78L131 78ZM134.832 90.4102L135.191 90.758L135.475 90.4647L135.245 90.1278L134.832 90.4102ZM127 98.5L126.641 98.1522L125.422 99.411L127.122 98.985L127 98.5ZM139.495 95.3681L139.802 94.9736L139.61 94.8238L139.373 94.8831L139.495 95.3681ZM153 100L153 100.5L153 100.5L153 100ZM175 78L174.5 78L174.5 78L175 78ZM131.5 78C131.5 66.1259 141.126 56.5 153 56.5V55.5C140.574 55.5 130.5 65.5736 130.5 78L131.5 78ZM135.245 90.1278C132.882 86.6757 131.5 82.5 131.5 78H130.5C130.5 82.7079 131.946 87.0794 134.419 90.6926L135.245 90.1278ZM134.473 90.0624L126.641 98.1522L127.359 98.8478L135.191 90.758L134.473 90.0624ZM127.122 98.985L139.616 95.8531L139.373 94.8831L126.878 98.015L127.122 98.985ZM153 99.5C148.024 99.5 143.445 97.8105 139.802 94.9736L139.187 95.7626C143 98.7314 147.794 100.5 153 100.5V99.5ZM174.5 78C174.5 89.8741 164.874 99.5 153 99.5L153 100.5C165.426 100.5 175.5 90.4264 175.5 78L174.5 78ZM153 56.5C164.874 56.5 174.5 66.1259 174.5 78H175.5C175.5 65.5736 165.426 55.5 153 55.5V56.5Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+52
View File
@@ -0,0 +1,52 @@
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<g mask="url(#mask0_16559_24251)">
<path d="M68 48L106.105 70V114L68 136L29.8949 114V70L68 48Z" fill="#97A3B7" />
<g filter="url(#filter0_f_16559_24251)">
<rect x="46.3911" y="92" width="80" height="60" fill="#E3E6EB" />
</g>
<g filter="url(#filter1_f_16559_24251)">
<rect y="23" width="80" height="60" fill="#E3E6EB" />
</g>
</g>
<mask id="mask1_16559_24251" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="80" y="9" width="78" height="88">
<path d="M119 9L157.105 31V75L119 97L80.8949 75V31L119 9Z" fill="currentcolor" />
</mask>
<g mask="url(#mask1_16559_24251)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M80.895 31V75L119 97L157.105 75V31L119 53L80.895 31Z"
fill="currentcolor" />
<path opacity="0.9" d="M119 -35L157.105 -13L157.105 31.5L119 53.5L80.8952 31.5L80.895 -13L119 -35Z"
fill="currentcolor" />
<g opacity="0.3" filter="url(#filter2_f_16559_24251)">
<path d="M68 48L106.105 70V114L68 136L29.8949 114V70L68 48Z" fill="#97A3B7" />
</g>
</g>
<path
d="M143 68.822L147.867 85.875L148 86.3405L148.469 86.2228L165.671 81.911L153.336 94.6522L152.999 95L153.336 95.3478L165.671 108.089L148.469 103.777L148 103.659L147.867 104.125L143 121.178L138.133 104.125L138 103.659L137.531 103.777L120.329 108.089L132.664 95.3478L133.001 95L132.664 94.6522L120.329 81.911L137.531 86.2228L138 86.3405L138.133 85.875L143 68.822Z"
fill="white" stroke="black" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M123.243 35.0821L126.071 33.4493L123.243 31.8164L120.414 33.4493L123.243 35.0821ZM119 32.6329L121.828 31L114.757 26.9179L111.929 28.5507L119 32.6329ZM127.485 35.8986C122.806 38.6001 115.194 38.6001 110.515 35.8986C105.835 33.197 105.835 28.803 110.515 26.1014C115.194 23.3999 122.806 23.3999 127.485 26.1014C132.165 28.803 132.165 33.197 127.485 35.8986ZM107.686 24.4686C101.438 28.0756 101.438 33.9244 107.686 37.5314C113.934 41.1384 124.066 41.1384 130.314 37.5314C136.562 33.9244 136.562 28.0756 130.314 24.4686C124.066 20.8616 113.934 20.8616 107.686 24.4686Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M41.8989 86.2863L44.7272 87.9193L44.7275 94.4512L41.8992 92.8181L41.8989 86.2863ZM53.9194 93.2269L56.7477 94.86L56.7479 101.392L53.9196 99.7587L53.9194 93.2269ZM44.7281 107.515L41.8999 105.882L41.9 109.148L44.7283 110.781L44.7282 107.515L53.92 112.822L53.9201 116.088L56.7484 117.721L56.7483 114.455L53.9201 112.822L53.92 109.556L44.728 104.249L44.7281 107.515Z"
fill="white" />
<defs>
<filter id="filter0_f_16559_24251" x="-3.60889" y="42" width="180" height="160" filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="25" result="effect1_foregroundBlur_16559_24251" />
</filter>
<filter id="filter1_f_16559_24251" x="-50" y="-27" width="180" height="160" filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="25" result="effect1_foregroundBlur_16559_24251" />
</filter>
<filter id="filter2_f_16559_24251" x="23.895" y="42" width="88.21" height="100" filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="3" result="effect1_foregroundBlur_16559_24251" />
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+33
View File
@@ -0,0 +1,33 @@
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<g mask="url(#mask0_22_990)">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M144.569 105.61L96.5692 133.322L48.5693 105.61V83.7121L96.569 55.9995L144.569 83.7122V105.61Z"
fill="#97A3B7" />
<g filter="url(#filter0_f_22_990)">
<rect x="-3" y="33.9995" width="80" height="60" fill="#E3E6EB" />
</g>
<g filter="url(#filter1_f_22_990)">
<rect x="97" y="97.9995" width="80" height="60" fill="#E3E6EB" />
</g>
</g>
<mask id="mask1_22_990" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="53" y="16" width="86" height="69">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M113.357 42.715L129.829 33.2059C128.859 32.4995 127.789 31.823 126.643 31.1615C121.268 28.0584 114.723 26.0153 107.758 25.023C103.549 19.4606 97.5775 16.1248 90.4344 16.1945C83.6788 16.2821 74.9482 21.9412 68.9271 30.5602C68.096 30.975 67.2847 31.4099 66.4949 31.8659C52.1168 40.1664 49.5542 52.6155 59.0218 61.9309C57.9871 56.1259 58.712 51.0657 62.1231 45.7161C62.0653 46.3482 61.9127 50.143 61.8906 50.7834C61.2209 69.6969 76.9107 84.8409 88.03 84.7113C96.4806 84.6119 103.595 79.6976 108.349 72.0797C114.563 70.8487 120.438 68.786 125.443 65.8968C138.919 58.1167 142.01 46.7146 134.547 37.629L117.948 47.2113C119.71 50.8655 117.997 55.034 112.87 57.9936C107.744 60.9532 100.523 61.9424 94.1928 60.9252C91.3499 60.4563 88.6706 59.5834 86.4524 58.3029L86.4042 58.275L113.357 42.715ZM78.6546 53.7727C72.5285 49.7965 72.9717 43.5469 79.8498 39.5762C86.7276 35.6056 97.5532 35.3496 104.441 38.8864L78.6546 53.7727ZM93.5561 18.1703C98.1657 18.1302 102.284 20.5752 105.496 24.7401C97.0486 23.8219 88.1122 24.4143 79.9732 26.5054C83.672 21.3809 88.444 18.2301 93.5561 18.1703ZM91.3238 81.6169C85.471 81.685 80.3525 77.691 76.9473 71.2848C85.7921 73.6267 95.8719 74.0599 105.374 72.6022C101.618 78.1222 96.6601 81.5531 91.3238 81.6169Z"
fill="currentcolor" />
</mask>
<g mask="url(#mask1_22_990)">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M113.357 42.715L129.829 33.2059C128.859 32.4995 127.789 31.823 126.643 31.1615C121.268 28.0584 114.723 26.0153 107.758 25.023C103.549 19.4606 97.5775 16.1248 90.4344 16.1945C83.6788 16.2821 74.9482 21.9412 68.9271 30.5602C68.096 30.975 67.2847 31.4099 66.4949 31.8659C52.1168 40.1664 49.5542 52.6155 59.0218 61.9309C57.9871 56.1259 58.712 51.0657 62.1231 45.7161C62.0653 46.3482 61.9127 50.143 61.8906 50.7834C61.2209 69.6969 76.9107 84.8409 88.03 84.7113C96.4806 84.6119 103.595 79.6976 108.349 72.0797C114.563 70.8487 120.438 68.786 125.443 65.8968C138.919 58.1167 142.01 46.7146 134.547 37.629L117.948 47.2113C119.71 50.8655 117.997 55.034 112.87 57.9936C107.744 60.9532 100.523 61.9424 94.1928 60.9252C91.3499 60.4563 88.6706 59.5834 86.4524 58.3029L86.4042 58.275L113.357 42.715ZM78.6546 53.7727C72.5285 49.7965 72.9717 43.5469 79.8498 39.5762C86.7276 35.6056 97.5532 35.3496 104.441 38.8864L78.6546 53.7727ZM93.5561 18.1703C98.1657 18.1302 102.284 20.5752 105.496 24.7401C97.0486 23.8219 88.1122 24.4143 79.9732 26.5054C83.672 21.3809 88.444 18.2301 93.5561 18.1703ZM91.3238 81.6169C85.471 81.685 80.3525 77.691 76.9473 71.2848C85.7921 73.6267 95.8719 74.0599 105.374 72.6022C101.618 78.1222 96.6601 81.5531 91.3238 81.6169Z"
fill="currentcolor" />
<g opacity="0.3" filter="url(#filter2_f_22_990)">
<path d="M96.569 55.9995L144.569 83.7122V139.138L96.569 166.85L48.5692 139.138V83.7122L96.569 55.9995Z"
fill="#97A3B7" />
</g>
</g>
<ellipse cx="155" cy="78" rx="22" ry="22" transform="rotate(180 155 78)" fill="white" stroke="black" />
<path d="M155 83L155 65" stroke="black" />
<rect x="155" y="87" width="0.00390625" height="0.00390625" fill="#C4C4C4" stroke="black" stroke-width="2"
stroke-linejoin="round" />
<path d="M96.5693 112L96.5693 87.9995" stroke="white" stroke-width="2" />
<path d="M86.5693 97.9995L96.5693 87.9995L106.569 97.9995" stroke="white" stroke-width="2" />
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,48 @@
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_216_313" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="80" y="9" width="78" height="88">
<path d="M119 9L157.105 31V75L119 97L80.8949 75V31L119 9Z" fill="currentColor"/>
</mask>
<g mask="url(#mask0_216_313)">
<path d="M119 9L157.105 31V75L119 97L80.8949 75V31L119 9Z" fill="currentColor"/>
<g opacity="0.3" filter="url(#filter0_f_216_313)">
<path d="M68 48L106.105 70V114L68 136L29.8949 114V70L68 48Z" fill="#97A3B7"/>
</g>
</g>
<mask id="mask1_216_313" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="29" y="48" width="78" height="88">
<path d="M68 48L106.105 70V114L68 136L29.8949 114V70L68 48Z" fill="#97A3B7"/>
</mask>
<g mask="url(#mask1_216_313)">
<path d="M68 48L106.105 70V114L68 136L29.8949 114V70L68 48Z" fill="#97A3B7"/>
<g filter="url(#filter1_f_216_313)">
<rect x="46.3906" y="92" width="80" height="60" fill="#E3E6EB"/>
</g>
<g filter="url(#filter2_f_216_313)">
<rect y="23" width="80" height="60" fill="#E3E6EB"/>
</g>
</g>
<path d="M41.8984 86.2866L44.7267 87.9197L44.727 94.4515L41.8987 92.8185L41.8984 86.2866Z" fill="white"/>
<path d="M53.9189 93.2273L56.7472 94.8603L56.7474 101.392L53.9191 99.7591L53.9189 93.2273Z" fill="white"/>
<path d="M44.7276 107.515L41.8994 105.882L41.8995 109.148L44.7278 110.781L44.7276 107.515L53.9195 112.823L53.9196 116.088L56.7479 117.721L56.7478 114.455L53.9195 112.823L53.9195 109.557L44.7275 104.249L44.7276 107.515Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.348 23.4792C106.188 25.9904 106.535 29.3829 109.395 31.5604C112.66 34.0461 117.963 34.007 121.24 31.4731C124.516 28.9392 124.526 24.8699 121.261 22.3841C118.401 20.2066 113.977 19.9666 110.721 21.6439L115.923 25.6047L113.55 27.4399L108.348 23.4792Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M120.865 33.0087L129.83 39.8346L132.203 37.9994L123.238 31.1735C122.917 31.521 122.554 31.8531 122.149 32.1657C121.745 32.4784 121.315 32.7594 120.865 33.0087ZM119.662 32.0932C120.123 31.8557 120.561 31.5798 120.967 31.2655C121.373 30.9513 121.73 30.6134 122.035 30.2579L122.035 30.2579C121.73 30.6134 121.373 30.9513 120.967 31.2655C120.561 31.5798 120.123 31.8557 119.662 32.0931L119.662 32.0932Z" fill="white"/>
<path d="M144 70L168.249 112H119.751L144 70Z" fill="white" stroke="#181818"/>
<path d="M144 100L144 82" stroke="#181818"/>
<path d="M144 105H144.004V105.004H144V105Z" stroke="#181818" stroke-width="2" stroke-linejoin="round"/>
<defs>
<filter id="filter0_f_216_313" x="23.8949" y="42" width="88.2102" height="100" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="3" result="effect1_foregroundBlur_216_313"/>
</filter>
<filter id="filter1_f_216_313" x="-3.60938" y="42" width="180" height="160" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="25" result="effect1_foregroundBlur_216_313"/>
</filter>
<filter id="filter2_f_216_313" x="-50" y="-27" width="180" height="160" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="25" result="effect1_foregroundBlur_216_313"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,23 @@
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<g mask="url(#mask0_21_716)">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M33 46.843L96.3214 119L159.643 46.843C142.742 31.9998 120.583 23 96.3214 23C72.0601 23 49.9009 31.9998 33 46.843Z"
fill="#97A3B7" />
<g filter="url(#filter0_f_21_716)">
<rect x="95" y="21" width="80" height="60" fill="#E3E6EB" />
</g>
<g filter="url(#filter1_f_21_716)">
<rect x="-7" y="43" width="80" height="60" fill="#E3E6EB" />
</g>
</g>
<path
d="M72.8122 63.6882L69.6548 66.8455L75.9009 73.0916C71.2469 75.1648 66.9663 77.925 63.188 81.2433L96.3213 119L108.234 105.425L114.647 111.837L117.804 108.68L80.4504 71.3261C80.4505 71.3261 80.4503 71.3261 80.4504 71.3261L72.8122 63.6882Z"
fill="currentcolor" />
<path
d="M129.455 81.2433L114.137 98.6982L85.3974 69.9585C88.9142 69.1786 92.5697 68.7674 96.3213 68.7674C109.016 68.7674 120.611 73.4766 129.455 81.2433Z"
fill="currentcolor" />
<path
d="M152 21.822L156.867 38.875L157 39.3405L157.469 39.2228L174.671 34.911L162.336 47.6522L161.999 48L162.336 48.3478L174.671 61.089L157.469 56.7772L157 56.6595L156.867 57.125L152 74.178L147.133 57.125L147 56.6595L146.531 56.7772L129.329 61.089L141.664 48.3478L142.001 48L141.664 47.6522L129.329 34.911L146.531 39.2228L147 39.3405L147.133 38.875L152 21.822Z"
fill="white" stroke="black" />
<path d="M101 31L90 42L101 53L93 61" stroke="white" stroke-width="2" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,28 @@
<template>
<t-dropdown trigger="click">
<t-button theme="default" shape="square" variant="text">
<translate-icon />
</t-button>
<t-dropdown-menu>
<t-dropdown-item
v-for="(lang, index) in languageList"
:key="index"
:value="lang.value"
@click="(options) => changeLang(options.value as string)"
>{{ lang.content }}</t-dropdown-item
></t-dropdown-menu
>
</t-dropdown>
</template>
<script setup lang="ts">
import { TranslateIcon } from 'tdesign-icons-vue-next';
import { languageList } from '@/locales';
import { useLocale } from '@/locales/useLocale';
const { changeLocale } = useLocale();
const changeLang = (lang: string) => {
changeLocale(lang);
};
</script>
@@ -0,0 +1,74 @@
<template>
<t-input
:model-value="model"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:clearable="clearable"
:input-props="{ inputmode: 'decimal' }"
align="right"
@update:model-value="handleInput"
@blur="normalize"
>
<template v-if="currency" #suffix>{{ currency }}</template>
</t-input>
</template>
<script setup lang="ts">
import Decimal from 'decimal.js';
const props = withDefaults(
defineProps<{
placeholder?: string;
currency?: string;
scale?: number;
maxIntegerDigits?: number;
min?: string;
max?: string;
disabled?: boolean;
readonly?: boolean;
clearable?: boolean;
allowNegative?: boolean;
}>(),
{
placeholder: '请输入金额',
currency: '',
scale: 2,
maxIntegerDigits: 18,
min: undefined,
max: undefined,
disabled: false,
readonly: false,
clearable: false,
allowNegative: false,
},
);
const model = defineModel<string>({ default: '' });
function handleInput(rawValue: string | number) {
const source = String(rawValue ?? '');
const negative = props.allowNegative && source.trim().startsWith('-');
const raw = source.replace(/[^\d.]/g, '');
const [integerPart = '', ...decimalParts] = raw.split('.');
const integer = integerPart.replace(/^0+(?=\d)/, '').slice(0, props.maxIntegerDigits);
const fraction = decimalParts.join('').slice(0, props.scale);
const hasDecimalPoint = raw.includes('.') && props.scale > 0;
const value = `${negative ? '-' : ''}${integer || (hasDecimalPoint ? '0' : '')}${hasDecimalPoint ? `.${fraction}` : ''}`;
model.value = value;
}
function normalize() {
if (!model.value || !isWithinRange(model.value)) return;
model.value = new Decimal(model.value).toFixed(props.scale);
}
function isWithinRange(value: string) {
try {
const decimal = new Decimal(value);
if (props.min !== undefined && decimal.lessThan(props.min)) return false;
if (props.max !== undefined && decimal.greaterThan(props.max)) return false;
return true;
} catch {
return false;
}
}
</script>
@@ -0,0 +1,255 @@
<template>
<t-dialog
v-model:visible="dialogVisible"
:header="isEdit ? '编辑项目' : '新建项目'"
:width="760"
:footer="false"
:close-on-overlay-click="false"
>
<template #body>
<t-alert v-if="errorMessage" class="form-alert" theme="error" :close-btn="false">
{{ errorMessage }}
</t-alert>
<t-form :data="formData" :rules="rules" :label-width="96" @submit="submit">
<t-row :gutter="[20, 0]">
<t-col :xs="12" :md="6">
<t-form-item label="项目编号" name="businessNo">
<t-input
v-model="formData.businessNo"
:disabled="isEdit"
maxlength="64"
placeholder="例如:XM-2026-001"
/>
</t-form-item>
</t-col>
<t-col :xs="12" :md="6">
<t-form-item label="项目名称" name="name">
<t-input v-model="formData.name" maxlength="200" placeholder="请输入项目名称" />
</t-form-item>
</t-col>
<t-col :xs="12" :md="6">
<t-form-item label="所属公司" name="companyId">
<t-select
v-model="formData.companyId"
:disabled="isEdit"
:options="companyOptions"
filterable
placeholder="选择已生效公司"
/>
</t-form-item>
</t-col>
<t-col :xs="12" :md="6">
<t-form-item label="客户" name="customerId">
<t-select
v-model="formData.customerId"
:options="customerOptions"
clearable
filterable
placeholder="可选"
/>
</t-form-item>
</t-col>
<t-col :xs="12" :md="6">
<t-form-item label="负责人" name="ownerId">
<t-select v-model="formData.ownerId" :options="ownerOptions" filterable placeholder="选择项目负责人" />
</t-form-item>
</t-col>
<t-col :xs="12" :md="6">
<t-form-item label="币种" name="currency">
<t-select v-model="formData.currency" :disabled="isEdit" :options="currencyOptions" />
</t-form-item>
</t-col>
<t-col :xs="12" :md="6">
<t-form-item label="开始日期" name="startDate">
<t-date-picker v-model="formData.startDate" clearable value-type="YYYY-MM-DD" />
</t-form-item>
</t-col>
<t-col :xs="12" :md="6">
<t-form-item label="结束日期" name="endDate">
<t-date-picker v-model="formData.endDate" clearable value-type="YYYY-MM-DD" />
</t-form-item>
</t-col>
<t-col :span="12">
<t-form-item label="项目说明" name="description">
<t-textarea
v-model="formData.description"
:autosize="{ minRows: 3, maxRows: 6 }"
maxlength="1000"
placeholder="填写项目范围、关键约定或内部说明"
/>
</t-form-item>
</t-col>
</t-row>
<div class="dialog-actions">
<t-button theme="default" variant="outline" type="button" @click="dialogVisible = false">取消</t-button>
<t-button theme="primary" type="submit" :loading="saving">
<template #icon><t-icon name="save" /></template>
{{ isEdit ? '保存修改' : '创建项目' }}
</t-button>
</div>
</t-form>
</template>
</t-dialog>
</template>
<script setup lang="ts">
import type { FormRules, SubmitContext } from 'tdesign-vue-next';
import { MessagePlugin } from 'tdesign-vue-next';
import { computed, reactive, ref, watch } from 'vue';
import type { ProjectCreatePayload, ProjectDetail, ProjectReferenceOptions, ProjectUpdatePayload } from '@/api/project';
import { createProject, updateProject } from '@/api/project';
interface ProjectFormData {
businessNo: string;
name: string;
description: string;
companyId: string;
customerId: string;
ownerId: string;
startDate: string;
endDate: string;
currency: string;
}
const props = defineProps<{
visible: boolean;
project?: ProjectDetail | null;
references: ProjectReferenceOptions;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
saved: [project: ProjectDetail];
}>();
const emptyForm = (): ProjectFormData => ({
businessNo: '',
name: '',
description: '',
companyId: '',
customerId: '',
ownerId: '',
startDate: '',
endDate: '',
currency: 'CNY',
});
const dialogVisible = ref(false);
const saving = ref(false);
const errorMessage = ref('');
const formData = reactive<ProjectFormData>(emptyForm());
const isEdit = computed(() => Boolean(props.project));
const asOptions = (items: ProjectReferenceOptions['companies']) =>
items.map((item) => ({ label: item.name, value: item.publicId }));
const companyOptions = computed(() => asOptions(props.references.companies));
const customerOptions = computed(() => asOptions(props.references.customers));
const ownerOptions = computed(() => asOptions(props.references.owners));
const currencyOptions = [
{ label: '人民币 CNY', value: 'CNY' },
{ label: '美元 USD', value: 'USD' },
{ label: '港币 HKD', value: 'HKD' },
];
const rules: FormRules<ProjectFormData> = {
businessNo: [{ required: true, message: '请输入项目编号', type: 'error' }],
name: [{ required: true, message: '请输入项目名称', type: 'error' }],
companyId: [{ required: true, message: '请选择所属公司', type: 'error' }],
ownerId: [{ required: true, message: '请选择项目负责人', type: 'error' }],
currency: [{ required: true, message: '请选择币种', type: 'error' }],
};
function fillForm() {
const project = props.project;
Object.assign(
formData,
project
? {
businessNo: project.businessNo,
name: project.name,
description: project.description || '',
companyId: project.company.publicId,
customerId: project.customer?.publicId || '',
ownerId: project.owner.publicId,
startDate: project.startDate || '',
endDate: project.endDate || '',
currency: project.currency,
}
: emptyForm(),
);
errorMessage.value = '';
}
async function submit({ validateResult, firstError }: SubmitContext) {
if (firstError || validateResult !== true) {
MessagePlugin.warning(firstError || '请检查必填内容');
return;
}
saving.value = true;
errorMessage.value = '';
try {
let saved: ProjectDetail;
if (props.project) {
const payload: ProjectUpdatePayload = {
name: formData.name.trim(),
description: formData.description.trim() || undefined,
customerId: formData.customerId || undefined,
ownerId: formData.ownerId,
startDate: formData.startDate || undefined,
endDate: formData.endDate || undefined,
version: props.project.version,
};
saved = await updateProject(props.project.publicId, payload);
} else {
const payload: ProjectCreatePayload = {
businessNo: formData.businessNo.trim(),
name: formData.name.trim(),
description: formData.description.trim() || undefined,
companyId: formData.companyId,
customerId: formData.customerId || undefined,
ownerId: formData.ownerId,
startDate: formData.startDate || undefined,
endDate: formData.endDate || undefined,
currency: formData.currency,
};
saved = await createProject(payload);
}
MessagePlugin.success(props.project ? '项目已更新' : '项目已创建');
emit('saved', saved);
dialogVisible.value = false;
} catch (error) {
errorMessage.value = (error as Error).message || '项目保存失败';
} finally {
saving.value = false;
}
}
watch(
() => props.visible,
(visible) => {
dialogVisible.value = visible;
if (visible) fillForm();
},
{ immediate: true },
);
watch(dialogVisible, (visible) => emit('update:visible', visible));
watch(
() => props.project,
() => dialogVisible.value && fillForm(),
);
</script>
<style scoped>
.form-alert {
margin-bottom: 20px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 8px;
}
:deep(.t-date-picker) {
width: 100%;
}
</style>
@@ -0,0 +1,138 @@
<template>
<section class="subresource-panel" :aria-label="title">
<div class="subresource-header">
<div>
<h2>{{ title }}</h2>
<p>{{ description }}</p>
</div>
<t-space align="center">
<t-tag theme="primary" variant="light">{{ total }} 条</t-tag>
<t-button v-if="route" variant="outline" @click="$emit('open')">
查看完整列表
<template #suffix><t-icon name="chevron-right" /></template>
</t-button>
<t-button v-if="secondaryRoute" variant="outline" @click="$emit('open-secondary')">
{{ secondaryLabel || '查看其他列表' }}
<template #suffix><t-icon name="chevron-right" /></template>
</t-button>
</t-space>
</div>
<t-alert v-if="error" theme="error" :close-btn="false" class="subresource-error">
<div class="error-content">
<span>{{ error }}</span>
<t-link theme="primary" @click="$emit('reload')">重新加载</t-link>
</div>
</t-alert>
<div v-if="loading" class="subresource-loading">
<t-loading size="small" text="正在加载项目明细…" />
</div>
<t-table
v-else-if="rows.length"
:columns="columns"
:data="rows"
row-key="publicId"
table-layout="auto"
:max-height="360"
stripe
hover
>
<template #businessNo="{ row }">
<t-link
theme="primary"
role="link"
tabindex="0"
:aria-label="`查看${String(row.businessNo || '业务记录')}`"
@click="$emit('open-row', row)"
@keydown.enter="$emit('open-row', row)"
>
{{ row.businessNo || '-' }}
</t-link>
</template>
</t-table>
<t-empty v-else-if="!error" description="该项目暂无相关记录" />
<div v-if="!loading && !error && rows.length" class="subresource-footnote">
当前显示项目范围内最近 {{ rows.length }} 条记录,完整列表支持分页、筛选和导出。
</div>
</section>
</template>
<script setup lang="ts">
import type { PrimaryTableCol } from 'tdesign-vue-next';
defineProps<{
title: string;
description: string;
columns: PrimaryTableCol[];
rows: Array<Record<string, unknown>>;
total: number;
loading: boolean;
error?: string;
route?: string;
secondaryRoute?: string;
secondaryLabel?: string;
}>();
defineEmits<{
open: [];
'open-secondary': [];
'open-row': [row: Record<string, unknown>];
reload: [];
}>();
</script>
<style scoped>
.subresource-panel {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 20px;
}
.subresource-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.subresource-header h2 {
margin: 0;
font-size: 16px;
line-height: 24px;
}
.subresource-header p {
margin: 4px 0 0;
color: var(--td-text-color-secondary);
}
.subresource-error {
margin-bottom: 0;
}
.error-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.subresource-loading {
display: flex;
min-height: 180px;
align-items: center;
justify-content: center;
}
.subresource-footnote {
color: var(--td-text-color-secondary);
font-size: 12px;
}
@media (width <= 800px) {
.subresource-header {
flex-direction: column;
}
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<template>
<div :style="style" class="color-container" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { DEFAULT_COLOR_OPTIONS } from '@/config/color';
const { value } = defineProps({
value: {
type: String,
default: '',
},
});
const panelColor =
'conic-gradient(from 90deg at 50% 50%, #FF0000 -19.41deg, #FF0000 18.76deg, #FF8A00 59.32deg, #FFE600 99.87deg, #14FF00 141.65deg, #00A3FF 177.72deg, #0500FF 220.23deg, #AD00FF 260.13deg, #FF00C7 300.69deg, #FF0000 340.59deg, #FF0000 378.76deg)';
const style = computed(() => {
return {
background: DEFAULT_COLOR_OPTIONS.includes(value) ? value : panelColor,
};
});
</script>
<style lang="less" scoped>
.color-container {
width: 24px;
height: 24px;
border-radius: var(--td-radius-circle);
display: inline-block;
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<template>
<div class="result-container">
<div class="result-bg-img">
<component :is="dynamicComponent"></component>
</div>
<div class="result-title">{{ title }}</div>
<div class="result-tip">{{ tip }}</div>
<slot />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import Result403Icon from '@/assets/assets-result-403.svg?component';
import Result404Icon from '@/assets/assets-result-404.svg?component';
import Result500Icon from '@/assets/assets-result-500.svg?component';
import ResultIeIcon from '@/assets/assets-result-ie.svg?component';
import ResultMaintenanceIcon from '@/assets/assets-result-maintenance.svg?component';
import ResultWifiIcon from '@/assets/assets-result-wifi.svg?component';
const { type } = defineProps({
bgUrl: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
tip: {
type: String,
default: '',
},
type: {
type: String,
default: '',
},
});
const dynamicComponent = computed(() => {
switch (type) {
case '403':
return Result403Icon;
case '404':
return Result404Icon;
case '500':
return Result500Icon;
case 'ie':
return ResultIeIcon;
case 'wifi':
return ResultWifiIcon;
case 'maintenance':
return ResultMaintenanceIcon;
default:
return Result403Icon;
}
});
</script>
<style lang="less" scoped>
.result {
&-link {
color: var(--td-brand-color);
text-decoration: none;
cursor: pointer;
&:hover {
color: var(--td-brand-color);
}
&:active {
color: var(--td-brand-color);
}
&--active {
color: var(--td-brand-color);
}
&:focus {
text-decoration: none;
}
}
&-container {
min-height: 400px;
height: 75vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
&-bg-img {
width: 200px;
color: var(--td-brand-color);
}
&-title {
font: var(--td-font-title-large);
font-style: normal;
margin-top: var(--td-comp-margin-l);
color: var(--td-text-color-primary);
}
&-tip {
margin: var(--td-comp-margin-s) 0 var(--td-comp-margin-xxxl);
font: var(--td-font-body-medium);
color: var(--td-text-color-secondary);
}
}
</style>
@@ -0,0 +1,45 @@
<template>
<img :class="className" :src="url" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
const { type } = defineProps({
url: {
type: String,
default: '',
},
type: {
type: String,
default: 'layout',
},
});
const className = computed(() => {
return [
'thumbnail-container',
{
'thumbnail-circle': type === 'circle',
'thumbnail-layout': type === 'layout',
},
];
});
</script>
<style lang="less" scoped>
@import '@/style/index.less';
.thumbnail {
&-container {
display: inline-block;
}
&-circle {
border-radius: var(--td-radius-circle);
}
&-layout {
width: 88px;
height: 48px;
}
}
</style>
@@ -0,0 +1,30 @@
import { onBeforeUnmount, onMounted } from 'vue';
const jumperSelector = '.t-pagination__jump input';
function labelPaginationJumpers(root: ParentNode) {
root.querySelectorAll<HTMLInputElement>(jumperSelector).forEach((input) => {
if (!input.getAttribute('aria-label')) input.setAttribute('aria-label', '跳转页码');
});
}
export function useTDesignAccessibility() {
let observer: MutationObserver | undefined;
onMounted(() => {
labelPaginationJumpers(document);
observer = new MutationObserver((records) => {
records.forEach((record) => {
record.addedNodes.forEach((node) => {
if (node instanceof Element) {
if (node.matches(jumperSelector)) node.setAttribute('aria-label', '跳转页码');
labelPaginationJumpers(node);
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
});
onBeforeUnmount(() => observer?.disconnect());
}
+30
View File
@@ -0,0 +1,30 @@
export type TColorToken = Record<string, string>;
export type TColorSeries = Record<string, TColorToken>;
// TODO: 中性色暂时固定 待tvision-color生成带色彩倾向的中性色
export const LIGHT_CHART_COLORS = {
textColor: 'rgba(0, 0, 0, 0.9)',
placeholderColor: 'rgba(0, 0, 0, 0.35)',
borderColor: '#dcdcdc',
containerColor: '#fff',
};
export const DARK_CHART_COLORS = {
textColor: 'rgba(255, 255, 255, 0.9)',
placeholderColor: 'rgba(255, 255, 255, 0.35)',
borderColor: '#5e5e5e',
containerColor: '#242424',
};
export type TChartColor = typeof LIGHT_CHART_COLORS;
export const DEFAULT_COLOR_OPTIONS = [
'#0052D9',
'#0594FA',
'#00A870',
'#EBB105',
'#ED7B2F',
'#E34D59',
'#ED49B4',
'#834EC2',
];
+1
View File
@@ -0,0 +1 @@
export const prefix = 'tdesign-starter';
+16
View File
@@ -0,0 +1,16 @@
export default {
showFooter: false,
isSidebarCompact: false,
showBreadcrumb: true,
menuAutoCollapsed: false,
mode: 'light',
layout: 'side',
splitMenu: false,
sideMode: 'light',
isFooterAside: false,
isSidebarFixed: true,
isHeaderFixed: true,
isUseTabsRouter: true,
showHeader: true,
brandTheme: '#0052D9',
};
+37
View File
@@ -0,0 +1,37 @@
// 合同状态枚举
export const CONTRACT_STATUS = {
FAIL: 0,
AUDIT_PENDING: 1,
EXEC_PENDING: 2,
EXECUTING: 3,
FINISH: 4,
};
// 合同类型枚举
export const CONTRACT_TYPES = {
MAIN: 0,
SUB: 1,
SUPPLEMENT: 2,
};
// 合同收付类型枚举
export const CONTRACT_PAYMENT_TYPES = {
PAYMENT: 0,
RECEIPT: 1,
};
// 标签类型
type TagTheme = 'default' | 'success' | 'primary' | 'warning' | 'danger';
// 通知的优先级对应的标签类型
export const NOTIFICATION_TYPES: Map<string, TagTheme> = new Map([
['low', 'primary'],
['middle', 'warning'],
['high', 'danger'],
]);
// 通用请求头
export enum ContentTypeEnum {
Json = 'application/json;charset=UTF-8',
FormURLEncoded = 'application/x-www-form-urlencoded;charset=UTF-8',
FormData = 'multipart/form-data;charset=UTF-8',
}
+61
View File
@@ -0,0 +1,61 @@
import * as echarts from 'echarts/core';
import type { Ref, ShallowRef } from 'vue';
import { onMounted, onUnmounted, ref, shallowRef } from 'vue';
/**
* eChart hook
* @param domId
*/
export const useChart = (domId: string): ShallowRef<echarts.ECharts> => {
let chartContainer: HTMLCanvasElement;
const selfChart = shallowRef<echarts.ECharts | any>();
const updateContainer = () => {
selfChart.value.resize({
width: chartContainer.clientWidth,
height: chartContainer.clientHeight,
});
};
onMounted(() => {
if (!chartContainer) {
chartContainer = document.getElementById(domId) as HTMLCanvasElement;
}
selfChart.value = echarts.init(chartContainer);
});
window.addEventListener('resize', updateContainer, false);
onUnmounted(() => {
window.removeEventListener('resize', updateContainer);
});
return selfChart;
};
/**
* counter utils
* @param duration
* @returns counter
*/
export const useCounter = (duration = 60): [Ref<number>, () => void] => {
let intervalTimer: ReturnType<typeof setInterval>;
onUnmounted(() => {
clearInterval(intervalTimer);
});
const countDown = ref(0);
return [
countDown,
() => {
countDown.value = duration;
intervalTimer = setInterval(() => {
if (countDown.value > 0) {
countDown.value -= 1;
} else {
clearInterval(intervalTimer);
countDown.value = 0;
}
}, 1000);
},
];
};
+12
View File
@@ -0,0 +1,12 @@
<template>
<div class="tdesign-wrapper">
<router-view />
</div>
</template>
<style lang="less" scoped>
.tdesign-wrapper {
height: 100vh;
display: flex;
flex-direction: column;
}
</style>
@@ -0,0 +1,34 @@
<template>
<t-breadcrumb max-item-width="180" class="tdesign-breadcrumb" aria-label="面包屑导航">
<t-breadcrumbItem v-for="item in crumbs" :key="`${item.title}-${item.to || 'current'}`" :to="item.to">
{{ item.title }}
</t-breadcrumbItem>
</t-breadcrumb>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const crumbs = computed(() => {
const labels = Array.isArray(route.meta.breadcrumb)
? route.meta.breadcrumb.filter((label): label is string => typeof label === 'string' && label.length > 0)
: [];
const segments = route.path.split('/').filter(Boolean);
return labels.map((title, index) => {
const current = index === labels.length - 1;
const segmentCount = Math.min(index + 1, segments.length);
return {
title,
to: current || segmentCount === 0 ? undefined : `/${segments.slice(0, segmentCount).join('/')}`,
};
});
});
</script>
<style scoped>
.tdesign-breadcrumb {
margin-bottom: 24px;
}
</style>
@@ -0,0 +1,62 @@
<template>
<div v-if="!isRefreshing">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<keep-alive :include="aliveViews">
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
</div>
<t-loading v-else />
</template>
<script setup lang="ts">
import isBoolean from 'lodash/isBoolean';
import isUndefined from 'lodash/isUndefined';
import type { ComputedRef } from 'vue';
import { computed } from 'vue';
import { useTabsRouterStore } from '@/store';
// <suspense>标签属于实验性功能,请谨慎使用
// 如果存在需解决/page/1=> /page/2 刷新数据问题 请修改代码 使用activeRouteFullPath 作为key
// <suspense>
// <component :is="Component" :key="activeRouteFullPath" />
// </suspense>
// import { useRouter } from 'vue-router';
// const activeRouteFullPath = computed(() => {
// const router = useRouter();
// return router.currentRoute.value.fullPath;
// });
const aliveViews = computed(() => {
const tabsRouterStore = useTabsRouterStore();
const { tabRouters } = tabsRouterStore;
return tabRouters
.filter((route) => {
const keepAliveConfig = route.meta?.keepAlive;
const isRouteKeepAlive = isUndefined(keepAliveConfig) || (isBoolean(keepAliveConfig) && keepAliveConfig); // 默认开启keepalive
return route.isAlive && isRouteKeepAlive;
})
.map((route) => route.name);
}) as ComputedRef<string[]>;
const isRefreshing = computed(() => {
const tabsRouterStore = useTabsRouterStore();
const { refreshing } = tabsRouterStore;
return refreshing;
});
</script>
<style lang="less" scoped>
.fade-leave-active,
.fade-enter-active {
transition: opacity @anim-duration-slow @anim-time-fn-easing;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,13 @@
<template>
<div :class="`${prefix}-footer`">财务系统</div>
</template>
<script setup lang="ts">
import { prefix } from '@/config/global';
</script>
<style lang="less" scoped>
.@{starter-prefix}-footer {
color: var(--td-text-color-placeholder);
line-height: 20px;
text-align: center;
}
</style>
@@ -0,0 +1,10 @@
<template>
<div></div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'FrameBlank',
});
</script>
+289
View File
@@ -0,0 +1,289 @@
<template>
<div :class="layoutCls">
<t-head-menu :class="menuCls" :theme="menuTheme" expand-type="popup" :value="active">
<template #logo>
<span v-if="showLogo" class="header-logo-container" @click="handleNav('/role-select')">
<span class="header-brand">财务系统</span>
</span>
<div v-else class="header-operate-left">
<span class="current-role">{{ user.roleName || '尚未选择身份' }}</span>
</div>
</template>
<template v-if="layout !== 'side'" #default>
<menu-content class="header-menu" :nav-data="menu" />
</template>
<template #operations>
<div class="operations-container">
<t-dropdown :min-column-width="120" trigger="click">
<template #dropdown>
<t-dropdown-item class="operations-dropdown-container-item" @click="handleNav('/profile')">
<user-circle-icon />{{ t('layout.header.user') }}
</t-dropdown-item>
<t-dropdown-item class="operations-dropdown-container-item" @click="handleLogout">
<poweroff-icon />{{ t('layout.header.signOut') }}
</t-dropdown-item>
</template>
<t-button class="header-user-btn" theme="default" variant="text">
<template #icon>
<t-icon class="header-user-avatar" name="user-circle" />
</template>
<div class="header-user-account">{{ user.displayName }}</div>
<template #suffix><chevron-down-icon /></template>
</t-button>
</t-dropdown>
</div>
</template>
</t-head-menu>
</div>
</template>
<script setup lang="ts">
import { ChevronDownIcon, PoweroffIcon, UserCircleIcon } from 'tdesign-icons-vue-next';
import type { PropType } from 'vue';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { prefix } from '@/config/global';
import { t } from '@/locales';
import { getActive } from '@/router';
import { usePermissionStore, useUserStore } from '@/store';
import type { MenuRoute, ModeType } from '@/types/interface';
import MenuContent from './MenuContent.vue';
const { theme, layout, showLogo, menu, isFixed, isCompact } = defineProps({
theme: {
type: String,
default: 'light',
},
layout: {
type: String,
default: 'top',
},
showLogo: {
type: Boolean,
default: true,
},
menu: {
type: Array as PropType<MenuRoute[]>,
default: () => [],
},
isFixed: {
type: Boolean,
default: false,
},
isCompact: {
type: Boolean,
default: false,
},
maxLevel: {
type: Number,
default: 3,
},
});
const router = useRouter();
const user = useUserStore();
const permissionStore = usePermissionStore();
const active = computed(() => getActive());
const layoutCls = computed(() => [`${prefix}-header-layout`]);
const menuCls = computed(() => {
return [
{
[`${prefix}-header-menu`]: !isFixed,
[`${prefix}-header-menu-fixed`]: isFixed,
[`${prefix}-header-menu-fixed-side`]: layout === 'side' && isFixed,
[`${prefix}-header-menu-fixed-side-compact`]: layout === 'side' && isFixed && isCompact,
},
];
});
const menuTheme = computed(() => theme as ModeType);
const handleNav = (url: string) => {
router.push(url);
};
const handleLogout = async () => {
await user.logout();
permissionStore.restoreRoutes();
await router.replace('/login');
};
</script>
<style lang="less" scoped>
.@{starter-prefix}-header {
&-menu-fixed {
position: fixed;
top: 0;
z-index: 1001;
:deep(.t-head-menu__inner) {
padding-right: var(--td-comp-margin-xl);
}
&-side {
left: 232px;
right: 0;
z-index: 10;
width: auto;
transition: all 0.3s;
&-compact {
left: 64px;
}
}
}
&-logo-container {
cursor: pointer;
display: inline-flex;
}
}
.header-menu {
flex: 1 1 auto;
display: inline-flex;
:deep(.t-menu__item) {
min-width: unset;
}
}
.operations-container {
min-width: 0;
display: flex;
align-items: center;
.t-popup__reference {
display: flex;
align-items: center;
justify-content: center;
}
.t-button {
margin-left: var(--td-comp-margin-l);
}
}
.header-operate-left {
display: flex;
align-items: normal;
line-height: 0;
}
.current-role {
color: var(--td-text-color-secondary);
font-size: 13px;
line-height: 24px;
}
.header-logo-container {
width: 184px;
height: 26px;
display: flex;
margin-left: 24px;
color: var(--td-text-color-primary);
.t-logo {
width: 100%;
height: 100%;
&:hover {
cursor: pointer;
}
}
&:hover {
cursor: pointer;
}
}
.header-user-account {
min-width: 0;
max-width: 160px;
display: inline-flex;
align-items: center;
overflow: hidden;
color: var(--td-text-color-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.header-brand {
color: var(--td-brand-color);
font-size: 16px;
font-weight: 600;
}
@media (width <= 640px) {
.operations-container {
max-width: calc(100vw - 96px);
.t-button {
min-width: 0;
margin-left: var(--td-comp-margin-s);
}
}
.header-user-account {
max-width: 104px;
}
}
:deep(.t-head-menu__inner) {
border-bottom: 1px solid var(--td-component-stroke);
}
.t-menu--light {
.header-user-account {
color: var(--td-text-color-primary);
}
}
.t-menu--dark {
.t-head-menu__inner {
border-bottom: 1px solid var(--td-gray-color-10);
}
.header-user-account {
color: rgb(255 255 255 / 55%);
}
}
.operations-dropdown-container-item {
width: 100%;
display: flex;
align-items: center;
:deep(.t-dropdown__item-text) {
display: flex;
align-items: center;
}
.t-icon {
font-size: var(--td-comp-size-xxxs);
margin-right: var(--td-comp-margin-s);
}
:deep(.t-dropdown__item) {
width: 100%;
margin-bottom: 0;
}
&:last-child {
:deep(.t-dropdown__item) {
margin-bottom: 8px;
}
}
}
</style>
<!-- eslint-disable-next-line vue-scoped-css/enforce-style-type -->
<style lang="less">
.operations-dropdown-container-item {
.t-dropdown__item-text {
display: flex;
align-items: center;
}
}
</style>
@@ -0,0 +1,173 @@
<template>
<t-layout :class="`${prefix}-layout`">
<t-tabs
v-if="settingStore.isUseTabsRouter"
drag-sort
theme="card"
:class="`${prefix}-layout-tabs-nav`"
:value="route.path"
:style="{ position: 'sticky', top: 0, width: '100%' }"
@change="(value) => handleChangeCurrentTab(value as string)"
@remove="handleRemove"
@drag-sort="handleDragend"
>
<t-tab-panel
v-for="(routeItem, index) in tabRouters"
:key="`${routeItem.path}_${index}`"
:value="routeItem.path"
:removable="!routeItem.isHome"
:draggable="!routeItem.isHome"
>
<template #label>
<t-dropdown
trigger="context-menu"
:min-column-width="128"
:popup-props="{
overlayClassName: 'route-tabs-dropdown',
onVisibleChange: (visible: boolean, ctx: PopupVisibleChangeContext) =>
handleTabMenuClick(visible, ctx, routeItem.path),
visible: activeTabPath === routeItem.path,
}"
>
<template v-if="!routeItem.isHome">
{{ renderTitle(routeItem.title) }}
</template>
<t-icon v-else name="home" />
<template #dropdown>
<t-dropdown-menu>
<t-dropdown-item @click="() => handleRefresh(routeItem, index)">
<t-icon name="refresh" />
{{ t('layout.tagTabs.refresh') }}
</t-dropdown-item>
<t-dropdown-item v-if="index > 1" @click="() => handleCloseAhead(routeItem.path, index)">
<t-icon name="arrow-left" />
{{ t('layout.tagTabs.closeLeft') }}
</t-dropdown-item>
<t-dropdown-item
v-if="index < tabRouters.length - 1"
@click="() => handleCloseBehind(routeItem.path, index)"
>
<t-icon name="arrow-right" />
{{ t('layout.tagTabs.closeRight') }}
</t-dropdown-item>
<t-dropdown-item v-if="tabRouters.length > 2" @click="() => handleCloseOther(routeItem.path, index)">
<t-icon name="close-circle" />
{{ t('layout.tagTabs.closeOther') }}
</t-dropdown-item>
</t-dropdown-menu>
</template>
</t-dropdown>
</template>
</t-tab-panel>
</t-tabs>
<t-content :class="`${prefix}-content-layout`">
<l-breadcrumb v-if="settingStore.showBreadcrumb" />
<l-content />
</t-content>
<t-footer v-if="settingStore.showFooter" :class="`${prefix}-footer-layout`">
<l-footer />
</t-footer>
</t-layout>
</template>
<script setup lang="ts">
import type { PopupVisibleChangeContext } from 'tdesign-vue-next';
import { computed, nextTick, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { prefix } from '@/config/global';
import type { LocalizedTitle } from '@/locales';
import { t } from '@/locales';
import { useLocale } from '@/locales/useLocale';
import { useSettingStore, useTabsRouterStore } from '@/store';
import type { TRouterInfo, TTabRemoveOptions } from '@/types/interface';
import LBreadcrumb from './Breadcrumb.vue';
import LContent from './Content.vue';
import LFooter from './Footer.vue';
const route = useRoute();
const router = useRouter();
const settingStore = useSettingStore();
const tabsRouterStore = useTabsRouterStore();
const tabRouters = computed(() => tabsRouterStore.tabRouters.filter((route) => route.isAlive || route.isHome));
const activeTabPath = ref<string | null>('');
const { locale } = useLocale();
const handleChangeCurrentTab = (path: string) => {
const { tabRouters } = tabsRouterStore;
const foundRoute = tabRouters.find((i) => i.path === path);
router.push({ path, query: foundRoute?.query });
};
const handleRemove = (options: TTabRemoveOptions) => {
const { tabRouters } = tabsRouterStore;
const nextRouter = tabRouters[options.index + 1] || tabRouters[options.index - 1];
tabsRouterStore.subtractCurrentTabRouter({ path: options.value as string, routeIdx: options.index });
if ((options.value as string) === route.path) router.push({ path: nextRouter.path, query: nextRouter.query });
};
const renderTitle = (title?: LocalizedTitle) => {
if (!title) return '';
return title[locale.value as keyof LocalizedTitle] || '';
};
const handleRefresh = (route: TRouterInfo, routeIdx: number) => {
tabsRouterStore.toggleTabRouterAlive(routeIdx);
nextTick(() => {
tabsRouterStore.toggleTabRouterAlive(routeIdx);
router.replace({ path: route.path, query: route.query });
});
activeTabPath.value = null;
};
const handleCloseAhead = (path: string, routeIdx: number) => {
tabsRouterStore.subtractTabRouterAhead({ path, routeIdx });
handleOperationEffect('ahead', routeIdx);
};
const handleCloseBehind = (path: string, routeIdx: number) => {
tabsRouterStore.subtractTabRouterBehind({ path, routeIdx });
handleOperationEffect('behind', routeIdx);
};
const handleCloseOther = (path: string, routeIdx: number) => {
tabsRouterStore.subtractTabRouterOther({ path, routeIdx });
handleOperationEffect('other', routeIdx);
};
// 处理非当前路由操作的副作用
const handleOperationEffect = (type: 'other' | 'ahead' | 'behind', routeIndex: number) => {
const currentPath = router.currentRoute.value.path;
const { tabRouters } = tabsRouterStore;
const currentIdx = tabRouters.findIndex((i) => i.path === currentPath);
// 存在三种情况需要刷新当前路由
// 点击非当前路由的关闭其他、点击非当前路由的关闭左侧且当前路由小于触发路由、点击非当前路由的关闭右侧且当前路由大于触发路由
const needRefreshRouter =
(type === 'other' && currentIdx !== routeIndex) ||
(type === 'ahead' && currentIdx < routeIndex) ||
(type === 'behind' && currentIdx === -1);
if (needRefreshRouter) {
const nextRouteIdx = type === 'behind' ? tabRouters.length - 1 : 1;
const nextRouter = tabRouters[nextRouteIdx];
router.push({ path: nextRouter.path, query: nextRouter.query });
}
activeTabPath.value = null;
};
const handleTabMenuClick = (visible: boolean, ctx: PopupVisibleChangeContext, path: string) => {
if (ctx.trigger === 'document') activeTabPath.value = null;
if (visible) activeTabPath.value = path;
};
const handleDragend = (options: { currentIndex: number; targetIndex: number }) => {
const { tabRouters } = tabsRouterStore;
[tabRouters[options.currentIndex], tabRouters[options.targetIndex]] = [
tabRouters[options.targetIndex],
tabRouters[options.currentIndex],
];
};
</script>
@@ -0,0 +1,36 @@
<template>
<l-header
v-if="settingStore.showHeader"
:show-logo="settingStore.showHeaderLogo"
:theme="settingStore.displayMode"
:layout="settingStore.layout"
:is-fixed="settingStore.isHeaderFixed"
:menu="headerMenu"
:is-compact="settingStore.isSidebarCompact"
/>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { usePermissionStore, useSettingStore } from '@/store';
import type { MenuRoute } from '@/types/interface';
import LHeader from './Header.vue';
const permissionStore = usePermissionStore();
const settingStore = useSettingStore();
const { routers: menuRouters } = storeToRefs(permissionStore);
const headerMenu = computed((): MenuRoute[] => {
if (settingStore.layout === 'mix') {
if (settingStore.splitMenu) {
return menuRouters.value.map((menu) => ({
...(menu as unknown as Record<string, unknown>),
children: [],
})) as unknown as MenuRoute[];
}
return [];
}
return menuRouters.value as MenuRoute[];
});
</script>
@@ -0,0 +1,39 @@
<template>
<l-side-nav
v-if="settingStore.showSidebar"
:show-logo="settingStore.showSidebarLogo"
:layout="settingStore.layout"
:is-fixed="settingStore.isSidebarFixed"
:menu="sideMenu"
:theme="settingStore.displaySideMode"
:is-compact="settingStore.isSidebarCompact"
/>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { usePermissionStore, useSettingStore } from '@/store';
import type { MenuRoute } from '@/types/interface';
import LSideNav from './SideNav.vue';
const route = useRoute();
const permissionStore = usePermissionStore();
const settingStore = useSettingStore();
const { routers: menuRouters } = storeToRefs(permissionStore);
const sideMenu = computed(() => {
const { layout, splitMenu } = settingStore;
let newMenuRouters = menuRouters.value as Array<MenuRoute>;
if (layout === 'mix' && splitMenu) {
newMenuRouters.forEach((menu) => {
if (route.path.indexOf(menu.path) === 0) {
newMenuRouters = menu.children.map((subMenu) => ({ ...subMenu, path: `${menu.path}/${subMenu.path}` }));
}
});
}
return newMenuRouters;
});
</script>
@@ -0,0 +1,99 @@
<template>
<template v-for="item in list" :key="item.path">
<template v-if="!item.children || !item.children.length || item.meta?.single">
<t-menu-item :name="item.path" :value="getPath(item)" :to="item.path">
<template #icon>
<component :is="menuIcon(item)" class="t-icon"></component>
</template>
{{ renderMenuTitle(item.title) }}
</t-menu-item>
</template>
<t-submenu v-else :name="item.path" :value="item.path" :title="renderMenuTitle(item.title)">
<template #icon>
<component :is="menuIcon(item)" class="t-icon"></component>
</template>
<menu-content v-if="item.children" :nav-data="item.children" />
</t-submenu>
</template>
</template>
<script setup lang="tsx">
import type { PropType } from 'vue';
import { computed } from 'vue';
import type { LocalizedTitle } from '@/locales';
import { useLocale } from '@/locales/useLocale';
import { getActive } from '@/router';
import type { MenuRoute } from '@/types/interface';
type ListItemType = MenuRoute;
const { navData } = defineProps({
navData: {
type: Array as PropType<MenuRoute[]>,
default: () => [],
},
});
const active = computed(() => getActive());
const { locale } = useLocale();
const list = computed(() => {
return getMenuList(navData);
});
const menuIcon = (item: ListItemType) => {
if (typeof item.icon === 'string') return <t-icon name={item.icon} />;
const RenderIcon = item.icon;
return RenderIcon;
};
const renderMenuTitle = (title?: LocalizedTitle) => {
if (!title) return '';
return title[locale.value as keyof LocalizedTitle] || '';
};
function getMenuList(list: MenuRoute[], basePath?: string): MenuRoute[] {
if (!list || list.length === 0) {
return [];
}
// 如果meta中有orderNo则按照从小到大排序
list.sort((a, b) => {
return (a.meta?.orderNo || 0) - (b.meta?.orderNo || 0);
});
return list
.map((item) => {
const path = !basePath
? item.path
: item.path === ''
? basePath
: item.path.startsWith('/')
? item.path
: `${basePath}/${item.path}`;
return {
path,
title: item.meta?.title as LocalizedTitle | undefined,
icon: item.meta?.icon,
children: getMenuList(item.children, path),
meta: item.meta,
redirect: item.redirect,
} as MenuRoute;
})
.filter((item) => item.meta && item.meta.hidden !== true);
}
const getPath = (item: ListItemType) => {
const activeLevel = active.value.split('/').length;
const pathLevel = item.path.split('/').length;
if (activeLevel > pathLevel && active.value.startsWith(item.path)) {
return active.value;
}
if (active.value === item.path) {
return active.value;
}
return item.meta?.single ? item.redirect : item.path;
};
</script>
+167
View File
@@ -0,0 +1,167 @@
<template>
<t-popup expand-animation placement="bottom-right" trigger="click">
<template #content>
<div class="header-msg">
<div class="header-msg-top">
<p>{{ t('layout.notice.title') }}</p>
<t-button
v-if="unreadMsg.length > 0"
class="clear-btn"
variant="text"
theme="primary"
@click="setRead('all')"
>{{ t('layout.notice.clear') }}</t-button
>
</div>
<t-list v-if="unreadMsg.length > 0" class="narrow-scrollbar" :split="false">
<t-list-item v-for="(item, index) in unreadMsg" :key="index">
<div>
<p class="msg-content">{{ item.content }}</p>
<p class="msg-type">{{ item.type }}</p>
</div>
<p class="msg-time">{{ item.date }}</p>
<template #action>
<t-button size="small" variant="outline" @click="setRead('radio', item)">
{{ t('layout.notice.setRead') }}
</t-button>
</template>
</t-list-item>
</t-list>
<div v-else class="empty-list">
<t-empty description="暂无通知" />
<p>{{ t('layout.notice.empty') }}</p>
</div>
</div>
</template>
<t-badge :count="unreadMsg.length" :offset="[4, 4]">
<t-button theme="default" shape="square" variant="text">
<t-icon name="mail" />
</t-button>
</t-badge>
</t-popup>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { t } from '@/locales';
import { useNotificationStore } from '@/store';
import type { NotificationItem } from '@/types/interface';
const store = useNotificationStore();
const { msgData, unreadMsg } = storeToRefs(store);
const setRead = (type: string, item?: NotificationItem) => {
const changeMsg = msgData.value;
if (type === 'all') {
changeMsg.forEach((e) => {
e.status = false;
});
} else {
changeMsg.forEach((e) => {
if (e.id === item?.id) {
e.status = false;
}
});
}
store.setMsgData(changeMsg);
};
</script>
<style lang="less" scoped>
.header-msg {
width: 400px;
margin: calc(0px - var(--td-comp-paddingTB-xs)) calc(0px - var(--td-comp-paddingLR-s));
.empty-list {
text-align: center;
padding: var(--td-comp-paddingTB-xxl) 0;
font: var(--td-font-body-medium);
color: var(--td-text-color-secondary);
img {
width: var(--td-comp-size-xxxxl);
}
p {
margin-top: var(--td-comp-margin-xs);
}
}
&-top {
position: relative;
font: var(--td-font-title-medium);
color: var(--td-text-color-primary);
text-align: left;
padding: var(--td-comp-paddingTB-l) var(--td-comp-paddingLR-xl) 0;
display: flex;
align-items: center;
justify-content: space-between;
.clear-btn {
right: calc(var(--td-comp-paddingTB-l) - var(--td-comp-paddingLR-xl));
}
}
.t-list {
height: calc(100% - 104px);
padding: var(--td-comp-margin-s) var(--td-comp-margin-s);
}
.t-list-item {
overflow: hidden;
width: 100%;
padding: var(--td-comp-paddingTB-l) var(--td-comp-paddingLR-l);
border-radius: var(--td-radius-default);
font: var(--td-font-body-medium);
color: var(--td-text-color-primary);
cursor: pointer;
transition: background-color 0.2s linear;
&:hover {
background-color: var(--td-bg-color-container-hover);
.msg-content {
color: var(--td-brand-color);
}
.t-list-item__action {
button {
bottom: var(--td-comp-margin-l);
opacity: 1;
}
}
.msg-time {
bottom: -6px;
opacity: 0;
}
}
.msg-content {
margin-bottom: var(--td-comp-margin-s);
}
.msg-type {
color: var(--td-text-color-secondary);
}
.t-list-item__action {
button {
opacity: 0;
position: absolute;
right: var(--td-comp-margin-xxl);
bottom: -6px;
}
}
.msg-time {
transition: all 0.2s ease;
opacity: 1;
position: absolute;
right: var(--td-comp-margin-xxl);
bottom: var(--td-comp-margin-l);
color: var(--td-text-color-secondary);
}
}
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<template>
<div v-if="layout === 'side'" class="header-menu-search">
<t-input
class="header-search"
:class="[{ 'hover-active': isSearchFocus }]"
:placeholder="t('layout.searchPlaceholder')"
@blur="changeSearchFocus(false)"
@focus="changeSearchFocus(true)"
>
<template #prefix-icon>
<t-icon class="icon" name="search" size="16" />
</template>
</t-input>
</div>
<div v-else class="header-menu-search-left">
<t-button
:class="{ 'search-icon-hide': isSearchFocus }"
theme="default"
shape="square"
variant="text"
@click="changeSearchFocus(true)"
>
<t-icon name="search" />
</t-button>
<t-input
v-model="searchData"
class="header-search"
:class="[{ 'width-zero': !isSearchFocus }]"
:placeholder="t('layout.search.placeholder')"
:autofocus="isSearchFocus"
@blur="changeSearchFocus(false)"
>
<template #prefix-icon>
<t-icon name="search" size="16" />
</template>
</t-input>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { t } from '@/locales';
defineProps({
layout: {
type: String,
default: '',
},
});
const isSearchFocus = ref(false);
const searchData = ref('');
const changeSearchFocus = (value: boolean) => {
if (!value) {
searchData.value = '';
}
isSearchFocus.value = value;
};
</script>
<style lang="less" scoped>
.header-menu-search {
display: flex;
margin-left: 16px;
.hover-active {
background: var(--td-bg-color-secondarycontainer);
}
.t-icon {
color: var(--td-text-color-primary) !important;
}
.header-search {
:deep(.t-input) {
border: none;
outline: none;
box-shadow: none;
transition: background @anim-duration-base linear;
.t-input__inner {
transition: background @anim-duration-base linear;
background: none;
}
&:hover {
background: var(--td-bg-color-secondarycontainer);
.t-input__inner {
background: var(--td-bg-color-secondarycontainer);
}
}
}
}
}
.t-button {
margin: 0 8px;
transition: opacity @anim-duration-base @anim-time-fn-easing;
.t-icon {
font-size: 20px;
&.general {
margin-right: 16px;
}
}
}
.search-icon-hide {
opacity: 0;
}
.header-menu-search-left {
display: flex;
align-items: center;
.header-search {
width: 200px;
transition: width @anim-duration-base @anim-time-fn-easing;
:deep(.t-input) {
border: 0;
&:focus {
box-shadow: none;
}
}
&.width-zero {
width: 0;
opacity: 0;
}
}
}
</style>
+198
View File
@@ -0,0 +1,198 @@
<template>
<div :class="sideNavCls">
<t-menu
:class="menuCls"
:theme="theme"
:value="active"
:collapsed="collapsed"
:expanded="expanded"
:expand-mutex="menuAutoCollapsed"
@expand="onExpanded"
>
<template #logo>
<span v-if="showLogo" :class="`${prefix}-side-nav-logo-wrapper brand-logo`" @click="goHome">
<span class="brand-name" :class="{ 'brand-name--compact': collapsed }">财务系统</span>
</span>
</template>
<menu-content :nav-data="menu" />
<template #operations>
<t-button
variant="text"
shape="square"
:aria-label="collapsed ? '展开侧栏' : '收起侧栏'"
:title="collapsed ? '展开侧栏' : '收起侧栏'"
@click="changeCollapsed"
>
<template #icon><t-icon name="view-list" /></template>
</t-button>
<span v-show="!isCompact" :class="versionCls">
{{ !collapsed ? t('common.appName') : '' }} {{ pgk.version }}
</span>
</template>
</t-menu>
<div :class="`${prefix}-side-nav-placeholder${collapsed ? '-hidden' : ''}`"></div>
</div>
</template>
<script setup lang="ts">
import difference from 'lodash/difference';
import remove from 'lodash/remove';
import union from 'lodash/union';
import type { MenuValue } from 'tdesign-vue-next';
import type { PropType } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { prefix } from '@/config/global';
import { t } from '@/locales';
import { getActive } from '@/router';
import { useSettingStore } from '@/store';
import type { MenuRoute, ModeType } from '@/types/interface';
import pgk from '../../../package.json';
import MenuContent from './MenuContent.vue';
const { menu, showLogo, isFixed, layout, theme, isCompact } = defineProps({
menu: {
type: Array as PropType<MenuRoute[]>,
default: () => [],
},
showLogo: {
type: Boolean as PropType<boolean>,
default: true,
},
isFixed: {
type: Boolean as PropType<boolean>,
default: true,
},
layout: {
type: String as PropType<string>,
default: '',
},
headerHeight: {
type: String as PropType<string>,
default: '64px',
},
theme: {
type: String as PropType<ModeType>,
default: 'light',
},
isCompact: {
type: Boolean as PropType<boolean>,
default: false,
},
});
const MIN_POINT = 992 - 1;
const collapsed = computed(() => useSettingStore().isSidebarCompact);
const menuAutoCollapsed = computed(() => useSettingStore().menuAutoCollapsed);
const active = computed(() => getActive());
const expanded = ref<MenuValue[]>([]);
const getExpanded = () => {
const path = getActive();
const parts = path.split('/').slice(1);
const result = parts.map((_, index) => `/${parts.slice(0, index + 1).join('/')}`);
const allRoutes = router.getRoutes();
const allRoutesExpanded = allRoutes.filter((item) => item.meta?.expanded).map((item) => item.path);
expanded.value = menuAutoCollapsed.value
? union(result, allRoutesExpanded)
: union(result, expanded.value, allRoutesExpanded);
};
watch(
() => active.value,
() => {
getExpanded();
},
);
const onExpanded = (value: MenuValue[]) => {
const currentOperationMenu = difference(expanded.value, value);
const allExpanded = union(value, expanded.value);
remove(allExpanded, (item) => currentOperationMenu.includes(item));
expanded.value = allExpanded;
};
const changeCollapsed = () => {
settingStore.updateConfig({
isSidebarCompact: !settingStore.isSidebarCompact,
});
};
const sideMode = computed(() => {
return theme === 'dark';
});
const versionCls = computed(() => {
return [
`version-container`,
{
[`${prefix}-side-nav-dark`]: sideMode.value,
},
];
});
const sideNavCls = computed(() => {
return [
`${prefix}-sidebar-layout`,
{
[`${prefix}-sidebar-compact`]: isCompact,
},
];
});
const menuCls = computed(() => {
return [
`${prefix}-side-nav`,
{
[`${prefix}-side-nav-no-logo`]: !showLogo,
[`${prefix}-side-nav-no-fixed`]: !isFixed,
[`${prefix}-side-nav-mix-fixed`]: layout === 'mix' && isFixed,
},
];
});
const router = useRouter();
const settingStore = useSettingStore();
const autoCollapsed = () => {
const isCompact = window.innerWidth <= MIN_POINT;
settingStore.updateConfig({
isSidebarCompact: isCompact,
});
};
onMounted(() => {
getExpanded();
autoCollapsed();
window.addEventListener('resize', autoCollapsed);
});
onUnmounted(() => {
window.removeEventListener('resize', autoCollapsed);
});
const goHome = () => {
router.push('/role-select');
};
</script>
<style lang="less" scoped>
.brand-logo {
display: inline-flex;
align-items: center;
width: 100%;
cursor: pointer;
}
.brand-name {
color: var(--td-text-color-primary);
font-size: 14px;
font-weight: 600;
white-space: nowrap;
}
.brand-name--compact {
font-size: 12px;
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<template>
<div>
<template v-if="setting.layout.value === 'side'">
<t-layout key="side" :class="mainLayoutCls">
<t-aside><layout-side-nav /></t-aside>
<t-layout>
<t-header><layout-header /></t-header>
<t-content><layout-content /></t-content>
</t-layout>
</t-layout>
</template>
<template v-else>
<t-layout key="no-side">
<t-header><layout-header /> </t-header>
<t-layout :class="mainLayoutCls">
<layout-side-nav />
<layout-content />
</t-layout>
</t-layout>
</template>
</div>
</template>
<script setup lang="ts">
import '@/style/layout.less';
import { storeToRefs } from 'pinia';
import { computed, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { prefix } from '@/config/global';
import { useSettingStore, useTabsRouterStore } from '@/store';
import LayoutContent from './components/LayoutContent.vue';
import LayoutHeader from './components/LayoutHeader.vue';
import LayoutSideNav from './components/LayoutSideNav.vue';
const route = useRoute();
const settingStore = useSettingStore();
const tabsRouterStore = useTabsRouterStore();
const setting = storeToRefs(settingStore);
const mainLayoutCls = computed(() => [
{
't-layout--with-sider': settingStore.showSidebar,
},
]);
const appendNewRoute = () => {
const {
path,
query,
meta: { title },
name,
} = route;
const titleObj = typeof title === 'string' ? { zh_CN: title, en_US: title } : title;
tabsRouterStore.appendTabRouterList({ path, query, title: titleObj, name, isAlive: true, meta: route.meta });
};
onMounted(() => {
appendNewRoute();
});
watch(
() => route.path,
() => {
appendNewRoute();
document.querySelector(`.${prefix}-layout`)?.scrollTo({ top: 0, behavior: 'smooth' });
},
);
</script>
<style lang="less" scoped></style>
+60
View File
@@ -0,0 +1,60 @@
import type { DropdownOption } from 'tdesign-vue-next';
import en_US from 'tdesign-vue-next/es/locale/en_US';
import zh_CN from 'tdesign-vue-next/es/locale/zh_CN';
import { computed } from 'vue';
import type { I18nOptions } from 'vue-i18n';
import { createI18n } from 'vue-i18n';
export const localeConfigKey = 'tdesign-starter-locale';
// 定义支持的语言列表,添加新语言时只需在此处添加
export const supportedLocales = ['zh_CN', 'en_US'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// 多语言标题类型,用于路由 meta.title 等场景
export type LocalizedTitle = Record<SupportedLocale, string>;
const tdesignLocaleMap: Record<SupportedLocale, typeof zh_CN | typeof en_US> = { zh_CN, en_US };
const langModules = import.meta.glob<{ default: Record<string, unknown> }>('./lang/*.json', { eager: true });
const langCode: SupportedLocale[] = [];
const messages: I18nOptions['messages'] = {};
const langList: DropdownOption[] = [];
Object.entries(langModules).forEach(([path, module]) => {
const code = path.match(/\.\/lang\/([^.]+)\.json$/)?.[1] as SupportedLocale | undefined;
if (!code || !supportedLocales.includes(code)) return;
langCode.push(code);
messages[code] = { ...module.default, componentsLocale: tdesignLocaleMap[code] };
langList.push({ content: module.default.lang as string, value: code });
});
export { langCode };
// 获取初始语言:优先本地存储,其次浏览器偏好,最后默认中文
const getInitialLocale = (): SupportedLocale => {
const stored = localStorage.getItem(localeConfigKey);
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
const preferred = navigator.languages?.[0]?.replace(/-/g, '_');
if (preferred && supportedLocales.includes(preferred as SupportedLocale)) {
return preferred as SupportedLocale;
}
return 'zh_CN';
};
const initialLocale = getInitialLocale();
export const i18n = createI18n({
legacy: false,
locale: initialLocale,
fallbackLocale: 'zh_CN',
messages,
globalInjection: true,
});
export const languageList = computed(() => langList);
export const { t } = i18n.global;
export default i18n;
+219
View File
@@ -0,0 +1,219 @@
{
"lang": "English",
"common": {
"appName": "Kaidi Finance Base System",
"copyright": "Kaidi Finance Base System",
"conjunction": "and",
"admin": "admin"
},
"components": {
"isSetup": {
"on": "Enabled",
"off": "Disabled"
},
"manage": "Manage",
"delete": "Delete",
"deleteSuccess": "Deleted successfully",
"copySuccess": "Copied successfully",
"copyFail": "Copy failed",
"submitSuccess": "Submitted successfully"
},
"layout": {
"header": {
"code": "Code Repository",
"help": "Document",
"user": "Profile",
"signOut": "Sign Out",
"setting": "Setting"
},
"notice": {
"title": "Notification Center",
"clear": "Clear",
"setRead": "Set Read",
"empty": "Empty",
"emptyNotice": "No Notice",
"viewAll": "View All"
},
"tagTabs": {
"closeOther": "close other",
"closeLeft": "close left",
"closeRight": "close right",
"refresh": "refresh"
},
"searchPlaceholder": "Enter search content",
"search": {
"placeholder": "Enter search content"
},
"setting": {
"title": "Setting",
"theme": {
"mode": "Theme Mode",
"color": "Theme Color",
"options": {
"light": "Light",
"dark": "Dark ",
"auto": "Follow System"
}
},
"navigationLayout": "Navigation Layout",
"sideMode": "Side Menu Mode",
"splitMenu": "Split Menu (Only Mix mode)",
"fixedSidebar": "Fixed Sidebar",
"element": {
"title": "Element Switch",
"showHeader": "Show Header",
"showBreadcrumb": "Show Breadcrumb",
"showFooter": "Show Footer",
"useTagTabs": "Use Tag Tabs",
"menuAutoCollapsed": "Menu Auto Collapsed"
},
"tips": "Please copy and manually modify the configuration file: /src/config/style.ts",
"copy": {
"title": "Copy",
"success": "copied",
"fail": "fail to copy"
}
}
},
"constants": {
"contract": {
"name": "Name",
"status": "Status",
"num": "Number",
"type": "Type",
"typePlaceholder": "Please enter type",
"payType": "Pay Type",
"amount": "Amount",
"amountPlaceholder": "Please enter amount",
"signDate": "Sign Date",
"effectiveDate": "Effective Date",
"endDate": "End Date",
"createDate": "Create Date",
"attachment": "Attachment",
"company": "Company",
"employee": "Employee",
"pay": "pay",
"receive": "received",
"remark": "remark",
"statusOptions": {
"fail": "Failure",
"auditPending": "Pending audit",
"execPending": "Pending performance",
"executing": "Successful",
"finish": "Finish"
},
"typeOptions": {
"main": "Master contract",
"sub": "Subcontract",
"supplement": "Supplementary contract"
}
}
},
"pages": {
"notification": {
"contract": "Contract Updates",
"invoice": "Invoice Updates",
"meeting": "Meeting Notification",
"msg1": "The example project has passed review.",
"msg2": "Third Quarter Raw Material Procurement Project invoicing successful!",
"msg3": "Meeting 【State Grid Offline Signing】at 2021-01-01 10:00 is about to start, please arrive at Conference Room 1 10 minutes early for check-in!",
"msg4": "First Quarter Raw Material Procurement Project invoicing successful!"
},
"login": {
"loginTitle": "Login in",
"noAccount": "No Account?",
"createAccount": "Create Account",
"remember": "Remember Account",
"forget": "Forget Account",
"signIn": "Sign in",
"existAccount": "Exist Account?",
"register": {
"phonePlaceholder": "Please enter your phone number",
"emailPlaceholder": "Please enter your email",
"passwordPlaceholder": "Please enter your password",
"verifyCodePlaceholder": "Please enter verification code",
"sendVerifyCode": "Send verification code",
"resendCountdown": "Resend in {count} seconds",
"agreeTerms": "I have read and agree to",
"serviceTerms": "TDesign Service Terms",
"privacyStatement": "TDesign Privacy Statement",
"registerBtn": "Register",
"useEmailRegister": "Register with email",
"usePhoneRegister": "Register with phone",
"validation": {
"phone": "Phone number is required",
"email": "Email is required",
"emailFormat": "Please enter a valid email",
"password": "Password is required",
"verifyCode": "Verification code is required",
"agreeTerms": "You must agree to the service terms and privacy statement"
},
"messages": {
"registerSuccess": "Registration successful"
}
},
"refresh": "refresh",
"wechatLogin": "Login with WeChat",
"accountLogin": "Login with Account",
"phoneLogin": "Login with Mobile Phone",
"loginSuccess": "Login successful",
"countdown": "Resend after {count} seconds",
"input": {
"account": "please enter account",
"password": "please enter password",
"phone": "please enter phone",
"verification": "please enter verification code"
},
"required": {
"account": "account is required",
"phone": "phone is required",
"password": "password is required",
"verification": "verification code is require"
},
"sendVerification": "send"
},
"result": {
"403": {
"tips": "sorry, you don not have permission to access this page. Please contact the creator through WeCom",
"back": "Back to homepage"
},
"404": {
"subtitle": "Sorry, the page is not found",
"back": "Back to homepage"
},
"500": {
"subtitle": "Sorry, the server is error",
"back": "Back to homepage"
},
"fail": {
"title": "Create fail",
"subtitle": "Sorry, your project creation has failed",
"back": "Back to homepage",
"modify": "Back to modify"
},
"success": {
"title": "Project is created",
"subtitle": "Contact the person in charge of distributing the application",
"back": "Back to homepage",
"progress": "Check Progress"
},
"maintenance": {
"title": "System maintenance",
"subtitle": "Please try again later",
"back": "Back to homepage"
},
"browserIncompatible": {
"title": "browser is incompatible",
"subtitle": "the browser version you are using is too outdated to open the current webpage.",
"back": "Back to homepage",
"recommend": "TDesign Starter recommend the following browser"
},
"networkError": {
"title": "Network Error",
"subtitle": "Network error, please try again later",
"back": "Back to homepage",
"reload": "Reload"
}
}
}
}
+219
View File
@@ -0,0 +1,219 @@
{
"lang": "简体中文",
"common": {
"appName": "财务系统",
"copyright": "财务系统",
"conjunction": "和",
"admin": "admin"
},
"components": {
"isSetup": {
"on": "已启用",
"off": "已停用"
},
"manage": "管理",
"delete": "删除",
"deleteSuccess": "删除成功",
"copySuccess": "复制成功",
"copyFail": "复制失败",
"submitSuccess": "提交成功"
},
"layout": {
"header": {
"code": "代码仓库",
"help": "帮助文档",
"user": "个人中心",
"signOut": "退出登录",
"setting": "系统设置"
},
"notice": {
"title": "通知中心",
"clear": "清空",
"setRead": "设为已读",
"empty": "空",
"emptyNotice": "暂无通知",
"viewAll": "查看全部"
},
"searchPlaceholder": "请输入搜索内容",
"search": {
"placeholder": "输入要搜索内容"
},
"tagTabs": {
"closeOther": "关闭其他",
"closeLeft": "关闭左侧",
"closeRight": "关闭右侧",
"refresh": "刷新"
},
"setting": {
"title": "页面配置",
"theme": {
"mode": "主题模式",
"color": "主题色",
"options": {
"light": "明亮",
"dark": "深色",
"auto": "跟随系统"
}
},
"navigationLayout": "导航布局",
"sideMode": "侧边栏模式",
"splitMenu": "分割菜单(混合模式下有效)",
"fixedSidebar": "固定侧边栏",
"element": {
"title": "元素开关",
"showHeader": "显示顶栏",
"showBreadcrumb": "显示面包屑",
"showFooter": "显示页脚",
"useTagTabs": "展示多标签Tab页",
"menuAutoCollapsed": "菜单自动折叠"
},
"tips": "请复制后手动修改配置文件: /src/config/style.ts",
"copy": {
"title": "复制配置项",
"success": "复制成功",
"fail": "复制失败"
}
}
},
"constants": {
"contract": {
"name": "合同名称",
"status": "合同状态",
"num": "合同编号",
"type": "合同类型",
"typePlaceholder": "请输入类型",
"payType": "合同收支类型",
"amount": "合同金额",
"amountPlaceholder": "请输入金额",
"signDate": "合同签订日期",
"effectiveDate": "合同生效日期",
"endDate": "合同结束日期",
"createDate": "合同创建时间",
"company": "甲方",
"employee": "乙方",
"pay": "付款",
"receive": "收款",
"remark": "备注",
"attachment": "附件",
"statusOptions": {
"fail": "审核失败",
"auditPending": "待审核",
"execPending": "待履行",
"executing": "审核成功",
"finish": "已完成"
},
"typeOptions": {
"main": "主合同",
"sub": "子合同",
"supplement": "补充合同"
}
}
},
"pages": {
"notification": {
"contract": "合同动态",
"invoice": "票务动态",
"meeting": "会议通知",
"msg1": "示例项目已通过审核",
"msg2": "三季度生产原材料采购项目 开票成功!",
"msg3": "2021-01-01 10:00的【国家电网线下签约】会议即将开始,请提前10分钟前往 会议室1 进行签到!",
"msg4": "一季度生产原材料采购项目 开票成功!"
},
"login": {
"loginTitle": "登录到",
"noAccount": "没有账号吗?",
"existAccount": "已有账号?",
"createAccount": "注册新账号",
"remember": "记住账号",
"forget": "忘记账号",
"signIn": "登录",
"register": {
"phonePlaceholder": "请输入您的手机号",
"emailPlaceholder": "请输入您的邮箱",
"passwordPlaceholder": "请输入登录密码",
"verifyCodePlaceholder": "请输入验证码",
"sendVerifyCode": "发送验证码",
"resendCountdown": "{count}秒后可重发",
"agreeTerms": "我已阅读并同意",
"serviceTerms": "TDesign服务协议",
"privacyStatement": "TDesign 隐私声明",
"registerBtn": "注册",
"useEmailRegister": "使用邮箱注册",
"usePhoneRegister": "使用手机号注册",
"validation": {
"phone": "手机号必填",
"email": "邮箱必填",
"emailFormat": "请输入正确的邮箱",
"password": "密码必填",
"verifyCode": "验证码必填",
"agreeTerms": "请同意TDesign服务协议和TDesign 隐私声明"
},
"messages": {
"registerSuccess": "注册成功"
}
},
"refresh": "刷新",
"wechatLogin": "使用微信扫一扫登录",
"accountLogin": "使用账号登录",
"phoneLogin": "使用手机号登录",
"loginSuccess": "登录成功",
"countdown": "{count}秒后可重发",
"input": {
"account": "请输入账号",
"password": "请输入登录密码",
"phone": "请输入手机号",
"verification": "请输入验证码"
},
"required": {
"account": "账号必填",
"phone": "手机号必填",
"password": "密码必填",
"verification": "验证码必填"
},
"sendVerification": "发送验证码"
},
"result": {
"403": {
"tips": "当前工作身份没有访问此页面的权限,请切换身份或联系系统管理员。",
"back": "返回首页"
},
"404": {
"subtitle": "抱歉,您访问的页面不存在",
"back": "返回首页"
},
"500": {
"subtitle": "抱歉,服务器出错啦",
"back": "返回首页"
},
"fail": {
"title": "创建失败",
"subtitle": "操作未完成,请根据提示检查权限和填写内容后重试。",
"back": "回到首页",
"modify": "返回修改"
},
"success": {
"title": "项目已创建成功",
"subtitle": "可以联系负责人分发应用",
"back": "回到首页",
"progress": "查看进度"
},
"maintenance": {
"title": "系统维护中",
"subtitle": "系统维护中,请稍后再试",
"back": "回到首页"
},
"browserIncompatible": {
"title": "浏览器不兼容",
"subtitle": "抱歉,您正在使用的浏览器版本过低,无法打开当前网页。",
"back": "回到首页",
"recommend": "TDesign Starter 推荐以下主流浏览器"
},
"networkError": {
"title": "网络异常",
"subtitle": "网络异常, 请稍后重试",
"back": "回到首页",
"reload": "重新加载"
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
import { useLocalStorage } from '@vueuse/core';
import type { GlobalConfigProvider } from 'tdesign-vue-next';
import { computed } from 'vue';
import type { SupportedLocale } from '@/locales/index';
import { i18n, localeConfigKey, supportedLocales } from '@/locales/index';
import { useNotificationStore } from '@/store/modules/notification';
export function useLocale() {
const locale = computed({
get: () => i18n.global.locale.value,
set: (val: string) => {
i18n.global.locale.value = val;
},
});
const storedLocale = useLocalStorage<SupportedLocale>(localeConfigKey, 'zh_CN');
const changeLocale = (lang: string) => {
const validLang = supportedLocales.includes(lang as SupportedLocale) ? (lang as SupportedLocale) : 'zh_CN';
locale.value = validLang;
storedLocale.value = validLang;
// 刷新持久化的翻译数据
useNotificationStore().refreshMsgData();
};
const getComponentsLocale = computed(() => {
return (i18n.global.getLocaleMessage(locale.value) as Record<string, any>).componentsLocale as GlobalConfigProvider;
});
return {
changeLocale,
getComponentsLocale,
locale,
};
}
+21
View File
@@ -0,0 +1,21 @@
/* eslint-disable simple-import-sort/imports */
import { createApp } from 'vue';
import TDesign from 'tdesign-vue-next';
import App from './App.vue';
import router from './router';
import { store } from './store';
import i18n from './locales';
import 'tdesign-vue-next/es/style/index.css';
import '@/style/index.less';
import './permission';
const app = createApp(App);
app.use(TDesign);
app.use(store);
app.use(router);
app.use(i18n);
app.mount('#app');
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+676
View File
@@ -0,0 +1,676 @@
<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>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,644 @@
<template>
<div class="audit-page">
<h1 class="page-title-sr-only">审计日志</h1>
<header class="page-header page-header--actions-only" aria-label="页面操作">
<t-space
><t-button variant="outline" :loading="loading" @click="loadLogs"
><template #icon><t-icon name="refresh" /></template>刷新</t-button
><t-button v-if="canExport" theme="primary" :loading="exporting" :disabled="!canView" @click="downloadLogs"
><template #icon><t-icon name="download" /></template>导出当前结果</t-button
></t-space
>
</header>
<t-alert v-if="errorMessage" theme="error" :close-btn="false"
><div class="alert-content">
<span>{{ errorMessage }}</span
><t-link theme="primary" @click="loadLogs">重新加载</t-link>
</div></t-alert
>
<section class="filter-panel" aria-label="审计日志查询条件">
<t-form :data="filters" label-align="top" @submit="search"
><div class="filter-grid">
<t-form-item label="关键词"
><t-input v-model="filters.keyword" clearable placeholder="用户、动作、对象或请求编号" @enter="search"
/></t-form-item>
<t-form-item label="用户编号"
><t-input v-model="filters.actorId" clearable maxlength="26" placeholder="用户公开编号"
/></t-form-item>
<t-form-item label="当前身份"
><t-select v-model="filters.identityCode" :options="roleOptions" clearable placeholder="全部身份"
/></t-form-item>
<t-form-item label="结果"
><t-select v-model="filters.result" :options="resultOptions" clearable placeholder="全部结果"
/></t-form-item>
<t-form-item label="动作编码"><t-input v-model="filters.action" clearable maxlength="100" /></t-form-item>
<t-form-item label="对象类型"><t-input v-model="filters.objectType" clearable maxlength="80" /></t-form-item>
<t-form-item label="对象编号"><t-input v-model="filters.objectId" clearable maxlength="26" /></t-form-item>
<t-form-item label="请求编号"><t-input v-model="filters.requestId" clearable maxlength="26" /></t-form-item>
<t-form-item label="发生时间"
><t-date-range-picker v-model="dateRange" value-type="YYYY-MM-DD" clearable
/></t-form-item>
<t-form-item label="排序"
><t-select v-model="filters.sort" :options="sortOptions" placeholder="选择排序方式"
/></t-form-item>
<div class="filter-actions">
<t-button theme="primary" type="submit"
><template #icon><t-icon name="search" /></template>查询</t-button
><t-button variant="outline" type="button" @click="resetFilters">重置</t-button>
</div>
</div></t-form
>
</section>
<section class="table-panel" aria-label="审计日志列表">
<div class="table-toolbar">
<span>共 {{ pagination.total }} 条记录</span><span>默认查询最近 24 小时;导出单次最多 50,000 条</span>
</div>
<t-table
v-if="loading || rows.length"
:columns="columns"
:data="rows"
row-key="publicId"
:loading="loading"
:hover="true"
table-layout="fixed"
:horizontal-scroll-affixed-bottom="true"
>
<template #occurredAt="{ row }">{{ formatDate(row.occurredAt) }}</template
><template #username="{ row }"
><t-link theme="primary" @click="openDetail(row)">{{ row.username || '-' }}</t-link></template
><template #activeRole="{ row }">{{ roleLabel(row.activeRole) }}</template
><template #scope="{ row }">{{ scopeLabel(row) }}</template
><template #resultCode="{ row }"
><t-tag :theme="resultTheme(row.resultCode)" variant="light">{{
resultLabel(row.resultCode)
}}</t-tag></template
><template #reason="{ row }">{{ row.reason || '-' }}</template
><template #operations="{ row }"
><t-button size="small" variant="text" theme="primary" @click="openDetail(row)"
><template #icon><t-icon name="browse" /></template>查看快照</t-button
></template
>
</t-table>
<t-empty v-else description="当前条件下暂无审计记录" />
<div v-if="pagination.total" 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="loadLogs"
@page-size-change="pageSizeChanged"
/>
</div>
</section>
<t-drawer
v-model:visible="detailVisible"
header="审计记录详情"
size="min(920px, 96vw)"
:footer="false"
destroy-on-close
>
<t-loading :loading="detailLoading" show-overlay
><t-alert v-if="detailError" theme="error" :close-btn="false">{{ detailError }}</t-alert
><template v-if="detail">
<t-descriptions bordered :column="2" item-layout="vertical"
><t-descriptions-item label="事件序号">{{ detail.eventSequence }}</t-descriptions-item
><t-descriptions-item label="发生时间">{{ formatDate(detail.occurredAt) }}</t-descriptions-item
><t-descriptions-item label="结果"
><t-tag :theme="resultTheme(detail.resultCode)" variant="light">{{
resultLabel(detail.resultCode)
}}</t-tag></t-descriptions-item
><t-descriptions-item label="用户">{{ detail.username }}</t-descriptions-item
><t-descriptions-item label="当前身份">{{ roleLabel(detail.activeRole) }}</t-descriptions-item
><t-descriptions-item label="数据范围" :span="2">{{ scopeLabel(detail) }}</t-descriptions-item
><t-descriptions-item label="动作">{{ detail.actionCode }}</t-descriptions-item
><t-descriptions-item label="对象"
>{{ detail.objectType }} / {{ detail.objectPublicId || '-' }}</t-descriptions-item
><t-descriptions-item label="请求编号">{{ detail.requestId }}</t-descriptions-item
><t-descriptions-item label="原因">{{ detail.reason || '-' }}</t-descriptions-item></t-descriptions
>
<section class="snapshot-grid">
<div>
<div class="section-heading">
<h2>变更前</h2>
<t-tag variant="light">已脱敏</t-tag>
</div>
<pre>{{ prettyJson(detail.beforeJson) }}</pre>
</div>
<div>
<div class="section-heading">
<h2>变更后</h2>
<t-tag variant="light">已脱敏</t-tag>
</div>
<pre>{{ prettyJson(detail.afterJson) }}</pre>
</div>
</section>
</template></t-loading
>
</t-drawer>
</div>
</template>
<script setup lang="ts">
import type { PrimaryTableCol } from 'tdesign-vue-next';
import { MessagePlugin } from 'tdesign-vue-next';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { AuditLog, AuditQuery, AuditSort } from '@/api/audit';
import { exportAuditLogs, getAuditLog, getAuditLogs } from '@/api/audit';
import { useUserStore } from '@/store';
defineOptions({ name: 'AuditLogsPage' });
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
const userStore = useUserStore();
const route = useRoute();
const router = useRouter();
const canView = computed(() => userStore.hasPermission('audit:log:view'));
const canExport = computed(() => userStore.hasPermission('audit:log:export'));
const rows = ref<AuditLog[]>([]);
const loading = ref(false);
const exporting = ref(false);
const errorMessage = ref('');
const pagination = reactive({ current: 1, pageSize: 20, total: 0 });
const defaultSort: AuditSort = 'occurredAt,desc';
const auditSortValues = new Set<AuditSort>([
'occurredAt,asc',
'occurredAt,desc',
'eventSequence,asc',
'eventSequence,desc',
]);
const parseAuditSort = (value: unknown): AuditSort =>
typeof value === 'string' && auditSortValues.has(value as AuditSort) ? (value as AuditSort) : defaultSort;
const filters = reactive<Omit<AuditQuery, 'page' | 'size'>>({
keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '',
identityCode: typeof route.query.identityCode === 'string' ? route.query.identityCode : '',
result: typeof route.query.result === 'string' ? route.query.result : '',
action: typeof route.query.action === 'string' ? route.query.action : '',
objectType: typeof route.query.objectType === 'string' ? route.query.objectType : '',
objectId: typeof route.query.objectId === 'string' ? route.query.objectId : '',
requestId: typeof route.query.requestId === 'string' ? route.query.requestId : '',
actorId: typeof route.query.actorId === 'string' ? route.query.actorId : '',
occurredFrom: typeof route.query.occurredFrom === 'string' ? route.query.occurredFrom : '',
occurredTo: typeof route.query.occurredTo === 'string' ? route.query.occurredTo : '',
sort: parseAuditSort(route.query.sort),
});
const datePart = (value: unknown) => {
const raw = typeof value === 'string' ? value : '';
return /^\d{4}-\d{2}-\d{2}/.test(raw) ? raw.slice(0, 10) : '';
};
// The date picker is a Beijing calendar date. Send UTC instants so the API
// can keep a precise [from, to) interval without relying on browser timezone.
function beijingStartUtc(value: string, dayOffset = 0) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return undefined;
const [year, month, day] = match.slice(1).map(Number);
const utcMillis = Date.UTC(year, month - 1, day + dayOffset) - 8 * 60 * 60 * 1000;
return new Date(utcMillis).toISOString();
}
const dateRange = ref<string[]>(
[datePart(route.query.occurredFrom), datePart(route.query.occurredTo)].some(Boolean)
? [datePart(route.query.occurredFrom), datePart(route.query.occurredTo)]
: [],
);
const roleOptions = [
{ label: '项目管理', value: 'PROJECT_MANAGER' },
{ label: '财务管理', value: 'FINANCE_MANAGER' },
{ label: '资料管理', value: 'ARCHIVE_MANAGER' },
{ label: '系统管理员', value: 'SYSTEM_ADMIN' },
];
const resultOptions = [
{ label: '成功', value: 'SUCCESS' },
{ label: '失败', value: 'FAILED' },
{ label: '已拒绝', value: 'DENIED' },
{ label: '已阻断', value: 'BLOCKED' },
];
const sortOptions: Array<{ label: string; value: AuditSort }> = [
{ label: '发生时间从新到旧', value: 'occurredAt,desc' },
{ label: '发生时间从旧到新', value: 'occurredAt,asc' },
{ label: '事件序号从大到小', value: 'eventSequence,desc' },
{ label: '事件序号从小到大', value: 'eventSequence,asc' },
];
const pageSizeOptions = [20, 50, 100];
const parsePage = (value: unknown) => {
const parsed = Number(typeof value === 'string' ? value : '');
return Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
};
const parsePageSize = (value: unknown) => {
const parsed = Number(typeof value === 'string' ? value : '');
return pageSizeOptions.includes(parsed) ? parsed : 20;
};
pagination.current = parsePage(route.query.page);
pagination.pageSize = parsePageSize(route.query.size);
const columns: PrimaryTableCol[] = [
{ colKey: 'eventSequence', title: '事件序号', width: 120, fixed: 'left' },
{ colKey: 'occurredAt', title: '发生时间', width: 180, fixed: 'left' },
{ colKey: 'username', title: '操作用户', width: 130 },
{ colKey: 'activeRole', title: '当前身份', width: 130 },
{ colKey: 'scope', title: '范围摘要', width: 260, ellipsis: true },
{ colKey: 'objectType', title: '对象类型', width: 150 },
{ colKey: 'objectPublicId', title: '对象编号', width: 190, ellipsis: true },
{ colKey: 'actionCode', title: '动作', width: 210, ellipsis: true },
{ colKey: 'resultCode', title: '结果', width: 100 },
{ colKey: 'reason', title: '原因', width: 220, ellipsis: true },
{ colKey: 'requestId', title: '请求编号', width: 190, ellipsis: true },
{ colKey: 'operations', title: '操作', width: 130, fixed: 'right' },
];
function queryFilters() {
const from = dateRange.value[0] ? beijingStartUtc(dateRange.value[0]) : undefined;
const to = dateRange.value[1] ? beijingStartUtc(dateRange.value[1], 1) : undefined;
return {
keyword: filters.keyword || undefined,
identityCode: filters.identityCode || undefined,
result: filters.result || undefined,
action: filters.action || undefined,
objectType: filters.objectType || undefined,
objectId: filters.objectId || undefined,
requestId: filters.requestId || undefined,
actorId: filters.actorId || undefined,
occurredFrom: from,
occurredTo: to,
sort: filters.sort,
};
}
const queryKeys = [
'keyword',
'identityCode',
'result',
'action',
'objectType',
'objectId',
'requestId',
'actorId',
'occurredFrom',
'occurredTo',
'sort',
'page',
'size',
] as const;
let loadSequence = 0;
let syncingUrl = false;
function stateSnapshot() {
return JSON.stringify({
keyword: filters.keyword,
identityCode: filters.identityCode,
result: filters.result,
action: filters.action,
objectType: filters.objectType,
objectId: filters.objectId,
requestId: filters.requestId,
actorId: filters.actorId,
dateRange: dateRange.value,
sort: filters.sort,
page: pagination.current,
size: pagination.pageSize,
});
}
function restoreFromRoute() {
const value = (key: string) => (typeof route.query[key] === 'string' ? String(route.query[key]) : '');
Object.assign(filters, {
keyword: value('keyword'),
identityCode: value('identityCode'),
result: value('result'),
action: value('action'),
objectType: value('objectType'),
objectId: value('objectId'),
requestId: value('requestId'),
actorId: value('actorId'),
sort: parseAuditSort(route.query.sort),
});
const from = datePart(route.query.occurredFrom);
const to = datePart(route.query.occurredTo);
dateRange.value = from || to ? [from, to] : [];
pagination.current = parsePage(route.query.page);
pagination.pageSize = parsePageSize(route.query.size);
}
function urlQuery() {
const query = { ...(route.query as Record<string, string | undefined>) };
const values = queryFilters();
const next: Record<string, string | undefined> = {
keyword: values.keyword,
identityCode: values.identityCode,
result: values.result,
action: values.action,
objectType: values.objectType,
objectId: values.objectId,
requestId: values.requestId,
actorId: values.actorId,
occurredFrom: dateRange.value[0] || undefined,
occurredTo: dateRange.value[1] || undefined,
sort: values.sort,
page: String(pagination.current),
size: String(pagination.pageSize),
};
for (const key of queryKeys) query[key] = next[key];
return query;
}
async function syncUrl() {
syncingUrl = true;
try {
await router.replace({ query: urlQuery() });
} finally {
syncingUrl = false;
}
}
function formatDate(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: date.toLocaleString('zh-CN', { hour12: false, timeZone: 'Asia/Shanghai' });
}
function roleLabel(value?: string | null) {
return roleOptions.find((item) => item.value === value)?.label || value || '-';
}
function scopeLabel(value: Pick<AuditLog, 'companyPublicId' | 'projectPublicId'>) {
if (!value.companyPublicId && !value.projectPublicId) return '全局';
return [
value.companyPublicId ? `公司 ${value.companyPublicId}` : '',
value.projectPublicId ? `项目 ${value.projectPublicId}` : '',
]
.filter(Boolean)
.join(' / ');
}
function resultLabel(value: string) {
return resultOptions.find((item) => item.value === value)?.label || value || '-';
}
function resultTheme(value: string): TagTheme {
if (value === 'SUCCESS') return 'success';
if (value === 'FAILED' || value === 'DENIED') return 'danger';
return 'warning';
}
function prettyJson(value?: string | null) {
if (!value) return '无';
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
async function loadLogs() {
if (!canView.value) {
rows.value = [];
pagination.total = 0;
errorMessage.value = '当前身份没有查看审计日志的权限';
return false;
}
const sequence = ++loadSequence;
loading.value = true;
errorMessage.value = '';
try {
await syncUrl();
if (sequence !== loadSequence) return false;
const result = await getAuditLogs({ ...queryFilters(), page: pagination.current, size: pagination.pageSize });
if (sequence !== loadSequence) return false;
rows.value = result.items;
pagination.total = result.meta.totalElements;
pagination.current = result.meta.page;
pagination.pageSize = result.meta.size;
} catch (error) {
if (sequence !== loadSequence) return false;
errorMessage.value = (error as Error).message || '审计日志加载失败';
MessagePlugin.error(errorMessage.value);
rows.value = [];
pagination.total = 0;
return false;
} finally {
if (sequence === loadSequence) loading.value = false;
}
return true;
}
function search() {
pagination.current = 1;
void loadLogs();
}
function pageSizeChanged() {
pagination.current = 1;
void loadLogs();
}
function resetFilters() {
Object.assign(filters, {
keyword: '',
identityCode: '',
result: '',
action: '',
objectType: '',
objectId: '',
requestId: '',
actorId: '',
occurredFrom: '',
occurredTo: '',
sort: defaultSort,
});
dateRange.value = [];
search();
}
const detailVisible = ref(false);
const detailLoading = ref(false);
const detailError = ref('');
const detail = ref<AuditLog | null>(null);
const detailId = ref('');
let detailSequence = 0;
async function openDetail(row: AuditLog) {
if (!canView.value) return;
const sequence = ++detailSequence;
detailId.value = row.publicId;
detailVisible.value = true;
detailLoading.value = true;
detailError.value = '';
detail.value = null;
try {
const loaded = await getAuditLog(row.publicId);
if (sequence !== detailSequence || detailId.value !== row.publicId) return;
detail.value = loaded;
} catch (error) {
if (sequence !== detailSequence || detailId.value !== row.publicId) return;
detailError.value = (error as Error).message || '审计记录加载失败';
} finally {
if (sequence === detailSequence && detailId.value === row.publicId) detailLoading.value = false;
}
}
async function downloadLogs() {
if (exporting.value || !canView.value || !canExport.value) return;
exporting.value = true;
try {
const result = await exportAuditLogs(queryFilters());
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(result.content));
const sha256 = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
if (!/^[a-f\d]{64}$/i.test(result.sha256) || sha256 !== result.sha256.toLowerCase()) {
throw new Error('审计导出文件完整性校验失败,请重新导出');
}
const blob = new Blob([result.content], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = result.fileName;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
const refreshed = await loadLogs();
if (refreshed) MessagePlugin.success(`已导出 ${result.rowCount} 条审计记录`);
else MessagePlugin.warning('审计导出文件已生成,但列表重新加载失败,请手动刷新核对最新记录');
} catch (error) {
MessagePlugin.error((error as Error).message || '审计日志导出失败');
} finally {
exporting.value = false;
}
}
onMounted(() => {
void loadLogs();
});
watch(
() => route.fullPath,
() => {
if (syncingUrl) return;
const before = stateSnapshot();
restoreFromRoute();
if (before !== stateSnapshot()) void loadLogs();
},
{ flush: 'sync' },
);
watch(detailVisible, (visible) => {
if (!visible) {
detailSequence += 1;
detailId.value = '';
detailLoading.value = false;
}
});
</script>
<style scoped>
.audit-page {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.page-header,
.table-toolbar,
.alert-content,
.section-heading {
display: flex;
align-items: center;
}
.page-header {
justify-content: space-between;
gap: 16px;
}
.page-header h1 {
margin: 0 0 8px;
font-size: 24px;
line-height: 32px;
}
.page-header p {
margin: 0;
color: var(--td-text-color-secondary);
}
.filter-panel,
.table-panel {
min-width: 0;
padding: 16px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(3, minmax(170px, 1fr));
gap: 0 16px;
align-items: end;
}
.filter-actions {
display: flex;
gap: 8px;
padding-bottom: 24px;
}
.table-toolbar {
justify-content: space-between;
margin-bottom: 12px;
color: var(--td-text-color-secondary);
font-size: 13px;
}
.pagination-row {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.snapshot-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 24px;
}
.section-heading {
justify-content: space-between;
margin-bottom: 8px;
}
.section-heading h2 {
margin: 0;
font-size: 16px;
}
pre {
min-height: 220px;
max-height: 460px;
margin: 0;
padding: 14px;
overflow: auto;
color: var(--td-text-color-primary);
font: 12px/1.6 var(--td-font-family-mono);
white-space: pre-wrap;
word-break: break-all;
background: var(--td-bg-color-secondarycontainer);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
@media (width <= 900px) {
.filter-grid {
grid-template-columns: repeat(2, minmax(160px, 1fr));
}
.snapshot-grid {
grid-template-columns: 1fr;
}
.table-toolbar {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
}
@media (width <= 600px) {
.page-header {
align-items: flex-start;
flex-direction: column;
}
.filter-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,996 @@
<template>
<div class="settings-page">
<h1 class="page-title-sr-only">权限与配置</h1>
<header class="page-header page-header--actions-only" aria-label="页面操作">
<t-space
><t-button variant="outline" :loading="loading" @click="loadRows"
><template #icon><t-icon name="refresh" /></template>刷新</t-button
><t-button v-if="canCreate" theme="primary" :disabled="!canView" @click="openCreate"
><template #icon><t-icon name="add" /></template>{{ createButtonLabel }}</t-button
></t-space
>
</header>
<t-alert theme="info" :close-btn="false">
此页面仅管理权限和业务配置。用户、角色和数据范围变更后,受影响账号需要重新登录获取最新权限。
</t-alert>
<t-alert v-if="errorMessage" theme="error" :close-btn="false"
><div class="alert-content">
<span>{{ errorMessage }}</span
><t-link theme="primary" @click="loadRows">重新加载</t-link>
</div></t-alert
>
<t-tabs v-model="activeResource" @change="resourceChanged"
><t-tab-panel
v-for="resource in resourceOptions"
:key="resource.value"
:value="resource.value"
:label="resource.label"
/></t-tabs>
<section class="filter-panel" aria-label="权限与配置查询条件">
<t-form :data="filters" label-align="top" @submit="search"
><div class="filter-grid">
<t-form-item label="关键词"
><t-input
v-model="filters.keyword"
clearable
:placeholder="activeResourceOption.placeholder"
@enter="search"
/></t-form-item>
<div class="filter-actions">
<t-button theme="primary" type="submit"
><template #icon><t-icon name="search" /></template>查询</t-button
><t-button variant="outline" type="button" @click="resetFilters">重置</t-button>
</div>
</div></t-form
>
</section>
<section class="table-panel" aria-label="权限与配置列表">
<div class="table-toolbar">
<span>共 {{ pagination.total }} 条记录</span><span>{{ activeResourceOption.description }}</span>
</div>
<t-table
v-if="loading || rows.length"
:columns="columns"
:data="rows"
row-key="publicId"
:loading="loading"
:hover="true"
table-layout="fixed"
:horizontal-scroll-affixed-bottom="true"
>
<template #publicId="{ row }"
><t-link theme="primary" @click="openDetail(row)">{{ primaryText(row) }}</t-link></template
><template #status="{ row }"
><t-tag :theme="statusTheme(String(row.status || enabledStatus(row)))" variant="light">{{
statusLabel(String(row.status || enabledStatus(row)))
}}</t-tag></template
><template #enabled="{ row }"
><t-tag :theme="row.enabled ? 'success' : 'default'" variant="light">{{
row.enabled ? '启用' : '停用'
}}</t-tag></template
><template #roleCodes="{ row }">{{ listText(row.roleCodes) }}</template
><template #permissionCodes="{ row }">{{ listText(row.permissionCodes) }}</template
><template #updatedAt="{ row }">{{ formatDate(row.updatedAt) }}</template
><template #operations="{ row }"
><t-button size="small" variant="text" theme="primary" @click="openDetail(row)"
><template #icon><t-icon name="setting" /></template>查看与管理</t-button
></template
>
</t-table>
<t-empty v-else description="当前条件下暂无配置记录" />
<div v-if="pagination.total" 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="loadRows"
@page-size-change="pageSizeChanged"
/>
</div>
</section>
<t-drawer
v-model:visible="detailVisible"
:header="detail?.businessCode || '配置详情'"
size="min(860px, 96vw)"
:footer="false"
:close-on-overlay-click="false"
destroy-on-close
>
<t-loading :loading="detailLoading" show-overlay
><t-alert v-if="detailError" theme="error" :close-btn="false">{{ detailError }}</t-alert
><template v-if="detail">
<t-descriptions bordered :column="2" item-layout="vertical"
><t-descriptions-item label="资源类型">{{ activeResourceOption.label }}</t-descriptions-item
><t-descriptions-item label="状态"
><t-tag :theme="statusTheme(detail.status)" variant="light">{{
statusLabel(detail.status)
}}</t-tag></t-descriptions-item
><t-descriptions-item label="业务编码">{{ detail.businessCode }}</t-descriptions-item
><t-descriptions-item label="数据版本">V{{ detail.version }}</t-descriptions-item
><t-descriptions-item
v-for="entry in detailEntries"
:key="entry.key"
:label="fieldLabel(entry.key)"
:span="wideFields.includes(entry.key) ? 2 : 1"
>
<pre v-if="entry.key === 'value'" class="inline-json">{{ jsonText(entry.value) }}</pre>
<span v-else>{{ displayValue(entry.value, entry.key) }}</span></t-descriptions-item
></t-descriptions
>
<div v-if="detail.allowedActions.length" class="drawer-actions">
<t-button
v-if="detail.allowedActions.includes('EDIT')"
size="small"
variant="outline"
theme="primary"
@click="openEdit(detail)"
><template #icon><t-icon name="edit" /></template>编辑</t-button
><t-button
v-if="detail.allowedActions.includes('ENABLE')"
size="small"
variant="outline"
theme="primary"
@click="openCommand('enable')"
>启用</t-button
><t-button
v-if="detail.allowedActions.includes('DISABLE')"
size="small"
variant="outline"
theme="danger"
@click="openCommand('disable')"
>停用</t-button
><t-button
v-if="detail.allowedActions.includes('PUBLISH')"
size="small"
variant="outline"
theme="primary"
@click="openCommand('publish')"
><template #icon><t-icon name="check" /></template>发布</t-button
><t-button
v-if="detail.allowedActions.includes('RESTORE')"
size="small"
variant="outline"
theme="primary"
@click="openCommand('restore')"
><template #icon><t-icon name="rollback" /></template>恢复此版本</t-button
>
</div>
</template></t-loading
>
</t-drawer>
<t-dialog
v-model:visible="formVisible"
:header="formMode === 'create' ? createButtonLabel : `编辑${activeResourceOption.label}`"
width="min(760px, 96vw)"
:footer="false"
:close-on-overlay-click="false"
destroy-on-close
>
<t-alert v-if="formError" theme="error" :close-btn="false">{{ formError }}</t-alert>
<t-form :data="form" label-align="top" @submit="submitForm"
><div class="form-grid">
<template v-if="activeResource === 'users'"
><t-form-item v-if="formMode === 'create'" label="登录账号" name="username"
><t-input v-model="form.username" maxlength="64" placeholder="小写字母开头" /></t-form-item
><t-form-item label="姓名" name="displayName"
><t-input v-model="form.displayName" maxlength="128" /></t-form-item
><t-form-item label="部门" name="departmentName"
><t-input v-model="form.departmentName" maxlength="128" /></t-form-item
><t-form-item v-if="formMode === 'create'" label="初始密码" name="password"
><t-input v-model="form.password" type="password" maxlength="64" /></t-form-item
><t-form-item label="业务身份" name="roleCodes" class="wide-field"
><t-select
v-model="form.roleCodes"
:options="businessRoleOptions"
multiple
clearable
placeholder="至少选择一个业务身份" /></t-form-item
></template>
<template v-else-if="activeResource === 'roles'"
><t-form-item v-if="formMode === 'create'" label="角色编码" name="code"
><t-input v-model="form.code" maxlength="40" placeholder="大写字母、数字、下划线" /></t-form-item
><t-form-item label="角色名称" name="name"><t-input v-model="form.name" maxlength="128" /></t-form-item
><t-form-item label="职责说明" name="description" class="wide-field"
><t-textarea
v-model="form.description"
maxlength="255"
:autosize="{ minRows: 3, maxRows: 6 }" /></t-form-item
><t-form-item label="权限编码" name="permissionCodes" class="wide-field"
><t-textarea
v-model="form.permissionCodesText"
placeholder="每行一个权限编码,业务角色不可授予 admin:* 或 audit:log:*"
:autosize="{ minRows: 5, maxRows: 10 }" /></t-form-item
></template>
<template v-else-if="activeResource === 'scopes'"
><t-form-item v-if="formMode === 'create'" label="用户公开编号" name="userId"
><t-input v-model="form.userId" maxlength="26" /></t-form-item
><t-form-item v-if="formMode === 'create'" label="业务身份" name="roleCode"
><t-select v-model="form.roleCode" :options="businessRoleOptions" /></t-form-item
><t-form-item v-if="formMode === 'create'" label="权限编码" name="permissionCode"
><t-input v-model="form.permissionCode" maxlength="100" /></t-form-item
><t-form-item label="范围类型" name="scopeType"
><t-select v-model="form.scopeType" :options="scopeTypeOptions" /></t-form-item
><t-form-item v-if="form.scopeType === 'COMPANY'" label="公司公开编号" name="companyId"
><t-input v-model="form.companyId" maxlength="26" /></t-form-item
><t-form-item v-if="form.scopeType === 'PROJECT'" label="项目公开编号" name="projectId"
><t-input v-model="form.projectId" maxlength="26" /></t-form-item
><t-form-item label="金额上限" name="amountLimit"
><t-input-number v-model="form.amountLimit" :min="0" :decimal-places="2" /></t-form-item
></template>
<template v-else-if="activeResource === 'templates'"
><t-form-item v-if="formMode === 'create'" label="表单类型" name="formType"
><t-input v-model="form.formType" maxlength="32" placeholder="例如 OA-01" /></t-form-item
><t-form-item label="模板名称" name="name"><t-input v-model="form.name" maxlength="128" /></t-form-item
><t-form-item v-if="formMode === 'create'" label="模板版本" name="templateVersion"
><t-input-number v-model="form.templateVersion" :min="1" /></t-form-item
><t-form-item v-if="formMode === 'create'" label="结构版本" name="schemaVersion"
><t-input-number v-model="form.schemaVersion" :min="1" /></t-form-item
></template>
<template v-else
><t-form-item v-if="formMode === 'create'" label="参数组" name="parameterGroup"
><t-input v-model="form.parameterGroup" maxlength="80" /></t-form-item
><t-form-item v-if="formMode === 'create'" label="期望版本号" name="versionNo"
><t-input-number v-model="form.versionNo" :min="1" placeholder="可留空,由系统连续生成" /></t-form-item
><t-form-item label="参数 JSON" name="valueJson" class="wide-field"
><t-textarea
v-model="form.valueJson"
:autosize="{ minRows: 10, maxRows: 18 }"
placeholder='例如 {"key":"value"}' /></t-form-item
></template>
</div>
<div class="dialog-footer">
<t-button variant="outline" type="button" @click="formVisible = false">取消</t-button
><t-button theme="primary" type="submit" :loading="formLoading">{{
formMode === 'create' ? '创建草稿' : '保存修改'
}}</t-button>
</div></t-form
>
</t-dialog>
<t-dialog
v-model:visible="commandVisible"
:header="commandLabel(commandType)"
width="min(560px, 94vw)"
:footer="false"
:close-on-overlay-click="false"
destroy-on-close
><t-alert v-if="commandError" theme="error" :close-btn="false">{{ commandError }}</t-alert
><t-form :data="commandForm" label-align="top" @submit="submitCommand"
><t-alert theme="warning" :close-btn="false">{{ commandWarning }}</t-alert
><t-form-item label="操作原因"
><t-textarea v-model="commandForm.reason" maxlength="1000" :autosize="{ minRows: 3, maxRows: 6 }"
/></t-form-item>
<div class="dialog-footer">
<t-button variant="outline" type="button" @click="commandVisible = false">取消</t-button
><t-button :theme="commandType === 'disable' ? 'danger' : 'primary'" type="submit" :loading="commandLoading"
>确认{{ commandLabel(commandType) }}</t-button
>
</div></t-form
></t-dialog
>
</div>
</template>
<script setup lang="ts">
import type { PrimaryTableCol } from 'tdesign-vue-next';
import { MessagePlugin } from 'tdesign-vue-next';
import { computed, onMounted, reactive, ref } from 'vue';
import type {
GovernanceCreatePayload,
GovernanceResource,
GovernanceResourceView,
GovernanceRow,
} from '@/api/governance';
import {
commandGovernanceResource,
createGovernanceResource,
getGovernanceDetail,
getGovernanceResource,
updateGovernanceResource,
} from '@/api/governance';
import { useUserStore } from '@/store';
defineOptions({ name: 'SystemSettingsPage' });
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
type FormMode = 'create' | 'edit';
type CommandType = 'enable' | 'disable' | 'publish' | 'restore';
interface ResourceOption {
label: string;
value: GovernanceResource;
description: string;
placeholder: string;
createLabel: string;
viewPermission: string;
permission: string;
}
interface FormState {
publicId: string;
version: number;
code: string;
name: string;
description: string;
username: string;
displayName: string;
departmentName: string;
password: string;
roleCode: string;
roleCodes: string[];
permissionCode: string;
permissionCodesText: string;
userId: string;
scopeType: string;
companyId: string;
projectId: string;
amountLimit?: number;
formType: string;
templateVersion: number;
schemaVersion: number;
parameterGroup: string;
versionNo?: number;
valueJson: string;
}
const resourceOptions: ResourceOption[] = [
{
label: '用户',
value: 'users',
description: '账号、姓名、部门和业务身份',
placeholder: '账号或姓名',
createLabel: '新建用户',
viewPermission: 'admin:user:view',
permission: 'admin:user:create',
},
{
label: '角色',
value: 'roles',
description: '业务身份职责和最小权限集合',
placeholder: '角色编码或名称',
createLabel: '新建角色',
viewPermission: 'admin:role:view',
permission: 'admin:role:create',
},
{
label: '数据范围',
value: 'scopes',
description: '用户在身份和权限下可访问的数据边界',
placeholder: '账号、公司或项目编号',
createLabel: '新增数据范围',
viewPermission: 'admin:scope:view',
permission: 'admin:scope:create',
},
{
label: '表单模板',
value: 'templates',
description: 'OA 表单模板版本和发布状态',
placeholder: '表单类型或模板名称',
createLabel: '新建模板版本',
viewPermission: 'admin:template:view',
permission: 'admin:template:create',
},
{
label: '参数版本',
value: 'parameters',
description: '规则参数的草稿、发布和停用版本',
placeholder: '参数组',
createLabel: '新建参数版本',
viewPermission: 'admin:parameter:view',
permission: 'admin:parameter:create',
},
];
const activeResource = ref<GovernanceResource>('users');
const activeResourceOption = computed(
() => resourceOptions.find((item) => item.value === activeResource.value) || resourceOptions[0],
);
const userStore = useUserStore();
const canView = computed(() => userStore.hasPermission(activeResourceOption.value.viewPermission));
const canCreate = computed(() => userStore.hasPermission(activeResourceOption.value.permission));
const createButtonLabel = computed(() => activeResourceOption.value.createLabel);
const filters = reactive({ keyword: '' });
const pagination = reactive({ current: 1, pageSize: 20, total: 0 });
const rows = ref<GovernanceRow[]>([]);
const loading = ref(false);
const errorMessage = ref('');
const businessRoleOptions = [
{ label: '项目管理', value: 'PROJECT_MANAGER' },
{ label: '财务管理', value: 'FINANCE_MANAGER' },
{ label: '资料管理', value: 'ARCHIVE_MANAGER' },
];
const scopeTypeOptions = [
{ label: '全局', value: 'GLOBAL' },
{ label: '公司', value: 'COMPANY' },
{ label: '项目', value: 'PROJECT' },
];
const columnLabels: Record<string, string> = {
publicId: '编号',
username: '账号',
displayName: '姓名',
departmentName: '部门',
roleCodes: '业务身份',
enabled: '状态',
code: '角色编码',
name: '名称',
description: '说明',
memberCount: '成员数',
userPublicId: '用户编号',
roleCode: '身份',
permissionCode: '权限',
scopeType: '范围类型',
companyPublicId: '公司范围',
projectPublicId: '项目范围',
amountLimit: '金额上限',
formType: '表单类型',
templateVersion: '模板版本',
schemaVersion: '结构版本',
parameterGroup: '参数组',
versionNo: '参数版本',
status: '状态',
publishedAt: '发布时间',
effectiveAt: '生效时间',
updatedAt: '更新时间',
operations: '操作',
};
const columnsByResource: Record<GovernanceResource, string[]> = {
users: ['publicId', 'displayName', 'departmentName', 'roleCodes', 'enabled', 'updatedAt'],
roles: ['publicId', 'name', 'description', 'memberCount', 'enabled', 'updatedAt'],
scopes: [
'publicId',
'username',
'roleCode',
'permissionCode',
'scopeType',
'companyPublicId',
'projectPublicId',
'amountLimit',
'status',
'updatedAt',
],
templates: ['publicId', 'formType', 'templateVersion', 'name', 'schemaVersion', 'status', 'publishedAt', 'updatedAt'],
parameters: ['publicId', 'parameterGroup', 'versionNo', 'status', 'effectiveAt', 'updatedAt'],
};
const columns = computed<PrimaryTableCol[]>(() => [
...columnsByResource[activeResource.value].map((key): PrimaryTableCol => ({
colKey: key,
title: columnLabels[key] || key,
width: key === 'description' ? 260 : key === 'permissionCode' ? 220 : /At$/.test(key) ? 180 : 150,
ellipsis: true,
fixed: key === 'publicId' ? 'left' : undefined,
cell: ['publicId', 'status', 'enabled', 'roleCodes', 'permissionCodes', 'updatedAt'].includes(key)
? undefined
: (_h, { row }) => displayValue((row as GovernanceRow)[key as keyof GovernanceRow], key),
})),
{ colKey: 'operations', title: '操作', width: 140, fixed: 'right' },
]);
function primaryText(row: GovernanceRow) {
return String(row.username || row.code || row.formType || row.parameterGroup || row.publicId || '-');
}
function enabledStatus(row: GovernanceRow) {
return row.enabled === false ? 'DISABLED' : 'ACTIVE';
}
function listText(value: unknown) {
if (Array.isArray(value)) return value.join('、') || '-';
return String(value || '-').replaceAll(',', '、');
}
function formatDate(value: unknown) {
if (!value) return '-';
const date = new Date(String(value));
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { hour12: false });
}
function displayValue(value: unknown, key: string) {
if (value === null || value === undefined || value === '') return '-';
if (Array.isArray(value)) return value.join('、') || '-';
if (typeof value === 'boolean') return value ? '是' : '否';
if (/At$/.test(key)) return formatDate(value);
if (key === 'amountLimit')
return new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value));
if (typeof value === 'object') return jsonText(value);
return String(value);
}
function statusLabel(value: string) {
return (
({ ACTIVE: '已生效', DISABLED: '已停用', DRAFT: '草稿', RETIRED: '已停用' } as Record<string, string>)[value] ||
value ||
'-'
);
}
function statusTheme(value: string): TagTheme {
if (value === 'ACTIVE') return 'success';
if (value === 'DISABLED' || value === 'RETIRED') return 'default';
return 'warning';
}
function fieldLabel(key: string) {
return (
(
{
...columnLabels,
roleCodes: '业务身份',
permissionCodes: '权限编码',
value: '参数 JSON',
id: '内部编号',
userId: '用户编号',
companyId: '公司编号',
projectId: '项目编号',
} as Record<string, string>
)[key] || key
);
}
function jsonText(value: unknown) {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
async function loadRows() {
if (!canView.value) {
rows.value = [];
pagination.total = 0;
errorMessage.value = '当前身份没有查看该配置资源的权限';
return false;
}
loading.value = true;
errorMessage.value = '';
try {
const result = await getGovernanceResource(
activeResource.value,
pagination.current,
pagination.pageSize,
filters.keyword.trim() || undefined,
);
rows.value = result.items;
pagination.total = result.meta.totalElements;
} catch (error) {
errorMessage.value = (error as Error).message || '权限与配置加载失败';
MessagePlugin.error(errorMessage.value);
rows.value = [];
pagination.total = 0;
return false;
} finally {
loading.value = false;
}
return true;
}
async function reloadServerState(publicId?: string) {
const listLoaded = await loadRows();
const detailLoaded = !publicId || !detailVisible.value || (await loadDetail(publicId));
return listLoaded && detailLoaded;
}
function search() {
pagination.current = 1;
void loadRows();
}
function resetFilters() {
filters.keyword = '';
search();
}
function resourceChanged() {
pagination.current = 1;
filters.keyword = '';
detailVisible.value = false;
void loadRows();
}
function pageSizeChanged() {
pagination.current = 1;
void loadRows();
}
const detailVisible = ref(false);
const detailLoading = ref(false);
const detailError = ref('');
const detail = ref<GovernanceResourceView | null>(null);
const wideFields = ['description', 'roleCodes', 'permissionCodes', 'value'];
const detailEntries = computed(() =>
Object.entries(detail.value?.values || {})
.filter(([key]) => !['publicId', 'enabled'].includes(key))
.map(([key, value]) => ({ key, value })),
);
async function loadDetail(publicId: string) {
if (!canView.value) return false;
detailLoading.value = true;
detailError.value = '';
try {
detail.value = await getGovernanceDetail(activeResource.value, publicId);
} catch (error) {
detailError.value = (error as Error).message || '配置详情加载失败';
return false;
} finally {
detailLoading.value = false;
}
return true;
}
function openDetail(row: GovernanceRow) {
const publicId = String(row.publicId || '');
if (!publicId) return;
detailVisible.value = true;
detail.value = null;
void loadDetail(publicId);
}
const emptyForm = (): FormState => ({
publicId: '',
version: 0,
code: '',
name: '',
description: '',
username: '',
displayName: '',
departmentName: '',
password: '',
roleCode: '',
roleCodes: [],
permissionCode: '',
permissionCodesText: '',
userId: '',
scopeType: 'GLOBAL',
companyId: '',
projectId: '',
amountLimit: undefined,
formType: '',
templateVersion: 1,
schemaVersion: 1,
parameterGroup: '',
versionNo: undefined,
valueJson: '{\n \n}',
});
const form = reactive<FormState>(emptyForm());
const formVisible = ref(false);
const formMode = ref<FormMode>('create');
const formLoading = ref(false);
const formError = ref('');
function resetForm() {
Object.assign(form, emptyForm());
}
function openCreate() {
if (!canView.value) return;
formMode.value = 'create';
formError.value = '';
resetForm();
formVisible.value = true;
}
function openEdit(view: GovernanceResourceView) {
if (!canView.value) return;
formMode.value = 'edit';
formError.value = '';
resetForm();
const values = view.values;
Object.assign(form, {
publicId: view.publicId,
version: view.version,
name: String(values.name || ''),
description: String(values.description || ''),
displayName: String(values.displayName || ''),
departmentName: String(values.departmentName || ''),
roleCodes: Array.isArray(values.roleCodes) ? values.roleCodes.map(String) : [],
permissionCodesText: Array.isArray(values.permissionCodes) ? values.permissionCodes.join('\n') : '',
scopeType: String(values.scopeType || 'GLOBAL'),
companyId: String(values.companyId || ''),
projectId: String(values.projectId || ''),
amountLimit:
values.amountLimit === null || values.amountLimit === undefined ? undefined : Number(values.amountLimit),
valueJson: activeResource.value === 'parameters' ? jsonText(values.value) : form.valueJson,
});
formVisible.value = true;
}
function lines(value: string) {
return [
...new Set(
value
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean),
),
];
}
function validateForm() {
if (
activeResource.value === 'users' &&
(!form.displayName.trim() || (formMode.value === 'create' && (!form.username.trim() || !form.password)))
) {
return '请填写账号、姓名和初始密码';
}
if (activeResource.value === 'roles' && (!form.name.trim() || (formMode.value === 'create' && !form.code.trim())))
return '请填写角色编码和名称';
if (
activeResource.value === 'scopes' &&
formMode.value === 'create' &&
(!form.userId.trim() || !form.roleCode || !form.permissionCode.trim())
) {
return '请填写用户、身份和权限编码';
}
if (activeResource.value === 'scopes' && form.scopeType === 'COMPANY' && !form.companyId.trim())
return '公司范围必须填写公司编号';
if (activeResource.value === 'scopes' && form.scopeType === 'PROJECT' && !form.projectId.trim())
return '项目范围必须填写项目编号';
if (
activeResource.value === 'templates' &&
(!form.name.trim() || (formMode.value === 'create' && !form.formType.trim()))
) {
return '请填写表单类型和模板名称';
}
if (activeResource.value === 'parameters') {
if (formMode.value === 'create' && !form.parameterGroup.trim()) return '请填写参数组';
try {
const parsed = JSON.parse(form.valueJson);
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') return '参数必须是 JSON 对象';
} catch {
return '参数 JSON 格式无效';
}
}
return '';
}
function createPayload(): GovernanceCreatePayload {
switch (activeResource.value) {
case 'users':
return {
username: form.username.trim(),
displayName: form.displayName.trim(),
departmentName: form.departmentName.trim() || undefined,
password: form.password,
roleCodes: form.roleCodes,
};
case 'roles':
return {
code: form.code.trim().toUpperCase(),
name: form.name.trim(),
description: form.description.trim() || undefined,
permissionCodes: lines(form.permissionCodesText),
};
case 'scopes':
return {
userId: form.userId.trim(),
roleCode: form.roleCode,
permissionCode: form.permissionCode.trim(),
scopeType: form.scopeType,
companyId: form.scopeType === 'COMPANY' ? form.companyId.trim() : undefined,
projectId: form.scopeType === 'PROJECT' ? form.projectId.trim() : undefined,
amountLimit: form.amountLimit,
};
case 'templates':
return {
formType: form.formType.trim().toUpperCase(),
name: form.name.trim(),
templateVersion: form.templateVersion,
schemaVersion: form.schemaVersion,
};
case 'parameters':
return {
parameterGroup: form.parameterGroup.trim().toUpperCase(),
versionNo: form.versionNo,
valueJson: form.valueJson.trim(),
};
}
}
function updatePayload() {
const base = { version: form.version };
switch (activeResource.value) {
case 'users':
return {
...base,
displayName: form.displayName.trim(),
departmentName: form.departmentName.trim() || undefined,
roleCodes: form.roleCodes,
};
case 'roles':
return {
...base,
name: form.name.trim(),
description: form.description.trim() || undefined,
permissionCodes: lines(form.permissionCodesText),
};
case 'scopes':
return {
...base,
scopeType: form.scopeType,
companyId: form.scopeType === 'COMPANY' ? form.companyId.trim() : undefined,
projectId: form.scopeType === 'PROJECT' ? form.projectId.trim() : undefined,
amountLimit: form.amountLimit,
};
case 'templates':
return { ...base, name: form.name.trim() };
case 'parameters':
return { ...base, valueJson: form.valueJson.trim() };
}
}
async function submitForm() {
if (formLoading.value) return;
formError.value = validateForm();
if (formError.value) return;
formLoading.value = true;
try {
const result =
formMode.value === 'create'
? await createGovernanceResource(activeResource.value, createPayload())
: await updateGovernanceResource(activeResource.value, form.publicId, updatePayload());
formVisible.value = false;
const refreshed = await reloadServerState(result.publicId);
if (refreshed) MessagePlugin.success(formMode.value === 'create' ? '配置已创建' : '配置已更新');
else MessagePlugin.warning('配置已提交,但服务端状态重新读取失败,请刷新后核对结果');
} catch (error) {
formError.value = (error as Error).message || '配置保存失败';
} finally {
formLoading.value = false;
}
}
const commandVisible = ref(false);
const commandLoading = ref(false);
const commandError = ref('');
const commandType = ref<CommandType>('enable');
const commandForm = reactive({ reason: '' });
const commandWarning = computed(() =>
commandType.value === 'restore'
? '恢复后,此版本将重新生效,当前生效版本会自动停用;操作会更新版本并写入审计日志。'
: '该操作会更新配置版本并写入审计日志,请确认当前详情版本仍为最新。',
);
function commandLabel(value: CommandType) {
if (value === 'enable') return '启用';
if (value === 'disable') return '停用';
if (value === 'restore') return '恢复旧版本';
return '发布';
}
function openCommand(value: CommandType) {
if (!canView.value) return;
commandType.value = value;
commandForm.reason = '';
commandError.value = '';
commandVisible.value = true;
}
async function submitCommand() {
if (!detail.value || commandLoading.value) return;
const publicId = detail.value.publicId;
commandLoading.value = true;
commandError.value = '';
try {
const result = await commandGovernanceResource(activeResource.value, detail.value.publicId, commandType.value, {
version: detail.value.version,
reason: commandForm.reason.trim() || undefined,
});
commandVisible.value = false;
const refreshed = await reloadServerState(result.publicId || publicId);
if (refreshed) MessagePlugin.success(`${commandLabel(commandType.value)}操作已完成`);
else MessagePlugin.warning('配置操作已提交,但服务端状态重新读取失败,请刷新后核对结果');
} catch (error) {
commandError.value = (error as Error).message || '配置操作失败';
} finally {
commandLoading.value = false;
}
}
onMounted(() => void loadRows());
</script>
<style scoped>
.settings-page {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.page-header,
.table-toolbar,
.alert-content,
.drawer-actions,
.dialog-footer {
display: flex;
align-items: center;
}
.page-header {
justify-content: space-between;
gap: 16px;
}
.page-header h1 {
margin: 0 0 8px;
font-size: 24px;
line-height: 32px;
}
.page-header p {
margin: 0;
color: var(--td-text-color-secondary);
}
.filter-panel,
.table-panel {
min-width: 0;
padding: 16px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
.filter-grid {
display: grid;
grid-template-columns: minmax(260px, 1fr) auto;
gap: 0 16px;
align-items: end;
max-width: 760px;
}
.filter-actions {
display: flex;
gap: 8px;
padding-bottom: 24px;
}
.table-toolbar {
justify-content: space-between;
margin-bottom: 12px;
color: var(--td-text-color-secondary);
font-size: 13px;
}
.pagination-row {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.drawer-actions {
flex-wrap: wrap;
gap: 8px;
margin-top: 18px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 0 16px;
}
.wide-field {
grid-column: 1 / -1;
}
.dialog-footer {
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
}
.inline-json {
max-height: 300px;
margin: 0;
padding: 12px;
overflow: auto;
font: 12px/1.6 var(--td-font-family-mono);
white-space: pre-wrap;
word-break: break-all;
background: var(--td-bg-color-secondarycontainer);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
@media (width <= 900px) {
.table-toolbar {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
}
@media (width <= 600px) {
.page-header {
align-items: flex-start;
flex-direction: column;
}
.filter-grid,
.form-grid {
grid-template-columns: 1fr;
}
.wide-field {
grid-column: auto;
}
}
</style>
@@ -0,0 +1,575 @@
<template>
<div class="system-update-page">
<h1 class="page-title-sr-only">系统更新</h1>
<header class="page-header page-header--actions-only" aria-label="页面操作">
<t-button variant="outline" :loading="statusLoading || historyLoading" @click="refreshPage">
<template #icon><t-icon name="refresh" /></template>
刷新状态
</t-button>
</header>
<section class="update-panel" aria-label="系统版本与更新操作">
<div class="update-heading">
<div class="version-grid">
<div class="version-item">
<span>当前版本</span>
<strong>{{ updateStatus?.currentVersion || '-' }}</strong>
</div>
<div class="version-item">
<span>最新版本</span>
<strong>{{ updateStatus?.latestVersion || '尚未获取' }}</strong>
</div>
<div class="version-item">
<span>更新状态</span>
<t-tag :theme="updateStateTheme" variant="light">{{ updateStateLabel }}</t-tag>
</div>
</div>
<div class="update-actions">
<t-button
variant="outline"
:loading="fetchingUpdate"
:disabled="!canFetchUpdate || updateBusy || restarting"
@click="fetchUpdate"
>
<template #icon><t-icon name="cloud-download" /></template>
获取更新
</t-button>
<t-button
v-if="canRestart"
theme="primary"
:loading="restarting"
:disabled="updateBusy || fetchingUpdate"
@click="restartNow"
>
<template #icon><t-icon name="poweroff" /></template>
立即重启
</t-button>
</div>
</div>
<t-steps :current="updateStep" :layout="stepLayout" status="process" class="update-steps">
<t-step-item title="获取版本" content="检查最新发布" />
<t-step-item title="下载校验" content="下载并验证签名" />
<t-step-item title="立即重启" content="备份并切换版本" />
<t-step-item title="完成更新" content="健康检查后刷新" />
</t-steps>
<t-alert v-if="statusError" theme="error" :close-btn="false">
<div class="alert-content">
<span>{{ statusError }}</span>
<t-link theme="primary" @click="refreshPage">重新加载</t-link>
</div>
</t-alert>
<t-alert v-else-if="updateRefreshSeconds !== null" theme="success" :close-btn="false" aria-live="polite">
新版本已通过健康检查,页面将在 {{ updateRefreshSeconds }} 秒后自动刷新。
</t-alert>
<t-alert v-else :theme="updateStatus?.enabled === false ? 'warning' : 'info'" :close-btn="false">
{{ updateStatus?.message || '正在读取系统版本状态' }}
</t-alert>
<div v-if="updateStatus?.releaseNotes" class="release-notes">
<span>版本说明</span>
<p>{{ updateStatus.releaseNotes }}</p>
</div>
</section>
<section class="history-panel" aria-label="历史更新记录">
<div class="section-heading">
<div>
<h2>历史更新记录</h2>
<p>记录版本获取、下载和重启安装操作。</p>
</div>
<span>最近 {{ historyRows.length }} 条</span>
</div>
<t-alert v-if="historyError" theme="error" :close-btn="false">
<div class="alert-content">
<span>{{ historyError }}</span>
<t-link theme="primary" @click="() => loadHistory()">重新加载</t-link>
</div>
</t-alert>
<t-table
v-if="historyLoading || historyRows.length"
:columns="historyColumns"
:data="historyRows"
row-key="publicId"
:loading="historyLoading"
table-layout="fixed"
:horizontal-scroll-affixed-bottom="true"
>
<template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template>
<template #actionCode="{ row }">{{ actionLabel(row.actionCode) }}</template>
<template #version="{ row }">{{ historyVersion(row) }}</template>
<template #resultCode="{ row }">
<t-tag :theme="resultTheme(row.resultCode)" variant="light">{{ resultLabel(row.resultCode) }}</t-tag>
</template>
<template #reason="{ row }">{{ row.reason || '-' }}</template>
</t-table>
<t-empty v-else description="暂无系统更新记录" />
</section>
</div>
</template>
<script setup lang="ts">
import type { PrimaryTableCol } from 'tdesign-vue-next';
import { MessagePlugin } from 'tdesign-vue-next';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import type { AuditLog } from '@/api/audit';
import { getAuditLogs } from '@/api/audit';
import type { SystemUpdateView } from '@/api/system-update';
import {
checkSystemUpdate,
downloadSystemUpdate,
getSystemUpdateStatus,
installSystemUpdate,
} from '@/api/system-update';
defineOptions({ name: 'SystemUpdatePage' });
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
const updateStatus = ref<SystemUpdateView | null>(null);
const isNarrow = ref(false);
const statusLoading = ref(false);
const fetchingUpdate = ref(false);
const restarting = ref(false);
const statusError = ref('');
const historyRows = ref<AuditLog[]>([]);
const historyLoading = ref(false);
const historyError = ref('');
const updateRefreshSeconds = ref<number | null>(null);
const awaitingRefresh = ref(false);
let pollTimer: ReturnType<typeof setInterval> | undefined;
let refreshTimer: ReturnType<typeof setInterval> | undefined;
const busyStates = new Set([
'QUEUED',
'DOWNLOAD_QUEUED',
'INSTALL_QUEUED',
'VERIFYING',
'DOWNLOADING',
'BACKING_UP',
'INSTALLING',
'RUNNING',
]);
const installStates = new Set(['INSTALL_QUEUED', 'BACKING_UP', 'INSTALLING', 'RUNNING']);
const updateBusy = computed(() => busyStates.has(updateStatus.value?.state || ''));
const stepLayout = computed(() => (isNarrow.value ? 'vertical' : 'horizontal'));
const currentIsLatest = computed(
() =>
updateStatus.value?.enabled === true &&
Boolean(updateStatus.value.latestVersion) &&
!updateStatus.value.updateAvailable &&
!updateBusy.value &&
!['FAILED', 'UNKNOWN'].includes(updateStatus.value.state),
);
const canFetchUpdate = computed(
() =>
updateStatus.value?.enabled !== false &&
updateStatus.value?.state !== 'READY' &&
(updateStatus.value?.allowedActions.includes('CHECK') === true ||
updateStatus.value?.allowedActions.includes('DOWNLOAD') === true),
);
const canRestart = computed(() => updateStatus.value?.allowedActions.includes('INSTALL') === true);
const updateStep = computed(() => {
const state = updateStatus.value?.state || 'IDLE';
if (state === 'SUCCEEDED' || state === 'CURRENT' || currentIsLatest.value) return 3;
if (installStates.has(state)) return 2;
if (['DOWNLOAD_QUEUED', 'VERIFYING', 'DOWNLOADING', 'READY'].includes(state)) return 1;
return 0;
});
const updateStateLabel = computed(() => {
if (currentIsLatest.value) return '已是最新';
return (
{
IDLE: '待获取',
DISABLED: '未启用',
CURRENT: '已是最新',
QUEUED: '已排队',
DOWNLOAD_QUEUED: '等待下载',
INSTALL_QUEUED: '等待重启',
VERIFYING: '校验中',
DOWNLOADING: '下载中',
READY: '等待重启',
BACKING_UP: '备份中',
INSTALLING: '安装中',
RUNNING: '重启中',
SUCCEEDED: '更新成功',
FAILED: '更新失败',
UNKNOWN: '状态未知',
}[updateStatus.value?.state || 'IDLE'] ||
updateStatus.value?.state ||
'待获取'
);
});
const updateStateTheme = computed<TagTheme>(() => {
const state = updateStatus.value?.state || 'IDLE';
if (['SUCCEEDED', 'CURRENT', 'READY'].includes(state) || currentIsLatest.value) return 'success';
if (['FAILED', 'UNKNOWN'].includes(state)) return 'danger';
if (updateBusy.value) return 'warning';
return 'default';
});
const historyColumns: PrimaryTableCol[] = [
{ colKey: 'occurredAt', title: '操作时间', width: 180 },
{ colKey: 'version', title: '版本', width: 170, ellipsis: true },
{ colKey: 'actionCode', title: '操作', width: 150 },
{ colKey: 'username', title: '操作人', width: 130, ellipsis: true },
{ colKey: 'resultCode', title: '结果', width: 100 },
{ colKey: 'reason', title: '说明', minWidth: 220, ellipsis: true },
];
async function loadStatus(silent = false) {
if (!silent) {
statusLoading.value = true;
statusError.value = '';
}
try {
const status = await getSystemUpdateStatus();
updateStatus.value = status;
if (installStates.has(status.state)) awaitingRefresh.value = true;
handleCompletion(status);
if (busyStates.has(status.state)) startPolling();
else stopPolling();
} catch (error) {
if (!silent) statusError.value = friendlyError(error, '系统版本状态读取失败');
} finally {
if (!silent) statusLoading.value = false;
}
}
async function loadHistory(silent = false) {
if (!silent) {
historyLoading.value = true;
historyError.value = '';
}
try {
const result = await getAuditLogs({
objectType: 'SYSTEM_UPDATE',
sort: 'occurredAt,desc',
page: 1,
size: 20,
});
historyRows.value = result.items;
} catch (error) {
if (!silent) historyError.value = friendlyError(error, '历史更新记录加载失败');
} finally {
if (!silent) historyLoading.value = false;
}
}
async function refreshPage() {
await Promise.all([loadStatus(), loadHistory()]);
}
async function fetchUpdate() {
if (fetchingUpdate.value || updateBusy.value || !canFetchUpdate.value) return;
fetchingUpdate.value = true;
statusError.value = '';
try {
const checked = await checkSystemUpdate();
updateStatus.value = checked;
await loadHistory(true);
if (!checked.updateAvailable) {
MessagePlugin.info('当前已是最新版本');
return;
}
if (!checked.latestVersion || !checked.allowedActions.includes('DOWNLOAD')) {
throw new Error('发现新版本,但当前账号没有下载更新包的权限');
}
updateStatus.value = await downloadSystemUpdate(checked.latestVersion);
await loadHistory(true);
MessagePlugin.success('更新包已开始下载,下载和签名校验完成后可立即重启');
startPolling();
} catch (error) {
statusError.value = friendlyError(error, '获取更新失败');
} finally {
fetchingUpdate.value = false;
}
}
async function restartNow() {
const version = updateStatus.value?.latestVersion;
if (!version || restarting.value || !canRestart.value) return;
restarting.value = true;
statusError.value = '';
stopRefreshCountdown();
try {
updateStatus.value = await installSystemUpdate(version, '管理员确认立即重启');
awaitingRefresh.value = true;
MessagePlugin.success('重启任务已提交,系统恢复后页面将自动刷新');
await loadHistory(true);
startPolling();
} catch (error) {
statusError.value = friendlyError(error, '立即重启失败');
} finally {
restarting.value = false;
}
}
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(() => void loadStatus(true), 3000);
}
function stopPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = undefined;
}
function handleCompletion(status: SystemUpdateView) {
if (!awaitingRefresh.value) return;
if (status.state === 'SUCCEEDED') {
awaitingRefresh.value = false;
startRefreshCountdown();
void loadHistory(true);
return;
}
if (['FAILED', 'CURRENT', 'DISABLED', 'UNKNOWN'].includes(status.state)) {
awaitingRefresh.value = false;
stopRefreshCountdown();
}
}
function startRefreshCountdown() {
if (refreshTimer) return;
updateRefreshSeconds.value = 10;
refreshTimer = setInterval(() => {
if (updateRefreshSeconds.value === null) return;
if (updateRefreshSeconds.value <= 1) {
stopRefreshCountdown();
window.location.reload();
return;
}
updateRefreshSeconds.value -= 1;
}, 1000);
}
function stopRefreshCountdown() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = undefined;
updateRefreshSeconds.value = null;
}
function historyVersion(row: AuditLog) {
for (const source of [row.afterJson, row.beforeJson]) {
if (!source) continue;
try {
const parsed = JSON.parse(source) as Record<string, unknown>;
const version = parsed.targetVersion || parsed.latestVersion || parsed.currentVersion || parsed.version;
if (version) return String(version);
} catch {
continue;
}
}
return '-';
}
function actionLabel(action: string) {
return (
{
SYSTEM_UPDATE_CHECK: '获取版本',
SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包',
SYSTEM_UPDATE_REQUEST: '立即重启',
}[action] || action
);
}
function resultLabel(result: string) {
return { SUCCESS: '成功', FAILED: '失败', DENIED: '已拒绝', BLOCKED: '已阻断' }[result] || result;
}
function resultTheme(result: string): TagTheme {
if (result === 'SUCCESS') return 'success';
if (result === 'FAILED' || result === 'BLOCKED') return 'danger';
if (result === 'DENIED') return 'warning';
return 'default';
}
function formatDateTime(value?: string | null) {
if (!value) return '-';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false });
}
function friendlyError(error: unknown, fallback: string) {
return (error as Error)?.message || fallback;
}
function updateViewportMode() {
isNarrow.value = window.innerWidth <= 640;
}
onMounted(() => {
updateViewportMode();
window.addEventListener('resize', updateViewportMode);
void refreshPage();
});
onBeforeUnmount(() => {
stopPolling();
stopRefreshCountdown();
window.removeEventListener('resize', updateViewportMode);
});
</script>
<style scoped>
.system-update-page {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
min-width: 0;
}
.page-header,
.update-heading,
.update-actions,
.section-heading,
.alert-content {
display: flex;
align-items: center;
}
.page-header {
justify-content: flex-end;
}
.update-panel,
.history-panel {
width: 100%;
min-width: 0;
padding: 20px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
.update-panel {
display: flex;
flex-direction: column;
gap: 18px;
}
.update-heading,
.section-heading {
justify-content: space-between;
gap: 20px;
min-width: 0;
}
.version-grid {
display: grid;
grid-template-columns: repeat(3, minmax(150px, 1fr));
gap: 24px;
min-width: 0;
}
.version-item {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.version-item > span {
color: var(--td-text-color-secondary);
font-size: 13px;
}
.version-item strong {
overflow: hidden;
color: var(--td-text-color-primary);
font-size: 16px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.update-actions {
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.release-notes {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--td-component-border);
}
.release-notes span,
.section-heading p,
.section-heading > span {
color: var(--td-text-color-secondary);
font-size: 13px;
}
.release-notes p {
min-width: 0;
color: var(--td-text-color-primary);
line-height: 22px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.section-heading {
margin-bottom: 16px;
}
.section-heading h2 {
margin: 0 0 4px;
font-size: 16px;
line-height: 24px;
}
.alert-content {
justify-content: space-between;
gap: 12px;
width: 100%;
}
@media (width <= 900px) {
.update-heading {
align-items: stretch;
flex-direction: column;
}
.update-actions {
justify-content: flex-start;
}
}
@media (width <= 640px) {
.update-panel,
.history-panel {
padding: 14px;
}
.version-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.update-actions,
.update-actions :deep(.t-button) {
width: 100%;
}
.release-notes {
grid-template-columns: 1fr;
gap: 6px;
}
.section-heading {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
}
</style>
@@ -0,0 +1,88 @@
<template>
<t-form ref="form" :data="formData" :rules="formRules" label-width="0" @submit="onSubmit">
<t-form-item name="username">
<t-input v-model="formData.username" size="large" autocomplete="username" placeholder="请输入账号">
<template #prefix-icon><t-icon name="user" /></template>
</t-input>
</t-form-item>
<t-form-item name="password">
<t-input
v-model="formData.password"
size="large"
autocomplete="current-password"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
>
<template #prefix-icon><t-icon name="lock-on" /></template>
<template #suffix-icon>
<button class="icon-button" type="button" aria-label="显示或隐藏密码" @click="showPassword = !showPassword">
<t-icon :name="showPassword ? 'browse' : 'browse-off'" />
</button>
</template>
</t-input>
</t-form-item>
<t-alert v-if="errorMessage" theme="error" :close-btn="false" class="form-alert">{{ errorMessage }}</t-alert>
<t-form-item class="submit-item">
<t-button block size="large" type="submit" :loading="userStore.loading">登录</t-button>
</t-form-item>
<p class="security-note">登录失败或账号锁定时,请联系系统管理员处理。</p>
</t-form>
</template>
<script setup lang="ts">
import type { FormInstanceFunctions, FormRule, SubmitContext } from 'tdesign-vue-next';
import { MessagePlugin } from 'tdesign-vue-next';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/store';
defineOptions({ name: 'LoginForm' });
const form = ref<FormInstanceFunctions>();
const formData = ref({ username: '', password: '' });
const showPassword = ref(false);
const errorMessage = ref('');
const userStore = useUserStore();
const router = useRouter();
const formRules = computed<Record<string, FormRule[]>>(() => ({
username: [{ required: true, message: '请输入账号', type: 'error' }],
password: [{ required: true, message: '请输入密码', type: 'error' }],
}));
const onSubmit = async (context?: SubmitContext) => {
if (context && context.validateResult !== true) return;
if (!context && form.value && (await form.value.validate()) !== true) return;
errorMessage.value = '';
try {
await userStore.login(formData.value);
MessagePlugin.success('登录成功');
// Every new login must make the user explicitly choose an identity first.
await router.replace('/role-select');
} catch (error) {
errorMessage.value = (error as Error).message || '登录失败,请检查账号和密码';
}
};
</script>
<style scoped>
.form-alert {
margin-bottom: 16px;
}
.submit-item {
margin-top: 8px;
}
.security-note {
margin: 16px 0 0;
color: var(--td-text-color-placeholder);
font-size: 12px;
line-height: 20px;
}
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: inherit;
background: transparent;
border: 0;
cursor: pointer;
}
</style>
+194
View File
@@ -0,0 +1,194 @@
.light {
&.login-wrapper {
background-color: white;
background-image: url('@/assets/assets-login-bg-white.png');
}
}
.dark {
&.login-wrapper {
background-color: var(--td-bg-color-page);
background-image: url('@/assets/assets-login-bg-black.png');
}
}
.login-wrapper {
height: 100vh;
display: flex;
flex-direction: column;
background-size: cover;
background-position: 100%;
position: relative;
}
.login-container {
position: absolute;
top: 22%;
left: 5%;
min-height: 500px;
}
.title-container {
.title {
font: var(--td-font-headline-large);
color: var(--td-text-color-primary);
margin-top: var(--td-comp-margin-xs);
&.margin-no {
margin-top: 0;
}
}
.sub-title {
margin-top: var(--td-comp-margin-xxl);
.tip {
display: inline-block;
margin-right: var(--td-comp-margin-s);
font: var(--td-font-body-medium);
&:first-child {
color: var(--td-text-color-secondary);
}
&:last-child {
color: var(--td-text-color-primary);
cursor: pointer;
}
}
}
}
.item-container {
width: 400px;
margin-top: var(--td-comp-margin-xxxxl);
&.login-qrcode {
.tip-container {
margin-bottom: var(--td-comp-margin-l);
font: var(--td-font-body-medium);
display: flex;
align-items: flex-start;
.tip {
color: var(--td-text-color-primary);
margin-right: var(--td-comp-margin-s);
}
.refresh {
display: flex;
align-items: center;
color: var(--td-brand-color);
.t-icon {
font-size: var(--td-comp-size-xxxs);
margin-left: var(--td-comp-margin-xs);
}
&:hover {
cursor: pointer;
}
}
}
.bottom-container {
margin-top: 32px;
}
}
&.login-phone {
.bottom-container {
margin-top: 66px;
}
}
.check-container {
display: flex;
align-items: center;
&.remember-pwd {
margin-bottom: var(--td-comp-margin-l);
justify-content: space-between;
}
span {
color: var(--td-brand-color);
&:hover {
cursor: pointer;
}
}
}
.verification-code {
display: flex;
align-items: center;
:deep(.t-form__controls) {
width: 100%;
button {
flex-shrink: 0;
margin-left: var(--td-comp-margin-l);
width: 128px;
}
}
}
.btn-container {
margin-top: var(--td-comp-margin-xxxxl);
}
}
.switch-container {
margin-top: var(--td-comp-margin-xxl);
.tip {
font: var(--td-font-body-medium);
color: var(--td-brand-color);
cursor: pointer;
display: inline-flex;
align-items: center;
margin-right: var(--td-comp-margin-l);
&:last-child {
&::after {
display: none;
}
}
&::after {
content: '';
display: block;
width: 1px;
height: 12px;
background: var(--td-component-stroke);
margin-left: var(--td-comp-margin-l);
}
}
}
.check-container {
font: var(--td-font-body-medium);
color: var(--td-text-color-secondary);
.tip {
float: right;
font: var(--td-font-body-medium);
color: var(--td-brand-color);
}
}
.copyright {
font: var(--td-font-body-medium);
position: absolute;
left: 5%;
bottom: 64px;
color: var(--td-text-color-secondary);
}
@media screen and (height <= 700px) {
.copyright {
display: none;
}
}
+65
View File
@@ -0,0 +1,65 @@
<template>
<main class="login-page">
<section class="login-panel">
<div class="brand-mark">财务系统</div>
<div class="title-container">
<div class="eyebrow">项目财务资料与流程平台</div>
<h1>登录系统</h1>
<p>使用分配给你的账号进入工作台。</p>
</div>
<login-form />
</section>
<p class="copyright">内部业务系统 · 请勿共享账号</p>
</main>
</template>
<script setup lang="ts">
import LoginForm from './components/Login.vue';
defineOptions({ name: 'LoginPage' });
</script>
<style lang="less" scoped>
.login-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: var(--td-bg-color-page);
}
.login-panel {
width: min(420px, 100%);
padding: 40px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
border-radius: var(--td-radius-large);
box-shadow: var(--td-shadow-1);
}
.brand-mark {
color: var(--td-brand-color);
font-size: 18px;
font-weight: 600;
}
.title-container {
margin: 48px 0 24px;
}
.eyebrow {
margin-bottom: 8px;
color: var(--td-text-color-secondary);
font-size: 13px;
}
h1 {
margin: 0 0 8px;
font-size: 30px;
line-height: 40px;
}
.title-container p {
margin: 0;
color: var(--td-text-color-secondary);
}
.copyright {
position: fixed;
bottom: 24px;
margin: 0;
color: var(--td-text-color-placeholder);
font-size: 12px;
}
</style>
+342
View File
@@ -0,0 +1,342 @@
<template>
<div class="profile-page">
<h1 class="page-title-sr-only">个人中心</h1>
<t-card :bordered="false">
<template #title>账号信息</template>
<t-skeleton v-if="profileLoading" animation="gradient" :row-col="[{ width: '40%' }, { width: '100%' }]" />
<template v-else>
<t-alert v-if="profileNotice" theme="error" :message="profileNotice">
<template #operation
><t-button size="small" variant="text" @click="() => loadProfile()">重新加载</t-button></template
>
</t-alert>
<t-descriptions :column="2" item-layout="vertical">
<t-descriptions-item label="姓名">{{ userStore.user.displayName }}</t-descriptions-item>
<t-descriptions-item label="账号">{{ userStore.user.username }}</t-descriptions-item>
<t-descriptions-item label="部门">{{ userStore.user.departmentName || '未设置' }}</t-descriptions-item>
<t-descriptions-item label="当前身份">{{ userStore.roleName || '尚未选择' }}</t-descriptions-item>
</t-descriptions>
<t-divider />
<div class="profile-section-heading">授权范围</div>
<t-table
row-key="rowKey"
:data="authorizationScopes"
:columns="scopeColumns"
size="small"
stripe
table-layout="auto"
>
<template #scopeType="{ row }">
<t-tag theme="primary" variant="light">{{ scopeTypeLabel(row.scopeType) }}</t-tag>
</template>
<template #target="{ row }">{{ scopeTarget(row) }}</template>
<template #amountLimit="{ row }">{{
row.amountLimit == null ? '不限制' : formatAmount(row.amountLimit)
}}</template>
</t-table>
<t-empty v-if="!(userStore.user.authorizationScopes || []).length" description="当前身份暂无授权范围" />
</template>
</t-card>
<t-card :bordered="false">
<template #title>修改密码</template>
<t-form :data="form" :rules="rules" @submit="submitPassword">
<t-form-item label="当前密码" name="currentPassword"
><t-input v-model="form.currentPassword" type="password"
/></t-form-item>
<t-form-item label="新密码" name="newPassword"
><t-input v-model="form.newPassword" type="password"
/></t-form-item>
<t-form-item label="确认新密码" name="confirmPassword"
><t-input v-model="form.confirmPassword" type="password"
/></t-form-item>
<t-form-item><t-button type="submit" :loading="saving">更新密码</t-button></t-form-item>
</t-form>
</t-card>
<t-card :bordered="false">
<template #title>工作身份</template>
<div class="role-list">
<div v-for="role in userStore.roles" :key="role.code" class="role-list-item">
<div>
<strong>{{ role.name }}</strong>
<p>{{ role.description || role.code }}</p>
</div>
<t-tag v-if="role.code === userStore.currentRole" class="current-role-tag" variant="outline">当前身份</t-tag>
</div>
</div>
<t-button variant="outline" @click="router.push('/role-select')">切换身份</t-button>
</t-card>
<t-card :bordered="false">
<template #title>登录会话</template>
<t-alert v-if="sessionNotice" theme="warning" :message="sessionNotice" />
<t-table
row-key="sessionId"
:data="sessions"
:columns="sessionColumns"
:loading="sessionsLoading"
size="small"
stripe
>
<template #current="{ row }">
<t-tag v-if="row.current" theme="success">当前会话</t-tag>
</template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
<template #lastAccessedAt="{ row }">{{ formatDateTime(row.lastAccessedAt) }}</template>
<template #expiresAt="{ row }">{{ formatDateTime(row.expiresAt) }}</template>
<template #deviceSummary="{ row }">{{ row.deviceSummary || '未知设备' }}</template>
<template #maskedIp="{ row }">{{ row.maskedIp || '未知' }}</template>
<template #operation="{ row }">
<t-button
v-if="!row.current"
variant="text"
theme="danger"
:loading="revokingSessionIds.has(row.sessionId)"
:disabled="revokingAll"
@click="revokeSession(row.sessionId)"
>
退出
</t-button>
</template>
</t-table>
<t-space class="session-actions">
<t-button variant="outline" :loading="sessionsLoading" :disabled="revokingAll" @click="() => loadSessions()"
>刷新会话</t-button
>
<t-button
class="danger-action"
theme="danger"
variant="outline"
:loading="revokingAll"
@click="revokeAllSessions"
>
退出全部会话
</t-button>
</t-space>
</t-card>
<t-card :bordered="false">
<template #title>最近登录</template>
<t-list v-if="(userStore.user.recentLogins || []).length">
<t-list-item v-for="(login, index) in userStore.user.recentLogins" :key="`${login.occurredAt}-${index}`">
<t-list-item-meta
:title="login.deviceSummary"
:description="`${login.maskedIp} · ${formatDateTime(login.occurredAt)}`"
/>
</t-list-item>
</t-list>
<t-empty v-else description="暂无登录记录" />
</t-card>
</div>
</template>
<script setup lang="ts">
import type { FormRule, SubmitContext } from 'tdesign-vue-next';
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import type { AuthorizationScopeView, SessionSummaryView } from '@/api/auth';
import * as authApi from '@/api/auth';
import { useUserStore } from '@/store';
defineOptions({ name: 'ProfilePage' });
const router = useRouter();
const userStore = useUserStore();
const saving = ref(false);
const profileLoading = ref(false);
const profileNotice = ref('');
const sessions = ref<SessionSummaryView[]>([]);
const sessionsLoading = ref(false);
const revokingAll = ref(false);
const revokingSessionIds = ref(new Set<string>());
const sessionNotice = ref('');
const authorizationScopes = computed(() =>
(userStore.user.authorizationScopes || []).map((scope) => ({
...scope,
rowKey: [scope.roleCode, scope.scopeType, scope.companyPublicId || '', scope.projectPublicId || ''].join(':'),
})),
);
const sessionColumns = [
{ colKey: 'current', title: '状态', width: 110 },
{ colKey: 'createdAt', title: '创建时间' },
{ colKey: 'lastAccessedAt', title: '最近活动' },
{ colKey: 'expiresAt', title: '到期时间', width: 180 },
{ colKey: 'deviceSummary', title: '设备', width: 150 },
{ colKey: 'maskedIp', title: 'IP(脱敏)', width: 130 },
{ colKey: 'operation', title: '操作', width: 90 },
];
const scopeColumns = [
{ colKey: 'roleName', title: '身份', width: 150 },
{ colKey: 'scopeType', title: '范围类型', width: 110 },
{ colKey: 'target', title: '授权对象' },
{ colKey: 'permissionCount', title: '权限数', width: 90 },
{ colKey: 'amountLimit', title: '额度上限', width: 130 },
];
const form = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' });
const rules: Record<string, FormRule[]> = {
currentPassword: [{ required: true, message: '请输入当前密码', type: 'error' }],
newPassword: [{ required: true, min: 12, message: '新密码至少 12 位', type: 'error' }],
confirmPassword: [{ required: true, message: '请再次输入新密码', type: 'error' }],
};
const submitPassword = async (context: SubmitContext) => {
if (context.validateResult !== true) return;
if (form.newPassword !== form.confirmPassword) {
MessagePlugin.error('两次输入的新密码不一致');
return;
}
saving.value = true;
try {
await userStore.updatePassword(form);
await Promise.all([loadProfile(true), loadSessions(true)]);
MessagePlugin.success('密码已更新');
Object.assign(form, { currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (error) {
MessagePlugin.error((error as Error).message || '密码更新失败');
} finally {
saving.value = false;
}
};
const formatDateTime = (value: string) => {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '未知时间' : date.toLocaleString('zh-CN', { hour12: false });
};
const formatAmount = (value: number) =>
`${new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 2 }).format(value)} CNY`;
const scopeTypeLabel = (type: string) => ({ GLOBAL: '全局', COMPANY: '公司', PROJECT: '项目' })[type] || type;
const scopeTarget = (row: AuthorizationScopeView) => {
if (row.scopeType === 'GLOBAL') return '全部授权范围';
if (row.scopeType === 'COMPANY')
return `${row.companyCode || ''} ${row.companyName || row.companyPublicId || ''}`.trim();
return `${row.projectCode || ''} ${row.projectName || row.projectPublicId || ''}`.trim();
};
function friendlyError(error: unknown, fallback: string) {
const value = error as Error & { status?: number; code?: string; fieldErrors?: Record<string, string> };
if (value.status === 401) return '登录会话已失效,请重新登录。';
if (value.status === 403) return '当前身份没有访问该数据或执行该操作的权限。';
if (value.status === 409) return '数据或身份配置已变化,请重新加载后再试。';
if (value.status === 422)
return Object.values(value.fieldErrors || {})[0] || value.message || '提交内容未通过服务端校验。';
return value.message || fallback;
}
const loadProfile = async (throwOnError = false) => {
profileLoading.value = true;
try {
await userStore.refreshProfile();
profileNotice.value = '';
} catch (error) {
profileNotice.value = friendlyError(error, '个人资料加载失败,请检查网络后重试。');
if (throwOnError) throw error;
} finally {
profileLoading.value = false;
}
};
const loadSessions = async (throwOnError = false) => {
sessionsLoading.value = true;
try {
sessions.value = await authApi.listSessions();
sessionNotice.value = '';
} catch (error) {
sessionNotice.value = friendlyError(error, '会话列表加载失败,请检查网络后重试。');
if (throwOnError) throw error;
} finally {
sessionsLoading.value = false;
}
};
const revokeSession = (sessionId: string) => {
const dialog = DialogPlugin.confirm({
header: '退出指定会话',
body: '退出后,该设备需要重新登录。',
onConfirm: async () => {
if (revokingSessionIds.value.has(sessionId)) return;
revokingSessionIds.value = new Set(revokingSessionIds.value).add(sessionId);
try {
await authApi.deleteSession(sessionId);
MessagePlugin.success('会话已退出');
await loadSessions(true);
} catch (error) {
MessagePlugin.error(friendlyError(error, '会话退出失败,请检查网络后重试。'));
} finally {
const next = new Set(revokingSessionIds.value);
next.delete(sessionId);
revokingSessionIds.value = next;
dialog.destroy();
}
},
});
};
const revokeAllSessions = () => {
const dialog = DialogPlugin.confirm({
header: '退出全部会话',
body: '当前设备也会退出,确认继续吗?',
onConfirm: async () => {
if (revokingAll.value) return;
revokingAll.value = true;
try {
await authApi.deleteAllSessions();
userStore.clearSession();
await router.replace('/login');
} catch (error) {
MessagePlugin.error(friendlyError(error, '退出全部会话失败,请检查网络后重试。'));
} finally {
revokingAll.value = false;
dialog.destroy();
}
},
});
};
onMounted(async () => {
await Promise.all([loadProfile(), loadSessions()]);
});
</script>
<style scoped>
.profile-page {
display: grid;
gap: 16px;
min-width: 0;
max-width: 960px;
}
.profile-page > h1 {
color: var(--td-text-color-primary);
font-size: 24px;
line-height: 32px;
}
.profile-page > :deep(.t-card) {
width: 100%;
min-width: 0;
}
.profile-page :deep(.t-card__body),
.profile-page :deep(.t-table) {
min-width: 0;
}
.profile-page :deep(.t-descriptions) {
max-width: 100%;
overflow-x: auto;
}
.session-actions {
margin-top: 16px;
}
.role-list {
margin-bottom: 16px;
}
.role-list-item {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 64px;
padding: 12px 0;
border-bottom: 1px solid var(--td-component-stroke);
}
.role-list-item strong {
color: var(--td-text-color-primary);
}
.role-list-item p {
margin-top: 4px;
color: var(--td-text-color-secondary);
}
.current-role-tag {
color: #176b46;
border-color: #176b46;
}
.danger-action {
color: #b42318;
border-color: #b42318;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,935 @@
<template>
<div class="project-list-page">
<h1 class="page-title-sr-only">项目列表</h1>
<header class="page-header page-header--actions-only" aria-label="页面操作">
<t-space>
<t-button variant="outline" :loading="loading" @click="loadProjects">
<template #icon><t-icon name="refresh" /></template>
刷新
</t-button>
<t-button v-if="canImport" variant="outline" @click="openImport">
<template #icon><t-icon name="upload" /></template>
导入项目
</t-button>
<t-button v-if="canExport" variant="outline" :loading="exporting" @click="downloadExport">
<template #icon><t-icon name="download" /></template>
导出当前结果
</t-button>
<t-button v-if="canCreate" theme="primary" :disabled="references.companies.length === 0" @click="openCreate">
<template #icon><t-icon name="add" /></template>
新建项目
</t-button>
</t-space>
</header>
<t-alert v-if="errorMessage" theme="error" :close-btn="false">
<div class="alert-content">
<span>{{ errorMessage }}</span>
<t-link theme="primary" @click="loadProjects">重新加载</t-link>
</div>
</t-alert>
<t-alert
v-if="canCreate && referencesLoaded && references.companies.length === 0"
theme="warning"
:close-btn="false"
>
暂无已生效且当前身份可见的公司,请先由财务人员在主数据中心完成公司复核。
</t-alert>
<section class="filter-panel" aria-label="项目查询条件">
<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="companyId">
<t-select
v-model="filters.companyId"
:options="companyOptions"
clearable
filterable
placeholder="全部公司"
/>
</t-form-item>
<t-form-item label="负责人" name="ownerId">
<t-select v-model="filters.ownerId" :options="ownerOptions" clearable filterable placeholder="全部负责人" />
</t-form-item>
<t-form-item label="客户" name="customerId">
<t-select
v-model="filters.customerId"
:options="customerOptions"
clearable
filterable
placeholder="全部客户"
/>
</t-form-item>
<t-form-item label="项目阶段" name="stage">
<t-select v-model="filters.stage" :options="stageOptions" clearable placeholder="全部阶段" />
</t-form-item>
<t-form-item label="财务状态" name="financeStatus">
<t-select
v-model="filters.financeStatus"
:options="financeStatusOptions"
clearable
placeholder="全部状态"
/>
</t-form-item>
<t-form-item label="归档状态" name="archiveStatus">
<t-select
v-model="filters.archiveStatus"
:options="archiveStatusOptions"
clearable
placeholder="全部状态"
/>
</t-form-item>
<t-form-item label="项目状态" name="status">
<t-select v-model="filters.status" :options="projectStatusOptions" clearable placeholder="全部状态" />
</t-form-item>
<t-form-item label="开始日期" name="startRange">
<t-date-range-picker v-model="filters.startRange" clearable value-type="YYYY-MM-DD" />
</t-form-item>
<t-form-item label="结束日期" name="endRange">
<t-date-range-picker v-model="filters.endRange" clearable value-type="YYYY-MM-DD" />
</t-form-item>
<t-form-item label="排序" name="sort">
<t-select v-model="filters.sort" :options="sortOptions" />
</t-form-item>
<div class="filter-actions">
<t-button theme="primary" type="submit">
<template #icon><t-icon name="search" /></template>
查询
</t-button>
<t-button theme="default" variant="outline" type="button" @click="resetFilters">重置</t-button>
</div>
</div>
</t-form>
</section>
<section class="table-panel" aria-label="项目数据">
<div class="table-toolbar">
<div class="result-count">共 {{ pagination.total }} 个项目</div>
<div class="table-hint">金额单位按各项目币种显示</div>
</div>
<t-table
v-if="loading || rows.length > 0"
:columns="columns"
:data="rows"
row-key="publicId"
:loading="loading"
:hover="true"
table-layout="fixed"
:horizontal-scroll-affixed-bottom="true"
>
<template #businessNo="{ row }">
<t-link theme="primary" @click="openDetail(row)">{{ row.businessNo }}</t-link>
</template>
<template #company="{ row }">{{ row.company.name }}</template>
<template #owner="{ row }">{{ row.owner.name }}</template>
<template #contractAmount="{ row }">{{ formatMoney(row.contractAmount, row.currency) }}</template>
<template #receivedAmount="{ row }">{{ formatMoney(row.receivedAmount, row.currency) }}</template>
<template #paidAmount="{ row }">{{ formatMoney(row.paidAmount, row.currency) }}</template>
<template #stage="{ row }">
<t-tag variant="light">{{ labelOf(stageOptions, row.stage) }}</t-tag>
</template>
<template #pendingTaskCount="{ row }">
<t-badge :count="row.pendingTaskCount" :show-zero="true" />
</template>
<template #documentCompleteness="{ row }">
<t-progress :percentage="row.documentCompleteness" size="small" :label="true" />
</template>
<template #status="{ row }">
<t-tag :theme="statusTheme(row.status)" variant="light">{{
labelOf(projectStatusOptions, row.status)
}}</t-tag>
</template>
<template #op="{ row }">
<t-space size="small">
<t-link theme="primary" @click="openDetail(row)">查看</t-link>
<t-link v-if="row.allowedActions.includes('EDIT')" theme="primary" @click="openEdit(row)">编辑</t-link>
</t-space>
</template>
</t-table>
<t-empty v-else description="当前条件下暂无项目">
<template v-if="canCreate" #action>
<t-button theme="primary" :disabled="references.companies.length === 0" @click="openCreate"
>新建项目</t-button
>
</template>
</t-empty>
<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="changePage"
@page-size-change="changePageSize"
/>
</div>
</section>
<project-form-dialog
v-model:visible="formVisible"
:project="editingProject"
:references="references"
@saved="projectSaved"
/>
<t-dialog
v-model:visible="importVisible"
header="批量导入项目"
:width="820"
:footer="false"
:close-on-overlay-click="false"
>
<div class="import-dialog-content">
<t-alert theme="info" :close-btn="false">
上传 CSV 后先完成服务端校验;只有全部行通过时才能确认创建。公司、客户和负责人可填写页面选项中的名称或公共 ID。
</t-alert>
<t-upload
v-model="importFiles"
accept=".csv,text/csv"
theme="file"
:auto-upload="false"
:max="1"
@change="importFilesChanged"
/>
<t-space>
<t-button
theme="primary"
:disabled="!importFiles.length || importing || confirmingImport"
:loading="importing"
@click="runImport"
>
<template #icon><t-icon name="upload" /></template>
上传并校验
</t-button>
<t-button variant="outline" :disabled="importing || confirmingImport" @click="downloadImportTemplate"
>下载 CSV 模板</t-button
>
</t-space>
<t-alert
v-if="importResult"
:theme="importResult.status === 'READY' ? 'success' : 'warning'"
:close-btn="false"
>
批次 {{ importResult.batchNo }}:共 {{ importResult.totalRows }} 行,有效
{{ importResult.validRows }} 行,错误 {{ importResult.errorRows }} 行。
<span v-if="importResult.status === 'READY'">确认后将一次性创建为项目草稿。</span>
</t-alert>
<t-alert v-if="importErrorMessage" theme="error" :close-btn="false">{{ importErrorMessage }}</t-alert>
<t-table
v-if="importResult?.issues.length"
:columns="importIssueColumns"
:data="importIssueRows"
row-key="issueKey"
size="small"
/>
<div v-if="importResult?.status === 'READY'" class="import-confirm-row">
<t-button theme="success" :loading="confirmingImport" @click="confirmImport">
确认创建 {{ importResult.validRows }} 个项目
</t-button>
</div>
</div>
</t-dialog>
</div>
</template>
<script setup lang="ts">
import type { PrimaryTableCol, SubmitContext, UploadFile } 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 {
ProjectDetail,
ProjectImportSummary,
ProjectListItem,
ProjectQuery,
ProjectReferenceOptions,
} from '@/api/project';
import {
confirmProjectImport,
createProjectImport,
exportProjects,
getProject,
getProjectReferences,
getProjects,
} from '@/api/project';
import { uploadBusinessFile } from '@/api/source';
import ProjectFormDialog from '@/components/business/project/ProjectFormDialog.vue';
import { useUserStore } from '@/store';
defineOptions({ name: 'ProjectListPage' });
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
interface Option {
label: string;
value: string;
}
interface FilterState {
keyword: string;
companyId: string;
ownerId: string;
customerId: string;
stage: string;
financeStatus: string;
archiveStatus: string;
status: string;
startRange: string[];
endRange: string[];
sort: string;
}
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const canCreate = computed(() => userStore.hasPermission('project:project:create'));
const canView = computed(() => userStore.hasPermission('project:project:view'));
const canImport = computed(() => canCreate.value && canView.value && userStore.hasPermission('file:upload'));
const canExport = computed(() => userStore.hasPermission('project:project:export'));
const loading = ref(false);
const exporting = ref(false);
const referencesLoaded = ref(false);
const errorMessage = ref('');
const rows = ref<ProjectListItem[]>([]);
const formVisible = ref(false);
const editingProject = ref<ProjectDetail | null>(null);
const importVisible = ref(false);
const importFiles = ref<UploadFile[]>([]);
const importResult = ref<ProjectImportSummary | null>(null);
const importing = ref(false);
const confirmingImport = ref(false);
const importErrorMessage = ref('');
const importCreateKey = ref('');
const importConfirmKey = ref('');
const references = reactive<ProjectReferenceOptions>({ companies: [], customers: [], owners: [] });
const pagination = reactive({ current: 1, pageSize: 20, total: 0 });
let requestSequence = 0;
const importIssueColumns: PrimaryTableCol[] = [
{ title: '行号', colKey: 'rowNo', width: 80 },
{ title: '字段', colKey: 'fieldCode', width: 140 },
{ title: '问题', colKey: 'message', minWidth: 360 },
];
const importIssueRows = computed(() =>
(importResult.value?.issues || []).map((issue, index) => ({ ...issue, issueKey: `${issue.rowNo}-${index}` })),
);
const queryString = (key: string) => (typeof route.query[key] === 'string' ? String(route.query[key]) : '');
const queryRange = (from: string, to: string) => {
const start = queryString(from);
const end = queryString(to);
return start || end ? [start, end] : [];
};
const filters = reactive<FilterState>({
keyword: queryString('keyword'),
companyId: queryString('companyId'),
ownerId: queryString('ownerId'),
customerId: queryString('customerId'),
stage: queryString('stage'),
financeStatus: queryString('financeStatus'),
archiveStatus: queryString('archiveStatus'),
status: queryString('status'),
startRange: queryRange('startDateFrom', 'startDateTo'),
endRange: queryRange('endDateFrom', 'endDateTo'),
sort: queryString('sort') || 'updatedAt,desc',
});
pagination.current = Math.max(1, Number(queryString('page')) || 1);
pagination.pageSize = [20, 50, 100].includes(Number(queryString('size'))) ? Number(queryString('size')) : 20;
const stageOptions: Option[] = [
{ label: '立项', value: 'INITIATION' },
{ label: '合同', value: 'CONTRACTING' },
{ label: '执行', value: 'EXECUTION' },
{ label: '验收', value: 'ACCEPTANCE' },
{ label: '结算', value: 'SETTLEMENT' },
{ label: '已结束', value: 'CLOSED' },
];
const financeStatusOptions: Option[] = [
{ label: '待处理', value: 'PENDING' },
{ label: '处理中', value: 'IN_PROGRESS' },
{ label: '已完成', value: 'COMPLETED' },
{ label: '已阻断', value: 'BLOCKED' },
];
const archiveStatusOptions: Option[] = [
{ label: '未准备', value: 'NOT_READY' },
{ label: '准备中', value: 'PREPARING' },
{ label: '检查中', value: 'CHECKING' },
{ label: '已归档', value: 'ARCHIVED' },
{ label: '已退回', value: 'RETURNED' },
{ label: '已冻结', value: 'FROZEN' },
];
const projectStatusOptions: Option[] = [
{ label: '草稿', value: 'DRAFT' },
{ label: '进行中', value: 'ACTIVE' },
{ label: '已暂停', value: 'SUSPENDED' },
{ label: '已关闭', value: 'CLOSED' },
{ label: '已归档', value: 'ARCHIVED' },
];
const sortOptions: Option[] = [
{ label: '最近更新优先', value: 'updatedAt,desc' },
{ label: '最早更新优先', value: 'updatedAt,asc' },
{ label: '项目编号升序', value: 'businessNo,asc' },
{ label: '项目名称升序', value: 'name,asc' },
{ label: '合同金额从高到低', value: 'contractAmount,desc' },
{ label: '项目状态升序', value: 'status,asc' },
];
const asOptions = (items: ProjectReferenceOptions['companies']) =>
items.map((item) => ({ label: item.name, value: item.publicId }));
const companyOptions = computed(() => asOptions(references.companies));
const customerOptions = computed(() => asOptions(references.customers));
const ownerOptions = computed(() => asOptions(references.owners));
const columns: PrimaryTableCol[] = [
{ title: '项目编号', colKey: 'businessNo', fixed: 'left', width: 150, ellipsis: true },
{ title: '项目名称', colKey: 'name', fixed: 'left', width: 210, ellipsis: true },
{ title: '所属公司', colKey: 'company', width: 170, ellipsis: true },
{ title: '负责人', colKey: 'owner', width: 110, ellipsis: true },
{ title: '合同额', colKey: 'contractAmount', width: 140, align: 'right' },
{ title: '已收', colKey: 'receivedAmount', width: 140, align: 'right' },
{ title: '已付', colKey: 'paidAmount', width: 140, align: 'right' },
{ title: '阶段', colKey: 'stage', width: 100 },
{ title: '待办', colKey: 'pendingTaskCount', width: 80, align: 'center' },
{ title: '资料完整率', colKey: 'documentCompleteness', width: 160 },
{ title: '状态', colKey: 'status', width: 100 },
{ title: '操作', colKey: 'op', fixed: 'right', width: 120 },
];
function labelOf(options: Option[], value: string) {
return options.find((option) => option.value === value)?.label || value;
}
function statusTheme(status: string): TagTheme {
if (status === 'ACTIVE') return 'success';
if (status === 'SUSPENDED') return 'warning';
if (status === 'CLOSED' || status === 'ARCHIVED') return 'default';
return 'primary';
}
function formatMoney(value: string, currency: string) {
const number = Number(value || 0);
return `${currency} ${new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)}`;
}
function requestParams(): ProjectQuery {
return {
keyword: filters.keyword.trim() || undefined,
companyId: filters.companyId || undefined,
ownerId: filters.ownerId || undefined,
customerId: filters.customerId || undefined,
stage: filters.stage || undefined,
financeStatus: filters.financeStatus || undefined,
archiveStatus: filters.archiveStatus || undefined,
status: filters.status || undefined,
startDateFrom: filters.startRange[0] || undefined,
startDateTo: filters.startRange[1] || undefined,
endDateFrom: filters.endRange[0] || undefined,
endDateTo: filters.endRange[1] || undefined,
sort: filters.sort,
page: pagination.current,
size: pagination.pageSize,
};
}
async function syncUrl(params: ProjectQuery) {
const query = Object.fromEntries(
Object.entries(params)
.filter(([, value]) => value !== undefined && value !== '')
.map(([key, value]) => [key, String(value)]),
);
await router.replace({ name: 'ProjectList', query });
}
async function loadProjects() {
const sequence = ++requestSequence;
loading.value = true;
errorMessage.value = '';
const params = requestParams();
try {
const result = await getProjects(params);
if (sequence !== requestSequence) return;
rows.value = result.items;
pagination.total = result.meta.totalElements;
pagination.current = result.meta.page;
pagination.pageSize = result.meta.size;
await syncUrl({ ...params, page: result.meta.page, size: result.meta.size });
} catch (error) {
if (sequence !== requestSequence) return;
rows.value = [];
pagination.total = 0;
errorMessage.value = (error as Error).message || '项目列表加载失败';
} finally {
if (sequence === requestSequence) loading.value = false;
}
}
async function sha256Hex(value: string) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
}
async function downloadExport() {
exporting.value = true;
try {
const { page: _page, size: _size, ...filtersForExport } = requestParams();
const result = await exportProjects({
...filtersForExport,
columns: [
'businessNo',
'name',
'company',
'owner',
'contractAmount',
'receivedAmount',
'paidAmount',
'stage',
'pendingTaskCount',
'documentCompleteness',
'status',
],
});
const actualHash = await sha256Hex(result.content);
if (!/^[a-f\d]{64}$/i.test(result.sha256) || actualHash !== result.sha256.toLowerCase()) {
throw new Error('导出文件完整性校验失败,请重新导出');
}
const blob = new Blob([result.content], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = result.fileName;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
MessagePlugin.success(`已导出 ${result.rowCount} 行,SHA-256 ${result.sha256.slice(0, 16)}...`);
} catch (error) {
MessagePlugin.error((error as Error).message || '项目导出失败');
} finally {
exporting.value = false;
}
}
function openImport() {
importFiles.value = [];
importResult.value = null;
importErrorMessage.value = '';
importCreateKey.value = '';
importConfirmKey.value = '';
importVisible.value = true;
}
function importFilesChanged(files: UploadFile[]) {
if (importing.value || confirmingImport.value) return;
importFiles.value = files;
importResult.value = null;
importErrorMessage.value = '';
importCreateKey.value = '';
importConfirmKey.value = '';
}
interface CsvRecord {
cells: string[];
rowNo: number;
}
function csvLines(text: string): CsvRecord[] {
const rows: CsvRecord[] = [];
let row: string[] = [];
let cell = '';
let quoted = false;
let fieldStarted = false;
let afterQuote = false;
let physicalLine = 1;
let recordStartLine = 1;
const finishCell = () => {
row.push(cell);
cell = '';
fieldStarted = false;
afterQuote = false;
};
const finishRecord = () => {
finishCell();
if (row.some((value) => value.trim())) rows.push({ cells: row, rowNo: recordStartLine });
row = [];
recordStartLine = physicalLine;
};
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
const next = text[index + 1];
if (quoted) {
if (character === '"' && next === '"') {
cell += '"';
index += 1;
} else if (character === '"') {
quoted = false;
afterQuote = true;
} else {
if (character === '\n') physicalLine += 1;
if (character === '\r') {
physicalLine += 1;
if (next === '\n') index += 1;
}
cell += character;
}
} else if (afterQuote) {
if (character === ',') {
finishCell();
} else if (character === '\n' || character === '\r') {
if (character === '\r' && next === '\n') index += 1;
physicalLine += 1;
finishRecord();
} else if (!/\s/.test(character)) {
throw new Error(`CSV 第 ${recordStartLine} 行的引号后存在无效字符`);
}
} else if (character === '"' && !fieldStarted && cell === '') {
quoted = true;
fieldStarted = true;
} else if (character === ',') {
finishCell();
} else if (character === '\n' || character === '\r') {
if (character === '\r' && next === '\n') index += 1;
physicalLine += 1;
finishRecord();
} else {
fieldStarted = true;
cell += character;
}
}
if (quoted) throw new Error(`CSV 第 ${recordStartLine} 行存在未闭合的双引号`);
if (cell || row.length || fieldStarted) finishRecord();
return rows;
}
async function parseProjectCsv(file: File): Promise<number> {
const lines = csvLines((await file.text()).replace(/^\uFEFF/, ''));
if (lines.length < 2) return 0;
const headers = lines[0].cells.map((header) => header.trim());
if (headers.length > 9) throw new Error('CSV 列数超过项目导入模板定义');
const aliases: Record<string, string> = {
项目编号: 'businessNo',
businessNo: 'businessNo',
项目名称: 'name',
name: 'name',
项目说明: 'description',
description: 'description',
公司: 'companyId',
公司名称或ID: 'companyId',
公司名称或公共ID: 'companyId',
companyId: 'companyId',
客户: 'customerId',
客户名称或ID: 'customerId',
客户名称或公共ID: 'customerId',
customerId: 'customerId',
负责人: 'ownerId',
负责人名称或ID: 'ownerId',
负责人名称或公共ID: 'ownerId',
ownerId: 'ownerId',
开始日期: 'startDate',
startDate: 'startDate',
结束日期: 'endDate',
endDate: 'endDate',
币种: 'currency',
currency: 'currency',
};
const canonicalHeaders = headers.map((header) => {
const canonical = aliases[header];
if (!canonical) throw new Error(`CSV 表头包含未知列:${header || '(空列名)'}`);
return canonical;
});
if (new Set(canonicalHeaders).size !== canonicalHeaders.length) {
throw new Error('CSV 表头存在重复列');
}
const missing = ['businessNo', 'name', 'companyId', 'ownerId', 'currency'].filter(
(field) => !canonicalHeaders.includes(field),
);
if (missing.length) {
throw new Error('CSV 缺少项目编号、项目名称、公司、负责人或币种列,请使用系统模板');
}
const rowCount = lines.length - 1;
if (rowCount > 10000) throw new Error('单次最多导入 10,000 行,请拆分文件');
return rowCount;
}
async function runImport() {
if (importing.value || confirmingImport.value) return;
const uploadFile = importFiles.value[0];
const file = uploadFile?.raw;
if (!file) return;
importing.value = true;
importResult.value = null;
importErrorMessage.value = '';
try {
const rowCount = await parseProjectCsv(file);
if (!rowCount) throw new Error('CSV 没有可导入的数据行');
const uploaded = await uploadBusinessFile(uploadFile);
if (!importCreateKey.value) importCreateKey.value = crypto.randomUUID();
importResult.value = await createProjectImport(
{
sourceFileId: uploaded.publicId,
fileSha256: uploaded.sha256,
},
importCreateKey.value,
);
if (importResult.value.status === 'READY') MessagePlugin.success('项目导入批次校验通过,请确认创建');
else MessagePlugin.warning('导入文件存在校验错误,请按问题清单修改后重新上传');
} catch (error) {
importErrorMessage.value = (error as Error).message || '项目导入校验失败';
MessagePlugin.error(importErrorMessage.value);
} finally {
importing.value = false;
}
}
async function confirmImport() {
if (importing.value || confirmingImport.value || importResult.value?.status !== 'READY') return;
confirmingImport.value = true;
importErrorMessage.value = '';
try {
if (!importConfirmKey.value) importConfirmKey.value = crypto.randomUUID();
const created = await confirmProjectImport(
importResult.value.publicId,
importResult.value.version,
importConfirmKey.value,
);
MessagePlugin.success(`已创建 ${created.length} 个项目草稿`);
importVisible.value = false;
importFiles.value = [];
importResult.value = null;
importCreateKey.value = '';
importConfirmKey.value = '';
pagination.current = 1;
await loadProjects();
} catch (error) {
importErrorMessage.value = (error as Error).message || '项目导入确认失败';
MessagePlugin.error(importErrorMessage.value);
} finally {
confirmingImport.value = false;
}
}
function downloadImportTemplate() {
const headers = [
'项目编号',
'项目名称',
'项目说明',
'公司名称或公共ID',
'客户名称或公共ID',
'负责人名称或公共ID',
'开始日期',
'结束日期',
'币种',
];
const content = `\uFEFF${headers.map((value) => `"${value}"`).join(',')}\n`;
const url = URL.createObjectURL(new Blob([content], { type: 'text/csv;charset=utf-8' }));
const link = document.createElement('a');
link.href = url;
link.download = '项目导入模板.csv';
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
async function loadReferences() {
try {
Object.assign(references, await getProjectReferences());
} catch (error) {
MessagePlugin.warning((error as Error).message || '项目筛选选项加载失败');
} finally {
referencesLoaded.value = true;
}
}
function search(_context?: SubmitContext) {
pagination.current = 1;
loadProjects();
}
function resetFilters() {
Object.assign(filters, {
keyword: '',
companyId: '',
ownerId: '',
customerId: '',
stage: '',
financeStatus: '',
archiveStatus: '',
status: '',
startRange: [],
endRange: [],
sort: 'updatedAt,desc',
});
pagination.current = 1;
loadProjects();
}
function changePage(current: number) {
pagination.current = current;
loadProjects();
}
function changePageSize(size: number) {
pagination.pageSize = size;
pagination.current = 1;
loadProjects();
}
function openCreate() {
editingProject.value = null;
formVisible.value = true;
}
async function openEdit(row: ProjectListItem) {
try {
editingProject.value = await getProject(row.publicId);
formVisible.value = true;
} catch (error) {
MessagePlugin.error((error as Error).message || '项目详情加载失败');
}
}
function openDetail(row: ProjectListItem) {
router.push({ name: 'ProjectDetail', params: { projectId: row.publicId }, query: { from: route.fullPath } });
}
function projectSaved() {
loadProjects();
}
onMounted(() => {
loadReferences();
loadProjects();
});
</script>
<style scoped>
.project-list-page {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.page-header {
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;
}
.filter-panel,
.table-panel {
padding: 20px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
border-radius: var(--td-radius-default);
}
.filter-grid {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 4px 20px;
}
.filter-actions {
display: flex;
align-items: flex-end;
gap: 10px;
padding-bottom: 24px;
}
.table-toolbar {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.result-count {
font-weight: 600;
}
.table-hint {
color: var(--td-text-color-secondary);
font-size: 13px;
}
.pagination-row {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.import-dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.import-confirm-row {
display: flex;
justify-content: flex-end;
}
:deep(.t-progress) {
min-width: 120px;
}
:deep(.t-date-range-picker) {
width: 100%;
}
@media (width <= 1366px) {
.filter-grid {
grid-template-columns: repeat(3, minmax(160px, 1fr));
}
}
@media (width <= 900px) {
.page-header {
flex-direction: column;
}
.filter-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 600px) {
.filter-grid {
grid-template-columns: 1fr;
}
.filter-panel,
.table-panel {
padding: 16px;
}
.table-toolbar {
flex-direction: column;
}
}
</style>
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
<template>
<result title="无权访问" tip="当前身份没有访问此页面的权限。请选择其他已授权身份,或返回上一页。">
<t-space>
<t-button theme="primary" @click="router.push('/role-select')">选择身份</t-button>
<t-button variant="outline" @click="router.back()">返回上一页</t-button>
</t-space>
</result>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
import Result from '@/components/result/index.vue';
defineOptions({
name: 'Result403',
});
const router = useRouter();
</script>
+17
View File
@@ -0,0 +1,17 @@
<template>
<result title="404 Not Found" :tip="t('pages.result.404.subtitle')" type="404">
<t-button @click="() => router.push('/')">{{ t('pages.result.404.back') }}</t-button>
</result>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
import Result from '@/components/result/index.vue';
import { t } from '@/locales';
defineOptions({
name: 'Result404',
});
const router = useRouter();
</script>
+17
View File
@@ -0,0 +1,17 @@
<template>
<result title="500 Internal Server Error" type="500" :tip="t('pages.result.500.subtitle')">
<t-button @click="() => router.push('/')">{{ t('pages.result.500.back') }}</t-button>
</result>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
import Result from '@/components/result/index.vue';
import { t } from '@/locales';
defineOptions({
name: 'Result500',
});
const router = useRouter();
</script>
@@ -0,0 +1,82 @@
<template>
<result
:title="t('pages.result.browserIncompatible.title')"
type="ie"
:tip="t('pages.result.browserIncompatible.subtitle')"
>
<div class="result-slot-container">
<t-button class="result-button" @click="() => router.push('/')">{{
t('pages.result.browserIncompatible.back')
}}</t-button>
<div class="recommend-container">
<div>{{ t('pages.result.browserIncompatible.recommend') }}</div>
<div class="recommend-browser">
<div>
<t-icon class="browser-icon" name="logo-chrome" />
<div>Chrome</div>
</div>
<div>
<t-icon class="browser-icon" name="internet" />
<div>QQ Browser</div>
</div>
</div>
</div>
</div>
</result>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
import Result from '@/components/result/index.vue';
import { t } from '@/locales';
defineOptions({
name: 'ResultBrowserIncompatible',
});
const router = useRouter();
</script>
<style lang="less" scoped>
.result-slot-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
color: var(--td-text-color-secondary);
height: 80vh;
max-height: 240px;
}
.recommend-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xxl);
width: 640px;
background: var(--td-bg-color-container);
box-shadow: 0 1px 2px rgb(0 0 0 / 10%);
border-radius: var(--td-radius-medium);
}
.recommend-browser {
display: flex;
> div {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
}
> div + div {
margin-left: var(--td-comp-margin-xxxl);
}
}
.browser-icon {
width: var(--td-comp-size-xl);
height: var(--td-comp-size-xl);
margin-bottom: var(--td-comp-margin-s);
}
</style>
+59
View File
@@ -0,0 +1,59 @@
<template>
<div class="result-success">
<t-icon class="result-success-icon" name="error-circle" />
<div class="result-success-title">{{ t('pages.result.fail.title') }}</div>
<div class="result-success-describe">{{ t('pages.result.fail.subtitle') }}</div>
<div>
<t-button theme="default" @click="() => router.push(workbenchRoute)">{{ t('pages.result.fail.back') }}</t-button>
<t-button @click="() => router.push('/forms')">{{ t('pages.result.fail.modify') }} </t-button>
</div>
</div>
</template>
<script lang="ts" setup>
import { useRouter } from 'vue-router';
import { t } from '@/locales';
import { useUserStore } from '@/store';
defineOptions({
name: 'ResultFail',
});
const router = useRouter();
const userStore = useUserStore();
const workbenchRoute =
userStore.currentRole === 'PROJECT_MANAGER'
? '/workbench/project'
: userStore.currentRole === 'FINANCE_MANAGER'
? '/workbench/finance'
: userStore.currentRole === 'ARCHIVE_MANAGER'
? '/workbench/archive'
: '/governance/settings';
</script>
<style lang="less" scoped>
.result-success {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 75vh;
&-icon {
font-size: var(--td-comp-size-xxxxl);
color: var(--td-text-color-secondary);
}
&-title {
margin-top: var(--td-comp-margin-xxl);
font: var(--td-font-title-large);
color: var(--td-text-color-primary);
text-align: center;
}
&-describe {
margin: var(--td-comp-margin-s) 0 var(--td-comp-margin-xxxl);
font: var(--td-font-body-medium);
color: var(--td-text-color-secondary);
}
}
</style>
@@ -0,0 +1,17 @@
<template>
<result :title="t('pages.result.maintenance.title')" :tip="t('pages.result.maintenance.subtitle')" type="maintenance">
<t-button theme="primary" @click="() => router.push('/')">{{ t('pages.result.maintenance.back') }}</t-button>
</result>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
import Result from '@/components/result/index.vue';
import { t } from '@/locales';
defineOptions({
name: 'ResultMaintenance',
});
const router = useRouter();
</script>
@@ -0,0 +1,20 @@
<template>
<result :title="t('pages.result.networkError.title')" :tip="t('pages.result.networkError.subtitle')" type="wifi">
<div>
<t-button theme="default" @click="() => router.push('/')">{{ t('pages.result.networkError.back') }}</t-button>
<t-button @click="() => router.push('/')">{{ t('pages.result.networkError.reload') }}</t-button>
</div>
</result>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
import Result from '@/components/result/index.vue';
import { t } from '@/locales';
defineOptions({
name: 'ResultNetworkError',
});
const router = useRouter();
</script>
@@ -0,0 +1,61 @@
<template>
<div class="result-success">
<t-icon class="result-success-icon" name="check-circle" />
<div class="result-success-title">{{ t('pages.result.success.title') }}</div>
<div class="result-success-describe">{{ t('pages.result.success.subtitle') }}</div>
<div>
<t-button theme="default" @click="() => router.push('/tasks')">
{{ t('pages.result.success.progress') }}
</t-button>
<t-button @click="() => router.push(workbenchRoute)"> {{ t('pages.result.success.back') }} </t-button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { t } from '@/locales';
import { useUserStore } from '@/store';
defineOptions({
name: 'ResultSuccess',
});
const router = useRouter();
const userStore = useUserStore();
const workbenchRoute =
userStore.currentRole === 'PROJECT_MANAGER'
? '/workbench/project'
: userStore.currentRole === 'FINANCE_MANAGER'
? '/workbench/finance'
: userStore.currentRole === 'ARCHIVE_MANAGER'
? '/workbench/archive'
: '/governance/settings';
</script>
<style lang="less" scoped>
.result-success {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 75vh;
&-icon {
font-size: var(--td-comp-size-xxxxl);
color: var(--td-success-color);
}
&-title {
margin-top: var(--td-comp-margin-xxl);
font: var(--td-font-title-large);
color: var(--td-text-color-primary);
text-align: center;
}
&-describe {
margin: var(--td-comp-margin-s) 0 var(--td-comp-margin-xxxl);
font: var(--td-font-body-medium);
color: var(--td-text-color-secondary);
}
}
</style>
+190
View File
@@ -0,0 +1,190 @@
<template>
<main class="role-page">
<section class="role-panel">
<div class="brand-mark">财务系统</div>
<p class="eyebrow">当前账号已登录</p>
<h1>选择工作身份</h1>
<p class="description">请选择本次要使用的身份,系统会按身份重新加载菜单和数据范围。</p>
<t-alert v-if="identityConfigurationError" theme="error" :close-btn="false">
{{ identityConfigurationError }}
</t-alert>
<t-alert v-else-if="userStore.roles.length === 0" theme="warning" :close-btn="false">
当前账号没有可用工作身份。
</t-alert>
<div v-else class="role-grid">
<t-card v-for="role in availableRoles" :key="role.code" class="role-card" :bordered="true" hover-shadow>
<t-icon :name="roleIcon(role.code)" size="28px" />
<span class="role-name">{{ role.name }}</span>
<span class="role-description">{{ role.description }}</span>
<t-button
class="role-action"
theme="primary"
variant="text"
:loading="loading"
:disabled="Boolean(identityConfigurationError)"
@click="choose(role.code)"
>
进入工作台
<template #icon><t-icon name="arrow-right" /></template>
</t-button>
</t-card>
</div>
<t-button variant="text" theme="default" @click="signOut">
<template #icon><t-icon name="poweroff" /></template>
退出登录
</t-button>
</section>
</main>
</template>
<script setup lang="ts">
import { MessagePlugin } from 'tdesign-vue-next';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import type { RoleView } from '@/api/auth';
import { usePermissionStore, useUserStore } from '@/store';
defineOptions({ name: 'RoleSelectPage' });
const router = useRouter();
const userStore = useUserStore();
const permissionStore = usePermissionStore();
const loading = ref(false);
const availableRoles = computed(() => userStore.roles as RoleView[]);
const identityConfigurationError = computed(() => {
const hasSystemAdmin = userStore.roles.some((role) => role.code === 'SYSTEM_ADMIN');
const hasBusinessRole = userStore.roles.some((role) =>
['PROJECT_MANAGER', 'FINANCE_MANAGER', 'ARCHIVE_MANAGER'].includes(role.code),
);
return hasSystemAdmin && hasBusinessRole ? '账号身份配置冲突,请联系系统管理员处理。' : '';
});
const roleIcon = (code: string) =>
({
PROJECT_MANAGER: 'folder-open',
FINANCE_MANAGER: 'chart-bubble',
ARCHIVE_MANAGER: 'file-attachment',
SYSTEM_ADMIN: 'setting',
})[code] || 'user-circle';
const choose = async (roleCode: string) => {
if (identityConfigurationError.value) return;
loading.value = true;
try {
await userStore.selectRole(roleCode);
permissionStore.initRoutes();
const target = userStore.roles.find((role) => role.code === roleCode)?.workbenchRoute;
const fallback =
roleCode === 'PROJECT_MANAGER'
? '/workbench/project'
: roleCode === 'ARCHIVE_MANAGER'
? '/workbench/archive'
: roleCode === 'SYSTEM_ADMIN'
? '/governance/settings'
: '/workbench/finance';
await router.replace(target || fallback);
} catch (error) {
MessagePlugin.error((error as Error).message || '身份选择失败,请重试');
} finally {
loading.value = false;
}
};
const signOut = async () => {
await userStore.logout();
await router.replace('/login');
};
</script>
<style scoped>
.role-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px;
background: var(--td-bg-color-page);
}
.role-panel {
width: min(720px, 100%);
padding: 40px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
border-radius: var(--td-radius-large);
box-shadow: var(--td-shadow-1);
}
.brand-mark {
color: var(--td-brand-color);
font-size: 18px;
font-weight: 600;
}
.eyebrow {
margin: 32px 0 4px;
color: var(--td-text-color-secondary);
font-size: 13px;
}
h1 {
margin: 0;
font-size: 30px;
line-height: 40px;
}
.description {
margin: 8px 0 24px;
color: var(--td-text-color-secondary);
}
.role-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin: 24px 0;
}
.role-card {
min-height: 190px;
color: var(--td-text-color-primary);
text-align: left;
}
.role-card :deep(.t-card__body) {
display: flex;
min-height: 150px;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.role-name {
font-size: 17px;
font-weight: 600;
}
.role-description {
min-height: 42px;
color: var(--td-text-color-secondary);
font-size: 13px;
line-height: 21px;
}
.role-action {
margin-top: auto;
padding: 0;
}
@media (width <= 720px) {
.role-page {
padding: 16px;
}
.role-panel {
padding: 24px;
}
.role-grid {
grid-template-columns: 1fr;
}
}
</style>
+513
View File
@@ -0,0 +1,513 @@
<template>
<main class="setup-page">
<header class="setup-brand">财务系统</header>
<section class="setup-panel" aria-labelledby="setup-title">
<div class="setup-heading">
<div>
<span class="eyebrow">首次安装</span>
<h1 id="setup-title">安装向导</h1>
</div>
<t-tag theme="primary" variant="light">一次性配置</t-tag>
</div>
<t-steps :current="currentStep" class="setup-steps">
<t-step-item title="配置数据库" />
<t-step-item title="创建管理员" />
<t-step-item title="完成安装" />
</t-steps>
<t-loading v-if="initializing" class="setup-loading" text="正在读取安装状态" />
<template v-else>
<t-alert v-if="errorMessage" class="setup-alert" theme="error" :close-btn="false">
{{ errorMessage }}
</t-alert>
<section v-if="currentStep === 0" class="setup-content" aria-label="配置数据库">
<t-form
ref="databaseFormRef"
:data="databaseForm"
:rules="databaseRules"
label-align="top"
@submit="testConnection"
>
<div class="form-grid">
<t-form-item class="form-span" label="数据库类型" name="databaseType">
<t-radio-group v-model="databaseForm.databaseType" variant="default-filled">
<t-radio-button value="MYSQL">MySQL 8.4</t-radio-button>
<t-tooltip content="当前业务迁移和查询使用 MySQL 方言,PostgreSQL 适配完成后开放">
<span><t-radio-button value="POSTGRESQL" disabled>PostgreSQL</t-radio-button></span>
</t-tooltip>
</t-radio-group>
</t-form-item>
<t-form-item for="setup-db-host-input" label="数据库地址" name="host">
<t-input
id="setup-db-host"
v-model="databaseForm.host"
v-native-input-id="'setup-db-host-input'"
placeholder="127.0.0.1"
autocomplete="off"
>
<template #prefix-icon><t-icon name="server" /></template>
</t-input>
</t-form-item>
<t-form-item for="setup-db-port-input" label="端口" name="port">
<t-input-number
id="setup-db-port"
v-model="databaseForm.port"
v-native-input-id="'setup-db-port-input'"
:min="1"
:max="65535"
theme="normal"
/>
</t-form-item>
<t-form-item for="setup-db-name-input" label="数据库名称" name="database">
<t-input
id="setup-db-name"
v-model="databaseForm.database"
v-native-input-id="'setup-db-name-input'"
placeholder="kaidi_finance"
autocomplete="off"
/>
</t-form-item>
<t-form-item for="setup-db-username-input" label="数据库账号" name="username">
<t-input
id="setup-db-username"
v-model="databaseForm.username"
v-native-input-id="'setup-db-username-input'"
autocomplete="username"
/>
</t-form-item>
<t-form-item for="setup-db-password-input" label="数据库密码" name="password">
<t-input
id="setup-db-password"
v-model="databaseForm.password"
v-native-input-id="'setup-db-password-input'"
type="password"
autocomplete="current-password"
/>
</t-form-item>
<t-form-item for="setup-code-input" label="安装码" name="setupCode">
<t-input
id="setup-code"
v-model="databaseForm.setupCode"
v-native-input-id="'setup-code-input'"
type="password"
autocomplete="one-time-code"
/>
</t-form-item>
</div>
<t-alert v-if="connectionResult" class="connection-result" theme="success" :close-btn="false">
{{ connectionResult.message }}({{ connectionResult.serverVersion }})
</t-alert>
<div class="setup-actions">
<t-button theme="primary" variant="outline" type="submit" :loading="testingConnection">
<template #icon><t-icon name="link" /></template>
测试连接
</t-button>
<t-button theme="primary" :disabled="!connectionVerified" @click="currentStep = 1">
下一步
<template #icon><t-icon name="chevron-right" /></template>
</t-button>
</div>
</t-form>
</section>
<section v-else-if="currentStep === 1" class="setup-content" aria-label="创建管理员">
<t-form ref="adminFormRef" :data="adminForm" :rules="adminRules" label-align="top" @submit="finishSetup">
<div class="form-grid">
<t-form-item for="setup-admin-username-input" label="管理员账号" name="adminUsername">
<t-input
id="setup-admin-username"
v-model="adminForm.adminUsername"
v-native-input-id="'setup-admin-username-input'"
autocomplete="username"
>
<template #prefix-icon><t-icon name="user" /></template>
</t-input>
</t-form-item>
<t-form-item for="setup-admin-display-name-input" label="显示名称" name="adminDisplayName">
<t-input
id="setup-admin-display-name"
v-model="adminForm.adminDisplayName"
v-native-input-id="'setup-admin-display-name-input'"
autocomplete="name"
/>
</t-form-item>
<t-form-item for="setup-admin-password-input" label="管理员密码" name="adminPassword">
<t-input
id="setup-admin-password"
v-model="adminForm.adminPassword"
v-native-input-id="'setup-admin-password-input'"
type="password"
autocomplete="new-password"
/>
</t-form-item>
<t-form-item
for="setup-admin-password-confirmation-input"
label="确认密码"
name="adminPasswordConfirmation"
>
<t-input
id="setup-admin-password-confirmation"
v-model="adminForm.adminPasswordConfirmation"
v-native-input-id="'setup-admin-password-confirmation-input'"
type="password"
autocomplete="new-password"
/>
</t-form-item>
</div>
<div class="setup-actions">
<t-button variant="outline" :disabled="completing" @click="currentStep = 0">
<template #icon><t-icon name="chevron-left" /></template>
上一步
</t-button>
<t-popconfirm content="确认后将初始化数据库并锁定安装向导。" @confirm="confirmSetup">
<t-button theme="primary" :loading="completing">
<template #icon><t-icon name="check" /></template>
完成安装
</t-button>
</t-popconfirm>
</div>
</t-form>
</section>
<section v-else class="setup-complete" aria-live="polite">
<t-icon class="complete-icon" name="check-circle-filled" />
<h2>安装完成</h2>
<p class="completion-message">{{ completionMessage }}</p>
<t-button theme="primary" @click="openLogin">
<template #icon><t-icon name="login" /></template>
进入登录页
</t-button>
<p>{{ countdown }} 秒后自动进入登录页</p>
</section>
</template>
</section>
</main>
</template>
<script setup lang="ts">
import type { FormInstanceFunctions, FormRule, SubmitContext } from 'tdesign-vue-next';
import { MessagePlugin } from 'tdesign-vue-next';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { SetupConnection, SetupDatabaseRequest } from '@/api/setup';
import { completeSetup, getSetupStatus, testSetupConnection } from '@/api/setup';
defineOptions({ name: 'SetupWizardPage' });
function syncNativeInput(element: HTMLElement, binding: { value: string }) {
const input = element.querySelector('input');
if (input && binding.value) input.id = binding.value;
}
const vNativeInputId = {
mounted: syncNativeInput,
updated: syncNativeInput,
};
const router = useRouter();
const databaseFormRef = ref<FormInstanceFunctions>();
const adminFormRef = ref<FormInstanceFunctions>();
const currentStep = ref(0);
const initializing = ref(true);
const testingConnection = ref(false);
const completing = ref(false);
const errorMessage = ref('');
const connectionResult = ref<SetupConnection | null>(null);
const verifiedFingerprint = ref('');
const completionMessage = ref('系统正在重启,请稍候。');
const countdown = ref(10);
let countdownTimer: number | undefined;
const databaseForm = reactive<SetupDatabaseRequest>({
setupCode: '',
databaseType: 'MYSQL',
host: '127.0.0.1',
port: 3306,
database: 'kaidi_finance',
username: 'kaidi',
password: '',
});
const adminForm = reactive({
adminUsername: 'admin',
adminDisplayName: '系统管理员',
adminPassword: '',
adminPasswordConfirmation: '',
});
const databaseRules: Record<string, FormRule[]> = {
databaseType: [{ required: true, message: '请选择数据库类型', type: 'error' }],
host: [
{ required: true, message: '请输入数据库地址', type: 'error' },
{ pattern: /^[A-Z0-9.-]+$/i, message: '数据库地址格式不正确', type: 'error' },
],
port: [{ required: true, message: '请输入数据库端口', type: 'error' }],
database: [
{ required: true, message: '请输入数据库名称', type: 'error' },
{ pattern: /^[\w$]+$/, message: '数据库名称只能包含字母、数字、下划线或 $', type: 'error' },
],
username: [{ required: true, message: '请输入数据库账号', type: 'error' }],
password: [{ required: true, message: '请输入数据库密码', type: 'error' }],
setupCode: [{ required: true, message: '请输入安装码', type: 'error' }],
};
const adminRules = computed<Record<string, FormRule[]>>(() => ({
adminUsername: [
{ required: true, message: '请输入管理员账号', type: 'error' },
{ pattern: /^[\w.-]{3,64}$/, message: '账号需为 3-64 位字母、数字、点、下划线或短横线', type: 'error' },
],
adminDisplayName: [{ required: true, message: '请输入显示名称', type: 'error' }],
adminPassword: [
{ required: true, message: '请输入管理员密码', type: 'error' },
{ min: 12, message: '管理员密码至少 12 位', type: 'error' },
],
adminPasswordConfirmation: [
{ required: true, message: '请再次输入管理员密码', type: 'error' },
{
validator: (value) => value === adminForm.adminPassword,
message: '两次输入的管理员密码不一致',
type: 'error',
},
],
}));
const databaseFingerprint = computed(() => JSON.stringify(databaseForm));
const connectionVerified = computed(
() =>
Boolean(connectionResult.value?.successful && connectionResult.value.schemaReady) &&
verifiedFingerprint.value === databaseFingerprint.value,
);
watch(databaseFingerprint, () => {
if (verifiedFingerprint.value !== databaseFingerprint.value) connectionResult.value = null;
});
async function testConnection(context?: SubmitContext) {
if (context && context.validateResult !== true) return;
if (!context && databaseFormRef.value && (await databaseFormRef.value.validate()) !== true) return;
testingConnection.value = true;
errorMessage.value = '';
try {
const result = await testSetupConnection({ ...databaseForm });
connectionResult.value = result;
verifiedFingerprint.value = databaseFingerprint.value;
MessagePlugin.success('数据库连接验证通过');
} catch (error) {
connectionResult.value = null;
errorMessage.value = (error as Error).message || '数据库连接验证失败';
} finally {
testingConnection.value = false;
}
}
async function finishSetup(context?: SubmitContext) {
if (context && context.validateResult !== true) return;
if (!context && adminFormRef.value && (await adminFormRef.value.validate()) !== true) return;
if (!connectionVerified.value) {
currentStep.value = 0;
errorMessage.value = '数据库配置已变化,请重新测试连接';
return;
}
completing.value = true;
errorMessage.value = '';
try {
const result = await completeSetup({ ...databaseForm, ...adminForm });
if (!result.completed) throw new Error('安装服务未确认完成,请稍后重试');
completionMessage.value = result.message;
countdown.value = Math.max(1, result.restartAfterSeconds || 10);
currentStep.value = 2;
startCountdown();
} catch (error) {
errorMessage.value = (error as Error).message || '安装初始化失败';
} finally {
completing.value = false;
}
}
function confirmSetup() {
void finishSetup();
}
function startCountdown() {
window.clearInterval(countdownTimer);
countdownTimer = window.setInterval(() => {
countdown.value -= 1;
if (countdown.value <= 0) openLogin();
}, 1000);
}
function openLogin() {
window.clearInterval(countdownTimer);
window.location.assign('/login');
}
onMounted(async () => {
try {
const status = await getSetupStatus();
if (!status.required) {
await router.replace('/login');
}
} catch (error) {
const statusCode = (error as { response?: { status?: number } }).response?.status;
if (statusCode === 404) await router.replace('/login');
errorMessage.value = (error as Error).message || '安装状态读取失败';
} finally {
initializing.value = false;
}
});
onBeforeUnmount(() => window.clearInterval(countdownTimer));
</script>
<style scoped>
.setup-page {
min-height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
padding: 24px clamp(16px, 4vw, 48px) 40px;
background: var(--td-bg-color-page);
}
.setup-brand {
color: var(--td-text-color-primary);
font-size: 18px;
font-weight: 600;
}
.setup-panel {
align-self: center;
justify-self: center;
width: min(760px, 100%);
min-width: 0;
padding: 32px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
border-radius: 8px;
box-shadow: var(--td-shadow-1);
}
.setup-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.eyebrow {
color: var(--td-text-color-secondary);
font-size: 13px;
}
h1 {
margin: 4px 0 0;
font-size: 24px;
line-height: 32px;
}
.setup-steps {
margin: 28px 0 32px;
}
.setup-loading {
min-height: 280px;
}
.setup-alert,
.connection-result {
margin-bottom: 20px;
}
.setup-content {
min-width: 0;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 20px;
}
.form-span {
grid-column: 1 / -1;
}
.setup-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
}
.setup-complete {
min-height: 280px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.setup-complete p {
margin: 0;
color: var(--td-text-color-secondary);
font-size: 13px;
}
.complete-icon {
color: var(--td-success-color);
font-size: 56px;
}
.setup-complete h2 {
margin: 16px 0 8px;
font-size: 24px;
line-height: 32px;
}
.setup-complete .completion-message {
margin-bottom: 20px;
}
@media (width <= 640px) {
.setup-page {
padding: 16px 12px 24px;
}
.setup-panel {
align-self: start;
margin-top: 20px;
padding: 20px 16px;
}
.setup-heading {
align-items: center;
}
.setup-steps {
margin: 24px 0;
}
.form-grid {
grid-template-columns: minmax(0, 1fr);
}
.form-span {
grid-column: auto;
}
.setup-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.setup-actions :deep(.t-button) {
width: 100%;
}
}
</style>
+836
View File
@@ -0,0 +1,836 @@
<template>
<div class="task-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="loadTasks"
><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="loadTasks">重新加载</t-link>
</div></t-alert
>
<section class="task-panel">
<t-tabs v-model="activeView" @change="viewChanged">
<t-tab-panel v-for="tab in viewTabs" :key="tab.value" :value="tab.value" :label="tab.label" />
</t-tabs>
<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="formOptions" 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="projectId"
><t-select
v-model="filters.projectId"
clearable
filterable
:options="projectOptions"
placeholder="全部项目"
/></t-form-item>
<t-form-item label="到达日期" name="arrivedDateRange"
><t-date-range-picker
v-model="filters.arrivedDateRange"
clearable
value-type="YYYY-MM-DD"
:placeholder="['开始日期', '结束日期']"
/></t-form-item>
<t-form-item label="排序" name="sort"
><t-select v-model="filters.sort" :options="sortOptions" placeholder="选择排序"
/></t-form-item>
<t-form-item label="时效" name="overdue" class="overdue-filter"
><t-checkbox v-model="filters.overdue">仅看已逾期</t-checkbox></t-form-item
>
<div class="filter-actions">
<t-button theme="primary" type="submit"
><template #icon><search-icon /></template>查询</t-button
><t-button variant="outline" type="button" @click="reset">重置</t-button>
</div>
</div>
</t-form>
<t-table
v-if="loading || rows.length"
:data="rows"
:columns="columns"
row-key="publicId"
:loading="loading"
table-layout="fixed"
:horizontal-scroll-affixed-bottom="true"
hover
>
<template #businessNo="{ row }"
><t-link theme="primary" @click="openTask(row)">{{ row.form.businessNo }}</t-link></template
>
<template #form="{ row }"
><div class="form-cell">
<strong>{{ row.form.formName }}</strong
><span>{{ row.form.formType }}</span>
</div></template
>
<template #project="{ row }">{{ row.form.project?.name || '未关联项目' }}</template>
<template #applicantName="{ row }">{{ row.form.applicantName || '-' }}</template>
<template #keyAmount="{ row }"
><span class="money-cell">{{ formatMoney(row.keyAmount) }}</span></template
>
<template #status="{ row }"
><t-tag :theme="taskTheme(row.status)" variant="light">{{ taskStatusLabel(row.status) }}</t-tag></template
>
<template #dueAt="{ row }"
><span :class="{ overdue: overdue(row.dueAt) && row.status === 'PENDING' }">{{
row.dueAt ? formatTime(row.dueAt) : '-'
}}</span></template
>
<template #arrivedAt="{ row }">{{ formatTime(row.arrivedAt) }}</template>
<template #op="{ row }"
><t-space size="small"
><t-link theme="primary" @click="openTask(row)">查看</t-link
><t-link v-if="row.allowedActions.includes('APPROVE')" theme="primary" @click="openAction(row, 'approve')"
>通过</t-link
><t-link v-if="row.allowedActions.includes('RETURN')" theme="warning" @click="openAction(row, 'return')"
>退回</t-link
><t-link v-if="row.allowedActions.includes('REJECT')" theme="danger" @click="openAction(row, 'reject')"
>驳回</t-link
><t-link
v-if="row.form.allowedActions.includes('WITHDRAW')"
theme="warning"
@click="openAction(row, 'withdraw')"
>撤回</t-link
><t-link v-if="row.form.allowedActions.includes('VOID')" theme="danger" @click="openAction(row, 'void')"
>作废</t-link
></t-space
></template
>
</t-table>
<t-empty v-else description="当前视图暂无审批任务" />
<div 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="loadTasks"
@page-size-change="pageSizeChanged"
/>
</div>
</section>
<t-dialog
v-model:visible="detailVisible"
:header="selectedTask?.form.formName || '审批详情'"
:width="980"
:footer="false"
:close-on-overlay-click="false"
>
<template #body>
<t-skeleton v-if="detailLoading" animation="gradient" />
<template v-else-if="taskDetail">
<div class="detail-header">
<div>
<div class="business-no">{{ taskDetail.task.form.businessNo }}</div>
<div class="detail-meta">
{{ taskDetail.task.form.company.name
}}<span v-if="taskDetail.task.form.project"> · {{ taskDetail.task.form.project.name }}</span>
</div>
</div>
<t-space break-line>
<t-button
v-if="taskDetail.task.allowedActions.includes('APPROVE')"
theme="success"
@click="openAction(taskDetail.task, 'approve')"
>通过</t-button
>
<t-button
v-if="taskDetail.task.allowedActions.includes('RETURN')"
theme="warning"
variant="outline"
@click="openAction(taskDetail.task, 'return')"
>退回补件</t-button
>
<t-button
v-if="taskDetail.task.allowedActions.includes('REJECT')"
theme="danger"
variant="outline"
@click="openAction(taskDetail.task, 'reject')"
>驳回</t-button
>
<t-button
v-if="taskDetail.task.form.allowedActions.includes('WITHDRAW')"
theme="warning"
variant="outline"
@click="openAction(taskDetail.task, 'withdraw')"
>撤回</t-button
>
<t-button
v-if="taskDetail.task.form.allowedActions.includes('VOID')"
theme="danger"
variant="outline"
@click="openAction(taskDetail.task, 'void')"
>作废</t-button
>
</t-space>
</div>
<t-tabs v-model="detailTab">
<t-tab-panel value="values" label="表单内容"
><div class="detail-values">
<div
v-for="field in taskDetail.form.fields"
:key="field.code"
class="value-item"
:class="{
wide: field.dataType === 'TABLE' || field.dataType === 'LONG_TEXT' || field.dataType === 'FILE',
}"
>
<span class="value-label">{{ field.label }}</span
><span class="value-content">{{ displayValue(field, taskDetail.form.values[field.code]) }}</span>
</div>
</div></t-tab-panel
>
<t-tab-panel value="approvals" label="审批时间线"
><t-timeline v-if="taskDetail.timeline.length" mode="same" theme="dot"
><t-timeline-item
v-for="item in taskDetail.timeline"
:key="item.publicId"
:label="formatTime(item.actedAt)"
><strong>{{ actionLabel(item.action) }} · {{ item.nodeName }}</strong>
<div class="muted">
{{ item.actorName }}<span v-if="item.opinion"> · {{ item.opinion }}</span>
</div></t-timeline-item
></t-timeline
><t-empty v-else description="暂无审批记录"
/></t-tab-panel>
<t-tab-panel value="validation" label="校验结果"
><t-alert :theme="taskDetail.form.validation.valid ? 'success' : 'warning'" :close-btn="false">{{
taskDetail.form.validation.valid
? '当前版本校验通过'
: `存在 ${taskDetail.form.validation.errors.length} 项校验错误`
}}</t-alert
><t-list v-if="taskDetail.form.validation.errors.length"
><t-list-item
v-for="(issue, index) in taskDetail.form.validation.errors"
:key="`${issue.fieldCode}-${index}`"
><t-list-item-meta :title="issue.message" :description="issue.fieldCode" /></t-list-item></t-list
><t-empty v-else description="暂无错误"
/></t-tab-panel>
</t-tabs>
</template>
</template>
</t-dialog>
<t-dialog
v-model:visible="actionVisible"
:header="actionDialogTitle"
:width="620"
:footer="false"
:close-on-overlay-click="false"
>
<t-form :data="actionForm" :rules="actionRules" @submit="submitAction">
<t-form-item v-if="action === 'return'" label="退回点" name="returnPoints"
><t-checkbox-group v-model="actionForm.returnPoints" :options="returnPointOptions"
/></t-form-item>
<t-form-item label="审批意见" name="opinion"
><t-textarea v-model="actionForm.opinion" maxlength="1000" :autosize="{ minRows: 4, maxRows: 8 }"
/></t-form-item>
<div class="dialog-actions">
<t-button variant="outline" type="button" @click="actionVisible = false">取消</t-button
><t-button :theme="actionTheme" type="submit" :loading="actionSaving">{{ actionSubmitLabel }}</t-button>
</div>
</t-form>
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { RefreshIcon, SearchIcon } from 'tdesign-icons-vue-next';
import type { FormRules, PrimaryTableCol, SubmitContext } from 'tdesign-vue-next';
import { MessagePlugin } from 'tdesign-vue-next';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { SourceTemplateField, WorkflowTaskDetail, WorkflowTaskQuery, WorkflowTaskSummary } from '@/api/source';
import {
actOnWorkflowTask,
getSourceReferences,
getWorkflowTask,
getWorkflowTasks,
voidSourceForm,
withdrawSourceForm,
} from '@/api/source';
import { useUserStore } from '@/store';
defineOptions({ name: 'TaskCenterPage' });
type ViewCode = WorkflowTaskQuery['view'];
type TaskTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
type WorkflowAction = 'approve' | 'return' | 'reject' | 'withdraw' | 'void';
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const allowedViews: ViewCode[] = ['TODO', 'PROCESSED', 'CREATED', 'RETURNED', 'COMPLETED'];
const allowedSorts: NonNullable<WorkflowTaskQuery['sort']>[] = [
'arrivedAt,asc',
'arrivedAt,desc',
'dueAt,asc',
'dueAt,desc',
'updatedAt,asc',
'updatedAt,desc',
];
const queryText = (key: string) => (typeof route.query[key] === 'string' ? String(route.query[key]) : '');
const normalizedView = (value: unknown): ViewCode => {
const candidate = typeof value === 'string' ? value.toUpperCase() : '';
return allowedViews.includes(candidate as ViewCode) ? (candidate as ViewCode) : 'TODO';
};
const normalizedSort = (value: unknown): NonNullable<WorkflowTaskQuery['sort']> =>
allowedSorts.includes(value as NonNullable<WorkflowTaskQuery['sort']>)
? (value as NonNullable<WorkflowTaskQuery['sort']>)
: 'arrivedAt,asc';
const dateRangeFromQuery = () => {
const from = queryText('arrivedDateFrom');
const to = queryText('arrivedDateTo');
return from || to ? [from, to] : [];
};
const activeView = ref<ViewCode>(normalizedView(route.query.view));
const loading = ref(false);
const detailLoading = ref(false);
const actionSaving = ref(false);
const actionKey = ref('');
const errorMessage = ref('');
const rows = ref<WorkflowTaskSummary[]>([]);
const projectOptions = ref<Array<{ label: string; value: string }>>([]);
const selectedTask = ref<WorkflowTaskSummary | null>(null);
const taskDetail = ref<WorkflowTaskDetail | null>(null);
const detailVisible = ref(false);
const detailTab = ref('values');
const actionVisible = ref(false);
const action = ref<WorkflowAction>('approve');
const filters = reactive({
keyword: queryText('keyword'),
formType: queryText('formType'),
status: queryText('status'),
projectId: queryText('projectId'),
arrivedDateRange: dateRangeFromQuery(),
overdue: queryText('overdue') === 'true',
sort: normalizedSort(route.query.sort),
});
const initialPageSize = [20, 50, 100].includes(Number(route.query.size)) ? Number(route.query.size) : 20;
const pagination = reactive({
current: Math.max(1, Number(route.query.page) || 1),
pageSize: initialPageSize,
total: 0,
});
let syncingUrl = false;
const actionForm = reactive({ opinion: '', returnPoints: [] as string[] });
const viewTabs = computed(() => {
const all = userStore.currentRole === 'SYSTEM_ADMIN';
return [
{ value: 'TODO' as const, label: all ? '全部待处理' : '待我处理' },
{ value: 'PROCESSED' as const, label: all ? '全部已处理' : '我已处理' },
{ value: 'CREATED' as const, label: all ? '全部发起' : '我发起' },
{ value: 'RETURNED' as const, label: all ? '全部退回' : '被退回' },
{ value: 'COMPLETED' as const, label: '已完成' },
];
});
const statusOptions = [
{ label: '待处理', value: 'PENDING' },
{ label: '已通过', value: 'APPROVED' },
{ label: '已退回', value: 'RETURNED' },
{ label: '已驳回', value: 'REJECTED' },
{ label: '已取消', value: 'CANCELLED' },
];
const formOptions = Array.from({ length: 14 }, (_, index) => ({
label: `OA-${String(index + 1).padStart(2, '0')}`,
value: `OA-${String(index + 1).padStart(2, '0')}`,
}));
const sortOptions = [
{ label: '到达时间升序', value: 'arrivedAt,asc' },
{ label: '到达时间降序', value: 'arrivedAt,desc' },
{ label: '截止时间升序', value: 'dueAt,asc' },
{ label: '截止时间降序', value: 'dueAt,desc' },
{ label: '更新时间升序', value: 'updatedAt,asc' },
{ label: '更新时间降序', value: 'updatedAt,desc' },
];
const columns: PrimaryTableCol[] = [
{ title: '表单号', colKey: 'businessNo', width: 185, fixed: 'left', ellipsis: true },
{ title: '任务', colKey: 'form', width: 190 },
{ title: '项目', colKey: 'project', width: 180, ellipsis: true },
{ title: '申请人', colKey: 'applicantName', width: 110, ellipsis: true },
{ title: '关键金额', colKey: 'keyAmount', width: 145, align: 'right' },
{ title: '当前节点', colKey: 'nodeName', width: 120, ellipsis: true },
{ title: '状态', colKey: 'status', width: 100 },
{ title: '到达时间', colKey: 'arrivedAt', width: 165 },
{ title: '截止时间', colKey: 'dueAt', width: 165 },
{ title: '操作', colKey: 'op', width: 300, fixed: 'right' },
];
const actionRules: FormRules<typeof actionForm> = {
opinion: [{ required: true, message: '请填写审批意见', type: 'error' }],
returnPoints: [
{
validator: (value) =>
action.value !== 'return' || (Array.isArray(value) && value.length > 0)
? true
: { result: false, message: '请选择至少一个退回点' },
type: 'error',
},
],
};
const returnPointOptions = computed(() => {
const fields = taskDetail.value?.form.fields || [];
return fields.map((field) => ({
label: field.label + (field.dataType === 'FILE' ? '(附件)' : ''),
value: field.code,
}));
});
const actionDialogTitle = computed(
() =>
({
approve: '通过审批',
return: '退回补件',
reject: '驳回审批',
withdraw: '撤回申请',
void: '作废申请',
})[action.value],
);
const actionSubmitLabel = computed(
() =>
({
approve: '确认通过',
return: '确认退回',
reject: '确认驳回',
withdraw: '确认撤回',
void: '确认作废',
})[action.value],
);
const actionTheme = computed<'primary' | 'success' | 'warning' | 'danger'>(() => {
if (action.value === 'approve') return 'success';
if (action.value === 'return' || action.value === 'withdraw') return 'warning';
return 'danger';
});
async function loadTasks() {
loading.value = true;
errorMessage.value = '';
try {
const params: WorkflowTaskQuery = {
view: activeView.value,
keyword: filters.keyword.trim() || undefined,
formType: filters.formType || undefined,
status: filters.status || undefined,
projectId: filters.projectId || undefined,
arrivedDateFrom: filters.arrivedDateRange[0] || undefined,
arrivedDateTo: filters.arrivedDateRange[1] || undefined,
overdue: filters.overdue || undefined,
sort: filters.sort,
page: pagination.current,
size: pagination.pageSize,
};
const result = await getWorkflowTasks(params);
rows.value = result.items;
pagination.total = result.meta.totalElements;
await syncUrl(params);
} catch (error) {
errorMessage.value = errorText(error, '审批任务加载失败');
} finally {
loading.value = false;
}
}
async function syncUrl(params: WorkflowTaskQuery) {
const query: Record<string, string> = {};
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== '') query[key] = String(value);
});
syncingUrl = true;
try {
await router.replace({ name: 'ApprovalTasks', query });
} finally {
syncingUrl = false;
}
}
function search() {
pagination.current = 1;
loadTasks();
}
function reset() {
Object.assign(filters, {
keyword: '',
formType: '',
status: '',
projectId: '',
arrivedDateRange: [],
overdue: false,
sort: 'arrivedAt,asc',
});
pagination.current = 1;
loadTasks();
}
async function loadReferences() {
try {
const references = await getSourceReferences();
projectOptions.value = references.projects.map((project) => ({
label: project.name,
value: project.publicId,
}));
} catch (error) {
MessagePlugin.warning(errorText(error, '项目选项加载失败'));
}
}
function viewChanged() {
pagination.current = 1;
loadTasks();
}
function pageSizeChanged() {
pagination.current = 1;
loadTasks();
}
async function openTask(task: WorkflowTaskSummary) {
selectedTask.value = task;
detailVisible.value = true;
detailLoading.value = true;
detailTab.value = 'values';
try {
taskDetail.value = await getWorkflowTask(task.publicId);
} catch (error) {
MessagePlugin.error(errorText(error, '审批详情加载失败'));
detailVisible.value = false;
} finally {
detailLoading.value = false;
}
}
function canRunAction(task: WorkflowTaskSummary, nextAction: WorkflowAction) {
const code = nextAction.toUpperCase();
return nextAction === 'withdraw' || nextAction === 'void'
? task.form.allowedActions.includes(code)
: task.allowedActions.includes(code);
}
async function openAction(task: WorkflowTaskSummary, nextAction: WorkflowAction) {
if (!canRunAction(task, nextAction)) return;
selectedTask.value = task;
action.value = nextAction;
actionForm.opinion = '';
actionForm.returnPoints = [];
if (nextAction === 'return') {
actionSaving.value = true;
try {
const detail = await getWorkflowTask(task.publicId);
taskDetail.value = detail;
selectedTask.value = detail.task;
} catch (error) {
MessagePlugin.error(errorText(error, '审批详情加载失败'));
return;
} finally {
actionSaving.value = false;
}
}
actionVisible.value = true;
}
async function submitAction(context: SubmitContext) {
if (
context.validateResult !== true ||
!selectedTask.value ||
actionSaving.value ||
!canRunAction(selectedTask.value, action.value)
) {
return;
}
actionSaving.value = true;
try {
const task = selectedTask.value;
if (action.value === 'withdraw') {
await withdrawSourceForm(
task.form.publicId,
task.form.version,
actionForm.opinion.trim(),
actionKey.value || undefined,
);
} else if (action.value === 'void') {
await voidSourceForm(
task.form.publicId,
task.form.version,
actionForm.opinion.trim(),
actionKey.value || undefined,
);
} else {
await actOnWorkflowTask(
task.publicId,
action.value,
{
version: task.version,
opinion: actionForm.opinion.trim(),
returnPoints: actionForm.returnPoints,
},
actionKey.value || undefined,
);
}
actionVisible.value = false;
if (action.value === 'withdraw' || action.value === 'void') {
detailVisible.value = false;
taskDetail.value = null;
} else {
taskDetail.value = await getWorkflowTask(task.publicId);
}
MessagePlugin.success(
{ approve: '审批已通过', return: '已退回补件', reject: '已驳回', withdraw: '申请已撤回', void: '申请已作废' }[
action.value
],
);
await loadTasks();
} catch (error) {
MessagePlugin.error(errorText(error, '审批操作失败'));
} finally {
actionSaving.value = false;
}
}
function taskStatusLabel(value: string) {
return (
(
{ PENDING: '待处理', APPROVED: '已通过', RETURNED: '已退回', REJECTED: '已驳回', CANCELLED: '已取消' } as Record<
string,
string
>
)[value] || value
);
}
function taskTheme(value: string): TaskTheme {
if (value === 'APPROVED') return 'success';
if (value === 'PENDING' || value === 'RETURNED') return 'warning';
if (value === 'REJECTED') return 'danger';
return 'default';
}
function overdue(value?: string | null) {
return !!value && new Date(value).getTime() < Date.now();
}
function formatTime(value: string) {
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
}
function formatMoney(value?: string | null) {
if (value === null || value === undefined || value === '') return '-';
const amount = Number(value);
if (!Number.isFinite(amount)) return `${value} CNY`;
return `${new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)} CNY`;
}
function actionLabel(value: string) {
return (
(
{
SUBMIT: '提交审批',
APPROVE: '审批通过',
RETURN: '退回补件',
REJECT: '驳回',
WITHDRAW: '撤回',
VOID: '作废',
} as Record<string, string>
)[value] || value
);
}
function displayValue(field: SourceTemplateField, value: unknown) {
if (value === null || value === undefined || value === '') return '未填写';
if (field.dataType === 'BOOLEAN') return value ? '是' : '否';
if (field.dataType === 'TABLE') {
const rows = Array.isArray(value) ? value : [];
return (
rows
.map((row) => field.options.map((column) => `${column.label}: ${row[column.code] || '-'}`).join(';'))
.join('\n') || '未填写'
);
}
if (field.dataType === 'FILE') return Array.isArray(value) ? value.join('、') : String(value);
return String(value);
}
function errorText(error: unknown, fallback: string) {
return (
(error as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
(error as Error)?.message ||
fallback
);
}
watch(
() => route.query,
() => {
if (syncingUrl) return;
activeView.value = normalizedView(route.query.view);
filters.keyword = queryText('keyword');
filters.formType = queryText('formType');
filters.status = queryText('status');
filters.projectId = queryText('projectId');
filters.arrivedDateRange = dateRangeFromQuery();
filters.overdue = queryText('overdue') === 'true';
filters.sort = normalizedSort(route.query.sort);
pagination.current = Math.max(1, Number(route.query.page) || 1);
pagination.pageSize = [20, 50, 100].includes(Number(route.query.size)) ? Number(route.query.size) : 20;
loadTasks();
},
);
onMounted(() => {
loadReferences();
loadTasks();
});
</script>
<style scoped>
.task-center-page {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.page-header,
.alert-content,
.detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.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);
}
.task-panel {
width: 100%;
min-width: 0;
overflow: hidden;
padding: 20px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
border-radius: var(--td-radius-default);
}
.filter-grid {
display: grid;
grid-template-columns: repeat(4, minmax(180px, 1fr));
gap: 0 20px;
margin-top: 16px;
}
.overdue-filter :deep(.t-form__controls-content) {
min-height: 32px;
align-items: center;
}
.money-cell {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.filter-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding-top: 28px;
}
.form-cell {
display: flex;
flex-direction: column;
gap: 3px;
}
.form-cell span,
.detail-meta,
.muted {
color: var(--td-text-color-secondary);
font-size: 12px;
}
.overdue {
color: var(--td-error-color);
font-weight: 500;
}
.pagination-row {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.business-no {
color: var(--td-brand-color);
font-weight: 600;
}
.detail-header {
align-items: center;
min-width: 0;
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px solid var(--td-component-border);
}
.detail-header > * {
min-width: 0;
}
.detail-values {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 24px;
padding: 18px 4px;
}
.value-item {
display: flex;
flex-direction: column;
gap: 5px;
min-height: 64px;
padding: 10px 0;
border-bottom: 1px solid var(--td-component-border);
}
.value-item.wide {
grid-column: 1 / -1;
}
.value-label {
color: var(--td-text-color-secondary);
font-size: 12px;
}
.value-content {
white-space: pre-wrap;
word-break: break-word;
line-height: 22px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 12px;
}
@media (max-width: 1366px) {
.filter-grid {
grid-template-columns: repeat(3, minmax(180px, 1fr));
}
}
@media (max-width: 900px) {
.filter-grid {
grid-template-columns: repeat(2, minmax(180px, 1fr));
}
}
@media (max-width: 600px) {
.page-header,
.detail-header {
flex-direction: column;
}
.filter-grid,
.detail-values {
grid-template-columns: 1fr;
}
.value-item.wide {
grid-column: auto;
}
.task-panel {
padding: 14px;
}
.alert-content {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
}
</style>
@@ -0,0 +1,41 @@
<template>
<t-card title="近期动态" :bordered="false">
<t-timeline v-if="activities.length" mode="same" theme="dot">
<t-timeline-item v-for="item in activities" :key="item.publicId" :label="formatTime(item.occurredAt)">
<div class="activity-item">
<t-link theme="primary" @click="$emit('navigate', item.targetRoute)">{{ item.title }}</t-link>
<small>{{ item.resultCode }} · {{ item.objectPublicId || '系统动作' }}</small>
</div>
</t-timeline-item>
</t-timeline>
<t-empty v-else description="暂无近期动态" />
</t-card>
</template>
<script setup lang="ts">
import type { WorkbenchActivity } from '@/api/workbench';
defineOptions({ name: 'WorkbenchActivityCard' });
defineProps<{ activities: WorkbenchActivity[] }>();
defineEmits<{ navigate: [target: string] }>();
const formatTime = (value?: string) => {
if (!value) return '-';
const date = new Date(value);
return Number.isNaN(date.getTime())
? '-'
: new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', dateStyle: 'short', timeStyle: 'short' }).format(
date,
);
};
</script>
<style scoped>
.activity-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.activity-item small {
color: var(--td-text-color-secondary);
font-size: 12px;
}
</style>
@@ -0,0 +1,602 @@
<template>
<div class="workbench-page">
<h1 class="page-title-sr-only">{{ workbench?.title || fallbackTitle }}</h1>
<header class="page-heading page-heading--actions-only" aria-label="页面操作">
<t-button variant="outline" :loading="loading" :disabled="loading" @click="load">
<template #icon><t-icon name="refresh" /></template>
刷新
</t-button>
</header>
<t-alert v-if="error" theme="error" :close-btn="false">
<div class="alert-content">
<span>{{ error }}</span>
<t-link theme="primary" @click="load">重新加载</t-link>
</div>
</t-alert>
<t-row v-if="loading && !workbench" :gutter="16">
<t-col v-for="index in 4" :key="index" :xs="12" :sm="6" :xl="3">
<t-skeleton animation="gradient" />
</t-col>
</t-row>
<t-empty v-else-if="!workbench" :description="emptyDescription" />
<template v-else>
<t-row class="metric-grid" :gutter="16">
<t-col v-for="metric in workbench.metrics" :key="metric.code" :xs="12" :sm="6" :xl="3">
<t-card class="metric-card" :bordered="false" hover-shadow>
<t-statistic :title="metric.label" :value="metric.value" />
<t-button class="metric-link" variant="text" theme="primary" @click="go(metric.targetRoute)">
查看明细
<template #icon><t-icon name="arrow-right" /></template>
</t-button>
</t-card>
</t-col>
</t-row>
<template v-if="props.role === 'project'">
<div class="content-grid content-grid--wide">
<t-card title="我的待办" :bordered="false">
<template #actions
><span class="muted">{{ workbench.todos.length }} 条</span></template
>
<t-table
v-if="workbench.todos.length"
:columns="todoColumns"
:data="workbench.todos"
row-key="publicId"
size="small"
table-layout="fixed"
>
<template #businessNo="{ row }">
<t-link theme="primary" @click="go(row.targetRoute)">{{ row.businessNo }}</t-link>
</template>
<template #status="{ row }"
><t-tag :theme="statusTheme(row.status)" variant="light">{{ statusLabel(row.status) }}</t-tag></template
>
<template #arrivedAt="{ row }">{{ formatTime(row.arrivedAt) }}</template>
<template #dueAt="{ row }">{{ formatTime(row.dueAt) }}</template>
<template #operations="{ row }"
><t-button size="small" variant="text" @click="go(row.targetRoute)">处理</t-button></template
>
</t-table>
<t-empty v-else description="当前没有待处理事项" />
</t-card>
<t-card title="我的项目" :bordered="false">
<template #actions><t-link theme="primary" @click="go('/projects')">全部项目</t-link></template>
<t-table
v-if="workbench.projects.length"
:columns="projectColumns"
:data="workbench.projects"
row-key="publicId"
size="small"
table-layout="fixed"
>
<template #name="{ row }"
><t-link theme="primary" @click="go(row.targetRoute)">{{ row.name }}</t-link></template
>
<template #contractAmount="{ row }">{{ formatMoney(row.contractAmount) }}</template>
<template #updatedAt="{ row }">{{ formatTime(row.updatedAt) }}</template>
<template #status="{ row }"
><t-tag :theme="statusTheme(row.status)" variant="light">{{ statusLabel(row.status) }}</t-tag></template
>
</t-table>
<t-empty v-else description="当前没有进行中的项目" />
</t-card>
</div>
<div class="content-grid">
<t-card title="异常提示" :bordered="false">
<div v-if="workbench.issues.length" class="issue-list">
<t-button
v-for="issue in workbench.issues"
:key="issue.code"
class="issue-row"
variant="text"
@click="go(issue.targetRoute)"
>
<span class="issue-count">
<t-tag :theme="severityTheme(issue.severity)" variant="light">{{ issue.count }}</t-tag>
</span>
<span class="issue-copy"
><strong>{{ issue.label }}</strong
><small>{{ issue.detail }}</small></span
>
<span class="issue-arrow" aria-hidden="true"><t-icon name="chevron-right" /></span>
</t-button>
</div>
<t-empty v-else description="暂无异常" />
</t-card>
<workbench-activity-card :activities="workbench.activities" @navigate="go" />
</div>
</template>
<template v-else-if="props.role === 'finance'">
<div class="content-grid content-grid--wide">
<t-card title="财务待办" :bordered="false">
<template #actions><t-link theme="primary" @click="go('/tasks?view=todo')">打开待办</t-link></template>
<t-table
v-if="workbench.todos.length"
:columns="todoColumns"
:data="workbench.todos"
row-key="publicId"
size="small"
table-layout="fixed"
>
<template #businessNo="{ row }"
><t-link theme="primary" @click="go(row.targetRoute)">{{ row.businessNo }}</t-link></template
>
<template #status="{ row }"
><t-tag :theme="statusTheme(row.status)" variant="light">{{ statusLabel(row.status) }}</t-tag></template
>
<template #arrivedAt="{ row }">{{ formatTime(row.arrivedAt) }}</template>
<template #dueAt="{ row }">{{ formatTime(row.dueAt) }}</template>
<template #operations="{ row }"
><t-button size="small" variant="text" @click="go(row.targetRoute)">处理</t-button></template
>
</t-table>
<t-empty v-else description="当前没有财务待办" />
</t-card>
<t-card title="阻断与待处理" :bordered="false">
<div v-if="workbench.issues.length" class="issue-list">
<t-button
v-for="issue in workbench.issues"
:key="issue.code"
class="issue-row"
variant="text"
@click="go(issue.targetRoute)"
>
<span class="issue-count">
<t-tag :theme="severityTheme(issue.severity)" variant="light">{{ issue.count }}</t-tag>
</span>
<span class="issue-copy"
><strong>{{ issue.label }}</strong
><small>{{ issue.detail }}</small></span
>
<span class="issue-arrow" aria-hidden="true"><t-icon name="chevron-right" /></span>
</t-button>
</div>
<t-empty v-else description="暂无阻断事项" />
</t-card>
</div>
<t-card title="项目资金概览" :bordered="false">
<t-table
v-if="workbench.funds.length"
:columns="fundColumns"
:data="workbench.funds"
row-key="projectPublicId"
size="small"
table-layout="fixed"
>
<template #projectName="{ row }"
><t-link theme="primary" @click="go(row.targetRoute)">{{ row.projectName }}</t-link></template
>
<template #confirmedReceipt="{ row }">{{ formatMoney(row.confirmedReceipt) }}</template>
<template #approvedUnpaid="{ row }">{{ formatMoney(row.approvedUnpaid) }}</template>
<template #paidAmount="{ row }">{{ formatMoney(row.paidAmount) }}</template>
<template #availableAmount="{ row }"
><strong>{{ formatMoney(row.availableAmount) }}</strong></template
>
</t-table>
<t-empty v-else description="当前授权范围暂无资金数据" />
</t-card>
<workbench-activity-card :activities="workbench.activities" @navigate="go" />
</template>
<template v-else>
<div class="content-grid content-grid--wide">
<t-card title="档案与缺件" :bordered="false">
<template #actions
><t-link theme="primary" @click="go('/archives/projects?view=prepare')">档案中心</t-link></template
>
<t-table
v-if="archiveWorkItems.length"
:columns="archiveColumns"
:data="archiveWorkItems"
row-key="publicId"
size="small"
table-layout="fixed"
>
<template #title="{ row }"
><t-link theme="primary" @click="go(row.targetRoute)">{{ row.title }}</t-link
><small class="subline">{{ row.subtitle }}</small></template
>
<template #kind="{ row }">{{ archiveKindLabel(row.kind) }}</template>
<template #status="{ row }"
><t-tag :theme="statusTheme(row.status)" variant="light">{{ statusLabel(row.status) }}</t-tag></template
>
<template #completeness="{ row }">{{ row.completeness ? `${row.completeness}%` : '-' }}</template>
<template #dueAt="{ row }">{{ formatTime(row.dueAt) }}</template>
<template #updatedAt="{ row }">{{ formatTime(row.updatedAt) }}</template>
</t-table>
<t-empty v-else description="当前没有待处理档案" />
</t-card>
<t-card title="处理提醒" :bordered="false">
<div class="archive-reminders">
<t-alert theme="info" :close-btn="false">缺件项目请在档案包详情中补充或退回。</t-alert>
<t-button theme="primary" variant="outline" @click="go('/archives/files?resource=borrows')">
<template #icon><t-icon name="folder-open" /></template>
查看借阅记录
</t-button>
</div>
</t-card>
</div>
<t-card title="近期下载" :bordered="false">
<t-table
v-if="recentDownloads.length"
:columns="downloadColumns"
:data="recentDownloads"
row-key="publicId"
size="small"
table-layout="fixed"
>
<template #title="{ row }"
><t-link theme="primary" @click="go(row.targetRoute)">{{ row.title }}</t-link></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>
</t-table>
<t-empty v-else description="暂无近期下载" />
</t-card>
<workbench-activity-card :activities="workbench.activities" @navigate="go" />
</template>
</template>
</div>
</template>
<script setup lang="ts">
import { MessagePlugin } from 'tdesign-vue-next';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import type { WorkbenchView } from '@/api/workbench';
import { getWorkbench } from '@/api/workbench';
import { parseWorkbenchTarget } from './workbench-navigation';
import WorkbenchActivityCard from './WorkbenchActivityCard.vue';
defineOptions({ name: 'WorkbenchPage' });
const props = defineProps<{ role: 'project' | 'finance' | 'archive'; fallbackTitle: string }>();
const router = useRouter();
const workbench = ref<WorkbenchView>();
const loading = ref(false);
const error = ref('');
let loadSequence = 0;
const archiveWorkItems = computed(() => workbench.value?.archiveItems.filter((item) => item.kind !== 'DOWNLOAD') || []);
const recentDownloads = computed(() => workbench.value?.archiveItems.filter((item) => item.kind === 'DOWNLOAD') || []);
const emptyDescription = computed(() => (error.value ? '工作台数据暂不可用' : '暂无工作台数据'));
const todoColumns = [
{ colKey: 'businessNo', title: '单号', width: 140 },
{ colKey: 'formName', title: '表单', ellipsis: true },
{ colKey: 'projectName', title: '项目', ellipsis: true },
{ colKey: 'nodeCode', title: '当前节点', width: 110 },
{ colKey: 'arrivedAt', title: '到达时间', width: 150 },
{ colKey: 'dueAt', title: '到期时间', width: 150 },
{ colKey: 'status', title: '状态', width: 90 },
{ colKey: 'operations', title: '操作', width: 76 },
];
const projectColumns = [
{ colKey: 'businessNo', title: '项目编号', width: 130 },
{ colKey: 'name', title: '项目名称', ellipsis: true },
{ colKey: 'companyName', title: '公司', ellipsis: true },
{ colKey: 'stage', title: '阶段', width: 100 },
{ colKey: 'contractAmount', title: '合同额', width: 130 },
{ colKey: 'status', title: '状态', width: 90 },
{ colKey: 'updatedAt', title: '更新时间', width: 150 },
];
const fundColumns = [
{ colKey: 'projectName', title: '项目', ellipsis: true },
{ colKey: 'currency', title: '币种', width: 70 },
{ colKey: 'confirmedReceipt', title: '已确认收款', width: 130 },
{ colKey: 'approvedUnpaid', title: '已批准待付', width: 130 },
{ colKey: 'paidAmount', title: '已付款', width: 120 },
{ colKey: 'frozenAmount', title: '冻结中', width: 120 },
{ colKey: 'availableAmount', title: '可用余额', width: 130 },
];
const archiveColumns = [
{ colKey: 'kind', title: '类型', width: 90 },
{ colKey: 'title', title: '对象', ellipsis: true },
{ colKey: 'status', title: '状态', width: 100 },
{ colKey: 'completeness', title: '完整率', width: 90 },
{ colKey: 'missingCount', title: '缺件', width: 70 },
{ colKey: 'dueAt', title: '到期时间', width: 150 },
{ colKey: 'updatedAt', title: '更新时间', width: 150 },
];
const downloadColumns = [
{ colKey: 'title', title: '文件名称', ellipsis: true },
{ colKey: 'subtitle', title: '项目', ellipsis: true },
{ colKey: 'status', title: '状态', width: 100 },
{ colKey: 'updatedAt', title: '下载时间', width: 170 },
];
const load = async () => {
if (loading.value) return;
const sequence = ++loadSequence;
loading.value = true;
error.value = '';
try {
const result = await getWorkbench(props.role);
if (sequence === loadSequence) workbench.value = result;
} catch (reason) {
if (sequence !== loadSequence) return;
error.value = errorText(reason, '工作台加载失败');
MessagePlugin.error(error.value);
} finally {
if (sequence === loadSequence) loading.value = false;
}
};
const formatTime = (value?: string) => {
if (!value) return '-';
const date = new Date(value);
return Number.isNaN(date.getTime())
? '-'
: new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', dateStyle: 'short', timeStyle: 'short' }).format(
date,
);
};
const formatMoney = (value?: string) => {
const amount = Number(value || 0);
return Number.isFinite(amount)
? new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(amount)
: '-';
};
const statusLabel = (status: string) =>
({
PENDING: '待处理',
RETURNED: '已退回',
DRAFT: '草稿',
ACTIVE: '进行中',
SUSPENDED: '已暂停',
PREPARING: '准备中',
CHECKED: '待提交',
REVIEWING: '复核中',
OVERDUE: '已逾期',
APPLIED: '已申请',
APPROVED: '已批准',
BORROWED: '借出中',
DOWNLOADED: '已下载',
})[status] || status;
const statusTheme = (status: string) =>
['RETURNED', 'OVERDUE', 'SUSPENDED'].includes(status)
? 'danger'
: ['PENDING', 'REVIEWING', 'PREPARING', 'CHECKED', 'APPLIED'].includes(status)
? 'warning'
: ['ACTIVE', 'APPROVED', 'BORROWED'].includes(status)
? 'success'
: 'default';
const severityTheme = (severity: string) =>
severity === 'DANGER' ? 'danger' : severity === 'WARNING' ? 'warning' : 'primary';
const archiveKindLabel = (kind: string) =>
({ PACKAGE: '档案包', MISSING: '缺件', BORROW: '借阅', DOWNLOAD: '下载' })[kind] || kind;
function errorText(reason: unknown, fallback: string) {
const error = reason as Error & { status?: number; code?: string; fieldErrors?: Record<string, string> };
const fields = error.fieldErrors ? Object.values(error.fieldErrors).join(';') : '';
if (error.status === 401) return '登录会话已失效,请重新登录后再查看工作台。';
if (error.status === 403) return '当前身份没有访问该工作台或其中数据的权限。';
if (error.status === 404) return '工作台资源不存在或已被移除,请返回后重试。';
if (error.code === 'VERSION_CONFLICT' || error.status === 409) return '数据已发生变化,请刷新后重试。';
if (error.status === 422)
return fields ? `当前条件不满足业务规则:${fields}` : '当前条件不满足业务规则,请检查后重试。';
if (error.status && error.status >= 500) return '服务暂时不可用,请稍后重新加载。';
return fields ? `${error.message || fallback}:${fields}` : error.message || fallback;
}
const go = (target: string) => {
const location = parseWorkbenchTarget(target);
if (location) router.push(location);
};
onMounted(load);
</script>
<style scoped>
.workbench-page {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.page-heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.eyebrow {
color: var(--td-text-color-secondary);
font-size: 13px;
}
h1 {
margin: 4px 0 8px;
font-size: 24px;
line-height: 32px;
}
.page-heading p {
margin: 0;
color: var(--td-text-color-secondary);
}
.metric-card {
width: 100%;
height: 100%;
}
.metric-card :deep(.t-card__body) {
display: grid;
grid-template-rows: minmax(72px, 1fr) auto;
height: 100%;
}
.metric-card :deep(.t-statistic) {
display: grid;
grid-template-rows: minmax(40px, auto) auto;
align-content: start;
}
.metric-card :deep(.t-statistic-title) {
line-height: 20px;
}
.metric-link {
margin-top: 12px;
padding: 0;
}
.content-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.7fr);
gap: 16px;
min-width: 0;
}
.content-grid--wide {
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 1fr);
}
.content-grid > * {
min-width: 0;
}
.content-grid > .t-card {
height: 100%;
min-width: 0;
}
.muted {
color: var(--td-text-color-secondary);
font-size: 12px;
}
.issue-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.issue-row {
display: flex !important;
align-items: stretch;
justify-content: stretch;
width: 100%;
height: auto !important;
min-height: 64px;
padding: 8px 4px;
color: var(--td-text-color-primary);
text-align: left;
background: transparent;
border: 0;
border-bottom: 1px solid var(--td-component-border);
cursor: pointer;
}
.issue-row :deep(.t-button__text) {
display: grid;
grid-template-columns: 48px minmax(0, 1fr) 20px;
align-items: center;
column-gap: 12px;
width: 100%;
min-width: 0;
}
.issue-count,
.issue-arrow {
display: flex;
align-items: center;
align-self: stretch;
}
.issue-count {
justify-content: center;
font-variant-numeric: tabular-nums;
}
.issue-count :deep(.t-tag) {
min-width: 32px;
justify-content: center;
}
.issue-arrow {
justify-content: flex-end;
color: var(--td-text-color-secondary);
}
.issue-row:last-child {
border-bottom: 0;
}
.issue-row + .issue-row {
margin-left: 0;
}
.issue-row:hover,
.issue-row:focus-visible {
color: var(--td-brand-color);
outline: none;
}
.issue-copy {
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
gap: 4px;
min-height: 42px;
min-width: 0;
}
.issue-copy small,
.subline {
color: var(--td-text-color-secondary);
font-size: 12px;
line-height: 18px;
overflow-wrap: anywhere;
}
.issue-copy strong {
line-height: 20px;
overflow-wrap: anywhere;
}
.archive-reminders {
display: flex;
flex-direction: column;
gap: 16px;
}
@media (width <= 1080px) {
.content-grid,
.content-grid--wide {
grid-template-columns: 1fr;
}
}
@media (width <= 640px) {
.page-heading {
flex-direction: column;
}
.page-heading .t-button {
align-self: stretch;
}
.content-grid,
.content-grid--wide {
gap: 12px;
}
}
</style>
+6
View File
@@ -0,0 +1,6 @@
<template><workbench-page role="archive" fallback-title="资料管理工作台" /></template>
<script setup lang="ts">
import WorkbenchPage from './WorkbenchPage.vue';
defineOptions({ name: 'ArchiveWorkbenchPage' });
</script>
+6
View File
@@ -0,0 +1,6 @@
<template><workbench-page role="finance" fallback-title="财务管理工作台" /></template>
<script setup lang="ts">
import WorkbenchPage from './WorkbenchPage.vue';
defineOptions({ name: 'FinanceWorkbenchPage' });
</script>
+6
View File
@@ -0,0 +1,6 @@
<template><workbench-page role="project" fallback-title="项目管理工作台" /></template>
<script setup lang="ts">
import WorkbenchPage from './WorkbenchPage.vue';
defineOptions({ name: 'ProjectWorkbenchPage' });
</script>
@@ -0,0 +1,42 @@
import type { RouteLocationRaw } from 'vue-router';
const routeQueryAllowlist: Record<string, string[]> = {
'/forms': ['status', 'view', 'createdByMe'],
'/tasks': ['view', 'formType', 'status', 'keyword', 'projectId', 'page', 'size'],
'/finance/master-data': ['status', 'resource'],
'/finance/payments': ['tab', 'riskCode', 'keyword', 'projectId', 'view'],
'/finance/accounting': ['tab', 'keyword', 'period'],
'/archives/projects': ['view', 'status', 'companyId', 'projectId', 'completeness', 'frozen'],
'/archives/files': ['resource', 'archiveStatus', 'scanStatus'],
'/reports': [
'reportCode',
'companyId',
'projectId',
'counterpartyId',
'keyword',
'status',
'dateFrom',
'dateTo',
'page',
'size',
],
};
const allowedPaths = new Set(Object.keys(routeQueryAllowlist).concat(['/projects']));
const projectDetailPattern = /^\/projects\/[0-9A-HJKMNP-TV-Z]{26}$/u;
export function parseWorkbenchTarget(target: string): RouteLocationRaw | null {
const [rawPath, rawQuery] = target.split('?');
const isProjectDetail = projectDetailPattern.test(rawPath);
if (!isProjectDetail && !allowedPaths.has(rawPath)) return null;
const query: Record<string, string> = {};
if (rawQuery && !isProjectDetail) {
const allowed = routeQueryAllowlist[rawPath] || [];
new URLSearchParams(rawQuery).forEach((value, key) => {
if (allowed.includes(key) && value.length <= 100) query[key] = value;
});
}
return { path: rawPath, ...(Object.keys(query).length ? { query } : {}) };
}
+101
View File
@@ -0,0 +1,101 @@
import 'nprogress/nprogress.css';
import NProgress from 'nprogress';
import { MessagePlugin } from 'tdesign-vue-next';
import type { RouteLocationNormalized } from 'vue-router';
import { getSetupStatus } from '@/api/setup';
import router from '@/router';
import { getPermissionStore, useUserStore } from '@/store';
import { hasRoleAccess } from '@/store/modules/menu-access';
NProgress.configure({ showSpinner: false });
const isPublic = (to: RouteLocationNormalized) =>
to.path === '/login' || to.path === '/result/404' || to.path === '/setup';
let setupStatusRequest: ReturnType<typeof getSetupStatus> | null = null;
function loadSetupStatus() {
setupStatusRequest ||= getSetupStatus();
return setupStatusRequest;
}
router.beforeEach(async (to) => {
NProgress.start();
const userStore = useUserStore();
const permissionStore = getPermissionStore();
let setupRequired = false;
let setupStatusAvailable = false;
try {
const setupStatus = await loadSetupStatus();
setupRequired = setupStatus.required;
setupStatusAvailable = true;
} catch (error) {
setupStatusRequest = null;
if (to.path === '/setup') {
const statusCode = (error as { response?: { status?: number } }).response?.status;
if (statusCode !== 404) return true;
}
}
if (setupStatusAvailable && setupRequired && to.path !== '/setup') return '/setup';
if (to.path === '/setup') return setupStatusAvailable && setupRequired ? true : '/login';
if (isPublic(to)) {
return true;
}
try {
if (!userStore.authenticated) await userStore.restoreSession();
permissionStore.initRoutes();
} catch {
userStore.clearSession();
permissionStore.restoreRoutes();
if (to.path !== '/login') {
MessagePlugin.warning('登录会话已失效,请重新登录');
return { path: '/login', query: { redirect: to.fullPath } };
}
return true;
}
if (to.path !== '/role-select' && to.path !== '/profile' && !userStore.currentRole) {
return { path: '/role-select', query: { redirect: to.fullPath } };
}
if (userStore.user.mustChangePassword && to.path !== '/profile') {
return { path: '/profile', query: { forcePassword: '1', redirect: to.fullPath } };
}
const roles = (to.meta.roleCodes || []) as string[];
const permission = to.meta.permission as string | string[] | undefined;
if (!hasRoleAccess(roles, userStore.currentRole)) return '/result/403';
if (
permission &&
(Array.isArray(permission)
? !permission.some((item) => userStore.hasPermission(item))
: !userStore.hasPermission(permission))
) {
return '/result/403';
}
return true;
});
router.afterEach(() => NProgress.done());
let handlingSessionExpiry = false;
window.addEventListener('kaidi:session-expired', async () => {
if (handlingSessionExpiry) return;
handlingSessionExpiry = true;
const userStore = useUserStore();
userStore.clearSession();
getPermissionStore().restoreRoutes();
try {
if (router.currentRoute.value.path !== '/login') {
await router.replace({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } });
}
} finally {
handlingSessionExpiry = false;
}
});
+37
View File
@@ -0,0 +1,37 @@
import type { RouteRecordRaw } from 'vue-router';
import { createMemoryHistory, createRouter, createWebHistory } from 'vue-router';
import { financeRoutes } from './modules/finance';
import { systemRoutes } from './modules/system';
const env = import.meta.env.MODE || 'development';
export const homepageRouterList = financeRoutes;
export const fixedRouterList: RouteRecordRaw[] = [];
export const defaultRouterList: RouteRecordRaw[] = systemRoutes;
export const allRoutes = [...financeRoutes, ...defaultRouterList];
export const getActive = (maxLevel = 3): string => {
const route = router.currentRoute.value;
if (!route.path) return '';
return route.path
.split('/')
.filter((_item: string, index: number) => index <= maxLevel && index > 0)
.map((item: string) => `/${item}`)
.join('');
};
const router = createRouter({
history:
typeof window === 'undefined'
? createMemoryHistory(env === 'site' ? '/' : import.meta.env.VITE_BASE_URL)
: createWebHistory(env === 'site' ? '/' : import.meta.env.VITE_BASE_URL),
routes: allRoutes,
scrollBehavior() {
return { el: '#app', top: 0, behavior: 'smooth' };
},
});
export default router;
+317
View File
@@ -0,0 +1,317 @@
import { ChartBubbleIcon, FileAttachmentIcon, FileIcon, FolderOpenIcon, SettingIcon } from 'tdesign-icons-vue-next';
import { shallowRef } from 'vue';
import type { RouteComponent, RouteRecordRaw, RouteRecordSingleView } from 'vue-router';
import { LAYOUT } from '@/utils/route/constant';
import { pageContractMeta } from '../route-contract';
const projectArchivesPage = () => import('@/pages/archives/ProjectArchivesPage.vue');
const archiveFilesPage = () => import('@/pages/archives/ArchiveFilesPage.vue');
const masterDataPage = () => import('@/pages/finance/MasterDataPage.vue');
const contractCostLedgerPage = () => import('@/pages/finance/ContractCostLedgerPage.vue');
const receiptsInvoicesPage = () => import('@/pages/finance/ReceiptsInvoicesPage.vue');
const paymentsPage = () => import('@/pages/finance/PaymentsPage.vue');
const accountingPage = () => import('@/pages/finance/AccountingPage.vue');
const formCenterPage = () => import('@/pages/forms/FormCenterPage.vue');
const formEditorPage = () => import('@/pages/forms/FormEditorPage.vue');
const taskCenterPage = () => import('@/pages/tasks/TaskCenterPage.vue');
const projectListPage = () => import('@/pages/projects/ProjectListPage.vue');
const projectDetailPage = () => import('@/pages/projects/ProjectDetailPage.vue');
const reportsPage = () => import('@/pages/reports/ReportsPage.vue');
const auditLogsPage = () => import('@/pages/governance/AuditLogsPage.vue');
const systemSettingsPage = () => import('@/pages/governance/SystemSettingsPage.vue');
const systemUpdatePage = () => import('@/pages/governance/SystemUpdatePage.vue');
const workbench = (name: 'project' | 'finance' | 'archive') => {
if (name === 'project') return () => import('@/pages/workbench/project.vue');
if (name === 'finance') return () => import('@/pages/workbench/finance.vue');
return () => import('@/pages/workbench/archive.vue');
};
const group = (
path: string,
name: string,
titleText: string,
icon: unknown,
orderNo: number,
children: RouteRecordRaw[],
): RouteRecordRaw => ({
path,
name,
component: LAYOUT,
redirect: (() => {
const first = children.find((item) => !item.meta?.hidden)?.path;
if (!first) return undefined;
if (first === '') return path;
return first.startsWith('/') ? first : `${path}/${first}`;
})(),
meta: { title: { zh_CN: titleText, en_US: titleText }, titleText, icon: shallowRef(icon as any), orderNo },
children: children.map((child) => ({
...child,
meta: {
...child.meta,
breadcrumb: child.meta?.breadcrumb || [titleText, child.meta?.titleText || String(child.name || '')],
},
})),
});
const businessContract = (
pageId: string,
path: string,
name: string,
titleText: string,
extraMeta: Record<string, unknown>,
component: RouteComponent,
): RouteRecordSingleView => {
return {
path,
name,
component,
meta: {
...pageContractMeta(pageId),
title: { zh_CN: titleText, en_US: titleText },
titleText,
...extraMeta,
},
};
};
export const financeRoutes: RouteRecordRaw[] = [
group('/workbench', 'Workbench', '工作台', ChartBubbleIcon, 10, [
{
path: 'project',
name: 'WorkbenchProject',
component: workbench('project'),
meta: {
...pageContractMeta('PAGE-04'),
title: { zh_CN: '项目管理工作台', en_US: 'Project workbench' },
titleText: '项目管理工作台',
},
},
{
path: 'finance',
name: 'WorkbenchFinance',
component: workbench('finance'),
meta: {
...pageContractMeta('PAGE-05'),
title: { zh_CN: '财务管理工作台', en_US: 'Finance workbench' },
titleText: '财务管理工作台',
},
},
{
path: 'archive',
name: 'WorkbenchArchive',
component: workbench('archive'),
meta: {
...pageContractMeta('PAGE-06'),
title: { zh_CN: '资料管理工作台', en_US: 'Archive workbench' },
titleText: '资料管理工作台',
},
},
]),
group('/projects', 'ProjectManagement', '项目管理', FolderOpenIcon, 20, [
businessContract('PAGE-07', '', 'ProjectList', '项目列表', {}, projectListPage),
businessContract(
'PAGE-08',
':projectId',
'ProjectDetail',
'项目详情',
{ hidden: true, pageTemplate: 'detail' },
projectDetailPage,
),
]),
group('/forms', 'FormWorkflow', '表单审批', FileIcon, 30, [
businessContract('PAGE-09', '', 'FormCenter', '表单中心', {}, formCenterPage),
{
path: ':formType/:documentId?',
name: 'FormEditor',
component: formEditorPage,
meta: {
...pageContractMeta('PAGE-10'),
title: { zh_CN: '表单填写与导入', en_US: 'Form editor' },
titleText: '表单填写与导入',
// Viewing an existing form is a read operation; creation/edit actions
// remain guarded by the server-side allowedActions and button-level
// permissions inside the editor.
hidden: true,
},
},
businessContract('PAGE-11', '/tasks', 'ApprovalTasks', '待办审批', {}, taskCenterPage),
]),
group('/finance', 'FinanceManagement', '财务管理', ChartBubbleIcon, 40, [
businessContract(
'PAGE-12',
'master-data',
'MasterData',
'主数据中心',
{
dataKind: 'master',
dataResources: [
{ label: '公司', value: 'companies' },
{ label: '往来单位', value: 'counterparties' },
{ label: '银行账户', value: 'bank-accounts' },
{ label: '合同', value: 'contracts' },
{ label: '成本类别', value: 'cost-categories' },
{ label: '会计科目', value: 'accounts' },
{ label: '税率', value: 'tax-rates' },
],
},
masterDataPage,
),
businessContract(
'PAGE-13',
'contracts-costs',
'ContractsCosts',
'合同与成本台账',
{
dataKind: 'finance',
dataResources: [
{ label: '合同执行', value: 'contracts' },
{ label: '成本台账', value: 'costs' },
{ label: '应付记录', value: 'payables' },
],
},
contractCostLedgerPage,
),
businessContract(
'PAGE-14',
'receipts-invoices',
'ReceiptsInvoices',
'收款与发票',
{
dataKind: 'finance',
dataResources: [
{ label: '收款记录', value: 'receipts' },
{ label: '开票申请', value: 'invoices' },
],
},
receiptsInvoicesPage,
),
businessContract(
'PAGE-15',
'payments',
'Payments',
'进度款与付款',
{ dataKind: 'finance', dataResources: [{ label: '付款申请', value: 'payments' }] },
paymentsPage,
),
businessContract(
'PAGE-16',
'accounting',
'Accounting',
'记账准备',
{
dataKind: 'finance',
dataResources: [
{ label: '财务事件', value: 'accounting-events' },
{ label: '凭证草稿', value: 'accounting-vouchers' },
],
},
accountingPage,
),
]),
group('/archives', 'Archives', '资料档案', FileAttachmentIcon, 50, [
businessContract(
'PAGE-17',
'projects',
'ProjectArchives',
'项目归档',
{ dataKind: 'archive', dataResources: [{ label: '档案包', value: 'packages' }] },
projectArchivesPage,
),
businessContract(
'PAGE-18',
'files',
'ArchiveFiles',
'文件检索与借阅',
{
dataKind: 'archive',
dataResources: [
{ label: '文件检索', value: 'files' },
{ label: '借阅记录', value: 'borrows' },
],
},
archiveFilesPage,
),
]),
group('/reports', 'Reports', '查询分析', ChartBubbleIcon, 60, [
businessContract(
'PAGE-19',
'',
'ReportsIndex',
'查询与报表',
{
dataKind: 'report',
dataResources: [
{ label: '项目台账', value: 'project-ledger' },
{ label: '合同执行', value: 'contract-execution' },
{ label: '收款与开票', value: 'receipt-invoice' },
{ label: '付款进度', value: 'payment-progress' },
{ label: '流程时长', value: 'workflow-duration' },
{ label: '资料完整性', value: 'archive-completeness' },
],
},
reportsPage,
),
]),
group('/governance', 'Governance', '系统治理', SettingIcon, 70, [
businessContract(
'PAGE-20',
'audit',
'AuditLogs',
'审计日志',
{ dataKind: 'audit', dataResources: [{ label: '审计事件', value: 'audit' }] },
auditLogsPage,
),
businessContract(
'PAGE-21',
'settings',
'SystemSettings',
'权限与配置',
{
dataKind: 'admin',
dataResources: [
{ label: '用户', value: 'users' },
{ label: '角色', value: 'roles' },
{ label: '数据范围', value: 'scopes' },
{ label: '表单模板', value: 'templates' },
{ label: '参数版本', value: 'parameters' },
],
},
systemSettingsPage,
),
businessContract(
'PAGE-22',
'update',
'SystemUpdate',
'系统更新',
{ dataKind: 'admin', dataResources: [{ label: '更新记录', value: 'update' }] },
systemUpdatePage,
),
]),
{
path: '/profile',
name: 'ProfileParent',
component: LAYOUT,
meta: { title: { zh_CN: '个人中心', en_US: 'Profile' }, hidden: true },
children: [
{
path: '',
name: 'Profile',
component: () => import('@/pages/profile/index.vue'),
meta: {
...pageContractMeta('PAGE-03'),
title: { zh_CN: '个人中心', en_US: 'Profile' },
titleText: '个人中心',
},
},
],
},
];
+38
View File
@@ -0,0 +1,38 @@
import type { RouteRecordRaw } from 'vue-router';
import { LAYOUT } from '@/utils/route/constant';
export default [
{
path: '/result',
name: 'Result',
component: LAYOUT,
meta: { hidden: true, title: { zh_CN: '系统提示', en_US: 'System result' } },
children: [
{
path: '403',
name: 'Result403',
component: () => import('@/pages/result/403/index.vue'),
meta: { title: { zh_CN: '无权限', en_US: 'Forbidden' }, hidden: true },
},
{
path: '404',
name: 'Result404',
component: () => import('@/pages/result/404/index.vue'),
meta: { title: { zh_CN: '页面不存在', en_US: 'Not found' }, hidden: true },
},
{
path: '500',
name: 'Result500',
component: () => import('@/pages/result/500/index.vue'),
meta: { title: { zh_CN: '系统错误', en_US: 'Server error' }, hidden: true },
},
{
path: 'network-error',
name: 'ResultNetworkError',
component: () => import('@/pages/result/network-error/index.vue'),
meta: { title: { zh_CN: '网络异常', en_US: 'Network error' }, hidden: true },
},
],
},
] satisfies RouteRecordRaw[];
+49
View File
@@ -0,0 +1,49 @@
import type { RouteRecordRaw } from 'vue-router';
import { BLANK_LAYOUT } from '@/utils/route/constant';
import { pageContractMeta } from '../route-contract';
import resultRoutes from './result';
export const systemRoutes: RouteRecordRaw[] = [
{
path: '/setup',
name: 'SetupWizard',
component: BLANK_LAYOUT,
children: [
{
path: '',
name: 'SetupWizardPage',
component: () => import('@/pages/setup/index.vue'),
meta: {
...pageContractMeta('PAGE-23'),
title: { zh_CN: '安装向导', en_US: 'Setup wizard' },
titleText: '安装向导',
blank: true,
},
},
],
},
{
path: '/login',
name: 'login',
component: () => import('@/pages/login/index.vue'),
meta: {
...pageContractMeta('PAGE-01'),
title: { zh_CN: '登录', en_US: 'Sign in' },
},
},
{
path: '/role-select',
name: 'RoleSelect',
component: () => import('@/pages/role-select/index.vue'),
meta: {
...pageContractMeta('PAGE-02'),
title: { zh_CN: '选择身份', en_US: 'Select role' },
blank: true,
},
},
...resultRoutes,
{ path: '/', redirect: '/role-select' },
{ path: '/:pathMatch(.*)*', redirect: '/result/404' },
];
+37
View File
@@ -0,0 +1,37 @@
import routesContract from '../../contracts/routes.json';
export interface PageRouteContract {
pageId: string;
path: string;
component: string;
pageType: string;
pageTemplate: string;
requiresAuth: boolean;
description: string;
breadcrumb: string[];
roles: string[];
permission: null | string | string[];
}
const pageContracts = new Map((routesContract.routes as PageRouteContract[]).map((route) => [route.pageId, route]));
export function getPageRouteContract(pageId: string): PageRouteContract {
const route = pageContracts.get(pageId);
if (!route) throw new Error(`Missing route contract for ${pageId}`);
return route;
}
export function pageContractMeta(pageId: string) {
const route = getPageRouteContract(pageId);
return {
pageId: route.pageId,
pageType: route.pageType,
pageTemplate: route.pageTemplate,
requiresAuth: route.requiresAuth,
roleCodes: [...route.roles],
permission: route.permission,
description: route.description,
breadcrumb: [...route.breadcrumb],
componentPath: route.component,
};
}
+15
View File
@@ -0,0 +1,15 @@
import { createPinia } from 'pinia';
import { createPersistedState } from 'pinia-plugin-persistedstate';
const store = createPinia();
store.use(createPersistedState());
export { store };
export * from './modules/notification';
export * from './modules/permission';
export * from './modules/setting';
export * from './modules/tabs-router';
export * from './modules/user';
export default store;

Some files were not shown because too many files have changed in this diff Show More