完善配送订单创建联动
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
// 功能描述:实现配送点范围内的合同、合同气瓶和配送订单接口。
|
// 功能描述:实现配送点范围内的合同、合同气瓶和配送订单接口。
|
||||||
// 版本:v1.1.0。
|
// 版本:v1.2.0。
|
||||||
package delivery
|
package delivery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -49,23 +49,33 @@ func scopedContract(ctx *gin.Context, identity string, pointID uint64) (models.G
|
|||||||
return contract, true
|
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) {
|
func ListContract(ctx *gin.Context) {
|
||||||
point, _, ok := currentScope(ctx)
|
point, _, ok := currentScope(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
page, size := common.PageSize(ctx)
|
page, size := common.PageSize(ctx)
|
||||||
query := common.ApplyKeywordFilter(ctx,
|
query := deliveryContractListQuery(
|
||||||
common.ActiveRecords(db().Model(&models.GasorderContract{})).
|
db(), point.GasBasicID, point.ID, strings.TrimSpace(ctx.Query("candidate")), time.Now(),
|
||||||
Where("gas_basic_id = ? AND delivery_basic_id = ?", point.GasBasicID, point.ID),
|
)
|
||||||
&models.GasorderContract{})
|
query = common.ApplyKeywordFilter(ctx, query, &models.GasorderContract{})
|
||||||
var total int64
|
var total int64
|
||||||
if err := query.Count(&total).Error; err != nil {
|
if err := query.Count(&total).Error; err != nil {
|
||||||
infra.Response.Error(ctx, err)
|
infra.Response.Error(ctx, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var list []models.GasorderContract
|
var list []platformgasorder.ContractPartyDisplay
|
||||||
if err := query.Order("gasorder_contract.created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
|
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)
|
infra.Response.Error(ctx, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -74,7 +84,11 @@ func ListContract(ctx *gin.Context) {
|
|||||||
infra.Response.Error(ctx, err)
|
infra.Response.Error(ctx, err)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
infra.Response.Error(ctx, err)
|
infra.Response.Error(ctx, err)
|
||||||
return
|
return
|
||||||
@@ -186,15 +200,63 @@ func TerminateContract(ctx *gin.Context) {
|
|||||||
withContract(ctx, platformgasorder.TerminateGasorderContract)
|
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) {
|
func ListContractProduct(ctx *gin.Context) {
|
||||||
point, _, ok := currentScope(ctx)
|
point, _, ok := currentScope(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
query := common.ActiveRecords(db().Model(&models.GasorderContractProduct{})).
|
contractIdentity := strings.TrimSpace(ctx.Query("contract_identity"))
|
||||||
Joins("JOIN gasorder_contract ON gasorder_contract.id = gasorder_contract_product.gasorder_contract_id").
|
var contractID uint64
|
||||||
Where("gasorder_contract.delivery_basic_id = ?", point.ID)
|
if contractIdentity != "" {
|
||||||
listScoped(ctx, &models.GasorderContractProduct{}, query, "gasorder_contract_product.created_at desc")
|
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) {
|
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
|
package delivery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.apinb.com/bsm-sdk/core/errcode"
|
"git.apinb.com/bsm-sdk/core/errcode"
|
||||||
"git.apinb.com/bsm-sdk/core/infra"
|
"git.apinb.com/bsm-sdk/core/infra"
|
||||||
@@ -231,16 +234,35 @@ func ArchiveUser(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func addressQuery(pointID uint64) *gorm.DB {
|
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 <> ?",
|
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)
|
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) {
|
func ListAddress(ctx *gin.Context) {
|
||||||
point, _, ok := currentScope(ctx)
|
point, _, ok := currentScope(ctx)
|
||||||
if ok {
|
if !ok {
|
||||||
listScoped(ctx, &models.UserAddress{}, addressQuery(point.ID), "user_address.created_at desc")
|
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) {
|
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,只展示可复制的系统唯一标识。
|
配送人员和用户账户列表同样参考 5173:联系电话后显示 32px 圆形头像,最多并发 6 个鉴权请求,接近可视区域才加载;当前页缓存结果,刷新或离页时取消请求并释放 Blob URL。404、网络错误和图片解码失败均回退本地默认头像。全部标准资源列表隐藏数据库自增 ID,只展示可复制的系统唯一标识。
|
||||||
|
|
||||||
|
创建配送订单时以配送合同为父级,收货地址只加载合同签约用户地址,合同气瓶只加载当前未解绑绑定;切换合同清空旧值并丢弃过期响应。候选为空或仍在加载时禁止保存,金额区只提供预估,最终金额继续由服务端计算。详细规则见《项目文档_配送点订单创建联动_v1.0.md》。
|
||||||
|
|
||||||
配送人员资质列表必须由配送人员列表进入。页面先通过受保护人员详情接口回查姓名,再以当前气站、配送点、`delivery` 角色和人员 ID 四重条件加载资质;缺少、无效或越权人员上下文时停止加载,绝不回退为全部资质。标题和上下文区域显示人员姓名及可复制唯一标识,表格隐藏重复人员列。新建页自动预填所属人员,创建和编辑均锁定该关系,返回地址只接受安全站内路径。
|
配送人员资质列表必须由配送人员列表进入。页面先通过受保护人员详情接口回查姓名,再以当前气站、配送点、`delivery` 角色和人员 ID 四重条件加载资质;缺少、无效或越权人员上下文时停止加载,绝不回退为全部资质。标题和上下文区域显示人员姓名及可复制唯一标识,表格隐藏重复人员列。新建页自动预填所属人员,创建和编辑均锁定该关系,返回地址只接受安全站内路径。
|
||||||
|
|
||||||
资源搜索由后端 `searchFields` 契约驱动。没有有效字段的配送点资料、用户地址和银行卡页面不渲染搜索区域;其他页面显示“可搜索:具体字段”提示。枚举字段同时接受中文展示名称和内部稳定编码,查询、分页和重置始终保留人员上下文及安全来源参数。
|
资源搜索由后端 `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 detailContent = read('src/views/resource/ResourceDetailContent.vue');
|
||||||
const detailContract = read('src/api/resource-detail-contract.ts');
|
const detailContract = read('src/api/resource-detail-contract.ts');
|
||||||
const resourceDisplay = read('src/api/resource-display.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(definitions.includes("type: 'contract-file'"), '合同附件仍按普通文本字段渲染');
|
||||||
assert(recordPage.includes('title="合同附件"'), '合同详情页缺少附件状态卡片');
|
assert(recordPage.includes('title="合同附件"'), '合同详情页缺少附件状态卡片');
|
||||||
assert(recordPage.includes('downloadContractAttachment'), '合同详情页缺少独立下载入口');
|
assert(recordPage.includes('downloadContractAttachment'), '合同详情页缺少独立下载入口');
|
||||||
@@ -120,5 +123,20 @@ assert(detailContent.includes('智能气阀名称取当前设备资料'), '合
|
|||||||
assert(resourceDisplay.includes("user_service_active: '签约用户仍属合同气站'"), '签约服务关系缺少中文业务标签');
|
assert(resourceDisplay.includes("user_service_active: '签约用户仍属合同气站'"), '签约服务关系缺少中文业务标签');
|
||||||
assert(resourceDisplay.includes("activate: '启用合同'"), '合同修订动作未中文化');
|
assert(resourceDisplay.includes("activate: '启用合同'"), '合同修订动作未中文化');
|
||||||
assert(recordPage.includes("record.value.user_service_active === false"), '失效服务关系下未限制合同动作');
|
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}`);
|
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>> = {
|
const editableFieldKeys: Record<string, Set<string>> = {
|
||||||
staff_account: new Set(['name', 'phone', 'avatar', 'work_status']),
|
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_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([
|
gasorder_contract: new Set([
|
||||||
'title', 'terms', 'file_uri', 'default_delivery_fee',
|
'title',
|
||||||
'signed_at', 'effective_at', 'expired_at',
|
'terms',
|
||||||
|
'file_uri',
|
||||||
|
'default_delivery_fee',
|
||||||
|
'signed_at',
|
||||||
|
'effective_at',
|
||||||
|
'expired_at',
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,13 +76,19 @@ export function validateResourceRecordForm(
|
|||||||
mode: 'create' | 'edit',
|
mode: 'create' | 'edit',
|
||||||
) {
|
) {
|
||||||
const required = fields.filter(
|
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(
|
const password = fields.find(
|
||||||
(field) => field.type === 'password' && !isMissingField(form[field.key]),
|
(field) => field.type === 'password' && !isMissingField(form[field.key]),
|
||||||
);
|
);
|
||||||
if (password && Array.from(String(form[password.key])).length < 6)
|
if (password && Array.from(String(form[password.key])).length < 6)
|
||||||
return '密码长度不能少于 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 '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export type ResourceFieldType =
|
|||||||
| 'select'
|
| 'select'
|
||||||
| 'contract-file';
|
| 'contract-file';
|
||||||
|
|
||||||
|
/** 父子关联候选配置;筛选仍由服务端按当前配送点范围执行。 */
|
||||||
|
export type ResourceRelationLinkage = {
|
||||||
|
parentKey: string;
|
||||||
|
filterKey: string;
|
||||||
|
requiresParent?: boolean;
|
||||||
|
parentChangeMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ResourceField = {
|
export type ResourceField = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -39,6 +47,20 @@ export type ResourceField = {
|
|||||||
readonlyRelationText?: boolean;
|
readonlyRelationText?: boolean;
|
||||||
/** 列表中以可悬浮、可复制的紧凑文本展示普通字段。 */
|
/** 列表中以可悬浮、可复制的紧凑文本展示普通字段。 */
|
||||||
listCopyable?: 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 }>;
|
options?: Array<{ label: string; value: string | number; disabled?: boolean }>;
|
||||||
defaultValue?: string | number | boolean;
|
defaultValue?: string | number | boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
@@ -530,7 +552,50 @@ const deliveryOverrides: ResourceUiDefinition[] = [
|
|||||||
]),
|
]),
|
||||||
define('gasorder_contract_revision', '合同修订记录', 'readonly', []),
|
define('gasorder_contract_revision', '合同修订记录', 'readonly', []),
|
||||||
define('product_info', '合同可选气瓶', '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/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/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] } },
|
{ 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"
|
mode="create"
|
||||||
:relation-options="relationOptions"
|
:relation-options="relationOptions"
|
||||||
:relation-loading="relationLoading"
|
:relation-loading="relationLoading"
|
||||||
@search-relation="(resource, keyword) => emit('searchRelation', resource, keyword)"
|
@search-relation="(field, keyword) => emit('searchRelation', field, keyword)"
|
||||||
/>
|
/>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
@@ -29,7 +29,7 @@ import { reactive, ref, watch } from 'vue';
|
|||||||
import { resourceApi } from '@/api/resource';
|
import { resourceApi } from '@/api/resource';
|
||||||
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
|
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
|
||||||
import type { ResourceRow } from '@/api/resource-record-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';
|
import ResourceFieldForm from './ResourceFieldForm.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -43,7 +43,7 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:visible': [visible: boolean];
|
'update:visible': [visible: boolean];
|
||||||
success: [];
|
success: [];
|
||||||
searchRelation: [resource: string | undefined, keyword: string];
|
searchRelation: [field: ResourceField, keyword: string];
|
||||||
}>();
|
}>();
|
||||||
const form = reactive<Record<string, any>>({});
|
const form = reactive<Record<string, any>>({});
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
@@ -53,8 +53,12 @@ watch(
|
|||||||
([visible, action]) => {
|
([visible, action]) => {
|
||||||
if (!visible || !action) return;
|
if (!visible || !action) return;
|
||||||
for (const key of Object.keys(form)) delete form[key];
|
for (const key of Object.keys(form)) delete form[key];
|
||||||
for (const field of action.fields ?? []) form[field.key] = field.defaultValue;
|
for (const field of action.fields ?? [])
|
||||||
if (action.resource.endsWith('/:identity/status') && [1, 2].includes(Number(props.currentStatus)))
|
form[field.key] = field.defaultValue;
|
||||||
|
if (
|
||||||
|
action.resource.endsWith('/:identity/status') &&
|
||||||
|
[1, 2].includes(Number(props.currentStatus))
|
||||||
|
)
|
||||||
form.status = props.currentStatus;
|
form.status = props.currentStatus;
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -64,11 +68,18 @@ watch(
|
|||||||
async function submit() {
|
async function submit() {
|
||||||
const action = props.action;
|
const action = props.action;
|
||||||
if (!action) return;
|
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('请填写必填字段');
|
Message.warning('请填写必填字段');
|
||||||
return;
|
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('审核状态未发生变化');
|
Message.warning('审核状态未发生变化');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- 功能描述:渲染配送点资源新建、编辑和动作表单字段。版本:v1.0.0。 -->
|
<!-- 功能描述:渲染配送点资源新建、编辑和动作表单字段。版本:v1.1.0。 -->
|
||||||
<template>
|
<template>
|
||||||
<a-form :model="model" layout="vertical" class="field-grid">
|
<a-form :model="model" layout="vertical" class="field-grid">
|
||||||
<a-form-item
|
<a-form-item
|
||||||
@@ -8,36 +8,37 @@
|
|||||||
:label="field.label"
|
:label="field.label"
|
||||||
:required="isRequired(field)"
|
: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
|
<a-input-number
|
||||||
v-else-if="field.type === 'number' || field.type === 'money'"
|
v-else-if="field.type === 'number' || field.type === 'money'"
|
||||||
v-model="model[field.key]"
|
v-model="model[field.key]"
|
||||||
:precision="field.type === 'money' ? 2 : 0"
|
:precision="field.type === 'money' ? 2 : 0"
|
||||||
:disabled="field.readonly"
|
:min="field.min"
|
||||||
|
:disabled="isFieldDisabled(field)"
|
||||||
/>
|
/>
|
||||||
<a-date-picker
|
<a-date-picker
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
v-model="model[field.key]"
|
v-model="model[field.key]"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
:disabled="field.readonly"
|
:disabled="isFieldDisabled(field)"
|
||||||
/>
|
/>
|
||||||
<a-date-picker
|
<a-date-picker
|
||||||
v-else-if="field.type === 'datetime'"
|
v-else-if="field.type === 'datetime'"
|
||||||
v-model="model[field.key]"
|
v-model="model[field.key]"
|
||||||
show-time
|
show-time
|
||||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||||
:disabled="field.readonly"
|
:disabled="isFieldDisabled(field)"
|
||||||
/>
|
/>
|
||||||
<a-textarea
|
<a-textarea
|
||||||
v-else-if="field.type === 'textarea'"
|
v-else-if="field.type === 'textarea'"
|
||||||
v-model="model[field.key]"
|
v-model="model[field.key]"
|
||||||
:auto-size="{ minRows: 3, maxRows: 8 }"
|
:auto-size="{ minRows: 3, maxRows: 8 }"
|
||||||
:disabled="field.readonly"
|
:disabled="isFieldDisabled(field)"
|
||||||
/>
|
/>
|
||||||
<ContractAttachmentField
|
<ContractAttachmentField
|
||||||
v-else-if="field.type === 'contract-file'"
|
v-else-if="field.type === 'contract-file'"
|
||||||
:attachment="contractAttachment"
|
:attachment="contractAttachment"
|
||||||
:disabled="field.readonly"
|
:disabled="isFieldDisabled(field)"
|
||||||
@select="(file) => emit('selectAttachment', file)"
|
@select="(file) => emit('selectAttachment', file)"
|
||||||
@remove="emit('removeAttachment')"
|
@remove="emit('removeAttachment')"
|
||||||
@clear-selection="emit('clearAttachmentSelection')"
|
@clear-selection="emit('clearAttachmentSelection')"
|
||||||
@@ -46,13 +47,14 @@
|
|||||||
<a-input-password
|
<a-input-password
|
||||||
v-else-if="field.type === 'password'"
|
v-else-if="field.type === 'password'"
|
||||||
v-model="model[field.key]"
|
v-model="model[field.key]"
|
||||||
|
:disabled="isFieldDisabled(field)"
|
||||||
:placeholder="mode === 'edit' ? '留空表示不修改' : `请输入${field.label}`"
|
:placeholder="mode === 'edit' ? '留空表示不修改' : `请输入${field.label}`"
|
||||||
/>
|
/>
|
||||||
<a-select
|
<a-select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="model[field.key]"
|
v-model="model[field.key]"
|
||||||
allow-clear
|
allow-clear
|
||||||
:disabled="field.readonly"
|
:disabled="isFieldDisabled(field)"
|
||||||
>
|
>
|
||||||
<a-option
|
<a-option
|
||||||
v-for="option in field.options ?? []"
|
v-for="option in field.options ?? []"
|
||||||
@@ -69,22 +71,27 @@
|
|||||||
:multiple="field.type === 'identity-list'"
|
:multiple="field.type === 'identity-list'"
|
||||||
allow-clear
|
allow-clear
|
||||||
allow-search
|
allow-search
|
||||||
:disabled="field.readonly"
|
:disabled="isFieldDisabled(field)"
|
||||||
:loading="relationLoading[field.relation ?? '']"
|
: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
|
<a-option
|
||||||
v-for="option in relationOptions[field.relation ?? ''] ?? []"
|
v-for="option in relationOptions[field.relation ?? ''] ?? []"
|
||||||
:key="String(option.identity)"
|
:key="String(option.identity)"
|
||||||
:value="String(option.identity)"
|
:value="String(option.identity)"
|
||||||
>
|
>
|
||||||
{{ optionLabel(option) }}
|
{{ optionLabel(field, option) }}
|
||||||
</a-option>
|
</a-option>
|
||||||
|
<template #empty>
|
||||||
|
<a-empty :description="relationEmptyDescription(field)" />
|
||||||
|
</template>
|
||||||
</a-select>
|
</a-select>
|
||||||
<a-input
|
<a-input
|
||||||
v-else
|
v-else
|
||||||
v-model="model[field.key]"
|
v-model="model[field.key]"
|
||||||
:disabled="field.readonly"
|
:disabled="isFieldDisabled(field)"
|
||||||
:placeholder="field.placeholder ?? `请输入${field.label}`"
|
:placeholder="field.placeholder ?? `请输入${field.label}`"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -92,20 +99,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ResourceField } from '@/api/resources';
|
|
||||||
import type { ResourceRow } from '@/api/resource-record-form';
|
import type { ResourceRow } from '@/api/resource-record-form';
|
||||||
|
import type { ResourceField } from '@/api/resources';
|
||||||
import ContractAttachmentField, {
|
import ContractAttachmentField, {
|
||||||
type ContractAttachmentFieldState,
|
type ContractAttachmentFieldState,
|
||||||
} from './ContractAttachmentField.vue';
|
} from './ContractAttachmentField.vue';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
fields: ResourceField[];
|
fields: ResourceField[];
|
||||||
model: Record<string, any>;
|
model: Record<string, any>;
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
relationOptions: Record<string, ResourceRow[]>;
|
relationOptions: Record<string, ResourceRow[]>;
|
||||||
relationLoading: Record<string, boolean>;
|
relationLoading: Record<string, boolean>;
|
||||||
contractAttachment?: ContractAttachmentFieldState;
|
contractAttachment?: ContractAttachmentFieldState;
|
||||||
}>(), {
|
formDisabled?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
formDisabled: false,
|
||||||
contractAttachment: () => ({
|
contractAttachment: () => ({
|
||||||
selectedFile: undefined,
|
selectedFile: undefined,
|
||||||
hasExisting: false,
|
hasExisting: false,
|
||||||
@@ -113,9 +124,11 @@ const props = withDefaults(defineProps<{
|
|||||||
requiresReupload: false,
|
requiresReupload: false,
|
||||||
removed: false,
|
removed: false,
|
||||||
}),
|
}),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
searchRelation: [resource: string | undefined, keyword: string];
|
searchRelation: [field: ResourceField, keyword: string];
|
||||||
|
changeRelation: [field: ResourceField, value: unknown];
|
||||||
selectAttachment: [file: File];
|
selectAttachment: [file: File];
|
||||||
removeAttachment: [];
|
removeAttachment: [];
|
||||||
clearAttachmentSelection: [];
|
clearAttachmentSelection: [];
|
||||||
@@ -124,21 +137,70 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
/** 判断当前模式下字段是否必填。 */
|
/** 判断当前模式下字段是否必填。 */
|
||||||
function isRequired(field: ResourceField) {
|
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) {
|
function isWide(field: ResourceField) {
|
||||||
return field.type === 'textarea' || field.type === 'identity-list' || field.type === 'contract-file' ||
|
return (
|
||||||
/(address|terms|content|body|remark|reason|params|args)$/.test(field.key);
|
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) {
|
function optionLabel(field: ResourceField, option: ResourceRow) {
|
||||||
return String(
|
let label = '';
|
||||||
option.name ?? option.title ?? option.code ?? option.username ??
|
if (field.relationOptionDisplay === 'product-name-type') {
|
||||||
option.contract_no ?? option.order_no ?? option.identity,
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- 功能描述:承载配送点标准资源的新建、详情和编辑独立页面。版本:v1.0.0。 -->
|
<!-- 功能描述:承载配送点标准资源的新建、详情和编辑独立页面。版本:v1.1.0。 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="record-page">
|
<div class="record-page">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
@@ -110,14 +110,32 @@
|
|||||||
:relation-options="relationOptions"
|
:relation-options="relationOptions"
|
||||||
:relation-loading="relationLoading"
|
:relation-loading="relationLoading"
|
||||||
:contract-attachment="contractAttachmentState"
|
:contract-attachment="contractAttachmentState"
|
||||||
|
:form-disabled="saving"
|
||||||
|
@change-relation="changeRelation"
|
||||||
@search-relation="searchRelation"
|
@search-relation="searchRelation"
|
||||||
@select-attachment="contractAttachment.select"
|
@select-attachment="contractAttachment.select"
|
||||||
@remove-attachment="confirmRemoveContractAttachment"
|
@remove-attachment="confirmRemoveContractAttachment"
|
||||||
@clear-attachment-selection="contractAttachment.clearSelection"
|
@clear-attachment-selection="contractAttachment.clearSelection"
|
||||||
@preview-attachment="previewContractAttachment"
|
@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">
|
<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>
|
<a-button :disabled="saving" @click="requestBack">取消</a-button>
|
||||||
</div>
|
</div>
|
||||||
</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 { 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';
|
||||||
|
import { orderAmountPreview, orderAmountValidation } from '@/api/order-creation';
|
||||||
import { resourceApi } from '@/api/resource';
|
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 { primaryRecord, recordEditReason } from '@/api/resource-display';
|
||||||
|
import { buildResourcePayload } from '@/api/resource-form';
|
||||||
import { recordRouteLocation, returnToList, safeReturnPath } from '@/api/resource-navigation';
|
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 IdentityText from '@/components/IdentityText.vue';
|
||||||
import ResourceActionDialog from './ResourceActionDialog.vue';
|
import OrderAmountPreview from './OrderAmountPreview.vue';
|
||||||
import ResourceAccountSummary from './ResourceAccountSummary.vue';
|
import ResourceAccountSummary from './ResourceAccountSummary.vue';
|
||||||
|
import ResourceActionDialog from './ResourceActionDialog.vue';
|
||||||
import ResourceDetailContent from './ResourceDetailContent.vue';
|
import ResourceDetailContent from './ResourceDetailContent.vue';
|
||||||
import ResourceFieldForm from './ResourceFieldForm.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 { 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';
|
type RecordPageMode = 'create' | 'detail' | 'edit';
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -179,18 +195,17 @@ const detail = ref<ResourceRow>({});
|
|||||||
const credentialOwner = ref<ResourceRow>();
|
const credentialOwner = ref<ResourceRow>();
|
||||||
const record = computed(() => primaryRecord(detail.value));
|
const record = computed(() => primaryRecord(detail.value));
|
||||||
const form = reactive<Record<string, any>>({});
|
const form = reactive<Record<string, any>>({});
|
||||||
const relationOptions = reactive<Record<string, ResourceRow[]>>({});
|
const relations = useResourceRelations(form);
|
||||||
const relationLoading = reactive<Record<string, boolean>>({});
|
const relationOptions = relations.options;
|
||||||
const relationTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const relationLoading = relations.loading;
|
||||||
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 contractAttachment = useContractAttachment();
|
||||||
const hasAvatarField = computed(() =>
|
const hasAvatarField = computed(() => definition.value.fields.some((field) => field.key === 'avatar'));
|
||||||
definition.value.fields.some((field) => field.key === 'avatar'),
|
|
||||||
);
|
|
||||||
const isCredentialResource = computed(() => definition.value.name === 'staff_credential');
|
const isCredentialResource = computed(() => definition.value.name === 'staff_credential');
|
||||||
const isContractResource = computed(() => definition.value.name === 'gasorder_contract');
|
const isContractResource = computed(() => definition.value.name === 'gasorder_contract');
|
||||||
|
const isOrderCreate = computed(() => definition.value.name === 'gasorder_basic' && mode.value === 'create');
|
||||||
const contractAttachmentState = computed(() => ({
|
const contractAttachmentState = computed(() => ({
|
||||||
selectedFile: contractAttachment.selectedFile.value,
|
selectedFile: contractAttachment.selectedFile.value,
|
||||||
hasExisting: contractAttachment.hasExisting.value,
|
hasExisting: contractAttachment.hasExisting.value,
|
||||||
@@ -198,37 +213,25 @@ const contractAttachmentState = computed(() => ({
|
|||||||
requiresReupload: contractAttachment.requiresReupload.value,
|
requiresReupload: contractAttachment.requiresReupload.value,
|
||||||
removed: contractAttachment.removed.value,
|
removed: contractAttachment.removed.value,
|
||||||
}));
|
}));
|
||||||
const summaryRecord = computed(() =>
|
const summaryRecord = computed(() => (mode.value === 'detail' ? record.value : { ...record.value, ...form }));
|
||||||
mode.value === 'detail' ? record.value : { ...record.value, ...form },
|
const formFields = computed(() =>
|
||||||
);
|
recordFormFields(definition.value.name, definition.value.fields, mode.value as 'create' | 'edit')
|
||||||
const formFields = computed(() => recordFormFields(
|
.filter((field) => field.key !== 'avatar')
|
||||||
definition.value.name,
|
.map((field) => (isCredentialResource.value && field.key === 'staff_account_identity' ? { ...field, readonly: true } : field)),
|
||||||
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 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(() =>
|
const visibleActions = computed(() =>
|
||||||
(definition.value.detailActions ?? []).filter(
|
(definition.value.detailActions ?? []).filter(
|
||||||
(action) =>
|
(action) =>
|
||||||
!(
|
!(definition.value.name === 'gasorder_contract' && record.value.user_service_active === false && action.name !== '终止合同') &&
|
||||||
definition.value.name === 'gasorder_contract' &&
|
(!action.visibleFor || action.visibleFor.values.includes(record.value[action.visibleFor.field] as string | number)),
|
||||||
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(
|
const unsaved = useUnsavedRecord(
|
||||||
() => JSON.stringify({
|
() =>
|
||||||
|
JSON.stringify({
|
||||||
form,
|
form,
|
||||||
avatar: avatar.marker(),
|
avatar: avatar.marker(),
|
||||||
contractAttachment: contractAttachment.marker(),
|
contractAttachment: contractAttachment.marker(),
|
||||||
@@ -250,9 +253,7 @@ async function loadRecord() {
|
|||||||
if (reason) throw new Error(reason);
|
if (reason) throw new Error(reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
contractAttachment.load(
|
contractAttachment.load(isContractResource.value ? (detail.value.attachment as any) : undefined);
|
||||||
isContractResource.value ? (detail.value.attachment as any) : undefined,
|
|
||||||
);
|
|
||||||
if (hasAvatarField.value && mode.value !== 'create') {
|
if (hasAvatarField.value && mode.value !== 'create') {
|
||||||
// 头像属于附加信息,读取失败不能阻断基础资料页面。
|
// 头像属于附加信息,读取失败不能阻断基础资料页面。
|
||||||
await avatar.load(definition.value.resource, identity.value).catch(() => undefined);
|
await avatar.load(definition.value.resource, identity.value).catch(() => undefined);
|
||||||
@@ -274,15 +275,12 @@ async function loadRecord() {
|
|||||||
/** 回查并锁定资质所属配送人员,禁止信任 URL 中的展示名称。 */
|
/** 回查并锁定资质所属配送人员,禁止信任 URL 中的展示名称。 */
|
||||||
async function loadCredentialOwner() {
|
async function loadCredentialOwner() {
|
||||||
if (!isCredentialResource.value) return;
|
if (!isCredentialResource.value) return;
|
||||||
const queryOwner = typeof route.query.owner_identity === 'string'
|
const queryOwner = typeof route.query.owner_identity === 'string' ? route.query.owner_identity.trim() : '';
|
||||||
? route.query.owner_identity.trim()
|
|
||||||
: '';
|
|
||||||
const recordOwner = String(record.value.staff_account_identity ?? '');
|
const recordOwner = String(record.value.staff_account_identity ?? '');
|
||||||
const targetIdentity = mode.value === 'create' ? queryOwner : recordOwner;
|
const targetIdentity = mode.value === 'create' ? queryOwner : recordOwner;
|
||||||
if (!targetIdentity) throw new Error('缺少配送人员上下文,请从配送人员列表进入');
|
if (!targetIdentity) throw new Error('缺少配送人员上下文,请从配送人员列表进入');
|
||||||
const owner = await resourceApi.detail<ResourceRow>('/staff_account', targetIdentity);
|
const owner = await resourceApi.detail<ResourceRow>('/staff_account', targetIdentity);
|
||||||
if (!owner || String(owner.identity ?? '') !== targetIdentity)
|
if (!owner || String(owner.identity ?? '') !== targetIdentity) throw new Error('人员详情响应无效');
|
||||||
throw new Error('人员详情响应无效');
|
|
||||||
credentialOwner.value = owner;
|
credentialOwner.value = owner;
|
||||||
relationOptions['/staff_account'] = [owner];
|
relationOptions['/staff_account'] = [owner];
|
||||||
if (mode.value !== 'detail') form.staff_account_identity = targetIdentity;
|
if (mode.value !== 'detail') form.staff_account_identity = targetIdentity;
|
||||||
@@ -290,14 +288,22 @@ async function loadCredentialOwner() {
|
|||||||
|
|
||||||
/** 保存新建或编辑表单并进入记录详情。 */
|
/** 保存新建或编辑表单并进入记录详情。 */
|
||||||
async function save() {
|
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');
|
const validation = validateResourceRecordForm(form, formFields.value, mode.value as 'create' | 'edit');
|
||||||
if (validation) return Message.warning(validation);
|
if (validation) return Message.warning(validation);
|
||||||
|
if (isOrderCreate.value) {
|
||||||
|
const amountValidation = orderAmountValidation(orderAmountPreview(form, relationOptions));
|
||||||
|
if (amountValidation) return Message.warning(amountValidation);
|
||||||
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = buildResourcePayload(formFields.value, form, mode.value as 'create' | 'edit');
|
const payload = buildResourcePayload(formFields.value, form, mode.value as 'create' | 'edit');
|
||||||
if (hasAvatarField.value) await avatar.applyToPayload(payload);
|
if (hasAvatarField.value) await avatar.applyToPayload(payload);
|
||||||
if (isContractResource.value) await contractAttachment.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.create<ResourceRow>(definition.value.resource, payload)
|
||||||
: await resourceApi.update<ResourceRow>(definition.value.resource, identity.value, payload);
|
: await resourceApi.update<ResourceRow>(definition.value.resource, identity.value, payload);
|
||||||
const savedIdentity = String(saved?.identity ?? identity.value);
|
const savedIdentity = String(saved?.identity ?? identity.value);
|
||||||
@@ -362,7 +368,8 @@ async function downloadContractAttachment() {
|
|||||||
|
|
||||||
/** 返回列表或编辑来源详情。 */
|
/** 返回列表或编辑来源详情。 */
|
||||||
function requestBack() {
|
function requestBack() {
|
||||||
const leave = () => mode.value === 'edit'
|
const leave = () =>
|
||||||
|
mode.value === 'edit'
|
||||||
? router.push(recordRouteLocation(listRouteName.value, 'detail', identity.value, returnPath.value))
|
? router.push(recordRouteLocation(listRouteName.value, 'detail', identity.value, returnPath.value))
|
||||||
: returnToList(router, returnPath.value, listRouteName.value);
|
: returnToList(router, returnPath.value, listRouteName.value);
|
||||||
if (mode.value === 'detail') return leave();
|
if (mode.value === 'detail') return leave();
|
||||||
@@ -393,10 +400,18 @@ function openStatusAction() {
|
|||||||
name: '审核状态',
|
name: '审核状态',
|
||||||
resource: `${definition.value.resource}/:identity/status`,
|
resource: `${definition.value.resource}/:identity/status`,
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
fields: [{
|
fields: [
|
||||||
key: 'status', label: '目标状态', type: 'select', required: true,
|
{
|
||||||
options: [{ label: '启用', value: 1 }, { label: '停用', value: 2 }],
|
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 = '') {
|
function searchRelation(field: ResourceField, keyword: string) {
|
||||||
if (isCredentialResource.value && resource === '/staff_account') {
|
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();
|
await loadCredentialOwner();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
relationLoading[resource] = true;
|
await relations.change(field);
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (
|
if (
|
||||||
isCredentialResource.value && mode.value === 'create' &&
|
isCredentialResource.value &&
|
||||||
(typeof route.query.owner_identity !== 'string' ||
|
mode.value === 'create' &&
|
||||||
route.query.relation_key !== 'staff_account_identity')
|
(typeof route.query.owner_identity !== 'string' || route.query.relation_key !== 'staff_account_identity')
|
||||||
) {
|
) {
|
||||||
Message.warning('请从配送人员列表进入资质新建页');
|
Message.warning('请从配送人员列表进入资质新建页');
|
||||||
await router.replace({ name: 'staff-delivery' });
|
await router.replace({ name: 'staff-delivery' });
|
||||||
@@ -456,14 +463,11 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
await loadRecord();
|
await loadRecord();
|
||||||
if (errorMessage.value) return;
|
if (errorMessage.value) return;
|
||||||
const paths = new Set(
|
const relationFields = [...definition.value.fields, ...(definition.value.detailActions ?? []).flatMap((item) => item.fields ?? [])];
|
||||||
[...definition.value.fields, ...(definition.value.detailActions ?? []).flatMap((item) => item.fields ?? [])]
|
relations.configure(relationFields);
|
||||||
.map((field: ResourceField) => field.relation)
|
|
||||||
.filter((value): value is string => Boolean(value)),
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([...paths].map((resource) => loadRelation(resource)));
|
if (isCredentialResource.value) await loadCredentialOwner();
|
||||||
if (isCredentialResource.value && mode.value === 'detail') await loadCredentialOwner();
|
await relations.preload(relationFields.filter((field) => !(isCredentialResource.value && field.relation === '/staff_account')));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = `所属配送人员加载失败:${(error as Error).message}`;
|
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