适配气站工作人员与用户受保护头像

This commit is contained in:
czl231
2026-08-18 20:28:01 +08:00
parent 084eccc3ec
commit fd2a2b38e3
11 changed files with 228 additions and 49 deletions

View File

@@ -1,12 +1,12 @@
/**
* 功能:平台总后台头像上传与鉴权读取客户端。
* 版本v1.1.0
* 功能:气站管理端头像上传与气站范围内鉴权读取客户端。
* 版本v1.2.0
*/
import { getToken } from '@/utils/auth';
const platformApiBaseURL =
const gasApiBaseURL =
import.meta.env.VITE_API_BASE_URL ||
'http://localhost:12426/heqi/platform/v1';
'http://localhost:12426/heqi/gas/v1';
export type AvatarUploadReply = {
uri: string;
@@ -17,13 +17,13 @@ export type AvatarUploadReply = {
type ApiEnvelope<T> = { code?: number; message?: string; details?: T };
/** 生成服务根路径 URL确保上传请求不会错误拼接平台 API 前缀。 */
/** 生成服务根路径 URL确保上传请求不会错误拼接气站 API 前缀。 */
function serviceURL(path: string) {
const platformURL = new URL(platformApiBaseURL, window.location.origin);
return new URL(path, platformURL.origin).toString();
const gasURL = new URL(gasApiBaseURL, window.location.origin);
return new URL(path, gasURL.origin).toString();
}
/** 返回与现有平台请求一致的 JWT 请求头。 */
/** 返回与现有气站请求一致的 JWT 请求头。 */
function authorizationHeaders(): Record<string, string> {
const token = getToken();
return token ? { Authorization: token } : {};
@@ -52,7 +52,7 @@ async function load(
signal?: AbortSignal,
): Promise<Blob | undefined> {
const response = await fetch(
`${platformApiBaseURL}${resource}/${encodeURIComponent(identity)}/avatar`,
`${gasApiBaseURL}${resource}/${encodeURIComponent(identity)}/avatar`,
{ headers: authorizationHeaders(), signal },
);
if (response.status === 404) return undefined;

View File

@@ -1,6 +1,6 @@
<!--
功能承载气站管理端全部标准资源的新建详情和编辑独立页面
版本v1.3.0
版本v1.4.0
-->
<template>
<div class="record-page">
@@ -37,9 +37,11 @@
:mode="mode"
:title="definition.title"
:record="summaryRecord"
avatar-url=""
:avatar-enabled="false"
:can-clear="false"
:avatar-url="avatarUrl"
:avatar-enabled="hasAvatarField"
:can-clear="avatarCanClear"
@select-avatar="selectAvatar"
@clear-avatar="clearAvatar"
/>
<template v-if="mode === 'detail'">
@@ -196,6 +198,7 @@ import ResourceFieldForm from './ResourceFieldForm.vue';
import ResourceWalletSummary from './ResourceWalletSummary.vue';
import { createResourceRecordNavigation } from './resource-record-navigation';
import { loadResourceRecordRelations } from './load-resource-record-relations';
import { useResourceAvatar } from './use-resource-avatar';
import { useResourceRelationLinkage } from './use-resource-relation-linkage';
import { useStaffCredentialOwnerGuard } from './use-staff-credential-owner-guard';
import { useUnsavedRecord } from './use-unsaved-record';
@@ -253,6 +256,11 @@ const productOwnershipKeys = [
];
const actionVisible = ref(false);
const activeAction = ref<DetailAction>();
const avatar = useResourceAvatar();
const avatarUrl = avatar.url;
const avatarCanClear = avatar.canClear;
const selectAvatar = avatar.select;
const clearAvatar = avatar.clear;
/** 新建智能气阀选择归属时清空其他互斥归属,再执行字段原有联动。 */
async function changeRelation(field: ResourceField, value: unknown) {
@@ -273,7 +281,10 @@ async function changeRelation(field: ResourceField, value: unknown) {
const accountSummary = computed(() => usesAccountSummary(definition.value));
const accountSummaryVisible = computed(
() =>
accountSummary.value && mode.value !== 'create',
accountSummary.value && (mode.value !== 'create' || hasAvatarField.value),
);
const hasAvatarField = computed(() =>
definition.value.fields.some((field) => field.key === 'avatar'),
);
const formFields = computed(() =>
mode.value === 'detail'
@@ -283,11 +294,13 @@ const formFields = computed(() =>
mode.value,
record.value,
context.value,
),
).filter((field) => field.key !== 'avatar'),
);
const payloadFields = computed(() =>
mode.value === 'edit'
? updatePayloadFields(definition.value, record.value, context.value)
? updatePayloadFields(definition.value, record.value, context.value).filter(
(field) => field.key !== 'avatar',
)
: formFields.value,
);
const editableKeySet = computed(
@@ -364,7 +377,7 @@ const modeLabel = computed(() =>
const errorTitle = computed(() => recordPageErrorTitle(errorStatus.value));
function snapshot() {
return JSON.stringify({ form });
return JSON.stringify({ form, avatar: avatar.marker() });
}
const unsaved = useUnsavedRecord(snapshot, () => mode.value !== 'detail');
const { goEdit, viewWallet, goBack, requestBack } =
@@ -429,8 +442,8 @@ async function initialize() {
fieldOptions,
roleOptions,
});
// 钱包属于附加信息,读取失败时不能阻断主详情或基础表单。
await Promise.allSettled([loadWallet()]);
// 钱包和头像属于附加信息,读取失败时不能阻断主详情或基础表单。
await Promise.allSettled([loadWallet(), loadAvatar()]);
unsaved.markInitialized();
} catch (error) {
const message = (error as Error).message;
@@ -463,6 +476,13 @@ async function loadWallet() {
).list[0];
}
/** 读取当前气站范围内账户的受保护头像。 */
async function loadAvatar() {
if (!hasAvatarField.value || mode.value === 'create' || !recordIdentity.value)
return;
await avatar.load(definition.value.resource, recordIdentity.value);
}
async function save() {
const validationError = validateResourceRecordForm(
form,
@@ -485,6 +505,7 @@ async function save() {
form,
mode.value as 'create' | 'edit',
);
await avatar.applyToPayload(payload);
if (mode.value === 'create') {
const created = await resourceApi.create<ResourceRow>(
definition.value.resource,
@@ -539,7 +560,7 @@ async function reloadDetail() {
definition.value.resource,
identity.value,
);
await Promise.allSettled([loadWallet()]);
await Promise.allSettled([loadWallet(), loadAvatar()]);
}
async function updateGasStatus(enabled: string | number | boolean) {

View File

@@ -1,12 +1,12 @@
/**
* 功能:为受控账户列表提供头像请求限流、当前页缓存和取消能力。
* 版本v1.0.0
* 版本v1.1.0
*/
import { avatarApi } from '@/api/avatar';
const MAX_CONCURRENT_REQUESTS = 6;
// 气站 API 暂无受控头像读取接口,头像继续按普通字段展示
const SUPPORTED_RESOURCES = new Set<string>();
// 仅启用已具备气站范围鉴权读取接口的真实头像资源
const SUPPORTED_RESOURCES = new Set(['staff_account', 'user_account']);
type QueueTask = {
run: () => void;

View File

@@ -0,0 +1,38 @@
# Design QA
- source visual truth path: `C:/Users/Lenovo/AppData/Local/Temp/codex-clipboard-1e3ee2da-a03c-4ade-af5d-81e88bacdc3e.png`
- implementation screenshot path: unavailable
- viewport: source image 2560 × 1159 px实现页需保持同一浏览器窗口与登录态
- source and implementation pixel dimensions: source 2560 × 1159 pximplementation unavailable
- CSS size and density normalization: blocked because the authenticated implementation could not be captured
- state: 5175 工作人员列表,安装人员菜单,单条带头像记录
## Full-view comparison evidence
源图显示头像列直接暴露 `/uploads/avatars/...` 存储路径。代码已按 5173 开启现有 `ProtectedAvatarThumbnail` 分支,目标表现为 32 像素圆形头像;但缺少登录后的实现截图,不能完成视觉对照。
## Focused region comparison evidence
头像列是唯一变更区域。组件沿用 5173 的圆形尺寸、默认头像、懒加载、失败提示和对象 URL 清理逻辑;仍需同一数据记录的刷新后截图作为最终证据。
## Findings
- [P1] 缺少登录态实现截图
- Location: `/staff/installers` 头像列
- Evidence: 只有修改前源图,没有刷新后的实现图
- Impact: 无法确认运行中的后端已重启并返回头像二进制,也无法确认最终列对齐
- Fix: 重启后端、刷新页面并捕获同一视口截图
## Comparison history
- Iteration 1: 定位为空的受保护资源集合,已加入 `staff_account``user_account`;自动化构建验证通过,视觉证据待补。
## Implementation checklist
- [x] 工作人员列表启用受保护头像缩略图
- [x] 用户列表启用受保护头像缩略图
- [x] 无头像时回退默认头像
- [x] 类型检查、单文件 lint、页面覆盖检查与生产构建
- [ ] 登录态同视口截图对照
final result: blocked