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

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

@@ -7,6 +7,7 @@ import (
"git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/upload"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models" "git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -52,15 +53,31 @@ func GetStaff(ctx *gin.Context) {
Where("identity = ? AND gas_basic_id = ?", ctx.Param("identity"), station.ID), &models.StaffAccount{}) Where("identity = ? AND gas_basic_id = ?", ctx.Param("identity"), station.ID), &models.StaffAccount{})
} }
// GetStaffAvatar 返回当前气站范围内工作人员的受保护头像。
func GetStaffAvatar(ctx *gin.Context) {
station, ok := currentGas(ctx)
if !ok {
return
}
var account models.StaffAccount
if err := common.ActiveRecords(impl.DBService).Select("avatar").
Where("identity = ? AND gas_basic_id = ?", ctx.Param("identity"), station.ID).
First(&account).Error; err != nil {
common.RespondRecordError(ctx, err)
return
}
upload.ServeAvatar(ctx, account.Avatar)
}
type staffRequest struct { type staffRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Name string `json:"name" binding:"required,max=64"` Name string `json:"name" binding:"required,max=64"`
Phone string `json:"phone" binding:"max=32"` Phone string `json:"phone" binding:"max=32"`
Avatar string `json:"avatar" binding:"max=512"` Avatar *string `json:"avatar" binding:"omitempty,max=512"`
RoleCode string `json:"role_code" binding:"required"` RoleCode string `json:"role_code" binding:"required"`
DeliveryBasicIdentity string `json:"delivery_basic_identity"` DeliveryBasicIdentity string `json:"delivery_basic_identity"`
WorkStatus string `json:"work_status" binding:"required"` WorkStatus string `json:"work_status" binding:"required"`
} }
func CreateStaff(ctx *gin.Context) { func CreateStaff(ctx *gin.Context) {
@@ -87,9 +104,13 @@ func CreateStaff(ctx *gin.Context) {
infra.Response.Error(ctx, err) infra.Response.Error(ctx, err)
return return
} }
avatar := ""
if request.Avatar != nil {
avatar = *request.Avatar
}
staff := models.StaffAccount{ staff := models.StaffAccount{
Entity: common.NewEntity(common.StatusEnable), Username: request.Username, PasswordHash: hash, Entity: common.NewEntity(common.StatusEnable), Username: request.Username, PasswordHash: hash,
Name: request.Name, Phone: request.Phone, Avatar: request.Avatar, RoleCode: request.RoleCode, Name: request.Name, Phone: request.Phone, Avatar: avatar, RoleCode: request.RoleCode,
GasBasicID: station.ID, DeliveryBasicID: deliveryID, WorkStatus: request.WorkStatus, GasBasicID: station.ID, DeliveryBasicID: deliveryID, WorkStatus: request.WorkStatus,
} }
if err := common.CreateStaffRecord(&staff); err != nil { if err := common.CreateStaffRecord(&staff); err != nil {
@@ -120,10 +141,15 @@ func UpdateStaff(ctx *gin.Context) {
} }
deliveryID = delivery.ID deliveryID = delivery.ID
} }
common.UpdateAllowedByIdentityWithError(ctx, &models.StaffAccount{}, gin.H{ values := gin.H{
"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "role_code": request.RoleCode, "name": request.Name, "phone": request.Phone, "role_code": request.RoleCode,
"delivery_basic_id": deliveryID, "work_status": request.WorkStatus, "delivery_basic_id": deliveryID, "work_status": request.WorkStatus,
}, []string{"name", "phone", "avatar", "role_code", "delivery_basic_id", "work_status"}, common.StaffWriteError) }
// 未选择新头像时不提交 avatar避免编辑基础资料误清空现有头像。
if request.Avatar != nil {
values["avatar"] = *request.Avatar
}
common.UpdateAllowedByIdentityWithError(ctx, &models.StaffAccount{}, values, []string{"name", "phone", "avatar", "role_code", "delivery_basic_id", "work_status"}, common.StaffWriteError)
} }
func UpdateStaffStatus(ctx *gin.Context) { func UpdateStaffStatus(ctx *gin.Context) {

View File

@@ -5,6 +5,7 @@ import (
"git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/upload"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models" "git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
@@ -57,14 +58,27 @@ func GetUser(ctx *gin.Context) {
infra.Response.Success(ctx, response) infra.Response.Success(ctx, response)
} }
// GetUserAvatar 返回与当前气站存在服务关系的用户受保护头像。
func GetUserAvatar(ctx *gin.Context) {
station, ok := currentGas(ctx)
if !ok {
return
}
user, _, ok := requireUser(ctx, ctx.Param("identity"), station.ID)
if !ok {
return
}
upload.ServeAvatar(ctx, user.Avatar)
}
type userRequest struct { type userRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Name string `json:"name" binding:"required,max=64"` Name string `json:"name" binding:"required,max=64"`
Phone string `json:"phone" binding:"max=32"` Phone string `json:"phone" binding:"max=32"`
Avatar string `json:"avatar" binding:"max=512"` Avatar *string `json:"avatar" binding:"omitempty,max=512"`
RealName string `json:"real_name" binding:"max=64"` RealName string `json:"real_name" binding:"max=64"`
DeliveryBasicIdentity string `json:"delivery_basic_identity" binding:"required"` DeliveryBasicIdentity string `json:"delivery_basic_identity" binding:"required"`
} }
func CreateUser(ctx *gin.Context) { func CreateUser(ctx *gin.Context) {
@@ -86,9 +100,13 @@ func CreateUser(ctx *gin.Context) {
infra.Response.Error(ctx, err) infra.Response.Error(ctx, err)
return return
} }
avatar := ""
if request.Avatar != nil {
avatar = *request.Avatar
}
user := models.UserAccount{ user := models.UserAccount{
Entity: common.NewEntity(common.StatusEnable), Username: request.Username, PasswordHash: hash, Entity: common.NewEntity(common.StatusEnable), Username: request.Username, PasswordHash: hash,
Name: request.Name, Phone: request.Phone, Avatar: request.Avatar, RealName: request.RealName, Name: request.Name, Phone: request.Phone, Avatar: avatar, RealName: request.RealName,
} }
relation := models.UserServiceRelation{Entity: common.NewEntity(common.StatusEnable), GasBasicID: station.ID, DeliveryBasicID: delivery.ID} relation := models.UserServiceRelation{Entity: common.NewEntity(common.StatusEnable), GasBasicID: station.ID, DeliveryBasicID: delivery.ID}
if err := impl.DBService.Transaction(func(tx *gorm.DB) error { if err := impl.DBService.Transaction(func(tx *gorm.DB) error {
@@ -123,9 +141,14 @@ func UpdateUser(ctx *gin.Context) {
return return
} }
if err := impl.DBService.Transaction(func(tx *gorm.DB) error { if err := impl.DBService.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&user).Updates(map[string]any{ values := map[string]any{
"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "real_name": request.RealName, "name": request.Name, "phone": request.Phone, "real_name": request.RealName,
}).Error; err != nil { }
// 未选择新头像时不提交 avatar避免编辑基础资料误清空现有头像。
if request.Avatar != nil {
values["avatar"] = *request.Avatar
}
if err := tx.Model(&user).Updates(values).Error; err != nil {
return err return err
} }
return tx.Model(&relation).Update("delivery_basic_id", delivery.ID).Error return tx.Model(&relation).Update("delivery_basic_id", delivery.ID).Error

View File

@@ -30,6 +30,7 @@ func registerGasBusinessRoutes(group *gin.RouterGroup) {
staff.GET("", gaslogic.ListStaff) staff.GET("", gaslogic.ListStaff)
staff.POST("", gaslogic.CreateStaff) staff.POST("", gaslogic.CreateStaff)
staff.GET("/:identity", gaslogic.GetStaff) staff.GET("/:identity", gaslogic.GetStaff)
staff.GET("/:identity/avatar", gaslogic.GetStaffAvatar)
staff.PUT("/:identity", gaslogic.UpdateStaff) staff.PUT("/:identity", gaslogic.UpdateStaff)
staff.PUT("/:identity/password", gaslogic.ResetStaffPassword) staff.PUT("/:identity/password", gaslogic.ResetStaffPassword)
staff.PATCH("/:identity/status", gaslogic.UpdateStaffStatus) staff.PATCH("/:identity/status", gaslogic.UpdateStaffStatus)
@@ -47,6 +48,7 @@ func registerGasBusinessRoutes(group *gin.RouterGroup) {
user.GET("", gaslogic.ListUser) user.GET("", gaslogic.ListUser)
user.POST("", gaslogic.CreateUser) user.POST("", gaslogic.CreateUser)
user.GET("/:identity", gaslogic.GetUser) user.GET("/:identity", gaslogic.GetUser)
user.GET("/:identity/avatar", gaslogic.GetUserAvatar)
user.PUT("/:identity", gaslogic.UpdateUser) user.PUT("/:identity", gaslogic.UpdateUser)
user.PUT("/:identity/password", gaslogic.ResetUserPassword) user.PUT("/:identity/password", gaslogic.ResetUserPassword)
user.PATCH("/:identity/status", gaslogic.UpdateUserStatus) user.PATCH("/:identity/status", gaslogic.UpdateUserStatus)

View File

@@ -81,3 +81,22 @@ func TestGasReadonlyDetailRoutes(t *testing.T) {
} }
} }
} }
// TestGasAvatarRoutes 验证工作人员与用户头像只能通过气站鉴权路由读取。
func TestGasAvatarRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
RegisterGas("heqi", engine)
routes := map[string]bool{}
for _, route := range engine.Routes() {
routes[route.Method+" "+route.Path] = true
}
for _, path := range []string{
"GET /heqi/gas/v1/staff_account/:identity/avatar",
"GET /heqi/gas/v1/user_account/:identity/avatar",
} {
if !routes[path] {
t.Fatalf("missing gas avatar route %s", path)
}
}
}

View File

@@ -0,0 +1,49 @@
# 气站工作人员头像适配操作日志
操作时间2026-08-18
操作类型:扩展
影响模块:气站管理端账户记录页、气站 API 工作人员与用户头像
## 操作前状态
- 5175 工作人员新建页把头像显示为普通文本输入框。
- 气站 API 没有工作人员和用户的受保护头像读取路由。
- 编辑工作人员或用户资料时,未提交头像也可能把已有头像清空。
## 具体操作
- 复用 5173 的账户摘要组件和头像组合式逻辑,在新建、详情、编辑页显示头像选择与预览。
- 启用工作人员与用户列表的受保护头像缩略图,替代头像存储路径文本。
- 从基础信息表单移除头像文本输入框,保存时仅在选择新头像或恢复默认头像后提交头像字段。
- 新增工作人员、用户头像读取路由,查询始终附加当前气站或服务关系范围。
- 将更新请求的头像字段改为可选指针,未修改头像时不更新数据库列。
## 操作后状态
- `/staff/add` 顶部显示账户摘要与头像选择区,布局和交互参考 5173。
- 工作人员与用户列表显示 32 像素圆形头像;无头像或加载失败时回退默认头像。
- 工作人员与用户的详情、编辑页可安全读取现有头像。
- 普通资料编辑不会误清空头像;主动恢复默认头像仍会提交空值。
## 代码变更
- `frontend/gas_admin/src/views/resource/ResourceRecordPage.vue`:启用头像摘要、过滤文本字段、接入上传载荷与未保存检测。
- `frontend/gas_admin/src/api/avatar.ts`:使用气站 API 前缀读取受保护头像。
- `frontend/gas_admin/src/views/shared/protected-list-avatar-loader.ts`:开放工作人员与用户缩略图渲染。
- `backend/api/internal/logic/gas/staff.go``user.go`:新增范围受限头像读取并保护头像更新语义。
- `backend/api/internal/routers/gas_business.go``gas_test.go`:注册并校验头像路由。
## 验证结果
- `go test ./internal/routers ./internal/logic/gas`:通过。
- `pnpm type:check`:通过。
- `pnpm contract:check`通过20 个资源一致。
- `pnpm resource-pages:check`:通过,详情 20、新建 11、编辑 8。
- `pnpm lint`:通过,无错误;保留仓库既有提示。
- `pnpm build`:通过。
## 风险评估
- 新头像接口只读取数据库中受控上传目录的文件,不暴露外部 URL 或任意文件路径。
- 工作人员头像按 `gas_basic_id` 限制,用户头像按当前气站服务关系限制,未扩大数据范围。
- 合同附件能力未变更,避免气站端调用不存在的平台专属接口。

View File

@@ -23,7 +23,7 @@
- 20 类标准资源具有独立详情页。 - 20 类标准资源具有独立详情页。
- 11 类资源具有独立新建页8 类资源具有独立编辑页。 - 11 类资源具有独立新建页8 类资源具有独立编辑页。
- `/staff/add``/gasorder/create` 原地址继续可用。 - `/staff/add``/gasorder/create` 原地址继续可用。
- 头像和合同文件字段保持 5175 原能力不访问平台专属端点。 - 初次改造时头像和合同文件字段保持 5175 原能力2026-08-18 已按 5173 补齐气站范围内头像能力,合同文件仍不访问平台专属端点。
## 代码变更 ## 代码变更

View File

@@ -62,7 +62,7 @@ platforms/
- 保留配送点、工作人员、用户、合同、订单、钱包和工单的数据范围。 - 保留配送点、工作人员、用户、合同、订单、钱包和工单的数据范围。
- 保留密码重置、合同启用/续签/终止、合同气瓶解绑和气站订单动作。 - 保留密码重置、合同启用/续签/终止、合同气瓶解绑和气站订单动作。
- 工作人员资质按来源角色校验;订单分配只加载配送人员。 - 工作人员资质按来源角色校验;订单分配只加载配送人员。
- 头像和 `file_uri` 继续作为普通字段,不调用平台总后台专属的受控头像或合同附件接口 - 工作人员和用户头像使用 5173 同款账户摘要、选择预览和保存流程,并通过气站数据范围内的受控接口读取;`file_uri` 仍保持普通字段
- 支付与退款资源使用后端真实名称 `payment_order``payment_refund` - 支付与退款资源使用后端真实名称 `payment_order``payment_refund`
### 4.4 只读详情接口 ### 4.4 只读详情接口
@@ -75,6 +75,7 @@ platforms/
- 列表的新建、详情和编辑入口改为路由跳转。 - 列表的新建、详情和编辑入口改为路由跳转。
- 新增 11 类受数据范围保护的详情接口及路由测试。 - 新增 11 类受数据范围保护的详情接口及路由测试。
- 同步气站资源契约和独立页面覆盖检查。 - 同步气站资源契约和独立页面覆盖检查。
- 为工作人员与用户补充受气站范围保护的头像读取接口,并避免普通资料编辑误清空头像。
- 未修改数据库模型、状态机、金额规则或现有写接口。 - 未修改数据库模型、状态机、金额规则或现有写接口。
## 6. 维护指南 ## 6. 维护指南
@@ -87,6 +88,6 @@ platforms/
## 7. 已知边界 ## 7. 已知边界
- 气站 API 暂无受控头像读取和合同附件预览接口,因此对应字段保持原普通文本能力。 - 气站 API 暂无合同附件预览接口,因此 `file_uri`保持原普通文本能力。
- 财务对账模型没有气站主体归属字段,气站端仍不展示全局对账数据。 - 财务对账模型没有气站主体归属字段,气站端仍不展示全局对账数据。
- 两个共享页面文件超过 500 行,但按列表编排与记录页编排保持单一职责;后续若继续增加气站专属区块,应优先拆分组合式函数或子组件。 - 两个共享页面文件超过 500 行,但按列表编排与记录页编排保持单一职责;后续若继续增加气站专属区块,应优先拆分组合式函数或子组件。

View File

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

View File

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

View File

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