完善配送端列表头像展示
This commit is contained in:
@@ -14,21 +14,26 @@ import (
|
|||||||
|
|
||||||
func scopedStaff(ctx *gin.Context, identity string, point models.DeliveryBasic) (models.StaffAccount, bool) {
|
func scopedStaff(ctx *gin.Context, identity string, point models.DeliveryBasic) (models.StaffAccount, bool) {
|
||||||
var staff models.StaffAccount
|
var staff models.StaffAccount
|
||||||
if err := common.ActiveRecords(db()).Where("identity = ? AND gas_basic_id = ? AND delivery_basic_id = ?",
|
if err := deliveryStaffQuery(db(), point).Where("identity = ?", identity).First(&staff).Error; err != nil {
|
||||||
identity, point.GasBasicID, point.ID).First(&staff).Error; err != nil {
|
|
||||||
common.RespondRecordError(ctx, err)
|
common.RespondRecordError(ctx, err)
|
||||||
return staff, false
|
return staff, false
|
||||||
}
|
}
|
||||||
return staff, true
|
return staff, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deliveryStaffQuery 固定配送人员的气站、配送点及角色范围。
|
||||||
|
func deliveryStaffQuery(databaseService *gorm.DB, point models.DeliveryBasic) *gorm.DB {
|
||||||
|
return common.ActiveRecords(databaseService.Model(&models.StaffAccount{})).
|
||||||
|
Where("gas_basic_id = ? AND delivery_basic_id = ? AND role_code = ?",
|
||||||
|
point.GasBasicID, point.ID, "delivery")
|
||||||
|
}
|
||||||
|
|
||||||
func ListStaff(ctx *gin.Context) {
|
func ListStaff(ctx *gin.Context) {
|
||||||
point, _, ok := currentScope(ctx)
|
point, _, ok := currentScope(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
query := common.ActiveRecords(db().Model(&models.StaffAccount{})).
|
query := deliveryStaffQuery(db(), point)
|
||||||
Where("gas_basic_id = ? AND delivery_basic_id = ? AND role_code = ?", point.GasBasicID, point.ID, "delivery")
|
|
||||||
listScoped(ctx, &models.StaffAccount{}, query, "staff_account.created_at desc")
|
listScoped(ctx, &models.StaffAccount{}, query, "staff_account.created_at desc")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,8 +43,7 @@ func GetStaff(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var staff models.StaffAccount
|
var staff models.StaffAccount
|
||||||
respondRecord(ctx, common.ActiveRecords(db()).Where("identity = ? AND gas_basic_id = ? AND delivery_basic_id = ? AND role_code = ?",
|
respondRecord(ctx, deliveryStaffQuery(db(), point).Where("identity = ?", ctx.Param("identity")), &staff)
|
||||||
ctx.Param("identity"), point.GasBasicID, point.ID, "delivery"), &staff)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStaffAvatar 返回当前配送点范围内配送人员的受保护头像。
|
// GetStaffAvatar 返回当前配送点范围内配送人员的受保护头像。
|
||||||
|
|||||||
33
backend/api/internal/logic/delivery/staff_test.go
Normal file
33
backend/api/internal/logic/delivery/staff_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// 功能描述:验证配送点人员查询始终限制为当前配送点的配送角色。版本:v1.0.0。
|
||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeliveryStaffQueryKeepsRoleScope 验证列表、详情和头像共用的范围包含 delivery 角色。
|
||||||
|
func TestDeliveryStaffQueryKeepsRoleScope(t *testing.T) {
|
||||||
|
connection, _, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建 SQL mock 失败:%v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = connection.Close() })
|
||||||
|
databaseService, err := gorm.Open(postgres.New(postgres.Config{Conn: connection}), &gorm.Config{DryRun: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("打开 GORM 失败:%v", err)
|
||||||
|
}
|
||||||
|
point := models.DeliveryBasic{Entity: models.Entity{ID: 22}, GasBasicID: 11}
|
||||||
|
statement := deliveryStaffQuery(databaseService, point).
|
||||||
|
Where("identity = ?", "staff-identity").Find(&models.StaffAccount{}).Statement.SQL.String()
|
||||||
|
for _, required := range []string{"gas_basic_id", "delivery_basic_id", "role_code"} {
|
||||||
|
if !strings.Contains(statement, required) {
|
||||||
|
t.Fatalf("配送人员范围缺少 %s:%s", required, statement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,7 @@ Global:
|
|||||||
- 资质支持新增、编辑、启停和归档。
|
- 资质支持新增、编辑、启停和归档。
|
||||||
- 删除均为归档;存在未完成订单时禁止归档人员。
|
- 删除均为归档;存在未完成订单时禁止归档人员。
|
||||||
- 不允许创建安装、运维、仓管、调度员或质控角色账号。
|
- 不允许创建安装、运维、仓管、调度员或质控角色账号。
|
||||||
|
- 配送人员列表不得展示数据库自增 ID;联系电话之后显示 32px 圆形头像缩略图,头像仅通过当前配送点 JWT 范围内的受保护接口懒加载。无头像或读取异常时显示默认头像,不得从列表响应读取或暴露头像 URI。
|
||||||
|
|
||||||
### 4.4 用户管理
|
### 4.4 用户管理
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ Global:
|
|||||||
- 配送点不能将用户迁移到其他配送点或其他气站。
|
- 配送点不能将用户迁移到其他配送点或其他气站。
|
||||||
- 归档用户时同时归档地址及服务关系。
|
- 归档用户时同时归档地址及服务关系。
|
||||||
- 用户存在未完成订单、未关闭工单、钱包余额、可提现余额或待处理提现时禁止归档。
|
- 用户存在未完成订单、未关闭工单、钱包余额、可提现余额或待处理提现时禁止归档。
|
||||||
|
- 用户账户列表沿用配送人员列表的受控头像缩略图、默认头像、限流缓存和自增 ID 隐藏规则。
|
||||||
|
|
||||||
### 4.5 合同管理
|
### 4.5 合同管理
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
7. 依据 5173 页面重新对齐灰色页面底、面包屑、分区卡片、详情信息网格、双列表单和底部操作区。
|
7. 依据 5173 页面重新对齐灰色页面底、面包屑、分区卡片、详情信息网格、双列表单和底部操作区。
|
||||||
8. 将配送人员和用户账户的头像文本框替换为 5173 头像摘要卡片,新增上传、预览、恢复默认头像和配送点范围鉴权读取能力。
|
8. 将配送人员和用户账户的头像文本框替换为 5173 头像摘要卡片,新增上传、预览、恢复默认头像和配送点范围鉴权读取能力。
|
||||||
9. 修正配送人员、用户编辑接口:没有提交头像字段时保留旧头像,避免编辑其他资料时误清空。
|
9. 修正配送人员、用户编辑接口:没有提交头像字段时保留旧头像,避免编辑其他资料时误清空。
|
||||||
|
10. 配送人员和用户账户列表增加 32px 受控头像缩略图,复用 5173 的懒加载、并发 6、当前页缓存、取消和 Blob URL 回收策略。
|
||||||
|
11. 删除全部标准资源列表的数据库自增 ID 列,只保留系统唯一标识;同时把该规则加入自动检查。
|
||||||
|
12. 配送人员头像读取补充 `role_code = delivery` 范围限制,并新增 SQL 范围测试。
|
||||||
|
|
||||||
## 操作后状态
|
## 操作后状态
|
||||||
|
|
||||||
@@ -28,6 +31,7 @@
|
|||||||
|
|
||||||
- 新增:`src/views/resource/` 下 5 个记录页组件与组合函数。
|
- 新增:`src/views/resource/` 下 5 个记录页组件与组合函数。
|
||||||
- 新增:`src/views/shared/ResourceListPage.vue`。
|
- 新增:`src/views/shared/ResourceListPage.vue`。
|
||||||
|
- 新增:`ProtectedAvatarThumbnail.vue`、`protected-list-avatar-loader.ts` 和配送人员角色范围测试。
|
||||||
- 新增:`src/router/routes/modules/resource-route-builder.ts`。
|
- 新增:`src/router/routes/modules/resource-route-builder.ts`。
|
||||||
- 新增:`src/api/resource-display.ts`、`resource-navigation.ts`、`resource-record-form.ts`。
|
- 新增:`src/api/resource-display.ts`、`resource-navigation.ts`、`resource-record-form.ts`。
|
||||||
- 修改:`platform.ts`、`ResourcePage.vue`、`resources.ts`、路由类型、资源契约和包脚本。
|
- 修改:`platform.ts`、`ResourcePage.vue`、`resources.ts`、路由类型、资源契约和包脚本。
|
||||||
@@ -45,12 +49,13 @@
|
|||||||
- `contract:check`:通过,18 个资源与后端契约一致。
|
- `contract:check`:通过,18 个资源与后端契约一致。
|
||||||
- `profile:check`:通过,资料专用只读页未回退。
|
- `profile:check`:通过,资料专用只读页未回退。
|
||||||
- `type:check`:通过。
|
- `type:check`:通过。
|
||||||
- `build`:通过,2628 个模块完成生产构建。
|
- `build`:通过,2632 个模块完成生产构建。
|
||||||
- `go test ./internal/logic/delivery`:通过。
|
- `go test ./internal/logic/delivery ./internal/routers`:通过,包含配送人员头像角色范围测试。
|
||||||
- `lint`:通过;仅报告项目既有警告,未产生失败项。
|
- `lint`:通过;仅报告项目既有警告,未产生失败项。
|
||||||
- 浏览器回归:工作人员列表、详情、编辑、正式新建地址、返回链路、未保存保护、配送订单新建页、支付列表均通过。
|
- 浏览器回归:工作人员列表、详情、编辑、正式新建地址、返回链路、未保存保护、配送订单新建页、支付列表均通过。
|
||||||
- 布局回归:工作人员详情、编辑和配送订单新建页已在应用内浏览器逐页截图检查,与 5173 的页面结构和响应式断点一致。
|
- 布局回归:工作人员详情、编辑和配送订单新建页已在应用内浏览器逐页截图检查,与 5173 的页面结构和响应式断点一致。
|
||||||
- 头像回归:配送人员新建、编辑页已确认不再显示头像文本框,头像选择按钮、格式大小提示、默认头像和身份摘要均正常;后端头像路由测试通过。
|
- 头像回归:配送人员新建、编辑页已确认不再显示头像文本框,头像选择按钮、格式大小提示、默认头像和身份摘要均正常;后端头像路由测试通过。
|
||||||
|
- 列表头像回归:配送人员、用户账户显示受控圆形头像,其他资源不生成无效头像列;数据库 ID 列已从全部标准列表移除;点击刷新后头像重新加载正常。
|
||||||
|
|
||||||
## 风险评估
|
## 风险评估
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ frontend/delivery_admin/
|
|||||||
└── src/
|
└── src/
|
||||||
├── api/
|
├── api/
|
||||||
│ ├── resource-display.ts # 中文字段、状态和详情值展示
|
│ ├── resource-display.ts # 中文字段、状态和详情值展示
|
||||||
|
│ ├── avatar.ts # 头像上传与配送点鉴权读取
|
||||||
│ ├── resource-navigation.ts # 独立页面路由和安全返回地址
|
│ ├── resource-navigation.ts # 独立页面路由和安全返回地址
|
||||||
│ └── resource-record-form.ts # 表单初始化、字段白名单和校验
|
│ └── resource-record-form.ts # 表单初始化、字段白名单和校验
|
||||||
├── router/routes/modules/
|
├── router/routes/modules/
|
||||||
@@ -37,7 +38,9 @@ frontend/delivery_admin/
|
|||||||
│ ├── use-resource-avatar.ts # 头像预览、上传和清除状态
|
│ ├── use-resource-avatar.ts # 头像预览、上传和清除状态
|
||||||
│ └── use-unsaved-record.ts # 未保存离开保护
|
│ └── use-unsaved-record.ts # 未保存离开保护
|
||||||
└── views/shared/
|
└── views/shared/
|
||||||
└── ResourceListPage.vue # 跳转独立页面的标准列表
|
├── ResourceListPage.vue # 跳转独立页面的标准列表
|
||||||
|
├── ProtectedAvatarThumbnail.vue # 32px 受控头像缩略图
|
||||||
|
└── protected-list-avatar-loader.ts # 懒加载、限流、缓存和取消
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. 核心实现
|
## 4. 核心实现
|
||||||
@@ -50,6 +53,8 @@ frontend/delivery_admin/
|
|||||||
|
|
||||||
配送人员和用户账户参考 5173 使用头像身份摘要卡片。新建、编辑时点击头像可选择不超过 2 MB 的 JPG/PNG 文件,保存时先上传到受控头像目录,再把返回 URI 写入资源;详情页通过配送点范围鉴权接口读取头像。未选择新头像时不会清空已有头像。
|
配送人员和用户账户参考 5173 使用头像身份摘要卡片。新建、编辑时点击头像可选择不超过 2 MB 的 JPG/PNG 文件,保存时先上传到受控头像目录,再把返回 URI 写入资源;详情页通过配送点范围鉴权接口读取头像。未选择新头像时不会清空已有头像。
|
||||||
|
|
||||||
|
配送人员和用户账户列表同样参考 5173:联系电话后显示 32px 圆形头像,最多并发 6 个鉴权请求,接近可视区域才加载;当前页缓存结果,刷新或离页时取消请求并释放 Blob URL。404、网络错误和图片解码失败均回退本地默认头像。全部标准资源列表隐藏数据库自增 ID,只展示可复制的系统唯一标识。
|
||||||
|
|
||||||
`resource-record-form.ts` 为五类可编辑资源声明后端更新字段白名单,防止用户名、合同编号等只读字段出现在编辑页或被无效提交。
|
`resource-record-form.ts` 为五类可编辑资源声明后端更新字段白名单,防止用户名、合同编号等只读字段出现在编辑页或被无效提交。
|
||||||
|
|
||||||
支付与退款资源统一使用后端正式名称 `payment_order`、`payment_refund`,菜单地址仍保持 `/finance/payments`、`/finance/refunds`,避免接口路径不一致导致 404。
|
支付与退款资源统一使用后端正式名称 `payment_order`、`payment_refund`,菜单地址仍保持 `/finance/payments`、`/finance/refunds`,避免接口路径不一致导致 404。
|
||||||
@@ -73,8 +78,9 @@ npm.cmd run build
|
|||||||
- 删除已停用的抽屉式 `CrudListPage.vue`。
|
- 删除已停用的抽屉式 `CrudListPage.vue`。
|
||||||
- 修复支付、退款资源与后端契约名称不一致的问题。
|
- 修复支付、退款资源与后端契约名称不一致的问题。
|
||||||
- 新增 17/9/5 页面能力矩阵自动检查。
|
- 新增 17/9/5 页面能力矩阵自动检查。
|
||||||
|
- 新增配送人员、用户账户的受控头像缩略图,并移除全部标准列表的数据库自增 ID。
|
||||||
- 保持配送点资料专用只读页面和现有公共后端接口不变。
|
- 保持配送点资料专用只读页面和现有公共后端接口不变。
|
||||||
|
|
||||||
## 7. 已知边界
|
## 7. 已知边界
|
||||||
|
|
||||||
独立页面只使用配送端已存在的关系查询和 CRUD 能力,不引入平台端头像上传、合同附件上传、平台角色或跨组织账户摘要接口。普通头像和附件 URI 字段仍按原后端文本契约展示或填写。
|
独立页面只使用配送端数据范围内的关系查询和 CRUD 能力;头像复用公共受控上传入口,并通过配送点专属鉴权接口读取。不引入合同附件上传、平台角色或跨组织账户摘要接口,附件 URI 字段仍按原后端文本契约展示或填写。
|
||||||
|
|||||||
@@ -65,5 +65,12 @@ assert(routeKeys.has('GET /user_account/:identity/avatar'), '用户账户缺少
|
|||||||
const recordPage = read('src/views/resource/ResourceRecordPage.vue');
|
const recordPage = read('src/views/resource/ResourceRecordPage.vue');
|
||||||
assert(recordPage.includes('ResourceAccountSummary'), '账户资源页尚未接入 5173 头像摘要卡片');
|
assert(recordPage.includes('ResourceAccountSummary'), '账户资源页尚未接入 5173 头像摘要卡片');
|
||||||
assert(recordPage.includes("field.key !== 'avatar'"), '头像字段仍可能显示为普通文本框');
|
assert(recordPage.includes("field.key !== 'avatar'"), '头像字段仍可能显示为普通文本框');
|
||||||
|
const listPage = read('src/views/shared/ResourceListPage.vue');
|
||||||
|
assert(!listPage.includes('title="ID"'), '标准列表仍暴露数据库自增 ID');
|
||||||
|
assert(listPage.includes('ProtectedAvatarThumbnail'), '账户列表尚未接入受控头像缩略图');
|
||||||
|
assert(listPage.includes('avatarLoader.reset()'), '列表刷新未清理头像缓存和请求');
|
||||||
|
const avatarLoader = read('src/views/shared/protected-list-avatar-loader.ts');
|
||||||
|
assert(avatarLoader.includes('MAX_CONCURRENT_REQUESTS = 6'), '头像请求并发上限未与 5173 对齐');
|
||||||
|
assert(avatarLoader.includes("new Set(['staff_account', 'user_account'])"), '头像列表资源白名单不正确');
|
||||||
|
|
||||||
console.log(`独立资源页面契约通过:详情 ${listResources.length},新建 ${creatable.size},编辑 ${editable.size}`);
|
console.log(`独立资源页面契约通过:详情 ${listResources.length},新建 ${creatable.size},编辑 ${editable.size}`);
|
||||||
|
|||||||
@@ -37,10 +37,14 @@ async function upload(file: File): Promise<AvatarUploadReply> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 读取配送点权限范围内的受保护头像。 */
|
/** 读取配送点权限范围内的受保护头像。 */
|
||||||
async function load(resource: string, identity: string): Promise<Blob | undefined> {
|
async function load(
|
||||||
|
resource: string,
|
||||||
|
identity: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Blob | undefined> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${deliveryApiBaseURL}${resource}/${encodeURIComponent(identity)}/avatar`,
|
`${deliveryApiBaseURL}${resource}/${encodeURIComponent(identity)}/avatar`,
|
||||||
{ headers: authorizationHeaders() },
|
{ headers: authorizationHeaders(), signal },
|
||||||
);
|
);
|
||||||
if (response.status === 404) return undefined;
|
if (response.status === 404) return undefined;
|
||||||
if (!response.ok) throw new Error('头像读取失败');
|
if (!response.ok) throw new Error('头像读取失败');
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<!-- 功能描述:在账户列表中懒加载并展示受鉴权保护的圆形头像。版本:v1.0.0。 -->
|
||||||
|
<template>
|
||||||
|
<a-tooltip :disabled="!unavailable" content="头像暂不可用">
|
||||||
|
<span
|
||||||
|
ref="root"
|
||||||
|
class="protected-avatar-thumbnail"
|
||||||
|
:tabindex="unavailable ? 0 : undefined"
|
||||||
|
:aria-label="unavailable ? `${avatarAlt},头像暂不可用` : undefined"
|
||||||
|
>
|
||||||
|
<a-avatar :size="32">
|
||||||
|
<img :src="source" :alt="avatarAlt" @error="handleImageError" />
|
||||||
|
</a-avatar>
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { DEFAULT_USER_AVATAR } from '@/constants/avatar';
|
||||||
|
import type { ProtectedListAvatarLoader } from './protected-list-avatar-loader';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
resource: string; identity: string; displayName: string;
|
||||||
|
refreshKey: number; loader: ProtectedListAvatarLoader;
|
||||||
|
}>();
|
||||||
|
const root = ref<HTMLElement>();
|
||||||
|
const source = ref(DEFAULT_USER_AVATAR);
|
||||||
|
const unavailable = ref(false);
|
||||||
|
const avatarAlt = computed(() => `${props.displayName || '账户'}头像`);
|
||||||
|
let observer: IntersectionObserver | undefined;
|
||||||
|
let controller: AbortController | undefined;
|
||||||
|
let objectURL = '';
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
function revokeObjectURL() {
|
||||||
|
if (objectURL) URL.revokeObjectURL(objectURL);
|
||||||
|
objectURL = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = undefined;
|
||||||
|
controller?.abort();
|
||||||
|
controller = undefined;
|
||||||
|
revokeObjectURL();
|
||||||
|
source.value = DEFAULT_USER_AVATAR;
|
||||||
|
unavailable.value = false;
|
||||||
|
loaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAvatar() {
|
||||||
|
if (loaded || !props.identity) return;
|
||||||
|
loaded = true;
|
||||||
|
controller = new AbortController();
|
||||||
|
const currentController = controller;
|
||||||
|
try {
|
||||||
|
const blob = await props.loader.load(props.resource, props.identity, currentController.signal);
|
||||||
|
if (currentController.signal.aborted || !blob) return;
|
||||||
|
objectURL = URL.createObjectURL(blob);
|
||||||
|
source.value = objectURL;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error).name !== 'AbortError') unavailable.value = true;
|
||||||
|
} finally {
|
||||||
|
if (controller === currentController) controller = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeVisibility() {
|
||||||
|
if (!root.value || loaded) return;
|
||||||
|
if (!('IntersectionObserver' in window)) return void loadAvatar();
|
||||||
|
observer = new IntersectionObserver((entries) => {
|
||||||
|
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = undefined;
|
||||||
|
void loadAvatar();
|
||||||
|
}, { rootMargin: '120px' });
|
||||||
|
observer.observe(root.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageError() {
|
||||||
|
if (source.value === DEFAULT_USER_AVATAR) return;
|
||||||
|
revokeObjectURL();
|
||||||
|
source.value = DEFAULT_USER_AVATAR;
|
||||||
|
unavailable.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(observeVisibility);
|
||||||
|
watch(() => [props.resource, props.identity, props.refreshKey], async () => {
|
||||||
|
resetState();
|
||||||
|
await nextTick();
|
||||||
|
observeVisibility();
|
||||||
|
});
|
||||||
|
onBeforeUnmount(resetState);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.protected-avatar-thumbnail {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 32px; height: 32px; vertical-align: middle; border-radius: 50%; outline: none;
|
||||||
|
}
|
||||||
|
.protected-avatar-thumbnail:focus-visible { box-shadow: 0 0 0 2px rgb(var(--primary-3)); }
|
||||||
|
.protected-avatar-thumbnail img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
</style>
|
||||||
@@ -30,21 +30,26 @@
|
|||||||
|
|
||||||
<a-table :data="list" :loading="loading" :pagination="false" row-key="identity">
|
<a-table :data="list" :loading="loading" :pagination="false" row-key="identity">
|
||||||
<template #columns>
|
<template #columns>
|
||||||
<a-table-column title="ID" data-index="id" :width="80" />
|
|
||||||
<a-table-column title="唯一标识" :width="150">
|
|
||||||
<template #cell="{ record }"><IdentityText :value="String(record.identity)" /></template>
|
|
||||||
</a-table-column>
|
|
||||||
<a-table-column
|
<a-table-column
|
||||||
v-for="field in displayFields"
|
v-for="field in displayFields"
|
||||||
:key="field.key"
|
:key="field.key"
|
||||||
:title="field.listLabel ?? field.label"
|
:title="field.listLabel ?? field.label"
|
||||||
:width="columnWidth(field)"
|
:width="columnWidth(field)"
|
||||||
ellipsis
|
:align="isProtectedListAvatarField(definition.name, field.key) ? 'center' : 'left'"
|
||||||
tooltip
|
:ellipsis="!isProtectedListAvatarField(definition.name, field.key)"
|
||||||
|
:tooltip="!isProtectedListAvatarField(definition.name, field.key)"
|
||||||
>
|
>
|
||||||
<template #cell="{ record }">
|
<template #cell="{ record }">
|
||||||
|
<ProtectedAvatarThumbnail
|
||||||
|
v-if="isProtectedListAvatarField(definition.name, field.key)"
|
||||||
|
:resource="definition.resource"
|
||||||
|
:identity="String(record.identity ?? '')"
|
||||||
|
:display-name="protectedListAvatarDisplayName(record)"
|
||||||
|
:refresh-key="avatarRefreshKey"
|
||||||
|
:loader="avatarLoader"
|
||||||
|
/>
|
||||||
<IdentityText
|
<IdentityText
|
||||||
v-if="(field.type === 'identity' || field.listCopyable) && fieldValue(field, record)"
|
v-else-if="(field.type === 'identity' || field.listCopyable) && fieldValue(field, record)"
|
||||||
:value="fieldValue(field, record)"
|
:value="fieldValue(field, record)"
|
||||||
/>
|
/>
|
||||||
<template v-else>{{ displayResourceValue(definition, field.key, record[field.key] ?? record[`${field.key}_masked`]) }}</template>
|
<template v-else>{{ displayResourceValue(definition, field.key, record[field.key] ?? record[`${field.key}_masked`]) }}</template>
|
||||||
@@ -57,6 +62,9 @@
|
|||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
</a-table-column>
|
</a-table-column>
|
||||||
|
<a-table-column title="系统唯一标识" :width="170">
|
||||||
|
<template #cell="{ record }"><IdentityText :value="String(record.identity)" /></template>
|
||||||
|
</a-table-column>
|
||||||
<a-table-column title="操作" :width="220" fixed="right">
|
<a-table-column title="操作" :width="220" fixed="right">
|
||||||
<template #cell="{ record }">
|
<template #cell="{ record }">
|
||||||
<a-space>
|
<a-space>
|
||||||
@@ -91,7 +99,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Message } from '@arco-design/web-vue';
|
import { Message } from '@arco-design/web-vue';
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { resourceApi } from '@/api/resource';
|
import { resourceApi } from '@/api/resource';
|
||||||
import {
|
import {
|
||||||
@@ -104,6 +112,12 @@ import { recordRouteLocation } from '@/api/resource-navigation';
|
|||||||
import type { ResourceRow } from '@/api/resource-record-form';
|
import type { ResourceRow } from '@/api/resource-record-form';
|
||||||
import type { ResourceField, ResourceUiDefinition } from '@/api/resources';
|
import type { ResourceField, ResourceUiDefinition } from '@/api/resources';
|
||||||
import IdentityText from '@/components/IdentityText.vue';
|
import IdentityText from '@/components/IdentityText.vue';
|
||||||
|
import ProtectedAvatarThumbnail from './ProtectedAvatarThumbnail.vue';
|
||||||
|
import {
|
||||||
|
createProtectedListAvatarLoader,
|
||||||
|
isProtectedListAvatarField,
|
||||||
|
protectedListAvatarDisplayName,
|
||||||
|
} from './protected-list-avatar-loader';
|
||||||
|
|
||||||
const props = defineProps<{ definition: ResourceUiDefinition }>();
|
const props = defineProps<{ definition: ResourceUiDefinition }>();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -113,15 +127,19 @@ const page = ref(Math.max(1, Number(route.query.page) || 1));
|
|||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const list = ref<ResourceRow[]>([]);
|
const list = ref<ResourceRow[]>([]);
|
||||||
|
const avatarLoader = createProtectedListAvatarLoader();
|
||||||
|
const avatarRefreshKey = ref(0);
|
||||||
const filters = reactive({ keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '' });
|
const filters = reactive({ keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '' });
|
||||||
const displayFields = computed(() =>
|
const displayFields = computed(() =>
|
||||||
props.definition.fields
|
props.definition.fields
|
||||||
.filter((field) => !['identity', 'password', 'avatar', 'status'].includes(field.key))
|
.filter((field) => !['identity', 'password', 'status'].includes(field.key))
|
||||||
|
.filter((field) => field.key !== 'avatar' || isProtectedListAvatarField(props.definition.name, field.key))
|
||||||
.slice(0, 6),
|
.slice(0, 6),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 返回列表列宽。 */
|
/** 返回列表列宽。 */
|
||||||
function columnWidth(field: ResourceField) {
|
function columnWidth(field: ResourceField) {
|
||||||
|
if (isProtectedListAvatarField(props.definition.name, field.key)) return 72;
|
||||||
if (field.type === 'datetime' || field.type === 'date') return 180;
|
if (field.type === 'datetime' || field.type === 'date') return 180;
|
||||||
if (field.type === 'money' || field.type === 'number') return 140;
|
if (field.type === 'money' || field.type === 'number') return 140;
|
||||||
return field.type === 'textarea' ? 240 : 160;
|
return field.type === 'textarea' ? 240 : 160;
|
||||||
@@ -134,6 +152,8 @@ function fieldValue(field: ResourceField, row: ResourceRow) {
|
|||||||
|
|
||||||
/** 加载当前列表和来源关系筛选。 */
|
/** 加载当前列表和来源关系筛选。 */
|
||||||
async function load() {
|
async function load() {
|
||||||
|
avatarLoader.reset();
|
||||||
|
avatarRefreshKey.value += 1;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const serverFilters: Record<string, string> = filters.keyword ? { keyword: filters.keyword } : {};
|
const serverFilters: Record<string, string> = filters.keyword ? { keyword: filters.keyword } : {};
|
||||||
@@ -207,6 +227,7 @@ async function changePage(next: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
|
onBeforeUnmount(avatarLoader.reset);
|
||||||
watch(() => props.definition.resource, async () => {
|
watch(() => props.definition.resource, async () => {
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
filters.keyword = '';
|
filters.keyword = '';
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/** 功能描述:为受控账户列表提供头像请求限流、当前页缓存和取消能力。版本:v1.0.0。 */
|
||||||
|
import { avatarApi } from '@/api/avatar';
|
||||||
|
|
||||||
|
const MAX_CONCURRENT_REQUESTS = 6;
|
||||||
|
const SUPPORTED_RESOURCES = new Set(['staff_account', 'user_account']);
|
||||||
|
type QueueTask = { run: () => void; cancel: () => void };
|
||||||
|
|
||||||
|
/** 判断资源是否具备配送点范围头像接口。 */
|
||||||
|
export function supportsProtectedListAvatar(resourceName: string) {
|
||||||
|
return SUPPORTED_RESOURCES.has(resourceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断字段是否应渲染受控头像缩略图。 */
|
||||||
|
export function isProtectedListAvatarField(resourceName: string, fieldKey: string) {
|
||||||
|
return fieldKey === 'avatar' && supportsProtectedListAvatar(resourceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回头像替代文本使用的账户名称。 */
|
||||||
|
export function protectedListAvatarDisplayName(row: Record<string, unknown>) {
|
||||||
|
return String(row.name ?? row.real_name ?? row.username ?? '账户');
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortError() {
|
||||||
|
const error = new Error('头像读取已取消');
|
||||||
|
error.name = 'AbortError';
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProtectedListAvatarLoader = ReturnType<typeof createProtectedListAvatarLoader>;
|
||||||
|
|
||||||
|
/** 创建仅在当前列表生命周期生效的头像加载器。 */
|
||||||
|
export function createProtectedListAvatarLoader() {
|
||||||
|
const cache = new Map<string, Promise<Blob | undefined>>();
|
||||||
|
const queue: QueueTask[] = [];
|
||||||
|
const runningControllers = new Set<AbortController>();
|
||||||
|
let activeCount = 0;
|
||||||
|
let generation = 0;
|
||||||
|
|
||||||
|
function pump() {
|
||||||
|
while (activeCount < MAX_CONCURRENT_REQUESTS && queue.length) queue.shift()?.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule(resource: string, identity: string, signal: AbortSignal) {
|
||||||
|
return new Promise<Blob | undefined>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
let started = false;
|
||||||
|
let finished = false;
|
||||||
|
let requestController: AbortController | undefined;
|
||||||
|
const finish = () => {
|
||||||
|
if (!started || finished) return;
|
||||||
|
finished = true;
|
||||||
|
activeCount -= 1;
|
||||||
|
if (requestController) runningControllers.delete(requestController);
|
||||||
|
signal.removeEventListener('abort', cancel);
|
||||||
|
pump();
|
||||||
|
};
|
||||||
|
const cancel = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
requestController?.abort();
|
||||||
|
reject(abortError());
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
const task: QueueTask = {
|
||||||
|
cancel,
|
||||||
|
run: () => {
|
||||||
|
if (settled || signal.aborted) return cancel();
|
||||||
|
started = true;
|
||||||
|
activeCount += 1;
|
||||||
|
requestController = new AbortController();
|
||||||
|
runningControllers.add(requestController);
|
||||||
|
avatarApi.load(resource, identity, requestController.signal)
|
||||||
|
.then((blob) => { if (!settled) { settled = true; resolve(blob); } })
|
||||||
|
.catch((error) => { if (!settled) { settled = true; reject(error); } })
|
||||||
|
.finally(finish);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (signal.aborted) return cancel();
|
||||||
|
signal.addEventListener('abort', cancel, { once: true });
|
||||||
|
queue.push(task);
|
||||||
|
pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(resource: string, identity: string, signal: AbortSignal) {
|
||||||
|
const key = `${resource}:${identity}`;
|
||||||
|
const existing = cache.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
const requestGeneration = generation;
|
||||||
|
const request = schedule(resource, identity, signal).catch((error) => {
|
||||||
|
if (generation === requestGeneration) cache.delete(key);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
cache.set(key, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
generation += 1;
|
||||||
|
cache.clear();
|
||||||
|
queue.splice(0).forEach((task) => {
|
||||||
|
task.cancel();
|
||||||
|
});
|
||||||
|
runningControllers.forEach((controller) => {
|
||||||
|
controller.abort();
|
||||||
|
});
|
||||||
|
runningControllers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { load, reset };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user