feat: 账户列表展示受控头像

This commit is contained in:
czl231
2026-08-11 11:49:50 +08:00
parent ed6e2f7d75
commit 5738a43f3e
9 changed files with 405 additions and 22 deletions

View File

@@ -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('头像读取失败');

View File

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

View File

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

View File

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

View File

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