diff --git a/backend/api/internal/logic/gas/staff.go b/backend/api/internal/logic/gas/staff.go index 4f3965c..f6247a2 100644 --- a/backend/api/internal/logic/gas/staff.go +++ b/backend/api/internal/logic/gas/staff.go @@ -7,6 +7,7 @@ import ( "git.apinb.com/bsm-sdk/core/infra" "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/upload" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "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{}) } +// 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 { - Username string `json:"username"` - Password string `json:"password"` - Name string `json:"name" binding:"required,max=64"` - Phone string `json:"phone" binding:"max=32"` - Avatar string `json:"avatar" binding:"max=512"` - RoleCode string `json:"role_code" binding:"required"` - DeliveryBasicIdentity string `json:"delivery_basic_identity"` - WorkStatus string `json:"work_status" binding:"required"` + Username string `json:"username"` + Password string `json:"password"` + Name string `json:"name" binding:"required,max=64"` + Phone string `json:"phone" binding:"max=32"` + Avatar *string `json:"avatar" binding:"omitempty,max=512"` + RoleCode string `json:"role_code" binding:"required"` + DeliveryBasicIdentity string `json:"delivery_basic_identity"` + WorkStatus string `json:"work_status" binding:"required"` } func CreateStaff(ctx *gin.Context) { @@ -87,9 +104,13 @@ func CreateStaff(ctx *gin.Context) { infra.Response.Error(ctx, err) return } + avatar := "" + if request.Avatar != nil { + avatar = *request.Avatar + } staff := models.StaffAccount{ 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, } if err := common.CreateStaffRecord(&staff); err != nil { @@ -120,10 +141,15 @@ func UpdateStaff(ctx *gin.Context) { } deliveryID = delivery.ID } - common.UpdateAllowedByIdentityWithError(ctx, &models.StaffAccount{}, gin.H{ - "name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "role_code": request.RoleCode, + values := gin.H{ + "name": request.Name, "phone": request.Phone, "role_code": request.RoleCode, "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) { diff --git a/backend/api/internal/logic/gas/user.go b/backend/api/internal/logic/gas/user.go index e866f5c..c4ceb76 100644 --- a/backend/api/internal/logic/gas/user.go +++ b/backend/api/internal/logic/gas/user.go @@ -5,6 +5,7 @@ import ( "git.apinb.com/bsm-sdk/core/infra" "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/upload" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -57,14 +58,27 @@ func GetUser(ctx *gin.Context) { 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 { - Username string `json:"username"` - Password string `json:"password"` - Name string `json:"name" binding:"required,max=64"` - Phone string `json:"phone" binding:"max=32"` - Avatar string `json:"avatar" binding:"max=512"` - RealName string `json:"real_name" binding:"max=64"` - DeliveryBasicIdentity string `json:"delivery_basic_identity" binding:"required"` + Username string `json:"username"` + Password string `json:"password"` + Name string `json:"name" binding:"required,max=64"` + Phone string `json:"phone" binding:"max=32"` + Avatar *string `json:"avatar" binding:"omitempty,max=512"` + RealName string `json:"real_name" binding:"max=64"` + DeliveryBasicIdentity string `json:"delivery_basic_identity" binding:"required"` } func CreateUser(ctx *gin.Context) { @@ -86,9 +100,13 @@ func CreateUser(ctx *gin.Context) { infra.Response.Error(ctx, err) return } + avatar := "" + if request.Avatar != nil { + avatar = *request.Avatar + } user := models.UserAccount{ 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} if err := impl.DBService.Transaction(func(tx *gorm.DB) error { @@ -123,9 +141,14 @@ func UpdateUser(ctx *gin.Context) { return } if err := impl.DBService.Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&user).Updates(map[string]any{ - "name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "real_name": request.RealName, - }).Error; err != nil { + values := map[string]any{ + "name": request.Name, "phone": request.Phone, "real_name": request.RealName, + } + // 未选择新头像时不提交 avatar,避免编辑基础资料误清空现有头像。 + if request.Avatar != nil { + values["avatar"] = *request.Avatar + } + if err := tx.Model(&user).Updates(values).Error; err != nil { return err } return tx.Model(&relation).Update("delivery_basic_id", delivery.ID).Error diff --git a/backend/api/internal/routers/gas_business.go b/backend/api/internal/routers/gas_business.go index 0adab02..a947d08 100644 --- a/backend/api/internal/routers/gas_business.go +++ b/backend/api/internal/routers/gas_business.go @@ -30,6 +30,7 @@ func registerGasBusinessRoutes(group *gin.RouterGroup) { staff.GET("", gaslogic.ListStaff) staff.POST("", gaslogic.CreateStaff) staff.GET("/:identity", gaslogic.GetStaff) + staff.GET("/:identity/avatar", gaslogic.GetStaffAvatar) staff.PUT("/:identity", gaslogic.UpdateStaff) staff.PUT("/:identity/password", gaslogic.ResetStaffPassword) staff.PATCH("/:identity/status", gaslogic.UpdateStaffStatus) @@ -47,6 +48,7 @@ func registerGasBusinessRoutes(group *gin.RouterGroup) { user.GET("", gaslogic.ListUser) user.POST("", gaslogic.CreateUser) user.GET("/:identity", gaslogic.GetUser) + user.GET("/:identity/avatar", gaslogic.GetUserAvatar) user.PUT("/:identity", gaslogic.UpdateUser) user.PUT("/:identity/password", gaslogic.ResetUserPassword) user.PATCH("/:identity/status", gaslogic.UpdateUserStatus) diff --git a/backend/api/internal/routers/gas_test.go b/backend/api/internal/routers/gas_test.go index 3fefb8d..c22f839 100644 --- a/backend/api/internal/routers/gas_test.go +++ b/backend/api/internal/routers/gas_test.go @@ -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) + } + } +} diff --git a/docs/操作日志_气站工作人员头像适配_20260818.md b/docs/操作日志_气站工作人员头像适配_20260818.md new file mode 100644 index 0000000..09ace42 --- /dev/null +++ b/docs/操作日志_气站工作人员头像适配_20260818.md @@ -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` 限制,用户头像按当前气站服务关系限制,未扩大数据范围。 +- 合同附件能力未变更,避免气站端调用不存在的平台专属接口。 diff --git a/docs/操作日志_气站标准资源全页改造_20260817.md b/docs/操作日志_气站标准资源全页改造_20260817.md index 7b0849f..9610d79 100644 --- a/docs/操作日志_气站标准资源全页改造_20260817.md +++ b/docs/操作日志_气站标准资源全页改造_20260817.md @@ -23,7 +23,7 @@ - 20 类标准资源具有独立详情页。 - 11 类资源具有独立新建页,8 类资源具有独立编辑页。 - `/staff/add` 和 `/gasorder/create` 原地址继续可用。 -- 头像和合同文件字段保持 5175 原能力,不访问平台专属端点。 +- 初次改造时头像和合同文件字段保持 5175 原能力;2026-08-18 已按 5173 补齐气站范围内头像能力,合同文件仍不访问平台专属端点。 ## 代码变更 diff --git a/docs/项目文档_气站标准资源全页管理_v1.0.md b/docs/项目文档_气站标准资源全页管理_v1.0.md index 166c1aa..054775e 100644 --- a/docs/项目文档_气站标准资源全页管理_v1.0.md +++ b/docs/项目文档_气站标准资源全页管理_v1.0.md @@ -62,7 +62,7 @@ platforms/ - 保留配送点、工作人员、用户、合同、订单、钱包和工单的数据范围。 - 保留密码重置、合同启用/续签/终止、合同气瓶解绑和气站订单动作。 - 工作人员资质按来源角色校验;订单分配只加载配送人员。 -- 头像和 `file_uri` 继续作为普通字段,不调用平台总后台专属的受控头像或合同附件接口。 +- 工作人员和用户头像使用 5173 同款账户摘要、选择预览和保存流程,并通过气站数据范围内的受控接口读取;`file_uri` 仍保持普通字段。 - 支付与退款资源使用后端真实名称 `payment_order`、`payment_refund`。 ### 4.4 只读详情接口 @@ -75,6 +75,7 @@ platforms/ - 列表的新建、详情和编辑入口改为路由跳转。 - 新增 11 类受数据范围保护的详情接口及路由测试。 - 同步气站资源契约和独立页面覆盖检查。 +- 为工作人员与用户补充受气站范围保护的头像读取接口,并避免普通资料编辑误清空头像。 - 未修改数据库模型、状态机、金额规则或现有写接口。 ## 6. 维护指南 @@ -87,6 +88,6 @@ platforms/ ## 7. 已知边界 -- 气站 API 暂无受控头像读取和合同附件预览接口,因此对应字段保持原普通文本能力。 +- 气站 API 暂无合同附件预览接口,因此 `file_uri` 仍保持原普通文本能力。 - 财务对账模型没有气站主体归属字段,气站端仍不展示全局对账数据。 - 两个共享页面文件超过 500 行,但按列表编排与记录页编排保持单一职责;后续若继续增加气站专属区块,应优先拆分组合式函数或子组件。 diff --git a/frontend/gas_admin/src/api/avatar.ts b/frontend/gas_admin/src/api/avatar.ts index 92b4e16..ba3dec6 100644 --- a/frontend/gas_admin/src/api/avatar.ts +++ b/frontend/gas_admin/src/api/avatar.ts @@ -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 = { 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 { const token = getToken(); return token ? { Authorization: token } : {}; @@ -52,7 +52,7 @@ async function load( signal?: AbortSignal, ): Promise { const response = await fetch( - `${platformApiBaseURL}${resource}/${encodeURIComponent(identity)}/avatar`, + `${gasApiBaseURL}${resource}/${encodeURIComponent(identity)}/avatar`, { headers: authorizationHeaders(), signal }, ); if (response.status === 404) return undefined; diff --git a/frontend/gas_admin/src/views/resource/ResourceRecordPage.vue b/frontend/gas_admin/src/views/resource/ResourceRecordPage.vue index 40f18dd..cd34093 100644 --- a/frontend/gas_admin/src/views/resource/ResourceRecordPage.vue +++ b/frontend/gas_admin/src/views/resource/ResourceRecordPage.vue @@ -1,6 +1,6 @@