diff --git a/backend/api/internal/logic/delivery/order.go b/backend/api/internal/logic/delivery/order.go index acb7fe1..da98c7e 100644 --- a/backend/api/internal/logic/delivery/order.go +++ b/backend/api/internal/logic/delivery/order.go @@ -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 { diff --git a/backend/api/internal/logic/delivery/order_contract_attachment_test.go b/backend/api/internal/logic/delivery/order_contract_attachment_test.go new file mode 100644 index 0000000..74f5f2a --- /dev/null +++ b/backend/api/internal/logic/delivery/order_contract_attachment_test.go @@ -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") + } +} diff --git a/backend/api/internal/routers/delivery.go b/backend/api/internal/routers/delivery.go index 4cdd345..a8f2b17 100644 --- a/backend/api/internal/routers/delivery.go +++ b/backend/api/internal/routers/delivery.go @@ -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) diff --git a/backend/api/internal/routers/delivery_test.go b/backend/api/internal/routers/delivery_test.go index 6c491cc..fa96a93 100644 --- a/backend/api/internal/routers/delivery_test.go +++ b/backend/api/internal/routers/delivery_test.go @@ -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) diff --git a/docs/操作日志_配送点合同附件管理_20260822.md b/docs/操作日志_配送点合同附件管理_20260822.md new file mode 100644 index 0000000..227d865 --- /dev/null +++ b/docs/操作日志_配送点合同附件管理_20260822.md @@ -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 列表响应的同类潜在泄露未在本次扩大处理,已登记为后续安全债务。 diff --git a/docs/操作日志_配送点独立资源页面_20260822.md b/docs/操作日志_配送点独立资源页面_20260822.md index f89b83e..fe4cba7 100644 --- a/docs/操作日志_配送点独立资源页面_20260822.md +++ b/docs/操作日志_配送点独立资源页面_20260822.md @@ -26,6 +26,7 @@ 14. 资质列表新增人员姓名上下文、安全返回、重复人员列隐藏;新建预填所属人员,创建和编辑锁定归属。 15. 新增后端生成的 `searchFields` 契约;无有效字段时隐藏搜索区,枚举字段支持中文名称和内部编码。 16. 标准列表搜索标签统一为“模糊搜索”,输入框继续提示当前资源的具体可搜索字段。 +17. 配送合同附件由文本 URI 升级为受控 PDF:列表脱敏、详情预览下载、草稿上传替换移除,并增加配送点范围鉴权接口。 ## 操作后状态 @@ -62,6 +63,7 @@ - 列表头像回归:配送人员、用户账户显示受控圆形头像,其他资源不生成无效头像列;数据库 ID 列已从全部标准列表移除;点击刷新后头像重新加载正常。 - 资质关联回归:有效人员仅返回本人资质,标题和上下文显示服务端姓名及唯一标识;新建页预填并锁定人员,缺少上下文自动返回,伪造人员标识显示错误且不渲染表格。 - 搜索回归:资质页提示“可搜索:资质类型、资质编号”;银行卡页不显示关键字、查询和重置,仅保留刷新。 +- 合同附件回归:Go 测试、附件路由检查、前端类型检查和生产构建通过;登录后的视觉回归记录在专项操作日志中。 ## 风险评估 diff --git a/docs/项目文档_配送点合同附件管理_v1.0.md b/docs/项目文档_配送点合同附件管理_v1.0.md new file mode 100644 index 0000000..d199931 --- /dev/null +++ b/docs/项目文档_配送点合同附件管理_v1.0.md @@ -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 输入框或 `` 直链。 + +## 7. 变更记录 + +- v1.0(2026-08-22):新增配送域附件接口、列表脱敏、详情预览下载及草稿上传替换移除。 +- 修正《项目文档_配送点独立资源页面_v1.0》中 2026-08-22 新增的“文本 URI”边界,使其重新符合 2026-07-30 原始敏感数据保护基线。 diff --git a/docs/项目文档_配送点独立资源页面_v1.0.md b/docs/项目文档_配送点独立资源页面_v1.0.md index 620f70d..e6be476 100644 --- a/docs/项目文档_配送点独立资源页面_v1.0.md +++ b/docs/项目文档_配送点独立资源页面_v1.0.md @@ -86,8 +86,9 @@ npm.cmd run build - 新增配送人员、用户账户的受控头像缩略图,并移除全部标准列表的数据库自增 ID。 - 新增配送人员资质的强制人员范围、上下文展示、关系预填锁定和安全返回链路。 - 新增后端生成的资源搜索契约,隐藏无效搜索并支持枚举中文名称与稳定编码。 +- 新增配送合同受控 PDF 附件上传、鉴权预览、下载、替换和移除流程,列表及接口均不暴露内部路径。 - 保持配送点资料专用只读页面和现有公共后端接口不变。 ## 7. 已知边界 -独立页面只使用配送端数据范围内的关系查询和 CRUD 能力;头像复用公共受控上传入口,并通过配送点专属鉴权接口读取。不引入合同附件上传、平台角色或跨组织账户摘要接口,附件 URI 字段仍按原后端文本契约展示或填写。 +独立页面只使用配送端数据范围内的关系查询和 CRUD 能力;头像与合同附件均通过配送点专属鉴权接口读取。合同附件仅接受不超过 10 MiB 的真实 PDF,草稿可上传、替换或确认移除,非草稿只允许预览和下载。页面与配送合同列表响应不得展示或返回内部附件 URI。平台角色和跨组织账户摘要接口仍不在本项目范围内。 diff --git a/frontend/delivery_admin/scripts/check-resource-pages.mjs b/frontend/delivery_admin/scripts/check-resource-pages.mjs index 0b613a2..f560683 100644 --- a/frontend/delivery_admin/scripts/check-resource-pages.mjs +++ b/frontend/delivery_admin/scripts/check-resource-pages.mjs @@ -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}`); diff --git a/frontend/delivery_admin/src/api/contract-attachment.ts b/frontend/delivery_admin/src/api/contract-attachment.ts new file mode 100644 index 0000000..be9a302 --- /dev/null +++ b/frontend/delivery_admin/src/api/contract-attachment.ts @@ -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 = { code?: number; message?: string; details?: T }; + +/** 返回当前配送点登录凭证请求头。 */ +function authorizationHeaders(): Record { + const token = getToken(); + return token ? { Authorization: token } : {}; +} + +/** 上传单个 PDF;服务端仍会校验真实文件类型和内容。 */ +async function upload(file: File): Promise { + 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; + if (!response.ok || payload.code !== 0 || !payload.details) + throw new Error(payload.message || '合同附件上传失败'); + return payload.details; +} + +/** 通过签名清理凭证删除尚未绑定的临时附件。 */ +async function cleanup(cleanupToken: string): Promise { + 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 { + 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 }; diff --git a/frontend/delivery_admin/src/api/resources.ts b/frontend/delivery_admin/src/api/resources.ts index 947ad12..8001252 100644 --- a/frontend/delivery_admin/src/api/resources.ts +++ b/frontend/delivery_admin/src/api/resources.ts @@ -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] } }, diff --git a/frontend/delivery_admin/src/contracts/delivery-resources.json b/frontend/delivery_admin/src/contracts/delivery-resources.json index 92e2c30..1aa01f5 100644 --- a/frontend/delivery_admin/src/contracts/delivery-resources.json +++ b/frontend/delivery_admin/src/contracts/delivery-resources.json @@ -1 +1 @@ -{"resources":[{"domain":"profile","name":"delivery_profile","path":"/delivery_profile","pageKind":"list","mode":"readonly"},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"}]},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable","searchFields":[{"key":"credential_type","kind":"text"},{"key":"credential_no","kind":"text"}]},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"}]},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"contract","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed","searchFields":[{"key":"contract_no","kind":"text"},{"key":"title","kind":"text"}]},{"domain":"contract","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only","searchFields":[{"key":"product_code","kind":"text"},{"key":"product_type_name","kind":"text"}]},{"domain":"contract","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly","searchFields":[{"key":"action","kind":"enum","values":[{"value":"activate","label":"启用"},{"value":"renew","label":"续签"},{"value":"terminate","label":"终止"}]}]},{"domain":"contract","name":"product_info","path":"/product_info","pageKind":"list","mode":"readonly","searchFields":[{"key":"code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only","searchFields":[{"key":"request_no","kind":"text"},{"key":"creator_type","kind":"enum","values":[{"value":"user","label":"用户"},{"value":"staff","label":"工作人员"},{"value":"delivery","label":"配送点"},{"value":"gas","label":"气站"}]}]},{"domain":"finance","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"payment_order","path":"/payment_order","pageKind":"list","mode":"readonly","searchFields":[{"key":"payment_no","kind":"text"},{"key":"request_no","kind":"text"},{"key":"channel","kind":"enum","values":[{"value":"wechat","label":"微信"},{"value":"alipay","label":"支付宝"},{"value":"mock","label":"模拟支付"}]}]},{"domain":"finance","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly","searchFields":[{"key":"record_no","kind":"text"},{"key":"request_no","kind":"text"}]},{"domain":"finance","name":"payment_refund","path":"/payment_refund","pageKind":"list","mode":"readonly","searchFields":[{"key":"refund_no","kind":"text"},{"key":"request_no","kind":"text"}]},{"domain":"finance","name":"wallet_recharge","path":"/wallet_recharge","pageKind":"list","mode":"append_only","searchFields":[{"key":"record_no","kind":"text"},{"key":"request_no","kind":"text"}]},{"domain":"finance","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"append_only","searchFields":[{"key":"cash_no","kind":"text"},{"key":"request_no","kind":"text"},{"key":"channel","kind":"enum","values":[{"value":"bank","label":"银行卡"},{"value":"alipay","label":"支付宝"},{"value":"wechat","label":"微信"}]}]},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"readonly","searchFields":[{"key":"settlement_no","kind":"text"}]}],"routes":[{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/adjust-amount"},{"method":"POST","path":"/gasorder_basic/:identity/reclaim"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/wallet_basic/recharge"},{"method":"POST","path":"/wallet_recharge"},{"method":"POST","path":"/wallet_apply_cash"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/auth/login"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_recharge"},{"method":"GET","path":"/wallet_recharge/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/payment_order"},{"method":"GET","path":"/payment_order/:identity"},{"method":"GET","path":"/payment_refund"},{"method":"GET","path":"/payment_refund/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_account/:identity/avatar"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_account/:identity/avatar"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/dashboard/reports"},{"method":"GET","path":"/delivery_menu"},{"method":"GET","path":"/delivery_profile"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"GET","path":"/invitation/qrcode"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_account/:identity/password"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_account/:identity/password"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"}]} +{"resources":[{"domain":"profile","name":"delivery_profile","path":"/delivery_profile","pageKind":"list","mode":"readonly"},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"}]},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable","searchFields":[{"key":"credential_type","kind":"text"},{"key":"credential_no","kind":"text"}]},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable","searchFields":[{"key":"username","kind":"text"}]},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"contract","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed","searchFields":[{"key":"contract_no","kind":"text"},{"key":"title","kind":"text"}]},{"domain":"contract","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only","searchFields":[{"key":"product_code","kind":"text"},{"key":"product_type_name","kind":"text"}]},{"domain":"contract","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly","searchFields":[{"key":"action","kind":"enum","values":[{"value":"activate","label":"启用"},{"value":"renew","label":"续签"},{"value":"terminate","label":"终止"}]}]},{"domain":"contract","name":"product_info","path":"/product_info","pageKind":"list","mode":"readonly","searchFields":[{"key":"code","kind":"text"},{"key":"name","kind":"text"}]},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only","searchFields":[{"key":"request_no","kind":"text"},{"key":"creator_type","kind":"enum","values":[{"value":"user","label":"用户"},{"value":"staff","label":"工作人员"},{"value":"delivery","label":"配送点"},{"value":"gas","label":"气站"}]}]},{"domain":"finance","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"payment_order","path":"/payment_order","pageKind":"list","mode":"readonly","searchFields":[{"key":"payment_no","kind":"text"},{"key":"request_no","kind":"text"},{"key":"channel","kind":"enum","values":[{"value":"wechat","label":"微信"},{"value":"alipay","label":"支付宝"},{"value":"mock","label":"模拟支付"}]}]},{"domain":"finance","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly","searchFields":[{"key":"record_no","kind":"text"},{"key":"request_no","kind":"text"}]},{"domain":"finance","name":"payment_refund","path":"/payment_refund","pageKind":"list","mode":"readonly","searchFields":[{"key":"refund_no","kind":"text"},{"key":"request_no","kind":"text"}]},{"domain":"finance","name":"wallet_recharge","path":"/wallet_recharge","pageKind":"list","mode":"append_only","searchFields":[{"key":"record_no","kind":"text"},{"key":"request_no","kind":"text"}]},{"domain":"finance","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"append_only","searchFields":[{"key":"cash_no","kind":"text"},{"key":"request_no","kind":"text"},{"key":"channel","kind":"enum","values":[{"value":"bank","label":"银行卡"},{"value":"alipay","label":"支付宝"},{"value":"wechat","label":"微信"}]}]},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"readonly","searchFields":[{"key":"settlement_no","kind":"text"}]}],"routes":[{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/attachment/upload"},{"method":"POST","path":"/gasorder_contract/attachment/cleanup"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/adjust-amount"},{"method":"POST","path":"/gasorder_basic/:identity/reclaim"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/wallet_basic/recharge"},{"method":"POST","path":"/wallet_recharge"},{"method":"POST","path":"/wallet_apply_cash"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/auth/login"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_recharge"},{"method":"GET","path":"/wallet_recharge/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_contract/:identity/attachment"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/payment_order"},{"method":"GET","path":"/payment_order/:identity"},{"method":"GET","path":"/payment_refund"},{"method":"GET","path":"/payment_refund/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_account/:identity/avatar"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_account/:identity/avatar"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/dashboard/reports"},{"method":"GET","path":"/delivery_menu"},{"method":"GET","path":"/delivery_profile"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"GET","path":"/invitation/qrcode"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_account/:identity/password"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_account/:identity/password"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"}]} diff --git a/frontend/delivery_admin/src/views/resource/ContractAttachmentField.vue b/frontend/delivery_admin/src/views/resource/ContractAttachmentField.vue new file mode 100644 index 0000000..7515182 --- /dev/null +++ b/frontend/delivery_admin/src/views/resource/ContractAttachmentField.vue @@ -0,0 +1,131 @@ + + + + + + diff --git a/frontend/delivery_admin/src/views/resource/ResourceFieldForm.vue b/frontend/delivery_admin/src/views/resource/ResourceFieldForm.vue index 99bc96a..7bded4a 100644 --- a/frontend/delivery_admin/src/views/resource/ResourceFieldForm.vue +++ b/frontend/delivery_admin/src/views/resource/ResourceFieldForm.vue @@ -34,6 +34,15 @@ :auto-size="{ minRows: 3, maxRows: 8 }" :disabled="field.readonly" /> + 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; mode: 'create' | 'edit'; relationOptions: Record; relationLoading: Record; -}>(); + 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); } diff --git a/frontend/delivery_admin/src/views/resource/ResourceRecordPage.vue b/frontend/delivery_admin/src/views/resource/ResourceRecordPage.vue index ab211a7..0ce402d 100644 --- a/frontend/delivery_admin/src/views/resource/ResourceRecordPage.vue +++ b/frontend/delivery_admin/src/views/resource/ResourceRecordPage.vue @@ -53,6 +53,31 @@ />