完善配送点合同附件管理
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
// 功能描述:实现配送点范围内的合同、合同气瓶和配送订单接口。
|
||||
// 版本:v1.1.0。
|
||||
package delivery
|
||||
|
||||
import (
|
||||
@@ -7,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
@@ -48,10 +51,53 @@ func scopedContract(ctx *gin.Context, identity string, pointID uint64) (models.G
|
||||
|
||||
func ListContract(ctx *gin.Context) {
|
||||
point, _, ok := currentScope(ctx)
|
||||
if ok {
|
||||
listScoped(ctx, &models.GasorderContract{}, common.ActiveRecords(db().Model(&models.GasorderContract{})).
|
||||
Where("gas_basic_id = ? AND delivery_basic_id = ?", point.GasBasicID, point.ID), "gasorder_contract.created_at desc")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, size := common.PageSize(ctx)
|
||||
query := common.ApplyKeywordFilter(ctx,
|
||||
common.ActiveRecords(db().Model(&models.GasorderContract{})).
|
||||
Where("gas_basic_id = ? AND delivery_basic_id = ?", point.GasBasicID, point.ID),
|
||||
&models.GasorderContract{})
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
var list []models.GasorderContract
|
||||
if err := query.Order("gasorder_contract.created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
response, err := common.PublicResourceResponse(list)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
protected, err := protectDeliveryContractListResponse(ctx, response, list)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"total": total, "list": protected})
|
||||
}
|
||||
|
||||
// protectDeliveryContractListResponse 删除内部附件路径,仅补充无敏感信息的存在性标识。
|
||||
func protectDeliveryContractListResponse(ctx *gin.Context, response any, list []models.GasorderContract) ([]any, error) {
|
||||
protected := common.ProtectPreciseLocation(ctx, &models.GasorderContract{}, response)
|
||||
items, valid := protected.([]any)
|
||||
if !valid || len(items) != len(list) {
|
||||
return nil, errors.New("配送合同列表响应结构无效")
|
||||
}
|
||||
for index, item := range items {
|
||||
row, rowValid := item.(map[string]any)
|
||||
if !rowValid {
|
||||
return nil, errors.New("配送合同列表记录结构无效")
|
||||
}
|
||||
// 只返回是否存在附件,内部存储 URI 已由统一保护层删除。
|
||||
row["has_attachment"] = strings.TrimSpace(list[index].FileURI) != ""
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func GetContract(ctx *gin.Context) {
|
||||
@@ -63,6 +109,33 @@ func GetContract(ctx *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// UploadContractAttachment 在当前配送点登录范围内上传受控 PDF 临时附件。
|
||||
func UploadContractAttachment(ctx *gin.Context) {
|
||||
if _, _, ok := currentScope(ctx); !ok {
|
||||
return
|
||||
}
|
||||
platformgasorder.UploadGasorderContractAttachment(ctx)
|
||||
}
|
||||
|
||||
// CleanupContractAttachment 清理当前配送点操作人尚未绑定的临时附件。
|
||||
func CleanupContractAttachment(ctx *gin.Context) {
|
||||
if _, _, ok := currentScope(ctx); !ok {
|
||||
return
|
||||
}
|
||||
platformgasorder.CleanupGasorderContractAttachment(ctx)
|
||||
}
|
||||
|
||||
// ServeContractAttachment 仅预览当前配送点拥有的正式合同附件。
|
||||
func ServeContractAttachment(ctx *gin.Context) {
|
||||
point, _, ok := currentScope(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, valid := scopedContract(ctx, ctx.Param("identity"), point.ID); valid {
|
||||
platformgasorder.ServeGasorderContractAttachment(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func CreateContract(ctx *gin.Context) {
|
||||
point, station, ok := currentScope(ctx)
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// 功能描述:验证配送合同附件列表脱敏与公开状态投影。
|
||||
// 版本:v1.0.0。
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestProtectDeliveryContractListResponse 验证内部路径不会进入配送端列表响应。
|
||||
func TestProtectDeliveryContractListResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("GET", "/gasorder_contract", nil)
|
||||
contracts := []models.GasorderContract{
|
||||
{FileURI: "/uploads/contracts/2026/08/22/protected.pdf"},
|
||||
{},
|
||||
}
|
||||
response, err := common.PublicResourceResponse(contracts)
|
||||
if err != nil {
|
||||
t.Fatalf("构造公开响应失败: %v", err)
|
||||
}
|
||||
protected, err := protectDeliveryContractListResponse(ctx, response, contracts)
|
||||
if err != nil {
|
||||
t.Fatalf("合同列表脱敏失败: %v", err)
|
||||
}
|
||||
for index, item := range protected {
|
||||
row, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("第 %d 条响应不是对象", index)
|
||||
}
|
||||
if _, exposed := row["file_uri"]; exposed {
|
||||
t.Fatalf("第 %d 条响应暴露了内部附件路径", index)
|
||||
}
|
||||
}
|
||||
if protected[0].(map[string]any)["has_attachment"] != true {
|
||||
t.Fatal("存在附件的合同必须返回 has_attachment=true")
|
||||
}
|
||||
if protected[1].(map[string]any)["has_attachment"] != false {
|
||||
t.Fatal("无附件的合同必须返回 has_attachment=false")
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,10 @@ func RegisterDelivery(serviceKey string, engine *gin.Engine) {
|
||||
contract := protected.Group("/gasorder_contract")
|
||||
contract.GET("", deliverylogic.ListContract)
|
||||
contract.POST("", deliverylogic.CreateContract)
|
||||
contract.POST("/attachment/upload", deliverylogic.UploadContractAttachment)
|
||||
contract.POST("/attachment/cleanup", deliverylogic.CleanupContractAttachment)
|
||||
contract.GET("/:identity", deliverylogic.GetContract)
|
||||
contract.GET("/:identity/attachment", deliverylogic.ServeContractAttachment)
|
||||
contract.PUT("/:identity", deliverylogic.UpdateContract)
|
||||
contract.POST("/:identity/activate", deliverylogic.ActivateContract)
|
||||
contract.POST("/:identity/renew", deliverylogic.RenewContract)
|
||||
|
||||
@@ -47,6 +47,9 @@ func TestDeliveryOrderActionBoundary(t *testing.T) {
|
||||
"GET /heqi/delivery/v1/wallet_recharge/:identity",
|
||||
"GET /heqi/delivery/v1/staff_account/:identity/avatar",
|
||||
"GET /heqi/delivery/v1/user_account/:identity/avatar",
|
||||
"POST /heqi/delivery/v1/gasorder_contract/attachment/upload",
|
||||
"POST /heqi/delivery/v1/gasorder_contract/attachment/cleanup",
|
||||
"GET /heqi/delivery/v1/gasorder_contract/:identity/attachment",
|
||||
} {
|
||||
if !routes[required] {
|
||||
t.Fatalf("missing confirmed delivery action %s", required)
|
||||
|
||||
40
docs/操作日志_配送点合同附件管理_20260822.md
Normal file
40
docs/操作日志_配送点合同附件管理_20260822.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 操作日志:配送点合同附件管理
|
||||
|
||||
操作时间:2026-08-22
|
||||
操作类型:扩展、修改
|
||||
影响模块:配送合同列表、独立记录页、配送域附件接口、项目文档
|
||||
|
||||
## 操作前状态
|
||||
|
||||
配送合同列表直接展示 `/uploads/contracts/...`,新建和编辑页允许手填 `file_uri`;配送域没有附件上传、清理和鉴权读取路由。合同列表 API 也会返回内部附件路径。
|
||||
|
||||
## 具体操作
|
||||
|
||||
1. 在配送域增加上传、临时清理和正式附件读取包装,并在读取前校验当前配送点合同归属。
|
||||
2. 复用平台合同附件的 10 MiB PDF 校验、30 分钟签名收据、并发版本和安全响应头。
|
||||
3. 配送合同列表经过统一敏感字段保护,删除 `file_uri`,增加 `has_attachment`。
|
||||
4. 5176 新增 PDF 选择、拖拽、替换、确认移除、预览、下载和失败清理流程。
|
||||
5. 新版前端保存时主动删除 `file_uri`,仅提交签名收据和附件版本。
|
||||
6. 更新独立资源项目文档,纠正与原始敏感数据保护要求冲突的文本 URI 边界。
|
||||
|
||||
## 行为变化
|
||||
|
||||
- 变更前:页面和网络响应暴露内部存储路径,管理员可手填任意 URI。
|
||||
- 变更后:列表只显示附件状态和鉴权入口;详情可预览、下载;草稿可上传、替换和移除;非草稿只读。
|
||||
- 兼容性:数据库字段、合同 CRUD 路径和后端旧 `file_uri` 输入保持兼容,5176 不再使用旧输入。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- `go test ./internal/logic/common ./internal/logic/delivery ./internal/logic/platform/gasorder ./internal/routers ./cmd/cli`:通过。
|
||||
- `npm.cmd run type:check`:通过。
|
||||
- `npm.cmd run resource-pages:check`:通过,详情 17、新建 9、编辑 5,并覆盖附件三条路由和前端签名收据编排。
|
||||
- `npm.cmd run contract:check`、`npm.cmd run profile:check`:通过。
|
||||
- `npm.cmd run build`:通过,2639 个模块完成生产构建。
|
||||
- 后端已重建并监听 `12426`;浏览器会话当前停留在登录页,登录后的视觉回归待补。
|
||||
|
||||
## 风险评估
|
||||
|
||||
- 临时上传成功但合同保存失败:前端调用签名清理接口,服务端仍有有效期兜底。
|
||||
- 历史附件缺失或格式异常:详情显示需重传,启用操作继续由服务端拒绝。
|
||||
- 并发编辑附件:附件版本与旧 URI 双重条件防止覆盖他人变更。
|
||||
- 5173 列表响应的同类潜在泄露未在本次扩大处理,已登记为后续安全债务。
|
||||
@@ -26,6 +26,7 @@
|
||||
14. 资质列表新增人员姓名上下文、安全返回、重复人员列隐藏;新建预填所属人员,创建和编辑锁定归属。
|
||||
15. 新增后端生成的 `searchFields` 契约;无有效字段时隐藏搜索区,枚举字段支持中文名称和内部编码。
|
||||
16. 标准列表搜索标签统一为“模糊搜索”,输入框继续提示当前资源的具体可搜索字段。
|
||||
17. 配送合同附件由文本 URI 升级为受控 PDF:列表脱敏、详情预览下载、草稿上传替换移除,并增加配送点范围鉴权接口。
|
||||
|
||||
## 操作后状态
|
||||
|
||||
@@ -62,6 +63,7 @@
|
||||
- 列表头像回归:配送人员、用户账户显示受控圆形头像,其他资源不生成无效头像列;数据库 ID 列已从全部标准列表移除;点击刷新后头像重新加载正常。
|
||||
- 资质关联回归:有效人员仅返回本人资质,标题和上下文显示服务端姓名及唯一标识;新建页预填并锁定人员,缺少上下文自动返回,伪造人员标识显示错误且不渲染表格。
|
||||
- 搜索回归:资质页提示“可搜索:资质类型、资质编号”;银行卡页不显示关键字、查询和重置,仅保留刷新。
|
||||
- 合同附件回归:Go 测试、附件路由检查、前端类型检查和生产构建通过;登录后的视觉回归记录在专项操作日志中。
|
||||
|
||||
## 风险评估
|
||||
|
||||
|
||||
53
docs/项目文档_配送点合同附件管理_v1.0.md
Normal file
53
docs/项目文档_配送点合同附件管理_v1.0.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 项目文档:配送点合同附件管理 v1.0
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
配送点管理端将配送合同的“附件地址”文本字段升级为受控 PDF 附件流程。数据库继续使用 `gasorder_contract.file_uri` 保存服务端受控路径,但浏览器只接收附件状态、签名收据和文件 Blob,不读取或提交内部路径。
|
||||
|
||||
技术栈:Go、Gin、GORM、Vue 3、TypeScript、Arco Design。运行时依赖现有合同附件目录及服务端签名密钥。
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
```text
|
||||
backend/api/internal/
|
||||
├── logic/delivery/order.go # 配送点范围包装、列表脱敏
|
||||
├── logic/platform/gasorder/ # 复用 PDF 校验、收据和文件服务
|
||||
└── routers/delivery.go # 上传、清理、读取路由
|
||||
frontend/delivery_admin/src/
|
||||
├── api/contract-attachment.ts # 附件 HTTP 客户端
|
||||
├── views/resource/ContractAttachmentField.vue
|
||||
├── views/resource/use-contract-attachment.ts
|
||||
├── views/resource/ResourceRecordPage.vue # 详情、表单和保存编排
|
||||
└── views/shared/ResourceListPage.vue # 脱敏附件状态入口
|
||||
```
|
||||
|
||||
## 3. 核心流程
|
||||
|
||||
1. 新建或草稿编辑页选择单个 PDF,前端预检扩展名、MIME 和 10 MiB 上限。
|
||||
2. 点击保存后上传至临时目录,服务端校验真实 MIME、`%PDF-` 文件头和 `%%EOF`,并签发绑定当前操作人的 30 分钟收据。
|
||||
3. 合同创建或更新提交 `attachment_receipt`;替换或移除同时提交 `attachment_version`,避免并发覆盖。
|
||||
4. 保存失败时调用清理接口;取消编辑不会改变原附件。
|
||||
5. 预览和下载只接受合同 identity,经当前气站、配送点和 JWT 范围校验后返回 `private, no-store` 的 PDF。
|
||||
|
||||
## 4. 安全与业务规则
|
||||
|
||||
- 合同列表响应删除 `file_uri`,只返回 `has_attachment`。
|
||||
- 详情响应使用 `attachment` 元数据,旧路径异常时显示“需重新上传”。
|
||||
- 只有草稿合同可上传、替换或移除;启用前必须存在有效 PDF。
|
||||
- 跨配送点读取统一不可访问,不返回文件路径或磁盘信息。
|
||||
- 新版配送点管理端始终删除载荷中的 `file_uri`;后端旧字段仅为兼容历史调用保留。
|
||||
|
||||
## 5. 接口
|
||||
|
||||
- `POST /gasorder_contract/attachment/upload`:上传临时 PDF。
|
||||
- `POST /gasorder_contract/attachment/cleanup`:按签名清理凭证删除临时文件。
|
||||
- `GET /gasorder_contract/:identity/attachment`:鉴权预览或下载正式附件。
|
||||
|
||||
## 6. 维护与验证
|
||||
|
||||
修改附件流程后应运行配送逻辑、平台附件服务和路由测试,以及配送前端 `type:check`、`resource-pages:check`、`contract:check` 和生产构建。不得恢复普通 URI 输入框或 `<a href="file_uri">` 直链。
|
||||
|
||||
## 7. 变更记录
|
||||
|
||||
- v1.0(2026-08-22):新增配送域附件接口、列表脱敏、详情预览下载及草稿上传替换移除。
|
||||
- 修正《项目文档_配送点独立资源页面_v1.0》中 2026-08-22 新增的“文本 URI”边界,使其重新符合 2026-07-30 原始敏感数据保护基线。
|
||||
@@ -86,8 +86,9 @@ npm.cmd run build
|
||||
- 新增配送人员、用户账户的受控头像缩略图,并移除全部标准列表的数据库自增 ID。
|
||||
- 新增配送人员资质的强制人员范围、上下文展示、关系预填锁定和安全返回链路。
|
||||
- 新增后端生成的资源搜索契约,隐藏无效搜索并支持枚举中文名称与稳定编码。
|
||||
- 新增配送合同受控 PDF 附件上传、鉴权预览、下载、替换和移除流程,列表及接口均不暴露内部路径。
|
||||
- 保持配送点资料专用只读页面和现有公共后端接口不变。
|
||||
|
||||
## 7. 已知边界
|
||||
|
||||
独立页面只使用配送端数据范围内的关系查询和 CRUD 能力;头像复用公共受控上传入口,并通过配送点专属鉴权接口读取。不引入合同附件上传、平台角色或跨组织账户摘要接口,附件 URI 字段仍按原后端文本契约展示或填写。
|
||||
独立页面只使用配送端数据范围内的关系查询和 CRUD 能力;头像与合同附件均通过配送点专属鉴权接口读取。合同附件仅接受不超过 10 MiB 的真实 PDF,草稿可上传、替换或确认移除,非草稿只允许预览和下载。页面与配送合同列表响应不得展示或返回内部附件 URI。平台角色和跨组织账户摘要接口仍不在本项目范围内。
|
||||
|
||||
@@ -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