feat: 账户列表展示受控头像
This commit is contained in:
@@ -142,7 +142,7 @@
|
||||
|
||||
工作人员支持安装人员 `installer`、配送人员 `delivery`、运维人员 `operations` 三种角色,可关联气站或配送点,并记录在岗/离岗状态。前端按角色提供独立列表和新增入口;后端按菜单和人员实际角色校验详情、修改及资质访问。
|
||||
|
||||
工作人员的新建、详情和编辑均使用独立页面。账户页面顶部展示头像、用户名、唯一标识和创建时间,下方维护现有基本信息与钱包摘要;头像仅支持 JPG/PNG、本地预览和保存时上传,读取继续受平台 JWT、菜单及人员角色权限保护。用户名和密码仅用于创建,编辑时用户名只读且密码不回填。
|
||||
工作人员的新建、详情和编辑均使用独立页面。账户页面顶部展示头像、用户名、唯一标识和创建时间,下方维护现有基本信息与钱包摘要;头像仅支持 JPG/PNG、本地预览和保存时上传,读取继续受平台 JWT、菜单及人员角色权限保护。工作人员列表使用现有受控头像接口懒加载 32px 圆形缩略图,无头像时显示默认头像;通用列表响应仍不得返回头像 URI。用户名和密码仅用于创建,编辑时用户名只读且密码不回填。
|
||||
|
||||
### 6.3 用户管理
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
|
||||
用户页面同时提供配送合同入口。当前后台可直接维护用户、地址和服务关系,但尚未实现邀请二维码注册、服务关系审批和完整历史时间线。
|
||||
|
||||
用户账户的新建、详情和编辑复用工作人员账户布局,并使用各自独立 URL。用户头像通过受保护资源接口读取,不在通用详情响应中暴露头像 URI;详情页继续展示钱包摘要并可进入钱包独立详情页。
|
||||
用户账户的新建、详情和编辑复用工作人员账户布局,并使用各自独立 URL。用户头像通过受保护资源接口读取,不在通用详情响应中暴露头像 URI;用户账户列表与平台账户列表沿用工作人员列表的受控缩略图规则。详情页继续展示钱包摘要并可进入钱包独立详情页。
|
||||
|
||||
### 6.4 智能气阀管理
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
- 银行卡号、身份证号、预留手机号使用 `Global.FieldEncryptionKey` 经 HKDF 派生独立 AES-GCM 加密键和 HMAC 指纹键;接口列表只返回末四位掩码。开发占位密钥不得用于生产。
|
||||
- 支付密码独立于登录密码,仅允许六位数字,使用 bcrypt 保存;连续失败达到阈值后在 Redis 短时锁定。绑卡、解绑、余额支付和提现均要求支付密码或限定用途的一次性验证码。
|
||||
- 公共上传接口 `/upload/file` 必须携带平台、气站、配送点、用户或工作人员任一合法 JWT;图片/PDF 最大 10MB,视频上限从配置读取。上传只返回资源 URI,业务接口负责建立关联并记录操作者、采集与接收时间。
|
||||
- 平台账户资料头像使用专用 `/upload/avatar` 上传入口,仅允许真实 JPG/PNG、最大 2MB、最大 4096×4096,并在服务端完成扩展名、MIME、尺寸和完整图片解码校验。头像读取通过 `/heqi/platform/v1/{staff_account|user_account|platform_account}/:identity/avatar` 受 JWT、菜单和对象角色权限保护;通用列表及详情响应继续移除 `avatar` 字段。普通资料更新未提交 `avatar` 时保持原头像,只有明确上传或恢复默认头像时才修改该字段。
|
||||
- 平台账户资料头像使用专用 `/upload/avatar` 上传入口,仅允许真实 JPG/PNG、最大 2MB、最大 4096×4096,并在服务端完成扩展名、MIME、尺寸和完整图片解码校验。头像读取通过 `/heqi/platform/v1/{staff_account|user_account|platform_account}/:identity/avatar` 受 JWT、菜单和对象角色权限保护;通用列表及详情响应继续移除 `avatar` 字段。平台总后台的工作人员、用户和平台账户列表可对当前可视记录调用该受控接口展示缩略图,必须限制并发、按页缓存、离页取消请求并释放本地 Blob URL,不得将头像 URI 写回列表数据或开放静态目录。普通资料更新未提交 `avatar` 时保持原头像,只有明确上传或恢复默认头像时才修改该字段。
|
||||
- 充值、支付、提现、工单证据、轨迹点、内容确认等写入均携带幂等号;资金入账在数据库事务内锁定钱包并同时写不可变流水。
|
||||
- 钱包可提现余额是当前总余额的子集,始终满足 `0 <= 可提现余额 <= 总余额`。普通消费扣减总余额后,必须同步把可提现余额限制在剩余总余额以内。
|
||||
- 提现申请在同一数据库事务内锁定钱包、同时预扣总余额和可提现余额并写入不可变流水;驳回只返还该申请实际预扣的两类余额,完成打款只确认外部结果,不得再次扣款。
|
||||
|
||||
64
docs/操作日志_账户列表受控头像展示_20260811.md
Normal file
64
docs/操作日志_账户列表受控头像展示_20260811.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 账户列表受控头像展示操作日志
|
||||
|
||||
操作时间:2026-08-11 11:32:28
|
||||
操作类型:扩展
|
||||
影响模块:平台总后台工作人员、用户账户、平台账户列表
|
||||
|
||||
## 操作前状态
|
||||
|
||||
- 工作人员、用户和平台账户资源定义均包含 `avatar` 字段,通用列表据此生成“头像”列。
|
||||
- 后端安全投影会从通用列表和详情响应中删除 `avatar`,前端又把空值格式化为“-”,因此头像列无法显示图片。
|
||||
- 独立账户资料页已有按账户身份读取头像的鉴权接口,但列表没有缩略图组件、懒加载、并发控制或 Blob URL 回收逻辑。
|
||||
|
||||
## 具体操作
|
||||
|
||||
- 扩展 `frontend/platform_admin/src/api/avatar.ts`,为头像读取增加可选取消信号,原调用方式保持兼容。
|
||||
- 新增受控列表头像加载器:
|
||||
- 仅允许 `staff_account`、`user_account`、`platform_account` 三类资源。
|
||||
- 当前列表生命周期内按“资源+唯一标识”缓存请求。
|
||||
- 同时最多执行 6 个头像请求。
|
||||
- 刷新、翻页、筛选、资源切换或离开列表时取消任务并清理缓存。
|
||||
- 新增 32px 圆形头像缩略图组件:
|
||||
- 默认先展示资料页现有默认头像。
|
||||
- 接近可视区域时才调用现有鉴权接口。
|
||||
- 404 保持默认头像;权限、网络或图片解码异常回退默认头像并显示“头像暂不可用”提示。
|
||||
- 组件卸载或记录变化时取消请求并释放 Blob URL。
|
||||
- 修改通用列表头像单元格,保持头像列位于联系电话之后、角色之前,列宽调整为 72px并居中;头像不增加跳转或弹层操作。
|
||||
- 把列表样式提取到独立 Less 文件,控制通用列表组件体积。
|
||||
|
||||
## 操作后状态
|
||||
|
||||
- 工作人员、用户和平台账户列表可在不暴露头像 URI的前提下显示真实头像缩略图。
|
||||
- 没有头像的账户稳定显示默认头像,异步加载不会改变表格行高。
|
||||
- 气站账户、配送点账户和生产商账户没有头像字段及受控读取接口,继续不显示头像列。
|
||||
- 通用列表与详情响应的敏感字段保护规则、后端接口和数据库结构均未改变。
|
||||
|
||||
## 代码变更
|
||||
|
||||
- `frontend/platform_admin/src/api/avatar.ts`:头像读取支持取消信号。
|
||||
- `frontend/platform_admin/src/views/shared/ProtectedAvatarThumbnail.vue`:列表缩略图、懒加载、异常回退与资源回收。
|
||||
- `frontend/platform_admin/src/views/shared/protected-list-avatar-loader.ts`:资源白名单、限流、缓存和任务取消。
|
||||
- `frontend/platform_admin/src/views/shared/CrudListPage.vue`:接入受控头像单元格。
|
||||
- `frontend/platform_admin/src/views/shared/CrudListPage.less`:列表布局样式。
|
||||
- `docs/05-平台总后台需求.md`、`docs/11-数据接口与安全.md`、`docs/项目文档_标准资源全页管理_v1.0.md`:同步功能和安全边界。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- `npm.cmd run type:check`:通过。
|
||||
- `npm.cmd run build`:通过,Vite 生产构建完成。
|
||||
- `npm.cmd run resource-pages:check`:通过,覆盖详情 46 类、新建 25 类、编辑 23 类。
|
||||
- `npm.cmd run account-roles:check`:通过,三套后台既有角色和账户摘要规则保持完整。
|
||||
- `npm.cmd run contract:check`:通过,48 个资源契约一致。
|
||||
- 浏览器回归:
|
||||
- 安装人员列表真实头像通过 Blob URL加载,原图 `220×168`,表格中按 `32×32` 圆形展示,头像列左右各保留 20px并居中。
|
||||
- 用户账户列表共10条记录,其中1条显示真实头像、9条无头像记录显示默认头像,所有缩略图均为32px。
|
||||
- 点击列表“刷新”后真实头像生成新的 Blob URL,确认当前页缓存已清除并重新读取。
|
||||
- 平台账户列表保留头像列,无头像的 root 账户显示默认头像;工作人员、用户和平台账户替代文本均包含显示名称。
|
||||
- 工作人员、用户和平台账户列表控制台均无警告或错误,页面无额外横向溢出。
|
||||
|
||||
## 风险评估
|
||||
|
||||
- 当前列表每页最多 50 条记录;懒加载和最多6个并发请求可降低数据库与网络瞬时压力,但首次滚动仍会产生按记录读取请求。
|
||||
- 头像 Blob仅在当前列表生命周期内缓存,避免跨页面长期占用内存;刷新会重新请求最新头像。
|
||||
- 平台账户头像接口仍执行现有对象权限校验,权限不足时只显示“头像暂不可用”,不会通过列表接口绕过限制。
|
||||
- 本次不新增批量头像接口、公开静态资源或头像 URI投影,现有安全契约保持不变。
|
||||
@@ -45,7 +45,10 @@ platforms/
|
||||
│ │ └── platform.ts # 平台业务列表路由
|
||||
│ └── views/
|
||||
│ ├── resource/ # 共享全页详情、表单、动作和摘要组件
|
||||
│ └── shared/CrudListPage.vue # 列表与全页跳转入口
|
||||
│ └── shared/
|
||||
│ ├── CrudListPage.vue # 列表与全页跳转入口
|
||||
│ ├── ProtectedAvatarThumbnail.vue # 受控头像缩略图
|
||||
│ └── protected-list-avatar-loader.ts # 懒加载、限流和当前页缓存
|
||||
├── backend/api/internal/
|
||||
│ ├── logic/platform/platform/account.go # 平台账户头像受控读取
|
||||
│ └── routers/platform.go # 平台账户头像路由
|
||||
@@ -73,6 +76,7 @@ platforms/
|
||||
- 聚合详情中的数组继续以页签和表格展示,例如合同产品、修订记录和订单轨迹。
|
||||
- 气站详情保留独立的紧凑启停区域;钱包归属资源保留钱包摘要和钱包详情入口,未开通钱包时显示单行紧凑提示。
|
||||
- 工作人员、用户和平台账户保留头像、用户名、唯一标识、创建时间的账户摘要;气站、配送点和生产商账户仅展示紧凑文字摘要,不显示无数据能力的装饰头像。
|
||||
- 工作人员、用户和平台账户列表将头像字段渲染为 32px 圆形缩略图。缩略图接近可视区时才通过单条受控接口读取,最多并发 6 个请求;404 使用默认头像,权限、网络或解码异常回退默认头像并提供非阻断提示。列表刷新会清除当前页缓存,离开列表会取消请求并释放 Blob URL。
|
||||
- 资源专属流程动作继续使用短模态框,危险动作显示风险提示,平台角色菜单分配保留专属适配。
|
||||
|
||||
### 4.3 新建与编辑
|
||||
@@ -89,7 +93,7 @@ platforms/
|
||||
### 4.4 安全与兼容
|
||||
|
||||
- 详情始终调用现有受保护资源接口,不使用列表缓存绕过脱敏和对象权限。
|
||||
- 头像继续通过专用上传与受控读取接口;平台账户补充同等头像读取能力。
|
||||
- 头像继续通过专用上传与受控读取接口;平台账户补充同等头像读取能力。列表缩略图不读取或暴露通用响应中的头像 URI,只允许 `staff_account`、`user_account`、`platform_account` 三种已配置受控路由的资源启用。
|
||||
- 平台账户普通编辑没有提交头像时保持原值,避免更新其他资料时意外清空头像。
|
||||
- 气站和配送点账户的 `admin` 分别显示为“气站管理员”和“配送点管理员”,表单只读;历史未知编码保留并显示明确提示,不做自动权限迁移。
|
||||
- 平台账户列表和详情使用动态平台角色名称,不直接暴露角色编码;无法匹配时显示“未知角色(原编码)”。
|
||||
@@ -107,6 +111,7 @@ platforms/
|
||||
| 树形资源 | 树页面抽屉 | 保持不变 |
|
||||
| 返回来源 | 关闭抽屉 | 安全 `return_to` 返回 |
|
||||
| 未保存修改 | 关闭即丢失 | 离开前二次确认 |
|
||||
| 账户列表头像 | 普通文本列固定显示“-” | 当前可视记录显示受控圆形缩略图,无头像回退默认图 |
|
||||
|
||||
## 6. 验证方法
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 功能:平台总后台头像上传与鉴权读取客户端。
|
||||
* 版本:v1.0.0
|
||||
* 版本:v1.1.0
|
||||
*/
|
||||
import { getToken } from '@/utils/auth';
|
||||
|
||||
@@ -49,10 +49,11 @@ async function upload(file: File): Promise<AvatarUploadReply> {
|
||||
async function load(
|
||||
resource: string,
|
||||
identity: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Blob | undefined> {
|
||||
const response = await fetch(
|
||||
`${platformApiBaseURL}${resource}/${encodeURIComponent(identity)}/avatar`,
|
||||
{ headers: authorizationHeaders() },
|
||||
{ headers: authorizationHeaders(), signal },
|
||||
);
|
||||
if (response.status === 404) return undefined;
|
||||
if (!response.ok) throw new Error('头像读取失败');
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/* 功能描述:标准资源列表工具栏、分页和辅助文本布局。版本:v1.0.0。 */
|
||||
.filters { flex: 1 1 420px; }
|
||||
.list-toolbar { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 12px 24px; margin-bottom: 16px; }
|
||||
.list-actions { margin-left: auto; }
|
||||
.workflow-alert { margin-bottom: 16px; }
|
||||
.pagination { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.muted-text { color: var(--color-text-3); }
|
||||
@@ -1,6 +1,6 @@
|
||||
<!--
|
||||
功能:展示标准资源列表,并将新建、详情和编辑入口导航到独立页面。
|
||||
版本:v2.0.0
|
||||
版本:v2.1.0
|
||||
-->
|
||||
<template>
|
||||
<a-card :title="listTitle" :bordered="false">
|
||||
@@ -43,12 +43,21 @@
|
||||
:key="field.key"
|
||||
:title="field.listLabel ?? field.label"
|
||||
:width="columnWidth(field)"
|
||||
ellipsis
|
||||
tooltip
|
||||
:align="isProtectedListAvatarField(definition.name, field.key) ? 'center' : 'left'"
|
||||
:ellipsis="!isProtectedListAvatarField(definition.name, field.key)"
|
||||
:tooltip="!isProtectedListAvatarField(definition.name, field.key)"
|
||||
>
|
||||
<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
|
||||
v-if="field.type === 'identity' && !field.displayRelationLabel && identityFieldValue(field, record)"
|
||||
v-else-if="field.type === 'identity' && !field.displayRelationLabel && identityFieldValue(field, record)"
|
||||
:value="identityFieldValue(field, record)"
|
||||
/>
|
||||
<template v-else>{{ displayListField(field, record) }}</template>
|
||||
@@ -120,7 +129,6 @@
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-modal :visible="statusVisible" title="审核状态" :ok-loading="statusSaving" @cancel="statusVisible = false" @ok="saveStatus">
|
||||
<a-form :model="statusRow" layout="vertical">
|
||||
<a-form-item label="目标状态" required>
|
||||
@@ -132,11 +140,10 @@
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import { IconLeft, IconPlus, IconRefresh } from '@arco-design/web-vue/es/icon';
|
||||
import { computed, onMounted, reactive, ref, toRef, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, toRef, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import IdentityText from '@/components/IdentityText.vue';
|
||||
import {
|
||||
@@ -152,6 +159,12 @@ import { platformApi } from '@/api/platform';
|
||||
import type { ResourceField, ResourceUiDefinition } from '@/api/resources';
|
||||
import { useUserStore } from '@/store';
|
||||
import { useResourceRelations } from '../resource/use-resource-relations';
|
||||
import ProtectedAvatarThumbnail from './ProtectedAvatarThumbnail.vue';
|
||||
import {
|
||||
createProtectedListAvatarLoader,
|
||||
isProtectedListAvatarField,
|
||||
protectedListAvatarDisplayName,
|
||||
} from './protected-list-avatar-loader';
|
||||
import { useResourceListExtras } from './use-resource-list-extras';
|
||||
|
||||
const props = defineProps<{ definition: ResourceUiDefinition }>();
|
||||
@@ -172,6 +185,8 @@ const fieldOptions = reactive<
|
||||
Record<string, Array<{ label: string; value: string | number }>>
|
||||
>({});
|
||||
const extras = useResourceListExtras(definitionRef, list);
|
||||
const avatarLoader = createProtectedListAvatarLoader();
|
||||
const avatarRefreshKey = ref(0);
|
||||
const statusVisible = ref(false);
|
||||
const statusSaving = ref(false);
|
||||
const statusRow = ref<ResourceRow>({});
|
||||
@@ -230,6 +245,8 @@ const listTitle = computed(() => {
|
||||
});
|
||||
|
||||
async function load() {
|
||||
avatarLoader.reset();
|
||||
avatarRefreshKey.value += 1;
|
||||
loading.value = true;
|
||||
try {
|
||||
const serverFilters: Record<string, string> = {};
|
||||
@@ -419,6 +436,7 @@ function identityFieldValue(field: ResourceField, row: ResourceRow) {
|
||||
}
|
||||
|
||||
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 === 'money' || field.type === 'number') return 140;
|
||||
if (field.type === 'boolean') return 110;
|
||||
@@ -464,6 +482,7 @@ onMounted(async () => {
|
||||
]);
|
||||
await load();
|
||||
});
|
||||
onBeforeUnmount(avatarLoader.reset);
|
||||
watch(
|
||||
() => props.definition.resource,
|
||||
async () => {
|
||||
@@ -478,11 +497,4 @@ watch(
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.filters { flex: 1 1 420px; }
|
||||
.list-toolbar { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 12px 24px; margin-bottom: 16px; }
|
||||
.list-actions { margin-left: auto; }
|
||||
.workflow-alert { margin-bottom: 16px; }
|
||||
.pagination { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.muted-text { color: var(--color-text-3); }
|
||||
</style>
|
||||
<style scoped lang="less" src="./CrudListPage.less" />
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<!--
|
||||
功能:在账户列表中懒加载并展示受鉴权保护的圆形头像缩略图。
|
||||
版本: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)) {
|
||||
void loadAvatar();
|
||||
return;
|
||||
}
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||
observer?.disconnect();
|
||||
observer = undefined;
|
||||
void loadAvatar();
|
||||
},
|
||||
{ rootMargin: '120px' },
|
||||
);
|
||||
observer.observe(root.value);
|
||||
}
|
||||
|
||||
/** Blob 无法解码时回退默认头像,同时保留可访问的异常提示。 */
|
||||
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>
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 功能:为受控账户列表提供头像请求限流、当前页缓存和取消能力。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import { avatarApi } from '@/api/avatar';
|
||||
|
||||
const MAX_CONCURRENT_REQUESTS = 6;
|
||||
const SUPPORTED_RESOURCES = new Set([
|
||||
'staff_account',
|
||||
'user_account',
|
||||
'platform_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.display_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) {
|
||||
const task = queue.shift();
|
||||
if (task) task.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) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
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