增加气站合同附件受控上传与预览
This commit is contained in:
@@ -73,6 +73,33 @@ func GetGasorderContract(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadGasorderContractAttachment 为当前气站账号提供受控 PDF 临时上传。
|
||||||
|
func UploadGasorderContractAttachment(ctx *gin.Context) {
|
||||||
|
if _, ok := currentGas(ctx); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
platformgasorder.UploadGasorderContractAttachment(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupGasorderContractAttachment 清理当前气站账号尚未绑定的临时附件。
|
||||||
|
func CleanupGasorderContractAttachment(ctx *gin.Context) {
|
||||||
|
if _, ok := currentGas(ctx); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
platformgasorder.CleanupGasorderContractAttachment(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeGasorderContractAttachment 仅预览当前气站合同范围内的正式附件。
|
||||||
|
func ServeGasorderContractAttachment(ctx *gin.Context) {
|
||||||
|
station, ok := currentGas(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := scopedContract(ctx, ctx.Param("identity"), station.ID); ok {
|
||||||
|
platformgasorder.ServeGasorderContractAttachment(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func CreateGasorderContract(ctx *gin.Context) {
|
func CreateGasorderContract(ctx *gin.Context) {
|
||||||
station, ok := currentGas(ctx)
|
station, ok := currentGas(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -65,8 +65,11 @@ func registerGasBusinessRoutes(group *gin.RouterGroup) {
|
|||||||
contract := group.Group("/gasorder_contract")
|
contract := group.Group("/gasorder_contract")
|
||||||
contract.GET("", gaslogic.ListGasorderContract)
|
contract.GET("", gaslogic.ListGasorderContract)
|
||||||
contract.POST("", gaslogic.CreateGasorderContract)
|
contract.POST("", gaslogic.CreateGasorderContract)
|
||||||
|
contract.POST("/attachment/upload", gaslogic.UploadGasorderContractAttachment)
|
||||||
|
contract.POST("/attachment/cleanup", gaslogic.CleanupGasorderContractAttachment)
|
||||||
contract.GET("/:identity", gaslogic.GetGasorderContract)
|
contract.GET("/:identity", gaslogic.GetGasorderContract)
|
||||||
contract.PUT("/:identity", gaslogic.UpdateGasorderContract)
|
contract.PUT("/:identity", gaslogic.UpdateGasorderContract)
|
||||||
|
contract.GET("/:identity/attachment", gaslogic.ServeGasorderContractAttachment)
|
||||||
contract.POST("/:identity/activate", gaslogic.ActivateGasorderContract)
|
contract.POST("/:identity/activate", gaslogic.ActivateGasorderContract)
|
||||||
contract.POST("/:identity/renew", gaslogic.RenewGasorderContract)
|
contract.POST("/:identity/renew", gaslogic.RenewGasorderContract)
|
||||||
contract.POST("/:identity/terminate", gaslogic.TerminateGasorderContract)
|
contract.POST("/:identity/terminate", gaslogic.TerminateGasorderContract)
|
||||||
|
|||||||
@@ -100,3 +100,23 @@ func TestGasAvatarRoutes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGasContractAttachmentRoutes 验证合同附件通过气站鉴权域提供专用接口。
|
||||||
|
func TestGasContractAttachmentRoutes(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
engine := gin.New()
|
||||||
|
RegisterGas("heqi", engine)
|
||||||
|
routes := map[string]bool{}
|
||||||
|
for _, route := range engine.Routes() {
|
||||||
|
routes[route.Method+" "+route.Path] = true
|
||||||
|
}
|
||||||
|
for _, path := range []string{
|
||||||
|
"POST /heqi/gas/v1/gasorder_contract/attachment/upload",
|
||||||
|
"POST /heqi/gas/v1/gasorder_contract/attachment/cleanup",
|
||||||
|
"GET /heqi/gas/v1/gasorder_contract/:identity/attachment",
|
||||||
|
} {
|
||||||
|
if !routes[path] {
|
||||||
|
t.Fatalf("missing gas contract attachment route %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
- 启用仅适用于草稿合同;已终止合同不得直接启用,只能通过续签填写新的生效时间、到期时间和原因后恢复履约。
|
- 启用仅适用于草稿合同;已终止合同不得直接启用,只能通过续签填写新的生效时间、到期时间和原因后恢复履约。
|
||||||
- 配送合同创建、草稿编辑和续签必须填写到期时间,且到期时间必须晚于生效时间;历史缺失值应提示补录,不得推测回填。
|
- 配送合同创建、草稿编辑和续签必须填写到期时间,且到期时间必须晚于生效时间;历史缺失值应提示补录,不得推测回填。
|
||||||
- 合同只能选择当前气站用户及当前气站配送点。
|
- 合同只能选择当前气站用户及当前气站配送点。
|
||||||
|
- 合同附件仅支持单个不超过 10 MiB 的 PDF;创建和草稿编辑时通过受控接口上传、替换或移除,详情页通过气站鉴权和合同归属校验后预览,不向前端暴露内部存储地址。
|
||||||
- 合同气瓶支持绑定和受控解绑。
|
- 合同气瓶支持绑定和受控解绑。
|
||||||
- 合同修订记录只读,不允许修改或删除。
|
- 合同修订记录只读,不允许修改或删除。
|
||||||
- 合同修订记录不展示公共软删除时间等无业务意义的基础设施字段。
|
- 合同修订记录不展示公共软删除时间等无业务意义的基础设施字段。
|
||||||
@@ -199,3 +200,4 @@
|
|||||||
9. 邀请二维码由配置 URL 和当前气站 identity 实时生成,不产生持久化记录。
|
9. 邀请二维码由配置 URL 和当前气站 identity 实时生成,不产生持久化记录。
|
||||||
10. 后端测试、路由权限测试、前端类型检查、契约检查和生产构建通过。
|
10. 后端测试、路由权限测试、前端类型检查、契约检查和生产构建通过。
|
||||||
11. 20 类标准资源均可从列表进入独立详情页并刷新;11 类可新建资源和 8 类可编辑资源具备对应独立页面。
|
11. 20 类标准资源均可从列表进入独立详情页并刷新;11 类可新建资源和 8 类可编辑资源具备对应独立页面。
|
||||||
|
12. 配送合同新建和草稿编辑页显示 PDF 选择控件而非附件地址文本框;附件预览、替换和移除均受当前气站数据范围约束。
|
||||||
|
|||||||
51
docs/操作日志_气站合同附件上传_20260818.md
Normal file
51
docs/操作日志_气站合同附件上传_20260818.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# 操作日志:气站合同附件上传
|
||||||
|
|
||||||
|
操作时间:2026-08-18 21:43:42
|
||||||
|
操作类型:扩展
|
||||||
|
影响模块:气站管理端 5175、气站 API 合同附件路由、气站需求文档
|
||||||
|
|
||||||
|
## 操作前状态
|
||||||
|
|
||||||
|
- 5175 配送合同表单把 `file_uri` 渲染为“附件地址”文本输入框。
|
||||||
|
- 气站前端已存在从 5173 复制的附件控件和状态逻辑,但标准记录页未接线,附件客户端默认地址仍误指向平台主管 `/heqi/platform/v1`。
|
||||||
|
- 后端只有平台主管附件专用路由;气站合同创建和更新虽复用平台业务逻辑,但没有气站鉴权域的上传、清理与预览入口。
|
||||||
|
|
||||||
|
## 具体操作
|
||||||
|
|
||||||
|
1. 将气站合同字段声明为 `contract-file`,在标准记录页接入选择、拖拽、预览、替换、移除、失败清理和未保存提示。
|
||||||
|
2. 将气站附件客户端默认 API 前缀修正为 `/heqi/gas/v1`。
|
||||||
|
3. 在气站 API 新增上传、临时清理和预览路由;上传与清理验证当前气站账号,预览额外校验合同归属。
|
||||||
|
4. 新增气站附件路由回归测试,并同步气站需求和项目维护文档。
|
||||||
|
|
||||||
|
## 操作后状态
|
||||||
|
|
||||||
|
- 5175 合同新建和草稿编辑页显示单 PDF 上传控件,不再允许手填内部附件地址。
|
||||||
|
- 合同详情页可通过鉴权 Blob 预览附件;旧地址不可用时提示重新上传。
|
||||||
|
- 气站 JWT 只访问气站域附件接口,跨气站合同在下载前被归属校验拦截。
|
||||||
|
- 5173 平台总后台、合同数据库结构和公共合同接口保持兼容。
|
||||||
|
|
||||||
|
## 代码变更
|
||||||
|
|
||||||
|
- `backend/api/internal/logic/gas/gasorder.go`:新增 `UploadGasorderContractAttachment`、`CleanupGasorderContractAttachment`、`ServeGasorderContractAttachment`。
|
||||||
|
- `backend/api/internal/routers/gas_business.go`:新增 3 条合同附件路由。
|
||||||
|
- `backend/api/internal/routers/gas_test.go`:新增 `TestGasContractAttachmentRoutes`。
|
||||||
|
- `frontend/gas_admin/src/api/contract-attachment.ts`:改用气站 API 前缀。
|
||||||
|
- `frontend/gas_admin/src/api/resources.ts`:合同附件改为专用文件字段。
|
||||||
|
- `frontend/gas_admin/src/views/resource/ResourceRecordPage.vue`:接入完整附件页面流程。
|
||||||
|
|
||||||
|
## 验证结果
|
||||||
|
|
||||||
|
- `go test ./...`:通过。
|
||||||
|
- `go vet ./...`:通过。
|
||||||
|
- `go build ./cmd/main/main.go`:通过。
|
||||||
|
- `pnpm type:check`:通过。
|
||||||
|
- `pnpm contract:check`:通过,20 个资源。
|
||||||
|
- `pnpm build`:通过。
|
||||||
|
- `pnpm lint`:命令成功,报告 190 条仓库既有警告;本次改动未产生 lint 错误。
|
||||||
|
- 浏览器只读检查:5175 服务和登录页正常、控制台无错误;因自动化会话没有气站登录态,未提交账号或读取受保护合同。
|
||||||
|
|
||||||
|
## 风险评估
|
||||||
|
|
||||||
|
- 风险:上传和绑定仍复用平台主管包中的既有实现,操作人名称在气站账号场景可能为空,但操作人 identity、合同 identity、动作和结果仍会记录。
|
||||||
|
- 缓解:气站路由受独立 JWT 中间件保护,入口验证有效气站账号,下载执行合同归属校验;签名收据继续绑定操作人 identity 并限制有效期。
|
||||||
|
- 后续人工边界验收:合法 PDF、非 PDF、空文件、超过 10 MiB、重复保存、编辑替换、移除、跨气站合同和旧地址不可用。
|
||||||
71
docs/项目文档_气站合同附件上传_v1.0.md
Normal file
71
docs/项目文档_气站合同附件上传_v1.0.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# 项目文档:气站合同附件上传 v1.0
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
- 项目:物联网智能瓶阀平台气站管理端。
|
||||||
|
- 本次功能:在气站管理端 5175 的配送合同新建、草稿编辑和详情页接入与平台总后台 5173 一致的受控 PDF 附件交互。
|
||||||
|
- 技术栈:Vue 3、TypeScript、Arco Design、Gin、GORM。
|
||||||
|
- 运行环境:前端 `http://localhost:5175`,后端 `/heqi/gas/v1`。
|
||||||
|
|
||||||
|
附件继续复用 `gasorder_contract.file_uri` 保存服务端受控地址。浏览器只接收附件元数据和签名收据,不读取或提交内部存储路径。
|
||||||
|
|
||||||
|
## 2. 目录结构说明
|
||||||
|
|
||||||
|
```text
|
||||||
|
platforms/
|
||||||
|
├── frontend/gas_admin/src/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── contract-attachment.ts # 气站域附件上传、清理和预览客户端
|
||||||
|
│ │ └── resources.ts # 将合同附件声明为专用文件字段
|
||||||
|
│ └── views/resource/
|
||||||
|
│ ├── ContractAttachmentField.vue # PDF 选择、拖拽、预览和移除控件
|
||||||
|
│ ├── use-contract-attachment.ts # 上传收据、并发版本和失败清理状态
|
||||||
|
│ └── ResourceRecordPage.vue # 标准记录页附件流程编排
|
||||||
|
├── backend/api/internal/
|
||||||
|
│ ├── logic/gas/gasorder.go # 气站范围包装与合同归属校验
|
||||||
|
│ └── routers/
|
||||||
|
│ ├── gas_business.go # 气站合同附件路由
|
||||||
|
│ └── gas_test.go # 路由回归测试
|
||||||
|
└── docs/
|
||||||
|
└── 06-气站管理系统需求.md # 气站附件需求和验收口径
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 核心文件说明
|
||||||
|
|
||||||
|
### `frontend/gas_admin/src/views/resource/ResourceRecordPage.vue`
|
||||||
|
|
||||||
|
- 新建和编辑页向字段组件传入合同附件状态。
|
||||||
|
- 保存前上传本地 PDF,并把签名收据加入合同载荷。
|
||||||
|
- 保存失败时清理临时文件;详情页显示附件可用状态和鉴权预览入口。
|
||||||
|
- 未保存附件变化纳入离开页面确认。
|
||||||
|
|
||||||
|
### `backend/api/internal/logic/gas/gasorder.go`
|
||||||
|
|
||||||
|
- 上传和清理入口先验证当前登录账号仍关联有效气站。
|
||||||
|
- 下载入口额外校验合同属于当前气站,再复用现有受控 PDF 输出逻辑。
|
||||||
|
- 合同创建和更新继续复用既有绑定、并发版本和旧文件清理事务。
|
||||||
|
|
||||||
|
### `frontend/gas_admin/src/api/contract-attachment.ts`
|
||||||
|
|
||||||
|
- API 默认前缀改为 `/heqi/gas/v1`,不再误用平台主管接口。
|
||||||
|
- 上传仅接受 PDF,预览返回 Blob,不暴露 `file_uri`。
|
||||||
|
|
||||||
|
## 4. 变更记录
|
||||||
|
|
||||||
|
- 将气站合同 `file_uri` 字段由普通文本输入改为“合同附件”专用控件。
|
||||||
|
- 新增气站域上传、临时清理和鉴权预览路由。
|
||||||
|
- 补齐新建、草稿编辑、详情预览、替换、移除和失败回滚流程。
|
||||||
|
- 新增气站附件路由回归测试并同步需求验收项。
|
||||||
|
- 未新增数据库表、字段或依赖,平台总后台 5173 行为保持不变。
|
||||||
|
|
||||||
|
## 5. 维护指南
|
||||||
|
|
||||||
|
- 调整大小或文件类型时,前端预检与后端真实内容校验必须同步,后端校验始终是安全边界。
|
||||||
|
- 新增其他业务端附件能力时,应先做该业务端的数据范围校验,再复用受控存储能力,不得直接开放平台路由。
|
||||||
|
- 修改气站路由后运行后端路由测试;修改表单后运行类型检查、契约检查和生产构建。
|
||||||
|
- 人工验收至少覆盖:合法 PDF、新建保存、编辑替换、移除、无效类型、超限文件、跨气站下载和旧地址不可用提示。
|
||||||
|
|
||||||
|
## 6. 已知限制
|
||||||
|
|
||||||
|
- 当前仅支持单个 PDF,最大 10 MiB。
|
||||||
|
- 自动化浏览器没有气站登录态,本次未对真实受保护合同执行上传或读取;完整交互仍需使用测试气站账号验收。
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
import { getToken } from '@/utils/auth';
|
import { getToken } from '@/utils/auth';
|
||||||
|
|
||||||
const platformApiBaseURL =
|
const gasApiBaseURL =
|
||||||
import.meta.env.VITE_API_BASE_URL ||
|
import.meta.env.VITE_API_BASE_URL ||
|
||||||
'http://localhost:12426/heqi/platform/v1';
|
'http://localhost:12426/heqi/gas/v1';
|
||||||
|
|
||||||
export type ContractAttachmentMetadata = {
|
export type ContractAttachmentMetadata = {
|
||||||
has_file: boolean;
|
has_file: boolean;
|
||||||
@@ -36,7 +36,7 @@ async function upload(file: File): Promise<ContractAttachmentUploadReply> {
|
|||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${platformApiBaseURL}/gasorder_contract/attachment/upload`,
|
`${gasApiBaseURL}/gasorder_contract/attachment/upload`,
|
||||||
{ method: 'POST', headers: authorizationHeaders(), body: form },
|
{ method: 'POST', headers: authorizationHeaders(), body: form },
|
||||||
);
|
);
|
||||||
const payload = (await response.json()) as ApiEnvelope<ContractAttachmentUploadReply>;
|
const payload = (await response.json()) as ApiEnvelope<ContractAttachmentUploadReply>;
|
||||||
@@ -49,7 +49,7 @@ async function upload(file: File): Promise<ContractAttachmentUploadReply> {
|
|||||||
/** 使用受签名保护的清理凭证删除尚未绑定的临时文件。 */
|
/** 使用受签名保护的清理凭证删除尚未绑定的临时文件。 */
|
||||||
async function cleanup(cleanupToken: string): Promise<void> {
|
async function cleanup(cleanupToken: string): Promise<void> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${platformApiBaseURL}/gasorder_contract/attachment/cleanup`,
|
`${gasApiBaseURL}/gasorder_contract/attachment/cleanup`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...authorizationHeaders() },
|
headers: { 'Content-Type': 'application/json', ...authorizationHeaders() },
|
||||||
@@ -64,7 +64,7 @@ async function cleanup(cleanupToken: string): Promise<void> {
|
|||||||
/** 获取鉴权 PDF Blob;内部存储 URI 始终不会暴露给浏览器。 */
|
/** 获取鉴权 PDF Blob;内部存储 URI 始终不会暴露给浏览器。 */
|
||||||
async function load(identity: string): Promise<Blob> {
|
async function load(identity: string): Promise<Blob> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${platformApiBaseURL}/gasorder_contract/${encodeURIComponent(identity)}/attachment`,
|
`${gasApiBaseURL}/gasorder_contract/${encodeURIComponent(identity)}/attachment`,
|
||||||
{ headers: authorizationHeaders() },
|
{ headers: authorizationHeaders() },
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error('合同附件不可用,请重新上传');
|
if (!response.ok) throw new Error('合同附件不可用,请重新上传');
|
||||||
|
|||||||
@@ -517,7 +517,7 @@ const gasOverrides: ResourceUiDefinition[] = [
|
|||||||
{ name: '重置密码', resource: '/user_account/:identity/password', method: 'PUT', fields: [f('password', { required: true })] },
|
{ 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('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 }), relation('delivery_basic_identity', '/delivery_basic', false, { label: '所属配送点', listLabel: '所属配送点', listRelationNameOnly: true, emptyText: '暂未指定', placeholder: '请选择默认配送点,可留空', showIdentityCopy: 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 }), relation('delivery_basic_identity', '/delivery_basic', false, { label: '所属配送点', listLabel: '所属配送点', listRelationNameOnly: true, emptyText: '暂未指定', placeholder: '请选择默认配送点,可留空', showIdentityCopy: 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/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/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] } },
|
{ name: '终止合同', resource: '/gasorder_contract/:identity/terminate', danger: true, fields: reason, visibleFor: { field: 'contract_status', values: [11] } },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!--
|
<!--
|
||||||
功能:承载气站管理端全部标准资源的新建、详情和编辑独立页面。
|
功能:承载气站管理端全部标准资源的新建、详情和编辑独立页面。
|
||||||
版本:v1.4.0
|
版本:v1.5.0
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<div class="record-page">
|
<div class="record-page">
|
||||||
@@ -52,6 +52,28 @@
|
|||||||
:field-options="fieldOptions"
|
:field-options="fieldOptions"
|
||||||
:account-summary="accountSummary"
|
:account-summary="accountSummary"
|
||||||
/>
|
/>
|
||||||
|
<a-card
|
||||||
|
v-if="definition.name === 'gasorder_contract'"
|
||||||
|
title="合同附件"
|
||||||
|
:bordered="false"
|
||||||
|
class="section-card"
|
||||||
|
>
|
||||||
|
<a-space>
|
||||||
|
<span v-if="contractAttachment.available">合同附件.pdf</span>
|
||||||
|
<a-tag v-else-if="contractAttachment.requiresReupload" color="orange">
|
||||||
|
旧附件地址不可用,请重新上传
|
||||||
|
</a-tag>
|
||||||
|
<span v-else>未上传</span>
|
||||||
|
<a-button
|
||||||
|
v-if="contractAttachment.available"
|
||||||
|
type="primary"
|
||||||
|
:loading="attachmentPreviewing"
|
||||||
|
@click="previewContractAttachment"
|
||||||
|
>
|
||||||
|
预览
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</a-card>
|
||||||
<ResourceWalletSummary
|
<ResourceWalletSummary
|
||||||
v-if="definition.walletOwnerType"
|
v-if="definition.walletOwnerType"
|
||||||
:wallet="wallet"
|
:wallet="wallet"
|
||||||
@@ -127,8 +149,13 @@
|
|||||||
:relation-options="relations.options"
|
:relation-options="relations.options"
|
||||||
:relation-loading="relations.loading"
|
:relation-loading="relations.loading"
|
||||||
:role-options="roleOptions"
|
:role-options="roleOptions"
|
||||||
|
:contract-attachment="contractAttachmentState"
|
||||||
@change-relation="changeRelation"
|
@change-relation="changeRelation"
|
||||||
@search-relation="relationLinkage.search"
|
@search-relation="relationLinkage.search"
|
||||||
|
@select-attachment="contractAttachment.select"
|
||||||
|
@remove-attachment="confirmRemoveContractAttachment"
|
||||||
|
@clear-attachment-selection="contractAttachment.clearSelection"
|
||||||
|
@preview-attachment="previewContractAttachment"
|
||||||
/>
|
/>
|
||||||
</a-form>
|
</a-form>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
@@ -150,7 +177,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Message } from '@arco-design/web-vue';
|
import { Message, Modal } from '@arco-design/web-vue';
|
||||||
import { IconEdit, IconLeft } from '@arco-design/web-vue/es/icon';
|
import { IconEdit, IconLeft } from '@arco-design/web-vue/es/icon';
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
@@ -199,6 +226,7 @@ import ResourceWalletSummary from './ResourceWalletSummary.vue';
|
|||||||
import { createResourceRecordNavigation } from './resource-record-navigation';
|
import { createResourceRecordNavigation } from './resource-record-navigation';
|
||||||
import { loadResourceRecordRelations } from './load-resource-record-relations';
|
import { loadResourceRecordRelations } from './load-resource-record-relations';
|
||||||
import { useResourceAvatar } from './use-resource-avatar';
|
import { useResourceAvatar } from './use-resource-avatar';
|
||||||
|
import { useContractAttachment } from './use-contract-attachment';
|
||||||
import { useResourceRelationLinkage } from './use-resource-relation-linkage';
|
import { useResourceRelationLinkage } from './use-resource-relation-linkage';
|
||||||
import { useStaffCredentialOwnerGuard } from './use-staff-credential-owner-guard';
|
import { useStaffCredentialOwnerGuard } from './use-staff-credential-owner-guard';
|
||||||
import { useUnsavedRecord } from './use-unsaved-record';
|
import { useUnsavedRecord } from './use-unsaved-record';
|
||||||
@@ -221,6 +249,7 @@ const form = reactive<Record<string, any>>({});
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const statusSaving = ref(false);
|
const statusSaving = ref(false);
|
||||||
|
const attachmentPreviewing = ref(false);
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const errorStatus = ref<'403' | '404' | 'error'>('error');
|
const errorStatus = ref<'403' | '404' | 'error'>('error');
|
||||||
const wallet = ref<ResourceRow>();
|
const wallet = ref<ResourceRow>();
|
||||||
@@ -257,10 +286,18 @@ const productOwnershipKeys = [
|
|||||||
const actionVisible = ref(false);
|
const actionVisible = ref(false);
|
||||||
const activeAction = ref<DetailAction>();
|
const activeAction = ref<DetailAction>();
|
||||||
const avatar = useResourceAvatar();
|
const avatar = useResourceAvatar();
|
||||||
|
const contractAttachment = useContractAttachment();
|
||||||
const avatarUrl = avatar.url;
|
const avatarUrl = avatar.url;
|
||||||
const avatarCanClear = avatar.canClear;
|
const avatarCanClear = avatar.canClear;
|
||||||
const selectAvatar = avatar.select;
|
const selectAvatar = avatar.select;
|
||||||
const clearAvatar = avatar.clear;
|
const clearAvatar = avatar.clear;
|
||||||
|
const contractAttachmentState = computed(() => ({
|
||||||
|
selectedFile: contractAttachment.selectedFile.value,
|
||||||
|
hasExisting: contractAttachment.hasExisting.value,
|
||||||
|
available: contractAttachment.available.value,
|
||||||
|
requiresReupload: contractAttachment.requiresReupload.value,
|
||||||
|
removed: contractAttachment.removed.value,
|
||||||
|
}));
|
||||||
|
|
||||||
/** 新建智能气阀选择归属时清空其他互斥归属,再执行字段原有联动。 */
|
/** 新建智能气阀选择归属时清空其他互斥归属,再执行字段原有联动。 */
|
||||||
async function changeRelation(field: ResourceField, value: unknown) {
|
async function changeRelation(field: ResourceField, value: unknown) {
|
||||||
@@ -377,7 +414,11 @@ const modeLabel = computed(() =>
|
|||||||
const errorTitle = computed(() => recordPageErrorTitle(errorStatus.value));
|
const errorTitle = computed(() => recordPageErrorTitle(errorStatus.value));
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
return JSON.stringify({ form, avatar: avatar.marker() });
|
return JSON.stringify({
|
||||||
|
form,
|
||||||
|
avatar: avatar.marker(),
|
||||||
|
contractAttachment: contractAttachment.marker(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const unsaved = useUnsavedRecord(snapshot, () => mode.value !== 'detail');
|
const unsaved = useUnsavedRecord(snapshot, () => mode.value !== 'detail');
|
||||||
const { goEdit, viewWallet, goBack, requestBack } =
|
const { goEdit, viewWallet, goBack, requestBack } =
|
||||||
@@ -414,6 +455,11 @@ async function initialize() {
|
|||||||
definition.value.resource,
|
definition.value.resource,
|
||||||
identity.value,
|
identity.value,
|
||||||
);
|
);
|
||||||
|
contractAttachment.load(
|
||||||
|
definition.value.name === 'gasorder_contract'
|
||||||
|
? (detail.value.attachment as any)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
if (blockReason.value) {
|
if (blockReason.value) {
|
||||||
errorStatus.value = '403';
|
errorStatus.value = '403';
|
||||||
errorMessage.value = blockReason.value;
|
errorMessage.value = blockReason.value;
|
||||||
@@ -506,6 +552,9 @@ async function save() {
|
|||||||
mode.value as 'create' | 'edit',
|
mode.value as 'create' | 'edit',
|
||||||
);
|
);
|
||||||
await avatar.applyToPayload(payload);
|
await avatar.applyToPayload(payload);
|
||||||
|
if (definition.value.name === 'gasorder_contract') {
|
||||||
|
await contractAttachment.applyToPayload(payload);
|
||||||
|
}
|
||||||
if (mode.value === 'create') {
|
if (mode.value === 'create') {
|
||||||
const created = await resourceApi.create<ResourceRow>(
|
const created = await resourceApi.create<ResourceRow>(
|
||||||
definition.value.resource,
|
definition.value.resource,
|
||||||
@@ -549,6 +598,9 @@ async function save() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (definition.value.name === 'gasorder_contract') {
|
||||||
|
await contractAttachment.rollbackUpload();
|
||||||
|
}
|
||||||
Message.error((error as Error).message);
|
Message.error((error as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
@@ -560,9 +612,36 @@ async function reloadDetail() {
|
|||||||
definition.value.resource,
|
definition.value.resource,
|
||||||
identity.value,
|
identity.value,
|
||||||
);
|
);
|
||||||
|
if (definition.value.name === 'gasorder_contract') {
|
||||||
|
contractAttachment.load(detail.value.attachment as any);
|
||||||
|
}
|
||||||
await Promise.allSettled([loadWallet(), loadAvatar()]);
|
await Promise.allSettled([loadWallet(), loadAvatar()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 二次确认移除草稿合同附件,真正删除发生在保存成功之后。 */
|
||||||
|
function confirmRemoveContractAttachment() {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认移除合同附件?',
|
||||||
|
content: '移除操作将在保存合同后生效。',
|
||||||
|
okText: '确认移除',
|
||||||
|
hideCancel: false,
|
||||||
|
onOk: () => contractAttachment.remove(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取鉴权 PDF 并在新标签页预览。 */
|
||||||
|
async function previewContractAttachment() {
|
||||||
|
if (!recordIdentity.value) return;
|
||||||
|
attachmentPreviewing.value = true;
|
||||||
|
try {
|
||||||
|
await contractAttachment.preview(recordIdentity.value);
|
||||||
|
} catch (error) {
|
||||||
|
Message.error((error as Error).message);
|
||||||
|
} finally {
|
||||||
|
attachmentPreviewing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function updateGasStatus(enabled: string | number | boolean) {
|
async function updateGasStatus(enabled: string | number | boolean) {
|
||||||
statusSaving.value = true;
|
statusSaving.value = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user