feat: 新增配送合同附件安全上传功能

- 增加合同专用 PDF 上传、鉴权预览和失败清理接口
- 支持草稿附件替换移除、并发保护和启用完整性校验
- 修复循环模板引用导致文件选择器无法打开的问题
- 补充专项测试、中文项目文档和操作日志
This commit is contained in:
czl231
2026-08-12 21:38:49 +08:00
parent deea31d79e
commit 99324acc89
16 changed files with 1379 additions and 16 deletions

View File

@@ -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",

View File

@@ -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('合同附件控件检查通过:单一引用、键盘入口、拖拽和事件转发均有效。');

View 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 };

View File

@@ -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 }),

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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));

View File

@@ -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 {

View File

@@ -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}`;
}