完善配送端列表头像展示

This commit is contained in:
czl231
2026-08-22 22:23:35 +08:00
parent 2e2c1a35dd
commit 5d4bd61672
10 changed files with 317 additions and 21 deletions

View File

@@ -65,5 +65,12 @@ assert(routeKeys.has('GET /user_account/:identity/avatar'), '用户账户缺少
const recordPage = read('src/views/resource/ResourceRecordPage.vue');
assert(recordPage.includes('ResourceAccountSummary'), '账户资源页尚未接入 5173 头像摘要卡片');
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}`);

View File

@@ -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(
`${deliveryApiBaseURL}${resource}/${encodeURIComponent(identity)}/avatar`,
{ headers: authorizationHeaders() },
{ headers: authorizationHeaders(), signal },
);
if (response.status === 404) return undefined;
if (!response.ok) throw new Error('头像读取失败');

View File

@@ -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>

View File

@@ -30,21 +30,26 @@
<a-table :data="list" :loading="loading" :pagination="false" row-key="identity">
<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
v-for="field in displayFields"
: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.listCopyable) && fieldValue(field, record)"
v-else-if="(field.type === 'identity' || field.listCopyable) && fieldValue(field, record)"
:value="fieldValue(field, record)"
/>
<template v-else>{{ displayResourceValue(definition, field.key, record[field.key] ?? record[`${field.key}_masked`]) }}</template>
@@ -57,6 +62,9 @@
</a-tag>
</template>
</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">
<template #cell="{ record }">
<a-space>
@@ -91,7 +99,7 @@
<script setup lang="ts">
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 { resourceApi } from '@/api/resource';
import {
@@ -104,6 +112,12 @@ import { recordRouteLocation } from '@/api/resource-navigation';
import type { ResourceRow } from '@/api/resource-record-form';
import type { ResourceField, ResourceUiDefinition } from '@/api/resources';
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 route = useRoute();
@@ -113,15 +127,19 @@ const page = ref(Math.max(1, Number(route.query.page) || 1));
const pageSize = 50;
const total = ref(0);
const list = ref<ResourceRow[]>([]);
const avatarLoader = createProtectedListAvatarLoader();
const avatarRefreshKey = ref(0);
const filters = reactive({ keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '' });
const displayFields = computed(() =>
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),
);
/** 返回列表列宽。 */
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;
return field.type === 'textarea' ? 240 : 160;
@@ -134,6 +152,8 @@ function fieldValue(field: ResourceField, row: ResourceRow) {
/** 加载当前列表和来源关系筛选。 */
async function load() {
avatarLoader.reset();
avatarRefreshKey.value += 1;
loading.value = true;
try {
const serverFilters: Record<string, string> = filters.keyword ? { keyword: filters.keyword } : {};
@@ -207,6 +227,7 @@ async function changePage(next: number) {
}
onMounted(load);
onBeforeUnmount(avatarLoader.reset);
watch(() => props.definition.resource, async () => {
page.value = 1;
filters.keyword = '';

View File

@@ -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 };
}