完善配送订单创建联动
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// 功能描述:实现配送点范围内的合同、合同气瓶和配送订单接口。
|
||||
// 版本:v1.1.0。
|
||||
// 版本:v1.2.0。
|
||||
package delivery
|
||||
|
||||
import (
|
||||
@@ -49,23 +49,33 @@ func scopedContract(ctx *gin.Context, identity string, pointID uint64) (models.G
|
||||
return contract, true
|
||||
}
|
||||
|
||||
// deliveryContractListQuery 构造当前配送点合同列表;订单候选模式额外限定可履约状态。
|
||||
func deliveryContractListQuery(database *gorm.DB, gasID, pointID uint64, candidate string, now time.Time) *gorm.DB {
|
||||
query := platformgasorder.ContractPartyDisplayQuery(database).
|
||||
Where("gasorder_contract.gas_basic_id = ? AND gasorder_contract.delivery_basic_id = ?", gasID, pointID)
|
||||
if candidate == "order" {
|
||||
return platformgasorder.FilterGasorderContractCandidates(query, "order", now)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func ListContract(ctx *gin.Context) {
|
||||
point, _, ok := currentScope(ctx)
|
||||
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{})
|
||||
query := deliveryContractListQuery(
|
||||
db(), point.GasBasicID, point.ID, strings.TrimSpace(ctx.Query("candidate")), time.Now(),
|
||||
)
|
||||
query = common.ApplyKeywordFilter(ctx, query, &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 {
|
||||
var list []platformgasorder.ContractPartyDisplay
|
||||
if err := query.Order("gasorder_contract.created_at desc").Offset((page - 1) * size).Limit(size).Scan(&list).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
@@ -74,7 +84,11 @@ func ListContract(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
protected, err := protectDeliveryContractListResponse(ctx, response, list)
|
||||
contracts := make([]models.GasorderContract, 0, len(list))
|
||||
for _, item := range list {
|
||||
contracts = append(contracts, item.GasorderContract)
|
||||
}
|
||||
protected, err := protectDeliveryContractListResponse(ctx, response, contracts)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
@@ -186,15 +200,63 @@ func TerminateContract(ctx *gin.Context) {
|
||||
withContract(ctx, platformgasorder.TerminateGasorderContract)
|
||||
}
|
||||
|
||||
// deliveryContractProductQuery 构造当前配送点合同气瓶查询;指定合同时仅保留未解绑候选。
|
||||
func deliveryContractProductQuery(database *gorm.DB, pointID, contractID uint64, keyword string) *gorm.DB {
|
||||
query := common.ActiveRecords(database.Model(&models.GasorderContractProduct{})).
|
||||
Select("gasorder_contract_product.*, COALESCE(display_product.name, '') AS product_name").
|
||||
Joins("JOIN gasorder_contract ON gasorder_contract.id = gasorder_contract_product.gasorder_contract_id").
|
||||
Joins("LEFT JOIN product_info AS display_product ON display_product.id = gasorder_contract_product.product_info_id AND display_product.deleted_at IS NULL AND display_product.status <> ?", common.StatusArchived).
|
||||
Where("gasorder_contract.delivery_basic_id = ?", pointID)
|
||||
if contractID > 0 {
|
||||
query = query.Where("gasorder_contract_product.gasorder_contract_id = ? AND gasorder_contract_product.unbound_at IS NULL", contractID)
|
||||
}
|
||||
keyword = strings.ToLower(strings.TrimSpace(keyword))
|
||||
if keyword == "" {
|
||||
return query
|
||||
}
|
||||
pattern := "%" + keyword + "%"
|
||||
return query.Where("(LOWER(COALESCE(display_product.name, '')) LIKE ? OR LOWER(gasorder_contract_product.product_code) LIKE ? OR LOWER(gasorder_contract_product.product_type_name) LIKE ?)", pattern, pattern, pattern)
|
||||
}
|
||||
|
||||
func ListContractProduct(ctx *gin.Context) {
|
||||
point, _, ok := currentScope(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
query := common.ActiveRecords(db().Model(&models.GasorderContractProduct{})).
|
||||
Joins("JOIN gasorder_contract ON gasorder_contract.id = gasorder_contract_product.gasorder_contract_id").
|
||||
Where("gasorder_contract.delivery_basic_id = ?", point.ID)
|
||||
listScoped(ctx, &models.GasorderContractProduct{}, query, "gasorder_contract_product.created_at desc")
|
||||
contractIdentity := strings.TrimSpace(ctx.Query("contract_identity"))
|
||||
var contractID uint64
|
||||
if contractIdentity != "" {
|
||||
contract, valid := scopedContract(ctx, contractIdentity, point.ID)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
contractID = contract.ID
|
||||
}
|
||||
query := deliveryContractProductQuery(db(), point.ID, contractID, ctx.Query("keyword"))
|
||||
page, size := common.PageSize(ctx)
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
type contractProductDisplay struct {
|
||||
models.GasorderContractProduct
|
||||
ProductName string `gorm:"column:product_name" json:"product_name"`
|
||||
}
|
||||
var list []contractProductDisplay
|
||||
if err := query.Order("gasorder_contract_product.created_at desc").Offset((page - 1) * size).Limit(size).Scan(&list).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
response, err := common.PublicResourceResponse(list)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{
|
||||
"total": total,
|
||||
"list": common.ProtectPreciseLocation(ctx, &models.GasorderContractProduct{}, response),
|
||||
})
|
||||
}
|
||||
|
||||
func GetContractProduct(ctx *gin.Context) {
|
||||
|
||||
78
backend/api/internal/logic/delivery/order_candidate_test.go
Normal file
78
backend/api/internal/logic/delivery/order_candidate_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// 功能描述:验证配送点创建订单的合同、地址和合同气瓶候选查询范围。
|
||||
// 版本:v1.0.0。
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// candidateTestDatabase 创建只用于生成 SQL 的模拟数据库。
|
||||
func candidateTestDatabase(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
sqlDatabase, _, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("创建模拟数据库失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDatabase.Close() })
|
||||
database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("创建 GORM 数据库失败:%v", err)
|
||||
}
|
||||
return database
|
||||
}
|
||||
|
||||
// assertSQLFragments 验证候选查询包含全部范围约束。
|
||||
func assertSQLFragments(t *testing.T, statement string, fragments ...string) {
|
||||
t.Helper()
|
||||
for _, fragment := range fragments {
|
||||
if !strings.Contains(statement, fragment) {
|
||||
t.Fatalf("候选 SQL 缺少 %q:%s", fragment, statement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryOrderContractCandidates(t *testing.T) {
|
||||
database := candidateTestDatabase(t)
|
||||
statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB {
|
||||
return deliveryContractListQuery(tx, 7, 9, "order", time.Now()).Find(&[]map[string]any{})
|
||||
})
|
||||
assertSQLFragments(t, statement,
|
||||
"gasorder_contract.gas_basic_id = 7",
|
||||
"gasorder_contract.delivery_basic_id = 9",
|
||||
"candidate_relation.user_account_id = gasorder_contract.user_account_id",
|
||||
"contract_status = 11",
|
||||
)
|
||||
}
|
||||
|
||||
func TestDeliveryOrderAddressCandidates(t *testing.T) {
|
||||
database := candidateTestDatabase(t)
|
||||
statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB {
|
||||
return addressQueryForContract(
|
||||
addressQueryWithDatabase(tx, 9), 31,
|
||||
).Find(&[]map[string]any{})
|
||||
})
|
||||
assertSQLFragments(t, statement,
|
||||
"user_service_relation.delivery_basic_id = 9",
|
||||
"user_address.user_account_id = 31",
|
||||
)
|
||||
}
|
||||
|
||||
func TestDeliveryOrderContractProductCandidates(t *testing.T) {
|
||||
database := candidateTestDatabase(t)
|
||||
statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB {
|
||||
return deliveryContractProductQuery(tx, 9, 27, "阀门").Find(&[]map[string]any{})
|
||||
})
|
||||
assertSQLFragments(t, statement,
|
||||
"gasorder_contract.delivery_basic_id = 9",
|
||||
"gasorder_contract_product.gasorder_contract_id = 27",
|
||||
"gasorder_contract_product.unbound_at IS NULL",
|
||||
"display_product.name",
|
||||
"gasorder_contract_product.product_code",
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
// 功能描述:实现配送点范围内的用户、服务关系和收货地址管理。
|
||||
// 版本:v1.1.0。
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
@@ -231,16 +234,35 @@ func ArchiveUser(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func addressQuery(pointID uint64) *gorm.DB {
|
||||
return common.ActiveRecords(db().Model(&models.UserAddress{})).
|
||||
return addressQueryWithDatabase(db(), pointID)
|
||||
}
|
||||
|
||||
// addressQueryWithDatabase 构造配送点有效服务用户的地址范围,便于独立验证查询约束。
|
||||
func addressQueryWithDatabase(database *gorm.DB, pointID uint64) *gorm.DB {
|
||||
return common.ActiveRecords(database.Model(&models.UserAddress{})).
|
||||
Joins("JOIN user_service_relation ON user_service_relation.user_account_id = user_address.user_account_id AND user_service_relation.status <> ?",
|
||||
common.StatusArchived).Where("user_service_relation.delivery_basic_id = ?", pointID)
|
||||
}
|
||||
|
||||
// addressQueryForContract 将当前配送点地址进一步限定为合同签约用户。
|
||||
func addressQueryForContract(query *gorm.DB, userAccountID uint64) *gorm.DB {
|
||||
return query.Where("user_address.user_account_id = ?", userAccountID)
|
||||
}
|
||||
|
||||
func ListAddress(ctx *gin.Context) {
|
||||
point, _, ok := currentScope(ctx)
|
||||
if ok {
|
||||
listScoped(ctx, &models.UserAddress{}, addressQuery(point.ID), "user_address.created_at desc")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
query := addressQuery(point.ID)
|
||||
if contractIdentity := strings.TrimSpace(ctx.Query("gasorder_contract_identity")); contractIdentity != "" {
|
||||
contract, valid := scopedContract(ctx, contractIdentity, point.ID)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
query = addressQueryForContract(query, contract.UserAccountID)
|
||||
}
|
||||
listScoped(ctx, &models.UserAddress{}, query, "user_address.is_default desc, user_address.created_at desc")
|
||||
}
|
||||
|
||||
func GetAddress(ctx *gin.Context) {
|
||||
|
||||
48
docs/操作日志_配送点订单创建联动_20260823.md
Normal file
48
docs/操作日志_配送点订单创建联动_20260823.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 操作日志:配送点订单创建联动
|
||||
|
||||
操作时间:2026-08-23
|
||||
操作类型:扩展、修改
|
||||
影响模块:5176 创建订单页、配送合同候选、用户地址候选、合同气瓶候选
|
||||
|
||||
## 操作前状态
|
||||
|
||||
- 页面挂载时并行加载所有关系,地址和气瓶不依赖所选合同。
|
||||
- 配送后端忽略订单合同、合同用户地址和合同气瓶筛选参数。
|
||||
- 地址候选退化显示唯一标识;合同气瓶无法区分当前名称、编码和类型。
|
||||
- 切换合同不会清空旧地址和气瓶,可能填到最后才被服务端拒绝。
|
||||
- 优惠金额没有非负前端约束,页面不展示金额预估。
|
||||
|
||||
## 具体操作
|
||||
|
||||
1. 合同候选增加当前配送点范围和可履约状态过滤。
|
||||
2. 地址候选增加合同归属校验,并限定合同签约用户。
|
||||
3. 合同气瓶候选增加合同归属、未解绑过滤和名称/编码/类型搜索。
|
||||
4. 新增前端父子联动、默认地址选择、防抖搜索和请求版本守卫。
|
||||
5. 切换合同时原子清空旧地址和气瓶,加载期间禁用控件及保存。
|
||||
6. 联系人和联系电话继续独立手填;请求流水号继续按 5173 人工必填。
|
||||
7. 优惠金额默认 0、禁止负数,并展示服务端最终计算声明的金额预估。
|
||||
8. 保存增加显式防重入、候选空态阻断和金额正数校验。
|
||||
|
||||
## 行为变化
|
||||
|
||||
- 变更前:候选看似可选,但服务端最终可能因跨合同或失效记录拒绝。
|
||||
- 变更后:页面只展示当前合同有效候选,后端仍执行最终授权与一致性校验。
|
||||
- 兼容性:不修改数据库和创建订单请求结构;三个列表接口仅新增可选筛选参数。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- `go test ./api/internal/logic/delivery ./api/internal/logic/platform/gasorder`:通过;覆盖当前点范围、有效合同、合同用户地址和未解绑气瓶。
|
||||
- `npm.cmd run type:check`:通过。
|
||||
- `npm.cmd run resource-pages:check`:通过,详情 17、新建 9、编辑 5;覆盖联动参数、可读候选、版本守卫、保存阻断和金额公式。
|
||||
- `npm.cmd run contract:check`:通过,18 个资源。
|
||||
- `npm.cmd run profile:check`:通过。
|
||||
- `npm.cmd run build`:通过,2648 个模块完成生产构建。
|
||||
- 后端已重新构建并恢复监听 12426;旧程序保存在 `platform-api.before-order-linkage.exe`。
|
||||
- 应用内浏览器访问创建订单页被登录页拦截,未在未授权状态下填写或提交账号信息;页面交互需由现有已登录会话刷新后复核。
|
||||
|
||||
## 风险评估
|
||||
|
||||
- 快速切换合同:通过逐资源版本号丢弃旧响应。
|
||||
- 伪造筛选参数:后端先校验合同属于当前配送点,并在创建时再次校验。
|
||||
- 前端金额与服务端不同:页面明确标为预估,写入金额完全由服务端计算。
|
||||
- 地址无联系人字段:保持独立必填快照,不生成虚假联系人数据。
|
||||
@@ -56,6 +56,8 @@ frontend/delivery_admin/
|
||||
|
||||
配送人员和用户账户列表同样参考 5173:联系电话后显示 32px 圆形头像,最多并发 6 个鉴权请求,接近可视区域才加载;当前页缓存结果,刷新或离页时取消请求并释放 Blob URL。404、网络错误和图片解码失败均回退本地默认头像。全部标准资源列表隐藏数据库自增 ID,只展示可复制的系统唯一标识。
|
||||
|
||||
创建配送订单时以配送合同为父级,收货地址只加载合同签约用户地址,合同气瓶只加载当前未解绑绑定;切换合同清空旧值并丢弃过期响应。候选为空或仍在加载时禁止保存,金额区只提供预估,最终金额继续由服务端计算。详细规则见《项目文档_配送点订单创建联动_v1.0.md》。
|
||||
|
||||
配送人员资质列表必须由配送人员列表进入。页面先通过受保护人员详情接口回查姓名,再以当前气站、配送点、`delivery` 角色和人员 ID 四重条件加载资质;缺少、无效或越权人员上下文时停止加载,绝不回退为全部资质。标题和上下文区域显示人员姓名及可复制唯一标识,表格隐藏重复人员列。新建页自动预填所属人员,创建和编辑均锁定该关系,返回地址只接受安全站内路径。
|
||||
|
||||
资源搜索由后端 `searchFields` 契约驱动。没有有效字段的配送点资料、用户地址和银行卡页面不渲染搜索区域;其他页面显示“可搜索:具体字段”提示。枚举字段同时接受中文展示名称和内部稳定编码,查询、分页和重置始终保留人员上下文及安全来源参数。
|
||||
|
||||
62
docs/项目文档_配送点订单创建联动_v1.0.md
Normal file
62
docs/项目文档_配送点订单创建联动_v1.0.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 项目文档:配送点订单创建联动 v1.0
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
5176 配送点管理端创建订单页按 5173 的安全联动规则展示候选。配送合同是地址和合同气瓶的父级;前端只负责筛选交互和金额预估,后端始终在当前配送点范围内重新校验并计算最终金额。
|
||||
|
||||
技术栈:Vue 3、TypeScript、Arco Design、Go、Gin、GORM。
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
```text
|
||||
frontend/delivery_admin/src/
|
||||
├── api/order-creation.ts # 金额预估与前端金额校验
|
||||
├── api/resources.ts # 订单字段和关系联动契约
|
||||
└── views/resource/
|
||||
├── OrderAmountPreview.vue # 只读金额预估
|
||||
├── ResourceFieldForm.vue # 关系空态、禁用和可读候选
|
||||
├── ResourceRecordPage.vue # 保存保护和页面编排
|
||||
└── use-resource-relations.ts # 父子联动、搜索和版本守卫
|
||||
backend/api/internal/logic/delivery/
|
||||
├── order.go # 可履约合同与未解绑合同气瓶候选
|
||||
├── user.go # 合同签约用户地址候选
|
||||
└── order_candidate_test.go # 候选 SQL 范围测试
|
||||
```
|
||||
|
||||
## 3. 业务规则
|
||||
|
||||
- 请求流水号按 5173 保持人工必填,用作唯一幂等追踪字段。
|
||||
- 合同候选仅包含当前配送点范围内、生效中、已到生效时间、尚未过期且签约用户服务关系仍有效的合同。
|
||||
- 未选择合同时,收货地址和合同气瓶禁用并提示先选合同。
|
||||
- 收货地址仅来自合同签约用户,显示完整地址;存在默认地址时自动选择。
|
||||
- 合同气瓶仅包含所选合同尚未解绑的绑定,显示“智能气阀名称 / 气瓶编码 / 气瓶类型”。
|
||||
- 切换合同立即清空旧地址和旧气瓶;同一资源仅最新请求可以回写候选。
|
||||
- 联系人和联系电话继续作为独立、必填、可编辑的订单快照字段,不从地址伪造回填。
|
||||
- 优惠金额默认 0,不得为负,且必须小于气瓶金额与配送费之和。
|
||||
- 金额预估公式为:气瓶金额 + 默认配送费 - 优惠金额。最终金额由服务端重新计算。
|
||||
|
||||
## 4. 接口兼容与安全
|
||||
|
||||
- `/gasorder_contract` 新增可选 `candidate=order` 筛选;不传参数时合同管理列表保持原行为。
|
||||
- `/user_address` 新增可选 `gasorder_contract_identity` 筛选;后端先确认合同属于当前配送点,再限定合同用户。
|
||||
- `/gasorder_contract_product` 新增可选 `contract_identity` 筛选;后端先确认合同属于当前配送点,再限定未解绑绑定。
|
||||
- 原路径、原响应字段和创建订单请求协议保持兼容;候选接口只新增可选参数和只读展示名称。
|
||||
- 前端候选不可作为授权依据,创建接口继续校验合同、地址、气瓶、创建方和金额。
|
||||
|
||||
## 5. 维护与验证
|
||||
|
||||
新增联动字段时必须声明父字段、服务端筛选键、空态文案和切换提示。禁止仅在浏览器中过滤全量候选。关系请求必须保留版本守卫,保存期间冻结表单并显式防重入。
|
||||
|
||||
验证命令:
|
||||
|
||||
```text
|
||||
go test ./api/internal/logic/delivery ./api/internal/logic/platform/gasorder
|
||||
npm.cmd run type:check
|
||||
npm.cmd run resource-pages:check
|
||||
npm.cmd run contract:check
|
||||
npm.cmd run build
|
||||
```
|
||||
|
||||
## 6. 变更记录
|
||||
|
||||
- v1.0(2026-08-23):新增配送合同到地址、合同气瓶的安全联动,加入金额预估、空态提示、过期响应保护和提交保护。
|
||||
@@ -98,6 +98,9 @@ const attachmentState = read('src/views/resource/use-contract-attachment.ts');
|
||||
const detailContent = read('src/views/resource/ResourceDetailContent.vue');
|
||||
const detailContract = read('src/api/resource-detail-contract.ts');
|
||||
const resourceDisplay = read('src/api/resource-display.ts');
|
||||
const relationLinkage = read('src/views/resource/use-resource-relations.ts');
|
||||
const orderCreation = read('src/api/order-creation.ts');
|
||||
const orderPreview = read('src/views/resource/OrderAmountPreview.vue');
|
||||
assert(definitions.includes("type: 'contract-file'"), '合同附件仍按普通文本字段渲染');
|
||||
assert(recordPage.includes('title="合同附件"'), '合同详情页缺少附件状态卡片');
|
||||
assert(recordPage.includes('downloadContractAttachment'), '合同详情页缺少独立下载入口');
|
||||
@@ -120,5 +123,20 @@ assert(detailContent.includes('智能气阀名称取当前设备资料'), '合
|
||||
assert(resourceDisplay.includes("user_service_active: '签约用户仍属合同气站'"), '签约服务关系缺少中文业务标签');
|
||||
assert(resourceDisplay.includes("activate: '启用合同'"), '合同修订动作未中文化');
|
||||
assert(recordPage.includes("record.value.user_service_active === false"), '失效服务关系下未限制合同动作');
|
||||
assert(definitions.includes("relationFilters: { candidate: 'order' }"), '订单合同候选未限定可履约范围');
|
||||
assert(definitions.includes("f('request_no', { required: true })"), '请求流水号未按 5173 保持人工必填');
|
||||
assert(definitions.includes("f('contact_name', { required: true })"), '订单联系人未保持独立必填');
|
||||
assert(definitions.includes("f('contact_phone', { required: true })"), '订单联系电话未保持独立必填');
|
||||
assert(definitions.includes("filterKey: 'gasorder_contract_identity'"), '收货地址未按配送合同联动');
|
||||
assert(definitions.includes("filterKey: 'contract_identity'"), '合同气瓶未按配送合同联动');
|
||||
assert(definitions.includes("relationOptionLabelKey: 'address'"), '收货地址候选仍可能显示裸唯一标识');
|
||||
assert(definitions.includes("relationOptionDisplay: 'product-name-type'"), '合同气瓶缺少名称、编码和类型组合展示');
|
||||
assert(relationLinkage.includes('const versions = new Map'), '关系联动缺少过期响应版本守卫');
|
||||
assert(relationLinkage.includes("field.type === 'identity-list' ? [] : ''"), '切换合同时未清空旧关联值');
|
||||
assert(recordPage.includes('relationBusy || Boolean(relationUnavailableMessage)'), '关联候选加载或为空时仍可保存');
|
||||
assert(recordPage.includes('if (saving.value) return'), '订单保存缺少显式防重入');
|
||||
assert(orderCreation.includes('productAmount + deliveryFee - discountAmount'), '订单金额预估公式不正确');
|
||||
assert(orderCreation.includes("优惠金额必须小于商品金额与配送费之和"), '优惠金额缺少应付金额正数校验');
|
||||
assert(orderPreview.includes('金额仅供预览'), '订单金额预估未声明服务端最终计算');
|
||||
|
||||
console.log(`独立资源页面契约通过:详情 ${listResources.length},新建 ${creatable.size},编辑 ${editable.size}`);
|
||||
|
||||
64
frontend/delivery_admin/src/api/order-creation.ts
Normal file
64
frontend/delivery_admin/src/api/order-creation.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 功能描述:计算配送订单创建页的只读金额预估并执行前端金额校验。
|
||||
* 版本:v1.0.0。
|
||||
*/
|
||||
import type { ResourceRow } from './resource-record-form';
|
||||
|
||||
export type OrderAmountPreview = {
|
||||
productAmount: number;
|
||||
deliveryFee: number;
|
||||
discountAmount: number;
|
||||
payableAmount: number;
|
||||
ready: boolean;
|
||||
};
|
||||
|
||||
/** 金额均以分计算,页面只展示预估,最终结果仍由服务端重新计算。 */
|
||||
export function orderAmountPreview(
|
||||
form: Record<string, any>,
|
||||
relationOptions: Record<string, ResourceRow[]>,
|
||||
): OrderAmountPreview {
|
||||
const contractIdentity = String(form.gasorder_contract_identity ?? '');
|
||||
const contract = (relationOptions['/gasorder_contract'] ?? []).find(
|
||||
(row) => String(row.identity ?? '') === contractIdentity,
|
||||
);
|
||||
const selected = new Set(
|
||||
(Array.isArray(form.gasorder_contract_product_identities)
|
||||
? form.gasorder_contract_product_identities
|
||||
: []
|
||||
)
|
||||
.map((value: unknown) => String(value ?? ''))
|
||||
.filter(Boolean),
|
||||
);
|
||||
const products = (relationOptions['/gasorder_contract_product'] ?? []).filter(
|
||||
(row) => selected.has(String(row.identity ?? '')),
|
||||
);
|
||||
const productAmount = products.reduce(
|
||||
(total, row) => total + Math.max(0, Number(row.unit_price ?? 0)),
|
||||
0,
|
||||
);
|
||||
const deliveryFee = Math.max(0, Number(contract?.default_delivery_fee ?? 0));
|
||||
const discountAmount = Math.round(Number(form.discount_amount ?? 0) * 100);
|
||||
return {
|
||||
productAmount,
|
||||
deliveryFee,
|
||||
discountAmount,
|
||||
payableAmount: productAmount + deliveryFee - discountAmount,
|
||||
ready: Boolean(
|
||||
contract && selected.size > 0 && products.length === selected.size,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 返回订单金额的前端校验结果;服务端仍执行相同约束和溢出保护。 */
|
||||
export function orderAmountValidation(preview: OrderAmountPreview) {
|
||||
if (!Number.isFinite(preview.discountAmount) || preview.discountAmount < 0)
|
||||
return '优惠金额不能为负数';
|
||||
if (preview.ready && preview.payableAmount <= 0)
|
||||
return '优惠金额必须小于商品金额与配送费之和';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 将分格式化为人民币展示文本。 */
|
||||
export function formatOrderMoney(value: number) {
|
||||
return `¥${(value / 100).toFixed(2)}`;
|
||||
}
|
||||
@@ -9,12 +9,28 @@ export type ResourceRow = Record<string, unknown>;
|
||||
|
||||
const editableFieldKeys: Record<string, Set<string>> = {
|
||||
staff_account: new Set(['name', 'phone', 'avatar', 'work_status']),
|
||||
staff_credential: new Set(['staff_account_identity', 'credential_type', 'credential_no', 'expired_at']),
|
||||
staff_credential: new Set([
|
||||
'staff_account_identity',
|
||||
'credential_type',
|
||||
'credential_no',
|
||||
'expired_at',
|
||||
]),
|
||||
user_account: new Set(['name', 'phone', 'avatar', 'real_name']),
|
||||
user_address: new Set(['user_account_identity', 'address', 'longitude', 'latitude', 'is_default']),
|
||||
user_address: new Set([
|
||||
'user_account_identity',
|
||||
'address',
|
||||
'longitude',
|
||||
'latitude',
|
||||
'is_default',
|
||||
]),
|
||||
gasorder_contract: new Set([
|
||||
'title', 'terms', 'file_uri', 'default_delivery_fee',
|
||||
'signed_at', 'effective_at', 'expired_at',
|
||||
'title',
|
||||
'terms',
|
||||
'file_uri',
|
||||
'default_delivery_fee',
|
||||
'signed_at',
|
||||
'effective_at',
|
||||
'expired_at',
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -60,13 +76,19 @@ export function validateResourceRecordForm(
|
||||
mode: 'create' | 'edit',
|
||||
) {
|
||||
const required = fields.filter(
|
||||
(field) => field.required && !(mode === 'edit' && field.type === 'password'),
|
||||
(field) =>
|
||||
field.required && !(mode === 'edit' && field.type === 'password'),
|
||||
);
|
||||
if (required.some((field) => isMissingField(form[field.key]))) return '请填写必填字段';
|
||||
if (required.some((field) => isMissingField(form[field.key])))
|
||||
return '请填写必填字段';
|
||||
const password = fields.find(
|
||||
(field) => field.type === 'password' && !isMissingField(form[field.key]),
|
||||
);
|
||||
if (password && Array.from(String(form[password.key])).length < 6)
|
||||
return '密码长度不能少于 6 个字符';
|
||||
const belowMinimum = fields.find(
|
||||
(field) => field.min != null && Number(form[field.key]) < field.min,
|
||||
);
|
||||
if (belowMinimum) return `${belowMinimum.label}不能小于 ${belowMinimum.min}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@ export type ResourceFieldType =
|
||||
| 'select'
|
||||
| 'contract-file';
|
||||
|
||||
/** 父子关联候选配置;筛选仍由服务端按当前配送点范围执行。 */
|
||||
export type ResourceRelationLinkage = {
|
||||
parentKey: string;
|
||||
filterKey: string;
|
||||
requiresParent?: boolean;
|
||||
parentChangeMessage?: string;
|
||||
};
|
||||
|
||||
export type ResourceField = {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -39,6 +47,20 @@ export type ResourceField = {
|
||||
readonlyRelationText?: boolean;
|
||||
/** 列表中以可悬浮、可复制的紧凑文本展示普通字段。 */
|
||||
listCopyable?: boolean;
|
||||
/** 关联选项优先展示的业务字段。 */
|
||||
relationOptionLabelKey?: string;
|
||||
/** 合同气瓶等关系的组合展示方式。 */
|
||||
relationOptionDisplay?: 'product-name-type';
|
||||
/** 为默认候选追加中文标识,并在加载完成后自动选择。 */
|
||||
relationOptionDefaultKey?: string;
|
||||
relationAutoSelectKey?: string;
|
||||
/** 关联候选为空时的业务提示。 */
|
||||
relationEmptyText?: string;
|
||||
relationFilters?: Record<string, string>;
|
||||
relationStrictFilter?: boolean;
|
||||
relationInvalidMessage?: string;
|
||||
relationLinkage?: ResourceRelationLinkage;
|
||||
min?: number;
|
||||
options?: Array<{ label: string; value: string | number; disabled?: boolean }>;
|
||||
defaultValue?: string | number | boolean;
|
||||
readonly?: boolean;
|
||||
@@ -530,7 +552,50 @@ const deliveryOverrides: ResourceUiDefinition[] = [
|
||||
]),
|
||||
define('gasorder_contract_revision', '合同修订记录', 'readonly', []),
|
||||
define('product_info', '合同可选气瓶', 'readonly', []),
|
||||
define('gasorder_basic', '配送订单', 'append_only', [f('request_no', { required: true }), relation('gasorder_contract_identity', '/gasorder_contract', true), relation('user_address_identity', '/user_address', true), f('gasorder_contract_product_identities', { required: true, type: 'identity-list', relation: '/gasorder_contract_product' }), f('contact_name', { required: true }), f('contact_phone', { required: true }), f('discount_amount'), f('remark')], 'list', [
|
||||
define('gasorder_basic', '配送订单', 'append_only', [
|
||||
f('request_no', { required: true }),
|
||||
relation('gasorder_contract_identity', '/gasorder_contract', true, {
|
||||
label: '配送合同',
|
||||
placeholder: '请选择当前可履约的生效合同',
|
||||
relationFilters: { candidate: 'order' },
|
||||
relationStrictFilter: true,
|
||||
relationInvalidMessage: '所选配送合同已不可履约,已清空,请重新选择',
|
||||
relationEmptyText: '暂无可下单的生效合同,请先启用或续签配送合同',
|
||||
}),
|
||||
relation('user_address_identity', '/user_address', true, {
|
||||
label: '收货地址',
|
||||
placeholder: '请先选择配送合同,再选择该合同用户的收货地址',
|
||||
relationOptionLabelKey: 'address',
|
||||
relationOptionDefaultKey: 'is_default',
|
||||
relationAutoSelectKey: 'is_default',
|
||||
relationEmptyText: '该合同用户暂无收货地址,请先维护用户地址',
|
||||
relationLinkage: {
|
||||
parentKey: 'gasorder_contract_identity',
|
||||
filterKey: 'gasorder_contract_identity',
|
||||
requiresParent: true,
|
||||
parentChangeMessage: '配送合同已变更,请重新选择该合同用户的收货地址',
|
||||
},
|
||||
}),
|
||||
f('gasorder_contract_product_identities', {
|
||||
label: '合同气瓶',
|
||||
required: true,
|
||||
type: 'identity-list',
|
||||
relation: '/gasorder_contract_product',
|
||||
placeholder: '请输入智能气阀名称、气瓶类型或气瓶编码搜索',
|
||||
relationOptionDisplay: 'product-name-type',
|
||||
relationEmptyText: '该合同暂无可用气瓶,请先为合同绑定气瓶',
|
||||
relationLinkage: {
|
||||
parentKey: 'gasorder_contract_identity',
|
||||
filterKey: 'contract_identity',
|
||||
requiresParent: true,
|
||||
parentChangeMessage: '配送合同已变更,请重新选择该合同可用的气瓶',
|
||||
},
|
||||
}),
|
||||
f('contact_name', { required: true }),
|
||||
f('contact_phone', { required: true }),
|
||||
f('discount_amount', { defaultValue: 0, min: 0 }),
|
||||
f('remark'),
|
||||
], 'list', [
|
||||
{ name: '分配/改派', resource: '/gasorder_basic/:identity/assign', fields: [relation('staff_account_identity', '/staff_account', true), ...reason], visibleFor: { field: 'order_status', values: [16, 18] } },
|
||||
{ name: '回收任务', resource: '/gasorder_basic/:identity/reclaim', danger: true, fields: reason, visibleFor: { field: 'order_status', values: [18] } },
|
||||
{ name: '调整金额', resource: '/gasorder_basic/:identity/adjust-amount', fields: [f('delivery_fee', { required: true }), f('discount_amount', { required: true }), ...reason], visibleFor: { field: 'order_status', values: [16, 18] } },
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<!-- 功能描述:展示配送订单创建时的只读金额预估。版本:v1.0.0。 -->
|
||||
<template>
|
||||
<a-alert v-if="preview.ready" class="amount-preview" type="info" show-icon>
|
||||
<div class="amount-grid">
|
||||
<span>气瓶金额<strong>{{ formatOrderMoney(preview.productAmount) }}</strong></span>
|
||||
<span>默认配送费<strong>{{ formatOrderMoney(preview.deliveryFee) }}</strong></span>
|
||||
<span>优惠金额<strong>- {{ formatOrderMoney(preview.discountAmount) }}</strong></span>
|
||||
<span class="payable">预计应付<strong>{{ formatOrderMoney(preview.payableAmount) }}</strong></span>
|
||||
</div>
|
||||
<div class="amount-note">金额仅供预览,保存时由服务端按合同价格重新计算。</div>
|
||||
</a-alert>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { formatOrderMoney, orderAmountPreview } from '@/api/order-creation';
|
||||
import type { ResourceRow } from '@/api/resource-record-form';
|
||||
|
||||
const props = defineProps<{
|
||||
form: Record<string, any>;
|
||||
relationOptions: Record<string, ResourceRow[]>;
|
||||
}>();
|
||||
const preview = computed(() =>
|
||||
orderAmountPreview(props.form, props.relationOptions),
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.amount-preview { margin: 4px 0 20px; }
|
||||
.amount-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
|
||||
.amount-grid span { display: grid; gap: 4px; color: var(--color-text-3); }
|
||||
.amount-grid strong { color: var(--color-text-1); font-size: 16px; }
|
||||
.amount-grid .payable strong { color: rgb(var(--primary-6)); font-size: 18px; }
|
||||
.amount-note { margin-top: 10px; color: var(--color-text-3); font-size: 12px; }
|
||||
@media (max-width: 900px) { .amount-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
</style>
|
||||
@@ -18,7 +18,7 @@
|
||||
mode="create"
|
||||
:relation-options="relationOptions"
|
||||
:relation-loading="relationLoading"
|
||||
@search-relation="(resource, keyword) => emit('searchRelation', resource, keyword)"
|
||||
@search-relation="(field, keyword) => emit('searchRelation', field, keyword)"
|
||||
/>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -29,7 +29,7 @@ import { reactive, ref, watch } from 'vue';
|
||||
import { resourceApi } from '@/api/resource';
|
||||
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
|
||||
import type { ResourceRow } from '@/api/resource-record-form';
|
||||
import type { DetailAction } from '@/api/resources';
|
||||
import type { DetailAction, ResourceField } from '@/api/resources';
|
||||
import ResourceFieldForm from './ResourceFieldForm.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -43,7 +43,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [visible: boolean];
|
||||
success: [];
|
||||
searchRelation: [resource: string | undefined, keyword: string];
|
||||
searchRelation: [field: ResourceField, keyword: string];
|
||||
}>();
|
||||
const form = reactive<Record<string, any>>({});
|
||||
const submitting = ref(false);
|
||||
@@ -53,8 +53,12 @@ watch(
|
||||
([visible, action]) => {
|
||||
if (!visible || !action) return;
|
||||
for (const key of Object.keys(form)) delete form[key];
|
||||
for (const field of action.fields ?? []) form[field.key] = field.defaultValue;
|
||||
if (action.resource.endsWith('/:identity/status') && [1, 2].includes(Number(props.currentStatus)))
|
||||
for (const field of action.fields ?? [])
|
||||
form[field.key] = field.defaultValue;
|
||||
if (
|
||||
action.resource.endsWith('/:identity/status') &&
|
||||
[1, 2].includes(Number(props.currentStatus))
|
||||
)
|
||||
form.status = props.currentStatus;
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -64,11 +68,18 @@ watch(
|
||||
async function submit() {
|
||||
const action = props.action;
|
||||
if (!action) return;
|
||||
if ((action.fields ?? []).some((field) => field.required && isMissingField(form[field.key]))) {
|
||||
if (
|
||||
(action.fields ?? []).some(
|
||||
(field) => field.required && isMissingField(form[field.key]),
|
||||
)
|
||||
) {
|
||||
Message.warning('请填写必填字段');
|
||||
return;
|
||||
}
|
||||
if (action.resource.endsWith('/:identity/status') && Number(form.status) === Number(props.currentStatus)) {
|
||||
if (
|
||||
action.resource.endsWith('/:identity/status') &&
|
||||
Number(form.status) === Number(props.currentStatus)
|
||||
) {
|
||||
Message.warning('审核状态未发生变化');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 功能描述:渲染配送点资源新建、编辑和动作表单字段。版本:v1.0.0。 -->
|
||||
<!-- 功能描述:渲染配送点资源新建、编辑和动作表单字段。版本:v1.1.0。 -->
|
||||
<template>
|
||||
<a-form :model="model" layout="vertical" class="field-grid">
|
||||
<a-form-item
|
||||
@@ -8,36 +8,37 @@
|
||||
:label="field.label"
|
||||
:required="isRequired(field)"
|
||||
>
|
||||
<a-switch v-if="field.type === 'boolean'" v-model="model[field.key]" :disabled="field.readonly" />
|
||||
<a-switch v-if="field.type === 'boolean'" v-model="model[field.key]" :disabled="isFieldDisabled(field)" />
|
||||
<a-input-number
|
||||
v-else-if="field.type === 'number' || field.type === 'money'"
|
||||
v-model="model[field.key]"
|
||||
:precision="field.type === 'money' ? 2 : 0"
|
||||
:disabled="field.readonly"
|
||||
:min="field.min"
|
||||
:disabled="isFieldDisabled(field)"
|
||||
/>
|
||||
<a-date-picker
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="model[field.key]"
|
||||
value-format="YYYY-MM-DD"
|
||||
:disabled="field.readonly"
|
||||
:disabled="isFieldDisabled(field)"
|
||||
/>
|
||||
<a-date-picker
|
||||
v-else-if="field.type === 'datetime'"
|
||||
v-model="model[field.key]"
|
||||
show-time
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
:disabled="field.readonly"
|
||||
:disabled="isFieldDisabled(field)"
|
||||
/>
|
||||
<a-textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
v-model="model[field.key]"
|
||||
:auto-size="{ minRows: 3, maxRows: 8 }"
|
||||
:disabled="field.readonly"
|
||||
:disabled="isFieldDisabled(field)"
|
||||
/>
|
||||
<ContractAttachmentField
|
||||
v-else-if="field.type === 'contract-file'"
|
||||
:attachment="contractAttachment"
|
||||
:disabled="field.readonly"
|
||||
:disabled="isFieldDisabled(field)"
|
||||
@select="(file) => emit('selectAttachment', file)"
|
||||
@remove="emit('removeAttachment')"
|
||||
@clear-selection="emit('clearAttachmentSelection')"
|
||||
@@ -46,13 +47,14 @@
|
||||
<a-input-password
|
||||
v-else-if="field.type === 'password'"
|
||||
v-model="model[field.key]"
|
||||
:disabled="isFieldDisabled(field)"
|
||||
:placeholder="mode === 'edit' ? '留空表示不修改' : `请输入${field.label}`"
|
||||
/>
|
||||
<a-select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="model[field.key]"
|
||||
allow-clear
|
||||
:disabled="field.readonly"
|
||||
:disabled="isFieldDisabled(field)"
|
||||
>
|
||||
<a-option
|
||||
v-for="option in field.options ?? []"
|
||||
@@ -69,22 +71,27 @@
|
||||
:multiple="field.type === 'identity-list'"
|
||||
allow-clear
|
||||
allow-search
|
||||
:disabled="field.readonly"
|
||||
:disabled="isFieldDisabled(field)"
|
||||
:loading="relationLoading[field.relation ?? '']"
|
||||
@search="(value: string) => emit('searchRelation', field.relation, value)"
|
||||
:placeholder="field.placeholder ?? `请选择${field.label}`"
|
||||
@change="(value: unknown) => emit('changeRelation', field, value)"
|
||||
@search="(value: string) => emit('searchRelation', field, value)"
|
||||
>
|
||||
<a-option
|
||||
v-for="option in relationOptions[field.relation ?? ''] ?? []"
|
||||
:key="String(option.identity)"
|
||||
:value="String(option.identity)"
|
||||
>
|
||||
{{ optionLabel(option) }}
|
||||
{{ optionLabel(field, option) }}
|
||||
</a-option>
|
||||
<template #empty>
|
||||
<a-empty :description="relationEmptyDescription(field)" />
|
||||
</template>
|
||||
</a-select>
|
||||
<a-input
|
||||
v-else
|
||||
v-model="model[field.key]"
|
||||
:disabled="field.readonly"
|
||||
:disabled="isFieldDisabled(field)"
|
||||
:placeholder="field.placeholder ?? `请输入${field.label}`"
|
||||
/>
|
||||
</a-form-item>
|
||||
@@ -92,20 +99,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ResourceField } from '@/api/resources';
|
||||
import type { ResourceRow } from '@/api/resource-record-form';
|
||||
import type { ResourceField } from '@/api/resources';
|
||||
import ContractAttachmentField, {
|
||||
type ContractAttachmentFieldState,
|
||||
} from './ContractAttachmentField.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
fields: ResourceField[];
|
||||
model: Record<string, any>;
|
||||
mode: 'create' | 'edit';
|
||||
relationOptions: Record<string, ResourceRow[]>;
|
||||
relationLoading: Record<string, boolean>;
|
||||
contractAttachment?: ContractAttachmentFieldState;
|
||||
}>(), {
|
||||
formDisabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
formDisabled: false,
|
||||
contractAttachment: () => ({
|
||||
selectedFile: undefined,
|
||||
hasExisting: false,
|
||||
@@ -113,9 +124,11 @@ const props = withDefaults(defineProps<{
|
||||
requiresReupload: false,
|
||||
removed: false,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
const emit = defineEmits<{
|
||||
searchRelation: [resource: string | undefined, keyword: string];
|
||||
searchRelation: [field: ResourceField, keyword: string];
|
||||
changeRelation: [field: ResourceField, value: unknown];
|
||||
selectAttachment: [file: File];
|
||||
removeAttachment: [];
|
||||
clearAttachmentSelection: [];
|
||||
@@ -124,21 +137,70 @@ const emit = defineEmits<{
|
||||
|
||||
/** 判断当前模式下字段是否必填。 */
|
||||
function isRequired(field: ResourceField) {
|
||||
return Boolean(field.required && !(props.mode === 'edit' && field.type === 'password'));
|
||||
return Boolean(
|
||||
field.required && !(props.mode === 'edit' && field.type === 'password'),
|
||||
);
|
||||
}
|
||||
|
||||
/** 长文本和多选关系占据整行,其余字段按双列排列。 */
|
||||
function isWide(field: ResourceField) {
|
||||
return field.type === 'textarea' || field.type === 'identity-list' || field.type === 'contract-file' ||
|
||||
/(address|terms|content|body|remark|reason|params|args)$/.test(field.key);
|
||||
return (
|
||||
field.type === 'textarea' ||
|
||||
field.type === 'identity-list' ||
|
||||
field.type === 'contract-file' ||
|
||||
/(address|terms|content|body|remark|reason|params|args)$/.test(field.key)
|
||||
);
|
||||
}
|
||||
|
||||
/** 父级未选、正在保存或字段只读时禁用输入。 */
|
||||
function isFieldDisabled(field: ResourceField) {
|
||||
const linkage = field.relationLinkage;
|
||||
return Boolean(
|
||||
props.formDisabled ||
|
||||
field.readonly ||
|
||||
Boolean(field.relation && props.relationLoading[field.relation]) ||
|
||||
(linkage?.requiresParent &&
|
||||
!String(props.model[linkage.parentKey] ?? '').trim()),
|
||||
);
|
||||
}
|
||||
|
||||
/** 返回关系空态,未选父级时优先给出下一步指引。 */
|
||||
function relationEmptyDescription(field: ResourceField) {
|
||||
if (
|
||||
field.relationLinkage?.requiresParent &&
|
||||
!String(props.model[field.relationLinkage.parentKey] ?? '').trim()
|
||||
)
|
||||
return '请先选择配送合同';
|
||||
return field.relationEmptyText ?? '暂无数据';
|
||||
}
|
||||
|
||||
/** 返回关联候选的可读名称。 */
|
||||
function optionLabel(option: ResourceRow) {
|
||||
return String(
|
||||
option.name ?? option.title ?? option.code ?? option.username ??
|
||||
option.contract_no ?? option.order_no ?? option.identity,
|
||||
function optionLabel(field: ResourceField, option: ResourceRow) {
|
||||
let label = '';
|
||||
if (field.relationOptionDisplay === 'product-name-type') {
|
||||
label = [option.product_name, option.product_code, option.product_type_name]
|
||||
.map((value) => String(value ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
} else if (field.relationOptionLabelKey) {
|
||||
label = String(option[field.relationOptionLabelKey] ?? '').trim();
|
||||
}
|
||||
if (!label) {
|
||||
label = String(
|
||||
option.name ??
|
||||
option.title ??
|
||||
option.code ??
|
||||
option.username ??
|
||||
option.contract_no ??
|
||||
option.order_no ??
|
||||
option.address ??
|
||||
option.identity,
|
||||
);
|
||||
}
|
||||
return field.relationOptionDefaultKey &&
|
||||
option[field.relationOptionDefaultKey]
|
||||
? `${label}(默认地址)`
|
||||
: label;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 功能描述:承载配送点标准资源的新建、详情和编辑独立页面。版本:v1.0.0。 -->
|
||||
<!-- 功能描述:承载配送点标准资源的新建、详情和编辑独立页面。版本:v1.1.0。 -->
|
||||
<template>
|
||||
<div class="record-page">
|
||||
<header class="page-header">
|
||||
@@ -110,14 +110,32 @@
|
||||
:relation-options="relationOptions"
|
||||
:relation-loading="relationLoading"
|
||||
:contract-attachment="contractAttachmentState"
|
||||
:form-disabled="saving"
|
||||
@change-relation="changeRelation"
|
||||
@search-relation="searchRelation"
|
||||
@select-attachment="contractAttachment.select"
|
||||
@remove-attachment="confirmRemoveContractAttachment"
|
||||
@clear-attachment-selection="contractAttachment.clearSelection"
|
||||
@preview-attachment="previewContractAttachment"
|
||||
/>
|
||||
<a-alert
|
||||
v-if="relationUnavailableMessage"
|
||||
class="workflow-alert"
|
||||
type="warning"
|
||||
show-icon
|
||||
>{{ relationUnavailableMessage }}</a-alert>
|
||||
<OrderAmountPreview
|
||||
v-if="isOrderCreate"
|
||||
:form="form"
|
||||
:relation-options="relationOptions"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<a-button type="primary" :loading="saving" @click="save">保存</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="relationBusy || Boolean(relationUnavailableMessage)"
|
||||
@click="save"
|
||||
>保存</a-button>
|
||||
<a-button :disabled="saving" @click="requestBack">取消</a-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,25 +160,23 @@ import { Message, Modal } from '@arco-design/web-vue';
|
||||
import { IconEdit, IconLeft } from '@arco-design/web-vue/es/icon';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { orderAmountPreview, orderAmountValidation } from '@/api/order-creation';
|
||||
import { resourceApi } from '@/api/resource';
|
||||
import { buildResourcePayload } from '@/api/resource-form';
|
||||
import {
|
||||
resetResourceRecordForm,
|
||||
recordFormFields,
|
||||
validateResourceRecordForm,
|
||||
type ResourceRow,
|
||||
} from '@/api/resource-record-form';
|
||||
import { primaryRecord, recordEditReason } from '@/api/resource-display';
|
||||
import { buildResourcePayload } from '@/api/resource-form';
|
||||
import { recordRouteLocation, returnToList, safeReturnPath } from '@/api/resource-navigation';
|
||||
import { getResource, type DetailAction, type ResourceField } from '@/api/resources';
|
||||
import { type ResourceRow, recordFormFields, resetResourceRecordForm, validateResourceRecordForm } from '@/api/resource-record-form';
|
||||
import { type DetailAction, getResource, type ResourceField } from '@/api/resources';
|
||||
import IdentityText from '@/components/IdentityText.vue';
|
||||
import ResourceActionDialog from './ResourceActionDialog.vue';
|
||||
import OrderAmountPreview from './OrderAmountPreview.vue';
|
||||
import ResourceAccountSummary from './ResourceAccountSummary.vue';
|
||||
import ResourceActionDialog from './ResourceActionDialog.vue';
|
||||
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';
|
||||
import { useResourceAvatar } from './use-resource-avatar';
|
||||
import { useResourceRelations } from './use-resource-relations';
|
||||
import { useUnsavedRecord } from './use-unsaved-record';
|
||||
|
||||
type RecordPageMode = 'create' | 'detail' | 'edit';
|
||||
const route = useRoute();
|
||||
@@ -179,18 +195,17 @@ const detail = ref<ResourceRow>({});
|
||||
const credentialOwner = ref<ResourceRow>();
|
||||
const record = computed(() => primaryRecord(detail.value));
|
||||
const form = reactive<Record<string, any>>({});
|
||||
const relationOptions = reactive<Record<string, ResourceRow[]>>({});
|
||||
const relationLoading = reactive<Record<string, boolean>>({});
|
||||
const relationTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const relations = useResourceRelations(form);
|
||||
const relationOptions = relations.options;
|
||||
const relationLoading = relations.loading;
|
||||
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 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 isOrderCreate = computed(() => definition.value.name === 'gasorder_basic' && mode.value === 'create');
|
||||
const contractAttachmentState = computed(() => ({
|
||||
selectedFile: contractAttachment.selectedFile.value,
|
||||
hasExisting: contractAttachment.hasExisting.value,
|
||||
@@ -198,37 +213,25 @@ const contractAttachmentState = computed(() => ({
|
||||
requiresReupload: contractAttachment.requiresReupload.value,
|
||||
removed: contractAttachment.removed.value,
|
||||
}));
|
||||
const summaryRecord = computed(() =>
|
||||
mode.value === 'detail' ? record.value : { ...record.value, ...form },
|
||||
);
|
||||
const formFields = computed(() => recordFormFields(
|
||||
definition.value.name,
|
||||
definition.value.fields,
|
||||
mode.value as 'create' | 'edit',
|
||||
).filter((field) => field.key !== 'avatar').map((field) =>
|
||||
isCredentialResource.value && field.key === 'staff_account_identity'
|
||||
? { ...field, readonly: true }
|
||||
: field,
|
||||
));
|
||||
const canEditRecord = computed(
|
||||
() => definition.value.canEdit && !recordEditReason(definition.value, record.value),
|
||||
const summaryRecord = computed(() => (mode.value === 'detail' ? record.value : { ...record.value, ...form }));
|
||||
const formFields = computed(() =>
|
||||
recordFormFields(definition.value.name, definition.value.fields, mode.value as 'create' | 'edit')
|
||||
.filter((field) => field.key !== 'avatar')
|
||||
.map((field) => (isCredentialResource.value && field.key === 'staff_account_identity' ? { ...field, readonly: true } : field)),
|
||||
);
|
||||
const relationBusy = computed(() => relations.isLoading(formFields.value));
|
||||
const relationUnavailableMessage = computed(() => relations.unavailableMessage(formFields.value));
|
||||
const canEditRecord = computed(() => definition.value.canEdit && !recordEditReason(definition.value, record.value));
|
||||
const visibleActions = computed(() =>
|
||||
(definition.value.detailActions ?? []).filter(
|
||||
(action) =>
|
||||
!(
|
||||
definition.value.name === 'gasorder_contract' &&
|
||||
record.value.user_service_active === false &&
|
||||
action.name !== '终止合同'
|
||||
) &&
|
||||
(
|
||||
!action.visibleFor ||
|
||||
action.visibleFor.values.includes(record.value[action.visibleFor.field] as string | number)
|
||||
),
|
||||
!(definition.value.name === 'gasorder_contract' && record.value.user_service_active === false && action.name !== '终止合同') &&
|
||||
(!action.visibleFor || action.visibleFor.values.includes(record.value[action.visibleFor.field] as string | number)),
|
||||
),
|
||||
);
|
||||
const unsaved = useUnsavedRecord(
|
||||
() => JSON.stringify({
|
||||
() =>
|
||||
JSON.stringify({
|
||||
form,
|
||||
avatar: avatar.marker(),
|
||||
contractAttachment: contractAttachment.marker(),
|
||||
@@ -250,9 +253,7 @@ async function loadRecord() {
|
||||
if (reason) throw new Error(reason);
|
||||
}
|
||||
}
|
||||
contractAttachment.load(
|
||||
isContractResource.value ? (detail.value.attachment as any) : undefined,
|
||||
);
|
||||
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);
|
||||
@@ -274,15 +275,12 @@ async function loadRecord() {
|
||||
/** 回查并锁定资质所属配送人员,禁止信任 URL 中的展示名称。 */
|
||||
async function loadCredentialOwner() {
|
||||
if (!isCredentialResource.value) return;
|
||||
const queryOwner = typeof route.query.owner_identity === 'string'
|
||||
? route.query.owner_identity.trim()
|
||||
: '';
|
||||
const queryOwner = typeof route.query.owner_identity === 'string' ? route.query.owner_identity.trim() : '';
|
||||
const recordOwner = String(record.value.staff_account_identity ?? '');
|
||||
const targetIdentity = mode.value === 'create' ? queryOwner : recordOwner;
|
||||
if (!targetIdentity) throw new Error('缺少配送人员上下文,请从配送人员列表进入');
|
||||
const owner = await resourceApi.detail<ResourceRow>('/staff_account', targetIdentity);
|
||||
if (!owner || String(owner.identity ?? '') !== targetIdentity)
|
||||
throw new Error('人员详情响应无效');
|
||||
if (!owner || String(owner.identity ?? '') !== targetIdentity) throw new Error('人员详情响应无效');
|
||||
credentialOwner.value = owner;
|
||||
relationOptions['/staff_account'] = [owner];
|
||||
if (mode.value !== 'detail') form.staff_account_identity = targetIdentity;
|
||||
@@ -290,14 +288,22 @@ async function loadCredentialOwner() {
|
||||
|
||||
/** 保存新建或编辑表单并进入记录详情。 */
|
||||
async function save() {
|
||||
if (saving.value) return;
|
||||
if (relationBusy.value) return Message.warning('合同关联数据正在加载,请稍候');
|
||||
if (relationUnavailableMessage.value) return Message.warning(relationUnavailableMessage.value);
|
||||
const validation = validateResourceRecordForm(form, formFields.value, mode.value as 'create' | 'edit');
|
||||
if (validation) return Message.warning(validation);
|
||||
if (isOrderCreate.value) {
|
||||
const amountValidation = orderAmountValidation(orderAmountPreview(form, relationOptions));
|
||||
if (amountValidation) return Message.warning(amountValidation);
|
||||
}
|
||||
saving.value = true;
|
||||
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'
|
||||
const saved =
|
||||
mode.value === 'create'
|
||||
? await resourceApi.create<ResourceRow>(definition.value.resource, payload)
|
||||
: await resourceApi.update<ResourceRow>(definition.value.resource, identity.value, payload);
|
||||
const savedIdentity = String(saved?.identity ?? identity.value);
|
||||
@@ -362,7 +368,8 @@ async function downloadContractAttachment() {
|
||||
|
||||
/** 返回列表或编辑来源详情。 */
|
||||
function requestBack() {
|
||||
const leave = () => mode.value === 'edit'
|
||||
const leave = () =>
|
||||
mode.value === 'edit'
|
||||
? router.push(recordRouteLocation(listRouteName.value, 'detail', identity.value, returnPath.value))
|
||||
: returnToList(router, returnPath.value, listRouteName.value);
|
||||
if (mode.value === 'detail') return leave();
|
||||
@@ -393,10 +400,18 @@ function openStatusAction() {
|
||||
name: '审核状态',
|
||||
resource: `${definition.value.resource}/:identity/status`,
|
||||
method: 'PATCH',
|
||||
fields: [{
|
||||
key: 'status', label: '目标状态', type: 'select', required: true,
|
||||
options: [{ label: '启用', value: 1 }, { label: '停用', value: 2 }],
|
||||
}],
|
||||
fields: [
|
||||
{
|
||||
key: 'status',
|
||||
label: '目标状态',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -418,37 +433,29 @@ function confirmArchive() {
|
||||
});
|
||||
}
|
||||
|
||||
/** 加载关联候选,配送人员关系固定为配送角色。 */
|
||||
async function loadRelation(resource: string, keyword = '') {
|
||||
if (isCredentialResource.value && resource === '/staff_account') {
|
||||
/** 搜索关系候选;配送人员资质继续使用锁定的单一人员。 */
|
||||
function searchRelation(field: ResourceField, keyword: string) {
|
||||
if (isCredentialResource.value && field.relation === '/staff_account') {
|
||||
void loadCredentialOwner();
|
||||
return;
|
||||
}
|
||||
relations.search(field, keyword);
|
||||
}
|
||||
|
||||
/** 处理合同变化,原子清空并重载地址和合同气瓶。 */
|
||||
async function changeRelation(field: ResourceField) {
|
||||
if (isCredentialResource.value && field.relation === '/staff_account') {
|
||||
await loadCredentialOwner();
|
||||
return;
|
||||
}
|
||||
relationLoading[resource] = true;
|
||||
try {
|
||||
const filters: Record<string, string> = keyword ? { keyword } : {};
|
||||
if (resource === '/staff_account') filters.role_code = 'delivery';
|
||||
relationOptions[resource] = (await resourceApi.list<ResourceRow>(resource, 1, 100, filters)).list;
|
||||
} catch (error) {
|
||||
Message.error(`关联数据加载失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
relationLoading[resource] = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 对关联搜索做短延迟合并。 */
|
||||
function searchRelation(resource: string | undefined, keyword: string) {
|
||||
if (!resource) return;
|
||||
const timer = relationTimers.get(resource);
|
||||
if (timer) clearTimeout(timer);
|
||||
relationTimers.set(resource, setTimeout(() => loadRelation(resource, keyword.trim()), 250));
|
||||
await relations.change(field);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (
|
||||
isCredentialResource.value && mode.value === 'create' &&
|
||||
(typeof route.query.owner_identity !== 'string' ||
|
||||
route.query.relation_key !== 'staff_account_identity')
|
||||
isCredentialResource.value &&
|
||||
mode.value === 'create' &&
|
||||
(typeof route.query.owner_identity !== 'string' || route.query.relation_key !== 'staff_account_identity')
|
||||
) {
|
||||
Message.warning('请从配送人员列表进入资质新建页');
|
||||
await router.replace({ name: 'staff-delivery' });
|
||||
@@ -456,14 +463,11 @@ onMounted(async () => {
|
||||
}
|
||||
await loadRecord();
|
||||
if (errorMessage.value) return;
|
||||
const paths = new Set(
|
||||
[...definition.value.fields, ...(definition.value.detailActions ?? []).flatMap((item) => item.fields ?? [])]
|
||||
.map((field: ResourceField) => field.relation)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
);
|
||||
const relationFields = [...definition.value.fields, ...(definition.value.detailActions ?? []).flatMap((item) => item.fields ?? [])];
|
||||
relations.configure(relationFields);
|
||||
try {
|
||||
await Promise.all([...paths].map((resource) => loadRelation(resource)));
|
||||
if (isCredentialResource.value && mode.value === 'detail') await loadCredentialOwner();
|
||||
if (isCredentialResource.value) await loadCredentialOwner();
|
||||
await relations.preload(relationFields.filter((field) => !(isCredentialResource.value && field.relation === '/staff_account')));
|
||||
} catch (error) {
|
||||
errorMessage.value = `所属配送人员加载失败:${(error as Error).message}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 功能描述:加载配送点资源关系候选,并处理合同驱动的父子联动与过期响应。
|
||||
* 版本:v1.0.0。
|
||||
*/
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { onBeforeUnmount, reactive } from 'vue';
|
||||
import { resourceApi } from '@/api/resource';
|
||||
import type { ResourceRow } from '@/api/resource-record-form';
|
||||
import type { ResourceField } from '@/api/resources';
|
||||
|
||||
/** 创建关系候选状态;同一资源仅允许最新请求更新页面。 */
|
||||
export function useResourceRelations(form: Record<string, any>) {
|
||||
const options = reactive<Record<string, ResourceRow[]>>({});
|
||||
const loading = reactive<Record<string, boolean>>({});
|
||||
const versions = new Map<string, number>();
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
function begin(resource: string) {
|
||||
const version = (versions.get(resource) ?? 0) + 1;
|
||||
versions.set(resource, version);
|
||||
return version;
|
||||
}
|
||||
|
||||
function isLatest(resource: string, version: number) {
|
||||
return versions.get(resource) === version;
|
||||
}
|
||||
|
||||
/** 清空候选并使尚未返回的旧请求失效。 */
|
||||
function clear(resource: string | undefined) {
|
||||
if (!resource) return;
|
||||
begin(resource);
|
||||
options[resource] = [];
|
||||
loading[resource] = false;
|
||||
}
|
||||
|
||||
/** 返回字段当前必须携带的静态与父级筛选参数。 */
|
||||
function filters(field: ResourceField) {
|
||||
const linkage = field.relationLinkage;
|
||||
const parent = linkage ? String(form[linkage.parentKey] ?? '').trim() : '';
|
||||
return {
|
||||
...(field.relation === '/staff_account' ? { role_code: 'delivery' } : {}),
|
||||
...(field.relationFilters ?? {}),
|
||||
...(linkage && parent ? { [linkage.filterKey]: parent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 加载一个关系字段,搜索时仍保留当前已选候选的可读文案。 */
|
||||
async function load(field: ResourceField, keyword = '') {
|
||||
const resource = field.relation;
|
||||
if (!resource) return;
|
||||
const linkage = field.relationLinkage;
|
||||
if (
|
||||
linkage?.requiresParent &&
|
||||
!String(form[linkage.parentKey] ?? '').trim()
|
||||
) {
|
||||
clear(resource);
|
||||
return;
|
||||
}
|
||||
const version = begin(resource);
|
||||
loading[resource] = true;
|
||||
const selected: string[] = (
|
||||
Array.isArray(form[field.key]) ? form[field.key] : [form[field.key]]
|
||||
)
|
||||
.map((value: unknown) => String(value ?? ''))
|
||||
.filter(Boolean);
|
||||
try {
|
||||
const rows = (
|
||||
await resourceApi.list<ResourceRow>(resource, 1, 100, {
|
||||
...filters(field),
|
||||
...(keyword ? { keyword } : {}),
|
||||
})
|
||||
).list;
|
||||
if (!isLatest(resource, version)) return;
|
||||
const validIdentities = new Set(
|
||||
rows.map((row) => String(row.identity ?? '')),
|
||||
);
|
||||
if (
|
||||
field.relationStrictFilter &&
|
||||
!keyword &&
|
||||
selected.some((identity) => !validIdentities.has(identity))
|
||||
) {
|
||||
form[field.key] = field.type === 'identity-list' ? [] : '';
|
||||
Message.info(
|
||||
field.relationInvalidMessage ?? '所选关联数据已失效,已清空',
|
||||
);
|
||||
}
|
||||
const merged = [...rows];
|
||||
const identities = new Set(validIdentities);
|
||||
for (const identity of selected) {
|
||||
if (field.relationStrictFilter && !keyword) continue;
|
||||
const current = (options[resource] ?? []).find(
|
||||
(row) => String(row.identity ?? '') === identity,
|
||||
);
|
||||
if (current && !identities.has(identity)) merged.push(current);
|
||||
}
|
||||
options[resource] = merged;
|
||||
if (!selected.length && field.relationAutoSelectKey) {
|
||||
const preferred = rows.find((row) =>
|
||||
Boolean(row[field.relationAutoSelectKey as string]),
|
||||
);
|
||||
if (preferred && isLatest(resource, version))
|
||||
form[field.key] = String(preferred.identity ?? '');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isLatest(resource, version))
|
||||
Message.error(`关联数据加载失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
if (isLatest(resource, version)) loading[resource] = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 先加载普通关系,再加载已有父值约束下的子关系。 */
|
||||
async function preload(fields: ResourceField[]) {
|
||||
const unique = new Map<string, ResourceField>();
|
||||
for (const field of fields) {
|
||||
if (field.relation && !unique.has(field.relation))
|
||||
unique.set(field.relation, field);
|
||||
}
|
||||
await Promise.all(
|
||||
[...unique.values()]
|
||||
.filter((field) => !field.relationLinkage)
|
||||
.map((field) => load(field)),
|
||||
);
|
||||
await Promise.all(
|
||||
[...unique.values()]
|
||||
.filter((field) => field.relationLinkage)
|
||||
.map((field) => load(field)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 父关系变化时原子清空所有子值,再按新父值加载候选。 */
|
||||
async function change(field: ResourceField) {
|
||||
const fields = configuredFields;
|
||||
const children = fields.filter(
|
||||
(item) => item.relationLinkage?.parentKey === field.key,
|
||||
);
|
||||
await Promise.all(
|
||||
children.map(async (child) => {
|
||||
const previous = Array.isArray(form[child.key])
|
||||
? form[child.key].length > 0
|
||||
: Boolean(form[child.key]);
|
||||
form[child.key] = child.type === 'identity-list' ? [] : '';
|
||||
clear(child.relation);
|
||||
if (previous)
|
||||
Message.info(
|
||||
child.relationLinkage?.parentChangeMessage ??
|
||||
'上级数据已变更,请重新选择',
|
||||
);
|
||||
await load(child);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let configuredFields: ResourceField[] = [];
|
||||
function configure(fields: ResourceField[]) {
|
||||
configuredFields = fields;
|
||||
}
|
||||
|
||||
/** 搜索采用 250ms 防抖,并始终附带当前父级过滤。 */
|
||||
function search(field: ResourceField, keyword: string) {
|
||||
const resource = field.relation;
|
||||
if (!resource) return;
|
||||
const timer = timers.get(resource);
|
||||
if (timer) clearTimeout(timer);
|
||||
timers.set(
|
||||
resource,
|
||||
setTimeout(() => {
|
||||
timers.delete(resource);
|
||||
void load(field, keyword.trim());
|
||||
}, 250),
|
||||
);
|
||||
}
|
||||
|
||||
/** 返回必填候选为空时的业务提示。 */
|
||||
function unavailableMessage(fields: ResourceField[]) {
|
||||
for (const field of fields) {
|
||||
if (!field.required || !field.relation || !field.relationEmptyText)
|
||||
continue;
|
||||
const linkage = field.relationLinkage;
|
||||
if (
|
||||
linkage?.requiresParent &&
|
||||
!String(form[linkage.parentKey] ?? '').trim()
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
!loading[field.relation] &&
|
||||
(options[field.relation] ?? []).length === 0
|
||||
)
|
||||
return field.relationEmptyText;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isLoading(fields: ResourceField[]) {
|
||||
return fields.some((field) =>
|
||||
Boolean(field.relation && loading[field.relation]),
|
||||
);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const timer of timers.values()) clearTimeout(timer);
|
||||
});
|
||||
|
||||
return {
|
||||
options,
|
||||
loading,
|
||||
configure,
|
||||
preload,
|
||||
load,
|
||||
change,
|
||||
search,
|
||||
clear,
|
||||
unavailableMessage,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user