feat: 新增配送合同附件安全上传功能
- 增加合同专用 PDF 上传、鉴权预览和失败清理接口 - 支持草稿附件替换移除、并发保护和启用完整性校验 - 修复循环模板引用导致文件选择器无法打开的问题 - 补充专项测试、中文项目文档和操作日志
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"resource-pages:check": "node scripts/check-resource-pages.mjs",
|
||||
"account-roles:check": "node scripts/check-account-role-presentation.mjs",
|
||||
"avatar-retry:check": "node scripts/check-avatar-upload-cache.mjs",
|
||||
"contract-attachment:check": "node scripts/check-contract-attachment-control.mjs",
|
||||
"staff-organization:check": "node scripts/check-staff-organization-linkage.mjs",
|
||||
"staff-relations:check": "node scripts/check-staff-relation-policy.mjs",
|
||||
"user-address-display:check": "node scripts/check-user-address-relation-display.mjs",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 功能:检查合同附件控件保持独立单一文件输入、键盘可访问及事件转发契约。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const componentPath = new URL(
|
||||
'../src/views/resource/ContractAttachmentField.vue',
|
||||
import.meta.url,
|
||||
);
|
||||
const formPath = new URL(
|
||||
'../src/views/resource/ResourceFieldForm.vue',
|
||||
import.meta.url,
|
||||
);
|
||||
const [component, form] = await Promise.all([
|
||||
readFile(componentPath, 'utf8'),
|
||||
readFile(formPath, 'utf8'),
|
||||
]);
|
||||
|
||||
assert.equal(
|
||||
component.includes('v-for='),
|
||||
false,
|
||||
'合同附件组件不得在 v-for 中持有模板引用',
|
||||
);
|
||||
assert.match(component, /ref="fileInput"/u, '组件必须持有唯一文件输入引用');
|
||||
assert.match(
|
||||
component,
|
||||
/fileInput\.value\?\.click\(\)/u,
|
||||
'点击区域必须调用单一文件输入元素',
|
||||
);
|
||||
assert.match(component, /@keydown\.enter\.prevent="openPicker"/u);
|
||||
assert.match(component, /@keydown\.space\.prevent="openPicker"/u);
|
||||
assert.match(component, /@drop\.prevent="selectDroppedFile"/u);
|
||||
assert.match(component, /:tabindex="disabled \? -1 : 0"/u);
|
||||
assert.match(component, /<a-space @click\.stop @keydown\.stop>/u);
|
||||
|
||||
assert.match(form, /<ContractAttachmentField/u);
|
||||
assert.equal(
|
||||
form.includes('ref="attachmentInput"'),
|
||||
false,
|
||||
'通用字段循环不得重新持有文件输入引用',
|
||||
);
|
||||
for (const marker of [
|
||||
'@select="(file) => emit(\'select-attachment\', file)"',
|
||||
'@remove="emit(\'remove-attachment\')"',
|
||||
'@clear-selection="emit(\'clear-attachment-selection\')"',
|
||||
'@preview="emit(\'preview-attachment\')"',
|
||||
]) {
|
||||
assert.ok(form.includes(marker), `ResourceFieldForm 缺少事件转发:${marker}`);
|
||||
}
|
||||
|
||||
console.log('合同附件控件检查通过:单一引用、键盘入口、拖拽和事件转发均有效。');
|
||||
74
frontend/platform_admin/src/api/contract-attachment.ts
Normal file
74
frontend/platform_admin/src/api/contract-attachment.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 功能:配送合同 PDF 附件的上传、失败清理与鉴权预览客户端。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import { getToken } from '@/utils/auth';
|
||||
|
||||
const platformApiBaseURL =
|
||||
import.meta.env.VITE_API_BASE_URL ||
|
||||
'http://localhost:12426/heqi/platform/v1';
|
||||
|
||||
export type ContractAttachmentMetadata = {
|
||||
has_file: boolean;
|
||||
available: boolean;
|
||||
requires_reupload: boolean;
|
||||
display_name: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type ContractAttachmentUploadReply = {
|
||||
receipt: string;
|
||||
cleanup_token: string;
|
||||
display_name: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type ApiEnvelope<T> = { code?: number; message?: string; details?: T };
|
||||
|
||||
/** 返回当前登录凭证请求头。 */
|
||||
function authorizationHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
return token ? { Authorization: token } : {};
|
||||
}
|
||||
|
||||
/** 上传经过前端预检的单个 PDF,服务端仍会验证真实内容。 */
|
||||
async function upload(file: File): Promise<ContractAttachmentUploadReply> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const response = await fetch(
|
||||
`${platformApiBaseURL}/gasorder_contract/attachment/upload`,
|
||||
{ method: 'POST', headers: authorizationHeaders(), body: form },
|
||||
);
|
||||
const payload = (await response.json()) as ApiEnvelope<ContractAttachmentUploadReply>;
|
||||
if (!response.ok || payload.code !== 0 || !payload.details) {
|
||||
throw new Error(payload.message || '合同附件上传失败');
|
||||
}
|
||||
return payload.details;
|
||||
}
|
||||
|
||||
/** 使用受签名保护的清理凭证删除尚未绑定的临时文件。 */
|
||||
async function cleanup(cleanupToken: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${platformApiBaseURL}/gasorder_contract/attachment/cleanup`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authorizationHeaders() },
|
||||
body: JSON.stringify({ cleanup_token: cleanupToken }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error('临时合同附件清理失败');
|
||||
const payload = (await response.json()) as ApiEnvelope<{ deleted: boolean }>;
|
||||
if (payload.code !== 0) throw new Error(payload.message || '临时合同附件清理失败');
|
||||
}
|
||||
|
||||
/** 获取鉴权 PDF Blob;内部存储 URI 始终不会暴露给浏览器。 */
|
||||
async function load(identity: string): Promise<Blob> {
|
||||
const response = await fetch(
|
||||
`${platformApiBaseURL}/gasorder_contract/${encodeURIComponent(identity)}/attachment`,
|
||||
{ headers: authorizationHeaders() },
|
||||
);
|
||||
if (!response.ok) throw new Error('合同附件不可用,请重新上传');
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export const contractAttachmentApi = { upload, cleanup, load };
|
||||
@@ -22,7 +22,8 @@ export type ResourceFieldType =
|
||||
| 'date'
|
||||
| 'datetime'
|
||||
| 'textarea'
|
||||
| 'select';
|
||||
| 'select'
|
||||
| 'contract-file';
|
||||
|
||||
/** 关联下拉的父子联动配置;未配置的资源继续保持独立选择。 */
|
||||
export type ResourceRelationLinkage = {
|
||||
@@ -458,10 +459,12 @@ export const resources: ResourceUiDefinition[] = [
|
||||
filterKey: 'gas_basic_identities', backfillParent: true,
|
||||
},
|
||||
}),
|
||||
f('title', { required: true }), f('terms'), f('file_uri'), f('default_delivery_fee'),
|
||||
f('title', { required: true }), f('terms'),
|
||||
f('file_uri', { label: '合同附件', type: 'contract-file' }),
|
||||
f('default_delivery_fee'),
|
||||
f('signed_at', { required: true }), f('effective_at', { required: true }), f('expired_at'),
|
||||
], 'list', [
|
||||
{ name: '启用合同', resource: '/gasorder_contract/:identity/activate', fields: reason, visibleFor: { field: 'contract_status', values: [10] } },
|
||||
{ name: '启用合同', resource: '/gasorder_contract/:identity/activate', fields: reason, visibleFor: { field: 'contract_status', values: [0, 13] } },
|
||||
{ name: '续签合同', resource: '/gasorder_contract/:identity/renew', fields: [f('effective_at', { required: true }), f('expired_at'), ...reason], visibleFor: { field: 'contract_status', values: [11, 12] } },
|
||||
{ name: '终止合同', resource: '/gasorder_contract/:identity/terminate', danger: true, fields: reason, visibleFor: { field: 'contract_status', values: [11] } },
|
||||
], { canCreate: true, canEdit: true }),
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<!--
|
||||
功能:提供单个配送合同 PDF 附件的选择、拖拽、预览和移除控件。
|
||||
版本:v1.0.0
|
||||
-->
|
||||
<template>
|
||||
<div
|
||||
class="contract-file-control"
|
||||
:class="{ 'contract-file-disabled': disabled }"
|
||||
:tabindex="disabled ? -1 : 0"
|
||||
role="button"
|
||||
:aria-disabled="disabled"
|
||||
aria-label="选择合同附件 PDF"
|
||||
@click="openPicker"
|
||||
@keydown.enter.prevent="openPicker"
|
||||
@keydown.space.prevent="openPicker"
|
||||
@dragover.prevent
|
||||
@drop.prevent="selectDroppedFile"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="contract-file-input"
|
||||
type="file"
|
||||
accept=".pdf,application/pdf"
|
||||
:disabled="disabled"
|
||||
@change="selectPickedFile"
|
||||
/>
|
||||
<div class="contract-file-main">
|
||||
<icon-upload />
|
||||
<div>
|
||||
<strong>{{ title }}</strong>
|
||||
<div class="contract-file-hint">点击选择或拖拽 PDF,最大 10 MiB</div>
|
||||
<div v-if="attachment.requiresReupload" class="contract-file-warning">
|
||||
旧附件地址不可用,请重新上传
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-space @click.stop @keydown.stop>
|
||||
<a-button
|
||||
v-if="attachment.available && !attachment.selectedFile && !attachment.removed"
|
||||
size="small"
|
||||
@click="emit('preview')"
|
||||
>
|
||||
<template #icon><icon-eye /></template>预览
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="attachment.selectedFile"
|
||||
size="small"
|
||||
@click="emit('clear-selection')"
|
||||
>
|
||||
取消选择
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else-if="attachment.hasExisting && !attachment.removed"
|
||||
size="small"
|
||||
status="danger"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<template #icon><icon-delete /></template>移除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconDelete, IconEye, IconUpload } from '@arco-design/web-vue/es/icon';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
export type ContractAttachmentFieldState = {
|
||||
selectedFile?: File;
|
||||
hasExisting: boolean;
|
||||
available: boolean;
|
||||
requiresReupload: boolean;
|
||||
removed: boolean;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
attachment: ContractAttachmentFieldState;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [file: File];
|
||||
remove: [];
|
||||
'clear-selection': [];
|
||||
preview: [];
|
||||
}>();
|
||||
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
const title = computed(() => {
|
||||
if (props.attachment.selectedFile) return props.attachment.selectedFile.name;
|
||||
if (props.attachment.removed) return '未选择合同附件';
|
||||
if (props.attachment.hasExisting) return '合同附件.pdf';
|
||||
return '选择合同附件';
|
||||
});
|
||||
|
||||
/** 打开组件内唯一的原生文件选择器。 */
|
||||
function openPicker() {
|
||||
if (!props.disabled) fileInput.value?.click();
|
||||
}
|
||||
|
||||
/** 读取点击选择的单个文件,并允许再次选择同名文件。 */
|
||||
function selectPickedFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) emit('select', file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
/** 接收拖拽到控件上的第一个文件。 */
|
||||
function selectDroppedFile(event: DragEvent) {
|
||||
if (props.disabled) return;
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (file) emit('select', file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.contract-file-control {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 88px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
border: 1px dashed var(--color-border-3);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.contract-file-control:hover,
|
||||
.contract-file-control:focus-visible {
|
||||
background: var(--color-fill-1);
|
||||
border-color: rgb(var(--primary-6));
|
||||
}
|
||||
.contract-file-control:focus-visible {
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.2);
|
||||
}
|
||||
.contract-file-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.contract-file-input {
|
||||
display: none;
|
||||
}
|
||||
.contract-file-main {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.contract-file-main strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-1);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.contract-file-hint {
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.contract-file-warning {
|
||||
margin-top: 4px;
|
||||
color: rgb(var(--warning-6));
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -80,7 +80,13 @@ type DetailEntry = {
|
||||
|
||||
const entries = computed<DetailEntry[]>(() => {
|
||||
const row = primaryRecord(props.detail);
|
||||
const excluded = new Set(['id', 'password', 'password_hash', 'avatar']);
|
||||
const excluded = new Set([
|
||||
'id',
|
||||
'password',
|
||||
'password_hash',
|
||||
'avatar',
|
||||
'attachment',
|
||||
]);
|
||||
if (props.accountSummary) {
|
||||
for (const key of ['username', 'identity', 'created_at']) excluded.add(key);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,15 @@
|
||||
:disabled="disabledSet.has(field.key)"
|
||||
:auto-size="{ minRows: 3, maxRows: 10 }"
|
||||
/>
|
||||
<ContractAttachmentField
|
||||
v-else-if="field.type === 'contract-file'"
|
||||
:attachment="contractAttachment"
|
||||
:disabled="disabledSet.has(field.key)"
|
||||
@select="(file) => emit('select-attachment', file)"
|
||||
@remove="emit('remove-attachment')"
|
||||
@clear-selection="emit('clear-attachment-selection')"
|
||||
@preview="emit('preview-attachment')"
|
||||
/>
|
||||
<a-input-password
|
||||
v-else-if="field.type === 'password'"
|
||||
v-model="model[field.key]"
|
||||
@@ -123,6 +132,9 @@ import type { ResourceField } from '@/api/resources';
|
||||
import type { ResourceRow } from '@/api/resource-page-rules';
|
||||
import type { PlatformRole } from '@/api/platform';
|
||||
import IdentityText from '@/components/IdentityText.vue';
|
||||
import ContractAttachmentField, {
|
||||
type ContractAttachmentFieldState,
|
||||
} from './ContractAttachmentField.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -132,6 +144,7 @@ const props = withDefaults(
|
||||
relationOptions?: Record<string, ResourceRow[]>;
|
||||
relationLoading?: Record<string, boolean>;
|
||||
roleOptions?: PlatformRole[];
|
||||
contractAttachment?: ContractAttachmentFieldState;
|
||||
}>(),
|
||||
{
|
||||
requiredKeys: () => [],
|
||||
@@ -139,12 +152,23 @@ const props = withDefaults(
|
||||
relationOptions: () => ({}),
|
||||
relationLoading: () => ({}),
|
||||
roleOptions: () => [],
|
||||
contractAttachment: () => ({
|
||||
selectedFile: undefined,
|
||||
hasExisting: false,
|
||||
available: false,
|
||||
requiresReupload: false,
|
||||
removed: false,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'change-relation': [field: ResourceField, value: unknown];
|
||||
'search-relation': [field: ResourceField, keyword: string];
|
||||
'select-attachment': [file: File];
|
||||
'remove-attachment': [];
|
||||
'clear-attachment-selection': [];
|
||||
'preview-attachment': [];
|
||||
}>();
|
||||
const model = defineModel<Record<string, any>>({ required: true });
|
||||
const disabledSet = computed(() => new Set(props.disabledKeys));
|
||||
|
||||
@@ -52,6 +52,28 @@
|
||||
:field-options="fieldOptions"
|
||||
:account-summary="accountSummary"
|
||||
/>
|
||||
<a-card
|
||||
v-if="definition.name === 'gasorder_contract'"
|
||||
title="合同附件"
|
||||
:bordered="false"
|
||||
class="section-card"
|
||||
>
|
||||
<a-space>
|
||||
<span v-if="contractAttachment.available">合同附件.pdf</span>
|
||||
<a-tag v-else-if="contractAttachment.requiresReupload" color="orange">
|
||||
旧附件地址不可用,请重新上传
|
||||
</a-tag>
|
||||
<span v-else>未上传</span>
|
||||
<a-button
|
||||
v-if="contractAttachment.available"
|
||||
type="primary"
|
||||
:loading="attachmentPreviewing"
|
||||
@click="previewContractAttachment"
|
||||
>
|
||||
预览
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
<ResourceWalletSummary
|
||||
v-if="definition.walletOwnerType"
|
||||
:wallet="wallet"
|
||||
@@ -103,8 +125,13 @@
|
||||
:relation-options="relations.options"
|
||||
:relation-loading="relations.loading"
|
||||
:role-options="roleOptions"
|
||||
:contract-attachment="contractAttachmentState"
|
||||
@change-relation="relationLinkage.change"
|
||||
@search-relation="relationLinkage.search"
|
||||
@select-attachment="contractAttachment.select"
|
||||
@remove-attachment="confirmRemoveContractAttachment"
|
||||
@clear-attachment-selection="contractAttachment.clearSelection"
|
||||
@preview-attachment="previewContractAttachment"
|
||||
/>
|
||||
</a-form>
|
||||
<div class="form-actions">
|
||||
@@ -126,7 +153,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import { IconEdit, IconLeft } from '@arco-design/web-vue/es/icon';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
@@ -171,6 +198,7 @@ import ResourceWalletSummary from './ResourceWalletSummary.vue';
|
||||
import { createResourceRecordNavigation } from './resource-record-navigation';
|
||||
import { loadResourceRecordRelations } from './load-resource-record-relations';
|
||||
import { useResourceAvatar } from './use-resource-avatar';
|
||||
import { useContractAttachment } from './use-contract-attachment';
|
||||
import { useResourceRelationLinkage } from './use-resource-relation-linkage';
|
||||
import { useStaffCredentialOwnerGuard } from './use-staff-credential-owner-guard';
|
||||
import { useUnsavedRecord } from './use-unsaved-record';
|
||||
@@ -193,6 +221,7 @@ const form = reactive<Record<string, any>>({});
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const statusSaving = ref(false);
|
||||
const attachmentPreviewing = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const errorStatus = ref<'403' | '404' | 'error'>('error');
|
||||
const wallet = ref<ResourceRow>();
|
||||
@@ -220,10 +249,18 @@ const relationLinkage = useResourceRelationLinkage(form, relations);
|
||||
const actionVisible = ref(false);
|
||||
const activeAction = ref<DetailAction>();
|
||||
const avatar = useResourceAvatar();
|
||||
const contractAttachment = useContractAttachment();
|
||||
const avatarUrl = avatar.url;
|
||||
const avatarCanClear = avatar.canClear;
|
||||
const selectAvatar = avatar.select;
|
||||
const clearAvatar = avatar.clear;
|
||||
const contractAttachmentState = computed(() => ({
|
||||
selectedFile: contractAttachment.selectedFile.value,
|
||||
hasExisting: contractAttachment.hasExisting.value,
|
||||
available: contractAttachment.available.value,
|
||||
requiresReupload: contractAttachment.requiresReupload.value,
|
||||
removed: contractAttachment.removed.value,
|
||||
}));
|
||||
|
||||
const accountSummary = computed(() => usesAccountSummary(definition.value));
|
||||
const accountSummaryVisible = computed(
|
||||
@@ -313,7 +350,11 @@ const modeLabel = computed(() =>
|
||||
const errorTitle = computed(() => recordPageErrorTitle(errorStatus.value));
|
||||
|
||||
function snapshot() {
|
||||
return JSON.stringify({ form, avatar: avatar.marker() });
|
||||
return JSON.stringify({
|
||||
form,
|
||||
avatar: avatar.marker(),
|
||||
contractAttachment: contractAttachment.marker(),
|
||||
});
|
||||
}
|
||||
const unsaved = useUnsavedRecord(snapshot, () => mode.value !== 'detail');
|
||||
const { goEdit, viewWallet, goBack, requestBack } =
|
||||
@@ -350,6 +391,11 @@ async function initialize() {
|
||||
definition.value.resource,
|
||||
identity.value,
|
||||
);
|
||||
contractAttachment.load(
|
||||
definition.value.name === 'gasorder_contract'
|
||||
? (detail.value.attachment as any)
|
||||
: undefined,
|
||||
);
|
||||
if (blockReason.value) {
|
||||
errorStatus.value = '403';
|
||||
errorMessage.value = blockReason.value;
|
||||
@@ -441,6 +487,9 @@ async function save() {
|
||||
mode.value as 'create' | 'edit',
|
||||
);
|
||||
await avatar.applyToPayload(payload);
|
||||
if (definition.value.name === 'gasorder_contract') {
|
||||
await contractAttachment.applyToPayload(payload);
|
||||
}
|
||||
if (mode.value === 'create') {
|
||||
const created = await resourceApi.create<ResourceRow>(
|
||||
definition.value.resource,
|
||||
@@ -484,6 +533,9 @@ async function save() {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (definition.value.name === 'gasorder_contract') {
|
||||
await contractAttachment.rollbackUpload();
|
||||
}
|
||||
Message.error((error as Error).message);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
@@ -495,9 +547,36 @@ async function reloadDetail() {
|
||||
definition.value.resource,
|
||||
identity.value,
|
||||
);
|
||||
if (definition.value.name === 'gasorder_contract') {
|
||||
contractAttachment.load(detail.value.attachment as any);
|
||||
}
|
||||
await Promise.allSettled([loadWallet(), loadAvatar()]);
|
||||
}
|
||||
|
||||
/** 二次确认移除草稿合同附件,真正删除发生在保存成功之后。 */
|
||||
function confirmRemoveContractAttachment() {
|
||||
Modal.confirm({
|
||||
title: '确认移除合同附件?',
|
||||
content: '移除操作将在保存合同后生效。',
|
||||
okText: '确认移除',
|
||||
hideCancel: false,
|
||||
onOk: () => contractAttachment.remove(),
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取鉴权 PDF 并在新标签页预览。 */
|
||||
async function previewContractAttachment() {
|
||||
if (!recordIdentity.value) return;
|
||||
attachmentPreviewing.value = true;
|
||||
try {
|
||||
await contractAttachment.preview(recordIdentity.value);
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
} finally {
|
||||
attachmentPreviewing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGasStatus(enabled: string | number | boolean) {
|
||||
statusSaving.value = true;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 功能:管理合同附件的本地选择、保存前上传、失败清理和鉴权预览状态。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import {
|
||||
contractAttachmentApi,
|
||||
type ContractAttachmentMetadata,
|
||||
type ContractAttachmentUploadReply,
|
||||
} from '@/api/contract-attachment';
|
||||
|
||||
const maxContractAttachmentSize = 10 * 1024 * 1024;
|
||||
|
||||
/** 提供单合同、单 PDF 附件的页面状态与保存编排。 */
|
||||
export function useContractAttachment() {
|
||||
const selectedFile = ref<File>();
|
||||
const metadata = ref<ContractAttachmentMetadata>();
|
||||
const removed = ref(false);
|
||||
let uploaded: ContractAttachmentUploadReply | undefined;
|
||||
let uploadedMarker = '';
|
||||
|
||||
const hasExisting = computed(
|
||||
() => Boolean(metadata.value?.has_file) && !removed.value,
|
||||
);
|
||||
const available = computed(
|
||||
() => Boolean(metadata.value?.available) && !removed.value,
|
||||
);
|
||||
const requiresReupload = computed(
|
||||
() => Boolean(metadata.value?.requires_reupload) && !removed.value,
|
||||
);
|
||||
|
||||
/** 从合同详情恢复附件存在性和并发版本,不读取内部 URI。 */
|
||||
function load(next?: ContractAttachmentMetadata) {
|
||||
selectedFile.value = undefined;
|
||||
metadata.value = next;
|
||||
removed.value = false;
|
||||
uploaded = undefined;
|
||||
uploadedMarker = '';
|
||||
}
|
||||
|
||||
/** 校验并保存用户选择的本地 PDF,真正上传发生在点击保存后。 */
|
||||
function select(file: File) {
|
||||
if (
|
||||
file.size <= 0 ||
|
||||
file.size > maxContractAttachmentSize ||
|
||||
file.type !== 'application/pdf' ||
|
||||
!file.name.toLowerCase().endsWith('.pdf')
|
||||
) {
|
||||
Message.warning('请选择不超过 10 MiB 的 PDF 文件');
|
||||
return false;
|
||||
}
|
||||
selectedFile.value = file;
|
||||
removed.value = false;
|
||||
uploaded = undefined;
|
||||
uploadedMarker = '';
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 标记移除已绑定附件;实际删除只在合同保存成功后执行。 */
|
||||
function remove() {
|
||||
selectedFile.value = undefined;
|
||||
uploaded = undefined;
|
||||
uploadedMarker = '';
|
||||
removed.value = true;
|
||||
}
|
||||
|
||||
/** 取消尚未保存的新选择,不影响已绑定附件。 */
|
||||
function clearSelection() {
|
||||
selectedFile.value = undefined;
|
||||
uploaded = undefined;
|
||||
uploadedMarker = '';
|
||||
}
|
||||
|
||||
/** 保存前上传新文件并向合同载荷写入签名收据及并发版本。 */
|
||||
async function applyToPayload(payload: Record<string, unknown>) {
|
||||
delete payload.file_uri;
|
||||
if (selectedFile.value) {
|
||||
const currentMarker = fileMarker(selectedFile.value);
|
||||
if (!uploaded || uploadedMarker !== currentMarker) {
|
||||
uploaded = await contractAttachmentApi.upload(selectedFile.value);
|
||||
uploadedMarker = currentMarker;
|
||||
}
|
||||
payload.attachment_receipt = uploaded.receipt;
|
||||
if (metadata.value) payload.attachment_version = metadata.value.version;
|
||||
return;
|
||||
}
|
||||
if (removed.value && metadata.value) {
|
||||
payload.remove_attachment = true;
|
||||
payload.attachment_version = metadata.value.version;
|
||||
}
|
||||
}
|
||||
|
||||
/** 合同保存失败时清理本轮临时上传,同时保留本地文件以便重试。 */
|
||||
async function rollbackUpload() {
|
||||
const pending = uploaded;
|
||||
uploaded = undefined;
|
||||
uploadedMarker = '';
|
||||
if (!pending) return;
|
||||
try {
|
||||
await contractAttachmentApi.cleanup(pending.cleanup_token);
|
||||
} catch {
|
||||
// 服务端已记录最终清理错误;页面保留本地文件供用户再次保存。
|
||||
}
|
||||
}
|
||||
|
||||
/** 在新标签页打开鉴权取得的 PDF Blob。 */
|
||||
async function preview(identity: string) {
|
||||
const blob = await contractAttachmentApi.load(identity);
|
||||
const objectURL = URL.createObjectURL(blob);
|
||||
const opened = window.open(objectURL, '_blank', 'noopener,noreferrer');
|
||||
if (!opened) {
|
||||
URL.revokeObjectURL(objectURL);
|
||||
throw new Error('浏览器阻止了合同附件预览窗口');
|
||||
}
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectURL), 60_000);
|
||||
}
|
||||
|
||||
/** 返回未保存变更标识,供离开页面前确认。 */
|
||||
function marker() {
|
||||
return selectedFile.value
|
||||
? `select:${fileMarker(selectedFile.value)}`
|
||||
: removed.value
|
||||
? 'remove'
|
||||
: 'unchanged';
|
||||
}
|
||||
|
||||
return {
|
||||
selectedFile,
|
||||
metadata,
|
||||
removed,
|
||||
hasExisting,
|
||||
available,
|
||||
requiresReupload,
|
||||
load,
|
||||
select,
|
||||
remove,
|
||||
clearSelection,
|
||||
applyToPayload,
|
||||
rollbackUpload,
|
||||
preview,
|
||||
marker,
|
||||
};
|
||||
}
|
||||
|
||||
/** 生成稳定的本地文件选择标识。 */
|
||||
function fileMarker(file: File) {
|
||||
return `${file.name}:${file.size}:${file.lastModified}`;
|
||||
}
|
||||
Reference in New Issue
Block a user