完善配送点合同附件管理
This commit is contained in:
@@ -63,6 +63,9 @@ assert(definitions.includes("define('payment_order'"), '支付资源必须使用
|
||||
assert(definitions.includes("define('payment_refund'"), '退款资源必须使用后端名称 payment_refund');
|
||||
assert(routeKeys.has('GET /staff_account/:identity/avatar'), '配送人员缺少受保护头像读取接口');
|
||||
assert(routeKeys.has('GET /user_account/:identity/avatar'), '用户账户缺少受保护头像读取接口');
|
||||
assert(routeKeys.has('POST /gasorder_contract/attachment/upload'), '配送合同缺少受控附件上传接口');
|
||||
assert(routeKeys.has('POST /gasorder_contract/attachment/cleanup'), '配送合同缺少临时附件清理接口');
|
||||
assert(routeKeys.has('GET /gasorder_contract/:identity/attachment'), '配送合同缺少受控附件读取接口');
|
||||
const recordPage = read('src/views/resource/ResourceRecordPage.vue');
|
||||
assert(recordPage.includes('ResourceAccountSummary'), '账户资源页尚未接入 5173 头像摘要卡片');
|
||||
assert(recordPage.includes("field.key !== 'avatar'"), '头像字段仍可能显示为普通文本框');
|
||||
@@ -88,5 +91,15 @@ assert(!listPage.includes('placeholder="关键字段模糊搜索"'), '列表仍
|
||||
assert(listPage.includes('ensureCredentialContext'), '人员资质列表缺少服务端人员上下文校验');
|
||||
assert(listPage.includes("field.key === 'staff_account_identity'"), '人员范围资质列表未隐藏重复人员列');
|
||||
assert(recordPage.includes('已锁定,不可更换'), '人员资质独立页未锁定所属配送人员');
|
||||
const attachmentApi = read('src/api/contract-attachment.ts');
|
||||
const attachmentState = read('src/views/resource/use-contract-attachment.ts');
|
||||
assert(definitions.includes("type: 'contract-file'"), '合同附件仍按普通文本字段渲染');
|
||||
assert(recordPage.includes('title="合同附件"'), '合同详情页缺少附件状态卡片');
|
||||
assert(recordPage.includes('downloadContractAttachment'), '合同详情页缺少独立下载入口');
|
||||
assert(listPage.includes('record.has_attachment'), '合同列表未使用脱敏附件存在性标识');
|
||||
assert(attachmentApi.includes('/gasorder_contract/attachment/upload'), '前端缺少受控附件上传调用');
|
||||
assert(attachmentApi.includes('/:identity') === false, '前端附件接口不得拼接路由模板字面量');
|
||||
assert(attachmentState.includes('delete payload.file_uri'), '新版管理端仍可能提交裸附件 URI');
|
||||
assert(attachmentState.includes('payload.attachment_receipt'), '附件保存未提交签名收据');
|
||||
|
||||
console.log(`独立资源页面契约通过:详情 ${listResources.length},新建 ${creatable.size},编辑 ${editable.size}`);
|
||||
|
||||
73
frontend/delivery_admin/src/api/contract-attachment.ts
Normal file
73
frontend/delivery_admin/src/api/contract-attachment.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 功能描述:配送点合同 PDF 附件的上传、临时清理与鉴权读取客户端。
|
||||
* 版本:v1.0.0。
|
||||
*/
|
||||
import { getToken } from '@/utils/auth';
|
||||
|
||||
const deliveryApiBaseURL =
|
||||
import.meta.env.VITE_API_BASE_URL ||
|
||||
'http://localhost:12426/heqi/delivery/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(
|
||||
`${deliveryApiBaseURL}/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(
|
||||
`${deliveryApiBaseURL}/gasorder_contract/attachment/cleanup`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authorizationHeaders() },
|
||||
body: JSON.stringify({ cleanup_token: cleanupToken }),
|
||||
},
|
||||
);
|
||||
const payload = (await response.json()) as ApiEnvelope<{ deleted: boolean }>;
|
||||
if (!response.ok || payload.code !== 0)
|
||||
throw new Error(payload.message || '临时合同附件清理失败');
|
||||
}
|
||||
|
||||
/** 从受控接口读取合同 PDF,浏览器不会接触内部存储路径。 */
|
||||
async function load(identity: string): Promise<Blob> {
|
||||
const response = await fetch(
|
||||
`${deliveryApiBaseURL}/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 ResourceField = {
|
||||
key: string;
|
||||
@@ -387,7 +388,7 @@ const platformResources: ResourceUiDefinition[] = [
|
||||
define('product_repair', '智能气阀检修记录', 'editable', [relation('product_info_identity', '/product_info', true), f('repair_no', { required: true }), f('repair_type', { required: true }), f('started_at', { required: true }), f('completed_at'), f('result', { type: 'select', options: [{ label: '待处理', value: 'pending' }, { label: '通过', value: 'passed' }, { label: '未通过', value: 'failed' }] }), f('target_product_status', { type: 'select', options: [{ label: '在库', value: 28 }, { label: '运输中', value: 29 }, { label: '使用中', value: 30 }, { label: '报废', value: 27 }] }), f('content'), f('operator'), f('remark')]),
|
||||
define('product_owner', '智能气阀归属记录', 'readonly', []),
|
||||
|
||||
define('gasorder_contract', '配送合同', 'managed', [f('contract_no', { required: true }), relation('user_account_identity', '/user_account', true), relation('gas_basic_identity', '/gas_basic', true), relation('delivery_basic_identity', '/delivery_basic'), f('title', { required: true }), f('terms'), f('file_uri'), f('default_delivery_fee'), f('signed_at', { required: true }), f('effective_at', { required: true }), f('expired_at')], 'list', [
|
||||
define('gasorder_contract', '配送合同', 'managed', [f('contract_no', { required: true }), relation('user_account_identity', '/user_account', true), relation('gas_basic_identity', '/gas_basic', true), relation('delivery_basic_identity', '/delivery_basic'), 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: [0] } },
|
||||
{ name: '续签合同', resource: '/gasorder_contract/:identity/renew', fields: [f('effective_at', { required: true }), f('expired_at'), ...reason], visibleFor: { field: 'contract_status', values: [11, 12, 13] } },
|
||||
{ name: '终止合同', resource: '/gasorder_contract/:identity/terminate', danger: true, fields: reason, visibleFor: { field: 'contract_status', values: [11] } },
|
||||
@@ -480,7 +481,7 @@ const gasOverrides: ResourceUiDefinition[] = [
|
||||
{ name: '重置密码', resource: '/user_account/:identity/password', method: 'PUT', fields: [f('password', { required: true })] },
|
||||
]),
|
||||
define('user_address', '用户地址', 'writable', [relation('user_account_identity', '/user_account', true), f('address', { required: true }), f('longitude'), f('latitude'), f('is_default')]),
|
||||
define('gasorder_contract', '配送合同', 'managed', [f('contract_no', { required: true }), relation('user_account_identity', '/user_account', true), relation('delivery_basic_identity', '/delivery_basic'), f('title', { required: true }), f('terms'), f('file_uri'), f('default_delivery_fee'), f('signed_at', { required: true }), f('effective_at', { required: true }), f('expired_at')], 'list', [
|
||||
define('gasorder_contract', '配送合同', 'managed', [f('contract_no', { required: true }), relation('user_account_identity', '/user_account', true), relation('delivery_basic_identity', '/delivery_basic'), 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: [0] } },
|
||||
{ name: '续签合同', resource: '/gasorder_contract/:identity/renew', fields: [f('effective_at', { required: true }), f('expired_at'), ...reason], visibleFor: { field: 'contract_status', values: [11, 12, 13] } },
|
||||
{ name: '终止合同', resource: '/gasorder_contract/:identity/terminate', danger: true, fields: reason, visibleFor: { field: 'contract_status', values: [11] } },
|
||||
@@ -519,7 +520,7 @@ const deliveryOverrides: ResourceUiDefinition[] = [
|
||||
{ name: '重置密码', resource: '/user_account/:identity/password', method: 'PUT', fields: [f('password', { required: true })] },
|
||||
]),
|
||||
define('user_address', '用户地址', 'writable', [relation('user_account_identity', '/user_account', true), f('address', { required: true }), f('longitude'), f('latitude'), f('is_default')]),
|
||||
define('gasorder_contract', '配送合同', 'managed', [f('contract_no', { required: true, listCopyable: true }), relation('user_account_identity', '/user_account', true, { label: '用户账户', listLabel: '用户账户', listRelationNameOnly: true, showIdentityCopy: true, readonlyRelationText: true }), f('title', { required: true }), f('terms'), f('file_uri'), f('default_delivery_fee'), f('signed_at', { required: true }), f('effective_at', { required: true }), f('expired_at')], 'list', [
|
||||
define('gasorder_contract', '配送合同', 'managed', [f('contract_no', { required: true, listCopyable: true }), relation('user_account_identity', '/user_account', true, { label: '用户账户', listLabel: '用户账户', listRelationNameOnly: true, showIdentityCopy: true, readonlyRelationText: true }), 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: [0] } },
|
||||
{ name: '续签合同', resource: '/gasorder_contract/:identity/renew', fields: [f('effective_at', { required: true }), f('expired_at'), ...reason], visibleFor: { field: 'contract_status', values: [11, 12, 13] } },
|
||||
{ name: '终止合同', resource: '/gasorder_contract/:identity/terminate', danger: true, fields: reason, visibleFor: { field: 'contract_status', values: [11] } },
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,131 @@
|
||||
<!-- 功能描述:提供配送合同 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')"
|
||||
>预览</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')"
|
||||
>移除</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { 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;
|
||||
}
|
||||
.contract-file-control:hover,
|
||||
.contract-file-control:focus-visible {
|
||||
background: var(--color-fill-1);
|
||||
border-color: rgb(var(--primary-6));
|
||||
}
|
||||
.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>
|
||||
@@ -34,6 +34,15 @@
|
||||
:auto-size="{ minRows: 3, maxRows: 8 }"
|
||||
:disabled="field.readonly"
|
||||
/>
|
||||
<ContractAttachmentField
|
||||
v-else-if="field.type === 'contract-file'"
|
||||
:attachment="contractAttachment"
|
||||
:disabled="field.readonly"
|
||||
@select="(file) => emit('selectAttachment', file)"
|
||||
@remove="emit('removeAttachment')"
|
||||
@clear-selection="emit('clearAttachmentSelection')"
|
||||
@preview="emit('previewAttachment')"
|
||||
/>
|
||||
<a-input-password
|
||||
v-else-if="field.type === 'password'"
|
||||
v-model="model[field.key]"
|
||||
@@ -85,16 +94,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { ResourceField } from '@/api/resources';
|
||||
import type { ResourceRow } from '@/api/resource-record-form';
|
||||
import ContractAttachmentField, {
|
||||
type ContractAttachmentFieldState,
|
||||
} from './ContractAttachmentField.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
fields: ResourceField[];
|
||||
model: Record<string, any>;
|
||||
mode: 'create' | 'edit';
|
||||
relationOptions: Record<string, ResourceRow[]>;
|
||||
relationLoading: Record<string, boolean>;
|
||||
}>();
|
||||
contractAttachment?: ContractAttachmentFieldState;
|
||||
}>(), {
|
||||
contractAttachment: () => ({
|
||||
selectedFile: undefined,
|
||||
hasExisting: false,
|
||||
available: false,
|
||||
requiresReupload: false,
|
||||
removed: false,
|
||||
}),
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
searchRelation: [resource: string | undefined, keyword: string];
|
||||
selectAttachment: [file: File];
|
||||
removeAttachment: [];
|
||||
clearAttachmentSelection: [];
|
||||
previewAttachment: [];
|
||||
}>();
|
||||
|
||||
/** 判断当前模式下字段是否必填。 */
|
||||
@@ -104,7 +129,7 @@ function isRequired(field: ResourceField) {
|
||||
|
||||
/** 长文本和多选关系占据整行,其余字段按双列排列。 */
|
||||
function isWide(field: ResourceField) {
|
||||
return field.type === 'textarea' || field.type === 'identity-list' ||
|
||||
return field.type === 'textarea' || field.type === 'identity-list' || field.type === 'contract-file' ||
|
||||
/(address|terms|content|body|remark|reason|params|args)$/.test(field.key);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,31 @@
|
||||
/>
|
||||
<template v-if="mode === 'detail'">
|
||||
<ResourceDetailContent :definition="definition" :detail="detail" />
|
||||
<a-card
|
||||
v-if="isContractResource"
|
||||
title="合同附件"
|
||||
:bordered="false"
|
||||
class="section-card"
|
||||
>
|
||||
<a-space wrap>
|
||||
<span v-if="contractAttachment.available.value">合同附件.pdf</span>
|
||||
<a-tag v-else-if="contractAttachment.requiresReupload.value" color="orange">
|
||||
附件不可用,需在草稿状态重新上传
|
||||
</a-tag>
|
||||
<span v-else>暂无附件</span>
|
||||
<a-button
|
||||
v-if="contractAttachment.available.value"
|
||||
type="primary"
|
||||
:loading="attachmentBusy"
|
||||
@click="previewContractAttachment"
|
||||
>预览</a-button>
|
||||
<a-button
|
||||
v-if="contractAttachment.available.value"
|
||||
:loading="attachmentBusy"
|
||||
@click="downloadContractAttachment"
|
||||
>下载</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
<a-card
|
||||
v-if="visibleActions.length || definition.canChangeStatus || definition.canArchive"
|
||||
title="业务操作"
|
||||
@@ -84,7 +109,12 @@
|
||||
:mode="mode"
|
||||
:relation-options="relationOptions"
|
||||
:relation-loading="relationLoading"
|
||||
:contract-attachment="contractAttachmentState"
|
||||
@search-relation="searchRelation"
|
||||
@select-attachment="contractAttachment.select"
|
||||
@remove-attachment="confirmRemoveContractAttachment"
|
||||
@clear-attachment-selection="contractAttachment.clearSelection"
|
||||
@preview-attachment="previewContractAttachment"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<a-button type="primary" :loading="saving" @click="save">保存</a-button>
|
||||
@@ -130,6 +160,7 @@ import ResourceDetailContent from './ResourceDetailContent.vue';
|
||||
import ResourceFieldForm from './ResourceFieldForm.vue';
|
||||
import { useUnsavedRecord } from './use-unsaved-record';
|
||||
import { useResourceAvatar } from './use-resource-avatar';
|
||||
import { useContractAttachment } from './use-contract-attachment';
|
||||
|
||||
type RecordPageMode = 'create' | 'detail' | 'edit';
|
||||
const route = useRoute();
|
||||
@@ -142,6 +173,7 @@ const identity = computed(() => String(route.params.identity ?? ''));
|
||||
const modeLabel = computed(() => ({ create: '新建', detail: '详情', edit: '编辑' })[mode.value]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const attachmentBusy = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const detail = ref<ResourceRow>({});
|
||||
const credentialOwner = ref<ResourceRow>();
|
||||
@@ -153,10 +185,19 @@ const relationTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const actionVisible = ref(false);
|
||||
const activeAction = ref<DetailAction>();
|
||||
const avatar = useResourceAvatar();
|
||||
const contractAttachment = useContractAttachment();
|
||||
const hasAvatarField = computed(() =>
|
||||
definition.value.fields.some((field) => field.key === 'avatar'),
|
||||
);
|
||||
const isCredentialResource = computed(() => definition.value.name === 'staff_credential');
|
||||
const isContractResource = computed(() => definition.value.name === 'gasorder_contract');
|
||||
const contractAttachmentState = computed(() => ({
|
||||
selectedFile: contractAttachment.selectedFile.value,
|
||||
hasExisting: contractAttachment.hasExisting.value,
|
||||
available: contractAttachment.available.value,
|
||||
requiresReupload: contractAttachment.requiresReupload.value,
|
||||
removed: contractAttachment.removed.value,
|
||||
}));
|
||||
const summaryRecord = computed(() =>
|
||||
mode.value === 'detail' ? record.value : { ...record.value, ...form },
|
||||
);
|
||||
@@ -180,7 +221,11 @@ const visibleActions = computed(() =>
|
||||
),
|
||||
);
|
||||
const unsaved = useUnsavedRecord(
|
||||
() => JSON.stringify({ form, avatar: avatar.marker() }),
|
||||
() => JSON.stringify({
|
||||
form,
|
||||
avatar: avatar.marker(),
|
||||
contractAttachment: contractAttachment.marker(),
|
||||
}),
|
||||
() => mode.value !== 'detail',
|
||||
);
|
||||
|
||||
@@ -198,6 +243,9 @@ async function loadRecord() {
|
||||
if (reason) throw new Error(reason);
|
||||
}
|
||||
}
|
||||
contractAttachment.load(
|
||||
isContractResource.value ? (detail.value.attachment as any) : undefined,
|
||||
);
|
||||
if (hasAvatarField.value && mode.value !== 'create') {
|
||||
// 头像属于附加信息,读取失败不能阻断基础资料页面。
|
||||
await avatar.load(definition.value.resource, identity.value).catch(() => undefined);
|
||||
@@ -241,6 +289,7 @@ async function save() {
|
||||
try {
|
||||
const payload = buildResourcePayload(formFields.value, form, mode.value as 'create' | 'edit');
|
||||
if (hasAvatarField.value) await avatar.applyToPayload(payload);
|
||||
if (isContractResource.value) await contractAttachment.applyToPayload(payload);
|
||||
const saved = mode.value === 'create'
|
||||
? await resourceApi.create<ResourceRow>(definition.value.resource, payload)
|
||||
: await resourceApi.update<ResourceRow>(definition.value.resource, identity.value, payload);
|
||||
@@ -250,12 +299,60 @@ async function save() {
|
||||
unsaved.allowNextNavigation();
|
||||
await router.replace(recordRouteLocation(listRouteName.value, 'detail', savedIdentity, returnPath.value));
|
||||
} catch (error) {
|
||||
if (isContractResource.value) await contractAttachment.rollbackUpload();
|
||||
Message.error((error as Error).message);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 二次确认后标记移除草稿合同的现有附件。 */
|
||||
function confirmRemoveContractAttachment() {
|
||||
Modal.warning({
|
||||
title: '确认移除合同附件',
|
||||
content: '附件只会在合同保存成功后移除;取消编辑不会改变原附件。',
|
||||
hideCancel: false,
|
||||
onOk: contractAttachment.remove,
|
||||
});
|
||||
}
|
||||
|
||||
/** 在新标签页打开鉴权读取的 PDF。 */
|
||||
async function previewContractAttachment() {
|
||||
const previewWindow = window.open('', '_blank');
|
||||
if (!previewWindow) return Message.warning('浏览器阻止了合同附件预览窗口');
|
||||
previewWindow.opener = null;
|
||||
attachmentBusy.value = true;
|
||||
try {
|
||||
const blob = await contractAttachment.fetchBlob(identity.value);
|
||||
const objectURL = URL.createObjectURL(blob);
|
||||
previewWindow.location.href = objectURL;
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectURL), 60_000);
|
||||
} catch (error) {
|
||||
previewWindow.close();
|
||||
Message.error((error as Error).message);
|
||||
} finally {
|
||||
attachmentBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载鉴权取得的 PDF,并使用固定安全文件名。 */
|
||||
async function downloadContractAttachment() {
|
||||
attachmentBusy.value = true;
|
||||
try {
|
||||
const blob = await contractAttachment.fetchBlob(identity.value);
|
||||
const objectURL = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectURL;
|
||||
anchor.download = `${String(record.value.contract_no ?? '配送合同')}_合同附件.pdf`;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectURL), 1_000);
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
} finally {
|
||||
attachmentBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回列表或编辑来源详情。 */
|
||||
function requestBack() {
|
||||
const leave = () => mode.value === 'edit'
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 功能描述:管理配送点合同附件的选择、保存前上传、失败清理与读取状态。
|
||||
* 版本: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 marker = fileMarker(selectedFile.value);
|
||||
if (!uploaded || uploadedMarker !== marker) {
|
||||
uploaded = await contractAttachmentApi.upload(selectedFile.value);
|
||||
uploadedMarker = marker;
|
||||
}
|
||||
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。 */
|
||||
function fetchBlob(identity: string) {
|
||||
return contractAttachmentApi.load(identity);
|
||||
}
|
||||
|
||||
/** 返回未保存附件变更标识。 */
|
||||
function marker() {
|
||||
if (selectedFile.value) return `select:${fileMarker(selectedFile.value)}`;
|
||||
return removed.value ? 'remove' : 'unchanged';
|
||||
}
|
||||
|
||||
return {
|
||||
selectedFile,
|
||||
metadata,
|
||||
removed,
|
||||
hasExisting,
|
||||
available,
|
||||
requiresReupload,
|
||||
load,
|
||||
select,
|
||||
remove,
|
||||
clearSelection,
|
||||
applyToPayload,
|
||||
rollbackUpload,
|
||||
fetchBlob,
|
||||
marker,
|
||||
};
|
||||
}
|
||||
|
||||
/** 生成稳定的本地文件选择标识。 */
|
||||
function fileMarker(file: File) {
|
||||
return `${file.name}:${file.size}:${file.lastModified}`;
|
||||
}
|
||||
@@ -67,6 +67,13 @@
|
||||
:refresh-key="avatarRefreshKey"
|
||||
:loader="avatarLoader"
|
||||
/>
|
||||
<a-button
|
||||
v-else-if="field.type === 'contract-file' && record.has_attachment"
|
||||
size="mini"
|
||||
:loading="attachmentPreviewing === String(record.identity ?? '')"
|
||||
@click="previewListContractAttachment(record)"
|
||||
>查看附件</a-button>
|
||||
<span v-else-if="field.type === 'contract-file'">暂无附件</span>
|
||||
<IdentityText
|
||||
v-else-if="(field.type === 'identity' || field.listCopyable) && fieldValue(field, record)"
|
||||
:value="fieldValue(field, record)"
|
||||
@@ -122,6 +129,7 @@ import { Message } from '@arco-design/web-vue';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { resourceApi } from '@/api/resource';
|
||||
import { contractAttachmentApi } from '@/api/contract-attachment';
|
||||
import {
|
||||
displayResourceValue,
|
||||
recordEditReason,
|
||||
@@ -151,6 +159,7 @@ const credentialOwner = ref<ResourceRow>();
|
||||
const contextError = ref('');
|
||||
const avatarLoader = createProtectedListAvatarLoader();
|
||||
const avatarRefreshKey = ref(0);
|
||||
const attachmentPreviewing = ref('');
|
||||
const filters = reactive({ keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '' });
|
||||
const isCredentialList = computed(() => props.definition.name === 'staff_credential');
|
||||
const ownerIdentity = computed(() =>
|
||||
@@ -201,11 +210,33 @@ async function ensureCredentialContext() {
|
||||
/** 返回列表列宽。 */
|
||||
function columnWidth(field: ResourceField) {
|
||||
if (isProtectedListAvatarField(props.definition.name, field.key)) return 72;
|
||||
if (field.type === 'contract-file') return 120;
|
||||
if (field.type === 'datetime' || field.type === 'date') return 180;
|
||||
if (field.type === 'money' || field.type === 'number') return 140;
|
||||
return field.type === 'textarea' ? 240 : 160;
|
||||
}
|
||||
|
||||
/** 从合同列表通过鉴权接口预览附件,不使用列表中的任何存储路径。 */
|
||||
async function previewListContractAttachment(row: ResourceRow) {
|
||||
const identity = String(row.identity ?? '');
|
||||
if (!identity) return;
|
||||
const previewWindow = window.open('', '_blank');
|
||||
if (!previewWindow) return Message.warning('浏览器阻止了合同附件预览窗口');
|
||||
previewWindow.opener = null;
|
||||
attachmentPreviewing.value = identity;
|
||||
try {
|
||||
const blob = await contractAttachmentApi.load(identity);
|
||||
const objectURL = URL.createObjectURL(blob);
|
||||
previewWindow.location.href = objectURL;
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectURL), 60_000);
|
||||
} catch (error) {
|
||||
previewWindow.close();
|
||||
Message.error((error as Error).message);
|
||||
} finally {
|
||||
attachmentPreviewing.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回兼容脱敏字段的文本值。 */
|
||||
function fieldValue(field: ResourceField, row: ResourceRow) {
|
||||
return String(row[field.key] ?? row[`${field.key}_masked`] ?? '');
|
||||
|
||||
Reference in New Issue
Block a user