feat: 新增账户资料页与头像上传

将工作人员和用户的详情、编辑改为独立账户资料页。

增加受控头像上传与读取、图片安全校验、接口测试,并优化只读及编辑布局。

同步更新平台需求、接口安全说明、项目文档和操作日志。
This commit is contained in:
czl231
2026-08-10 22:35:47 +08:00
parent cef7223841
commit 7242048abf
20 changed files with 1351 additions and 13 deletions

View File

@@ -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"
)
@@ -47,6 +48,16 @@ func ListStaff(ctx *gin.Context) {
// GetStaff 查询一个服务人员档案。
func GetStaff(ctx *gin.Context) { common.GetByIdentity[models.StaffAccount](ctx) }
// GetStaffAvatar 返回已鉴权工作人员资料页使用的头像二进制内容。
func GetStaffAvatar(ctx *gin.Context) {
var account models.StaffAccount
if err := common.ActiveRecords(impl.DBService).Select("avatar").Where("identity = ?", ctx.Param("identity")).First(&account).Error; err != nil {
common.RespondRecordError(ctx, err)
return
}
upload.ServeAvatar(ctx, account.Avatar)
}
// CreateStaff 创建服务人员档案。
func CreateStaff(ctx *gin.Context) {
var request struct {
@@ -103,13 +114,13 @@ func CreateStaff(ctx *gin.Context) {
// UpdateStaff 更新服务人员档案。
func UpdateStaff(ctx *gin.Context) {
var request struct {
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:"max=64"`
GasBasicIdentity string `json:"gas_basic_identity"`
DeliveryBasicIdentity string `json:"delivery_basic_identity"`
WorkStatus string `json:"work_status" binding:"max=32"`
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:"max=64"`
GasBasicIdentity string `json:"gas_basic_identity"`
DeliveryBasicIdentity string `json:"delivery_basic_identity"`
WorkStatus string `json:"work_status" binding:"max=32"`
}
if err := ctx.ShouldBindJSON(&request); err != nil || !validWorkStatus(request.WorkStatus) || !validStaffRole(request.RoleCode) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
@@ -129,7 +140,12 @@ func UpdateStaff(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
common.UpdateAllowedByIdentity(ctx, &models.StaffAccount{}, gin.H{"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "role_code": request.RoleCode, "gas_basic_id": gasBasicID, "delivery_basic_id": deliveryBasicID, "work_status": request.WorkStatus}, []string{"name", "phone", "avatar", "role_code", "gas_basic_id", "delivery_basic_id", "work_status"})
values := gin.H{"name": request.Name, "phone": request.Phone, "role_code": request.RoleCode, "gas_basic_id": gasBasicID, "delivery_basic_id": deliveryBasicID, "work_status": request.WorkStatus}
// 未选择新头像时不提交 avatar避免普通资料编辑误清空现有头像。
if request.Avatar != nil {
values["avatar"] = *request.Avatar
}
common.UpdateAllowedByIdentity(ctx, &models.StaffAccount{}, values, []string{"name", "phone", "avatar", "role_code", "gas_basic_id", "delivery_basic_id", "work_status"})
}
func validWorkStatus(status string) bool { return status == "on_duty" || status == "off_duty" }

View File

@@ -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"
)
@@ -15,6 +16,16 @@ func ListUser(ctx *gin.Context) { common.ListPage[models.UserAccount](ctx) }
// GetUser 查询一个业主客户档案。
func GetUser(ctx *gin.Context) { common.GetByIdentity[models.UserAccount](ctx) }
// GetUserAvatar 返回已鉴权用户资料页使用的头像二进制内容。
func GetUserAvatar(ctx *gin.Context) {
var account models.UserAccount
if err := common.ActiveRecords(impl.DBService).Select("avatar").Where("identity = ?", ctx.Param("identity")).First(&account).Error; err != nil {
common.RespondRecordError(ctx, err)
return
}
upload.ServeAvatar(ctx, account.Avatar)
}
// CreateUser 创建业主客户档案。
func CreateUser(ctx *gin.Context) {
var request struct {
@@ -49,14 +60,19 @@ func CreateUser(ctx *gin.Context) {
// UpdateUser 更新业主客户档案。
func UpdateUser(ctx *gin.Context) {
var request struct {
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"`
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"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
common.UpdateAllowedByIdentity(ctx, &models.UserAccount{}, gin.H{"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "real_name": request.RealName}, []string{"name", "phone", "avatar", "real_name"})
values := gin.H{"name": request.Name, "phone": request.Phone, "real_name": request.RealName}
// 未选择新头像时不提交 avatar避免普通资料编辑误清空现有头像。
if request.Avatar != nil {
values["avatar"] = *request.Avatar
}
common.UpdateAllowedByIdentity(ctx, &models.UserAccount{}, values, []string{"name", "phone", "avatar", "real_name"})
}

View File

@@ -0,0 +1,171 @@
// Package upload 提供受控头像文件的上传、校验与读取能力。
// 版本v1.0.0
package upload
import (
"bytes"
"errors"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
sdkmiddleware "git.apinb.com/bsm-sdk/core/middleware"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
)
const (
maxAvatarSize int64 = 2 << 20
maxAvatarDimension = 4096
)
// UploadAvatar 接收 JPG/PNG 头像,验证真实图片内容后写入受控目录。
func UploadAvatar(ctx *gin.Context) {
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxAvatarSize+(256<<10))
fileHeader, err := ctx.FormFile("file")
if err != nil || fileHeader == nil || fileHeader.Size <= 0 || fileHeader.Size > maxAvatarSize {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
file, err := fileHeader.Open()
if err != nil {
infra.Response.Error(ctx, err)
return
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, maxAvatarSize+1))
if err != nil || int64(len(content)) > maxAvatarSize {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
extension, contentType, err := validateAvatar(fileHeader.Filename, content)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
datePath := time.Now().Format("2006/01/02")
filename := models.NewIdentity() + extension
directory := filepath.Join(uploadRoot(), "avatars", filepath.FromSlash(datePath))
if err := os.MkdirAll(directory, 0o750); err != nil {
infra.Response.Error(ctx, err)
return
}
targetPath := filepath.Join(directory, filename)
target, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
if err != nil {
infra.Response.Error(ctx, err)
return
}
if _, err = target.Write(content); err != nil {
target.Close()
_ = os.Remove(targetPath)
infra.Response.Error(ctx, err)
return
}
if err = target.Close(); err != nil {
_ = os.Remove(targetPath)
infra.Response.Error(ctx, err)
return
}
uri := "/uploads/avatars/" + datePath + "/" + filename
infra.Response.Success(ctx, UploadFileReply{
URI: uri,
OriginalName: fileHeader.Filename,
ContentType: contentType,
Size: int64(len(content)),
})
if claims, parseErr := sdkmiddleware.ParseAuth(ctx); parseErr == nil {
log.Printf("avatar upload client=%s account=%s type=%s size=%d uri=%s", claims.Client, claims.Identity, contentType, len(content), uri)
}
}
// ServeAvatar 仅从头像受控目录读取文件,拒绝外部 URL 与目录穿越路径。
func ServeAvatar(ctx *gin.Context, uri string) {
path, err := avatarPathFromURI(uri)
if err != nil {
ctx.Status(http.StatusNotFound)
return
}
file, err := os.Open(path)
if err != nil {
ctx.Status(http.StatusNotFound)
return
}
defer file.Close()
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() {
ctx.Status(http.StatusNotFound)
return
}
ctx.Header("Cache-Control", "private, no-store")
ctx.Header("X-Content-Type-Options", "nosniff")
http.ServeContent(ctx.Writer, ctx.Request, filepath.Base(path), info.ModTime(), file)
}
// validateAvatar 验证扩展名、MIME、尺寸和完整解码结果返回规范化扩展名。
func validateAvatar(filename string, content []byte) (string, string, error) {
extension := strings.ToLower(filepath.Ext(filename))
if extension != ".jpg" && extension != ".jpeg" && extension != ".png" {
return "", "", errors.New("unsupported avatar extension")
}
contentType := http.DetectContentType(content)
expectedType := "image/jpeg"
normalizedExtension := ".jpg"
if extension == ".png" {
expectedType = "image/png"
normalizedExtension = ".png"
}
if contentType != expectedType {
return "", "", errors.New("avatar content type mismatch")
}
config, format, err := image.DecodeConfig(bytes.NewReader(content))
if err != nil || config.Width <= 0 || config.Height <= 0 ||
config.Width > maxAvatarDimension || config.Height > maxAvatarDimension {
return "", "", errors.New("invalid avatar dimensions")
}
if (expectedType == "image/jpeg" && format != "jpeg") || (expectedType == "image/png" && format != "png") {
return "", "", errors.New("avatar format mismatch")
}
if _, _, err := image.Decode(bytes.NewReader(content)); err != nil {
return "", "", errors.New("invalid avatar content")
}
return normalizedExtension, expectedType, nil
}
// avatarPathFromURI 将数据库中的受控 URI 映射为头像目录内的真实文件路径。
func avatarPathFromURI(uri string) (string, error) {
const prefix = "/uploads/avatars/"
if !strings.HasPrefix(uri, prefix) {
return "", errors.New("avatar URI is not controlled")
}
root, err := filepath.Abs(filepath.Join(uploadRoot(), "avatars"))
if err != nil {
return "", err
}
relative := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(uri, "/uploads/avatars/")))
if relative == "." || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", errors.New("invalid avatar path")
}
candidate, err := filepath.Abs(filepath.Join(root, relative))
if err != nil {
return "", err
}
relativeToRoot, err := filepath.Rel(root, candidate)
if err != nil || relativeToRoot == ".." || strings.HasPrefix(relativeToRoot, ".."+string(filepath.Separator)) {
return "", errors.New("avatar path escapes root")
}
return candidate, nil
}

View File

@@ -0,0 +1,70 @@
// Package upload 测试头像上传的格式、尺寸与路径安全边界。
// 版本v1.0.0
package upload
import (
"bytes"
"image"
"image/color"
"image/png"
"path/filepath"
"strings"
"testing"
)
// pngBytes 生成指定尺寸的有效 PNG 测试图片。
func pngBytes(t *testing.T, width, height int) []byte {
t.Helper()
picture := image.NewRGBA(image.Rect(0, 0, width, height))
picture.Set(0, 0, color.RGBA{R: 32, G: 96, B: 192, A: 255})
var buffer bytes.Buffer
if err := png.Encode(&buffer, picture); err != nil {
t.Fatalf("encode PNG: %v", err)
}
return buffer.Bytes()
}
// TestValidateAvatarAcceptsRealPNG 验证真实 PNG 可通过并规范化类型。
func TestValidateAvatarAcceptsRealPNG(t *testing.T) {
extension, contentType, err := validateAvatar("头像.PNG", pngBytes(t, 2, 2))
if err != nil {
t.Fatalf("valid PNG was rejected: %v", err)
}
if extension != ".png" || contentType != "image/png" {
t.Fatalf("avatar metadata = (%q, %q)", extension, contentType)
}
}
// TestValidateAvatarRejectsSpoofedAndOversizedImage 验证伪造扩展名与超大像素尺寸会被拒绝。
func TestValidateAvatarRejectsSpoofedAndOversizedImage(t *testing.T) {
if _, _, err := validateAvatar("fake.png", []byte("not an image")); err == nil {
t.Fatal("spoofed PNG was accepted")
}
if _, _, err := validateAvatar("wide.png", pngBytes(t, maxAvatarDimension+1, 1)); err == nil {
t.Fatal("oversized image dimensions were accepted")
}
}
// TestAvatarPathFromURIStaysInsideControlledRoot 验证头像 URI 不能逃逸受控目录。
func TestAvatarPathFromURIStaysInsideControlledRoot(t *testing.T) {
root := t.TempDir()
t.Setenv("HEQI_UPLOAD_DIR", root)
path, err := avatarPathFromURI("/uploads/avatars/2026/08/10/example.png")
if err != nil {
t.Fatalf("controlled URI was rejected: %v", err)
}
expectedRoot := filepath.Join(root, "avatars")
relative, err := filepath.Rel(expectedRoot, path)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
t.Fatalf("avatar path escaped root: %q", path)
}
for _, uri := range []string{
"https://example.com/avatar.png",
"/uploads/example.png",
"/uploads/avatars/../../secret.png",
} {
if _, err := avatarPathFromURI(uri); err == nil {
t.Fatalf("unsafe avatar URI was accepted: %q", uri)
}
}
}

View File

@@ -186,11 +186,13 @@ func registerCommerceRoute(group *gin.RouterGroup) {
func registerStaffRoute(group *gin.RouterGroup) {
registerWritableResource(group, "/staff_account", staff.ListStaff, staff.CreateStaff, staff.GetStaff, staff.UpdateStaff, &models.StaffAccount{})
group.GET("/staff_account/:identity/avatar", staff.GetStaffAvatar)
registerWritableResource(group, "/staff_credential", staff.ListStaffCredential, staff.CreateStaffCredential, staff.GetStaffCredential, staff.UpdateStaffCredential, &models.StaffCredential{})
}
func registerUserRoute(group *gin.RouterGroup) {
registerWritableResource(group, "/user_account", userlogic.ListUser, userlogic.CreateUser, userlogic.GetUser, userlogic.UpdateUser, &models.UserAccount{})
group.GET("/user_account/:identity/avatar", userlogic.GetUserAvatar)
registerWritableResource(group, "/user_address", userlogic.ListUserAddress, userlogic.CreateUserAddress, userlogic.GetUserAddress, userlogic.UpdateUserAddress, &models.UserAddress{})
registerWritableResource(group, "/user_service_relation", userlogic.ListUserServiceRelation, userlogic.CreateUserServiceRelation, userlogic.GetUserServiceRelation, userlogic.UpdateUserServiceRelation, &models.UserServiceRelation{})
}

View File

@@ -109,6 +109,8 @@ func TestPlatformOrganizationAndAccountRoutesExposeResourceCRUD(t *testing.T) {
assertNoRouteMethods(t, routes, "/heqi/platform/v1/platform_menu", http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
assertNoRouteMethods(t, routes, "/heqi/platform/v1/platform_menu/:identity", http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
assertRouteMethods(t, routes, "/heqi/platform/v1/platform_role/:identity/menu", http.MethodGet, http.MethodPut)
assertRouteMethods(t, routes, "/heqi/platform/v1/staff_account/:identity/avatar", http.MethodGet)
assertRouteMethods(t, routes, "/heqi/platform/v1/user_account/:identity/avatar", http.MethodGet)
assertNoRouteMethods(t, routes, "/heqi/platform/v1/platform_role/:identity/menus", http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
assertNoRouteMethods(t, routes, "/heqi/platform/v1/platfrom_account", http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
}

View File

@@ -11,4 +11,5 @@ func registerUploadRoute(serviceKey string, engine *gin.Engine) {
authorized := engine.Group("/upload")
authorized.Use(sdkmiddleware.JwtAuth(true))
authorized.POST("/file", upload.UploadFile)
authorized.POST("/avatar", upload.UploadAvatar)
}

View File

@@ -0,0 +1,25 @@
// Package routers 测试受鉴权上传端点的路由注册。
// 版本v1.0.0
package routers
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
)
// TestUploadRoutesExposeDedicatedAvatarEndpoint 验证专用头像上传路由已注册且不改变原文件上传路由。
func TestUploadRoutesExposeDedicatedAvatarEndpoint(t *testing.T) {
engine := gin.New()
registerUploadRoute("heqi", engine)
routes := make(map[string]map[string]bool)
for _, route := range engine.Routes() {
if routes[route.Path] == nil {
routes[route.Path] = make(map[string]bool)
}
routes[route.Path][route.Method] = true
}
assertRouteMethods(t, routes, "/upload/file", http.MethodPost)
assertRouteMethods(t, routes, "/upload/avatar", http.MethodPost)
}

View File

@@ -130,6 +130,8 @@
工作人员支持安装人员 `installer`、配送人员 `delivery`、运维人员 `operations` 三种角色,可关联气站或配送点,并记录在岗/离岗状态。前端按角色提供独立列表和新增入口;后端按菜单和人员实际角色校验详情、修改及资质访问。
工作人员详情与编辑使用独立资料页,不再使用列表抽屉。资料页默认只读,可切换编辑状态;顶部展示头像、用户名、唯一标识和创建时间,下方维护现有基本信息。头像仅支持 JPG/PNG、本地预览和保存时上传读取继续受平台 JWT、菜单及人员角色权限保护。
### 6.3 用户管理
| 资源 | 路径 | 模式 | 已实现能力 |
@@ -140,6 +142,8 @@
用户页面同时提供配送合同入口。当前后台可直接维护用户、地址和服务关系,但尚未实现邀请二维码注册、服务关系审批和完整历史时间线。
用户账户详情与编辑复用工作人员资料页布局,默认只读并支持页内编辑。用户头像通过受保护资源接口读取,不在通用详情响应中暴露头像 URI新增用户仍沿用现有新增抽屉。
### 6.4 智能气阀管理
| 资源 | 路径 | 模式 | 已实现能力 |

View File

@@ -121,6 +121,7 @@
- 银行卡号、身份证号、预留手机号使用 `Global.FieldEncryptionKey` 经 HKDF 派生独立 AES-GCM 加密键和 HMAC 指纹键;接口列表只返回末四位掩码。开发占位密钥不得用于生产。
- 支付密码独立于登录密码,仅允许六位数字,使用 bcrypt 保存;连续失败达到阈值后在 Redis 短时锁定。绑卡、解绑、余额支付和提现均要求支付密码或限定用途的一次性验证码。
- 公共上传接口 `/upload/file` 必须携带平台、气站、配送点、用户或工作人员任一合法 JWT图片/PDF 最大 10MB视频上限从配置读取。上传只返回资源 URI业务接口负责建立关联并记录操作者、采集与接收时间。
- 平台账户资料头像使用专用 `/upload/avatar` 上传入口,仅允许真实 JPG/PNG、最大 2MB、最大 4096×4096并在服务端完成扩展名、MIME、尺寸和完整图片解码校验。头像读取通过 `/heqi/platform/v1/{staff_account|user_account}/:identity/avatar` 受 JWT、菜单和对象角色权限保护通用列表及详情响应继续移除 `avatar` 字段。
- 充值、支付、提现、工单证据、轨迹点、内容确认等写入均携带幂等号;资金入账在数据库事务内锁定钱包并同时写不可变流水。
- 钱包可提现余额是当前总余额的子集,始终满足 `0 <= 可提现余额 <= 总余额`。普通消费扣减总余额后,必须同步把可提现余额限制在剩余总余额以内。
- 提现申请在同一数据库事务内锁定钱包、同时预扣总余额和可提现余额并写入不可变流水;驳回只返还该申请实际预扣的两类余额,完成打款只确认外部结果,不得再次扣款。

View File

@@ -0,0 +1,52 @@
# 账户资料页与头像上传操作日志
操作时间2026-08-10 21:55:14
操作类型:扩展
影响模块:平台总后台工作人员管理、用户账户管理、平台头像上传与读取接口
## 操作前状态
工作人员和用户账户的详情、编辑均在通用列表抽屉内完成。头像是普通 URL 文本输入框;已有公共上传接口只保存文件,没有 Web 头像组件和受保护读取链路。更新接口未携带头像时会将头像写为空字符串。
## 具体操作
1. 为安装、配送、运维工作人员和用户账户增加独立资料路由,保留各自菜单权限与列表高亮。
2. 新增复用资料页,上方展示头像与身份摘要,下方只维护现有基本信息;详情默认只读,编辑通过查询参数切换。
3. 列表中的目标资源详情/编辑改为路由跳转,其他资源、新增、审核和归档行为保持不变。
4. 新增头像专用上传接口,限制 JPG/PNG、2MB、4096×4096并校验真实图片内容。
5. 新增工作人员和用户头像鉴权读取接口,通用详情继续移除头像 URI。
6. 将更新 DTO 的头像字段改为可选,省略头像时保留原值。
7. 新增头像校验、目录穿越和路由注册测试,并同步需求、安全和项目文档。
## 操作后状态
平台总后台工作人员与用户账户可以在独立资料页查看、编辑资料,并通过本地文件选择更新头像。头像文件地址不会通过普通详情接口暴露,读取仍受 JWT、菜单和工作人员角色范围保护。其他后台和资源继续使用原交互。
## 代码变更
- `frontend/platform_admin/src/views/account/AccountProfilePage.vue`:新增账户资料页。
- `frontend/platform_admin/src/api/avatar.ts`:新增头像上传与读取客户端。
- `frontend/platform_admin/src/router/routes/modules/platform.ts`:新增隐藏资料路由。
- `frontend/platform_admin/src/views/shared/CrudListPage.vue`:目标资源改为资料页跳转。
- `backend/api/internal/logic/upload/avatar.go`:新增头像安全处理。
- `backend/api/internal/logic/platform/staff/staff.go``user/user.go`:新增头像读取并保护省略头像的更新行为。
- `backend/api/internal/routers/platform.go``upload.go`:注册受保护路由。
- 对应测试与中文文档同步更新。
## 验证结果
- 后端目标包:上传、平台路由、工作人员和用户逻辑测试通过。
- 后端全量:`go test ./...` 通过。
- 前端TypeScript 类型检查和 Vite 生产构建通过。
- 浏览器:使用真实用户记录验证列表详情跳转、只读资料页、编辑直达、取消恢复和头像本地预览,未提交或修改测试数据,控制台无错误。
- 边界案例:伪造 PNG、超大像素尺寸、外部 URI、非头像 URI、目录穿越均被拒绝。
- `git diff --check`:通过。
## 风险评估
- 数据库结构和现有资源路径未改变,公共更新接口保持兼容。
- 本地 Mock 存储尚无孤立文件自动清理;通过保存时才上传降低无效文件数量,生产环境仍需清理机制。
- 完整图片解码可以阻止伪造或损坏图片,但不能替代病毒扫描;生产发布前需接入受控对象存储和恶意内容扫描。
- 影响范围限定为平台总后台,气站后台和配送后台没有行为变化。

View File

@@ -0,0 +1,49 @@
# 账户资料页视觉优化操作日志
操作时间2026-08-10
操作类型:修改
影响模块:平台总后台工作人员与用户账户资料页
## 操作前状态
宽屏下顶部资料内容集中在卡片左侧,右侧留白较大;基本信息使用灰色禁用输入框表示查看态,容易与不可用状态混淆;表单内容偏左且必填星号与“名称”标签距离过大。
## 具体操作
1. 将顶部资料区改为最大宽度网格,头像和身份信息整体居中。
2. 缩小头像、标题、行高、卡片内边距和卡片间距。
3. 为只读状态新增 `displayProfileValue` 文本格式化逻辑,编辑状态继续使用原表单控件。
4. 将基本信息内容区居中并统一标签、值、输入框和按钮基线。
5. 在资料页局部覆盖 Arco 必填符号尺寸,修复星号错位。
6. 保留现有头像内容、上传流程、接口、路由和响应式行为。
## 操作后状态
用户与工作人员资料页在宽屏下内容集中且层级明确;查看态展示纯文本,编辑态展示输入控件;必填星号紧贴字段标签,保存与取消按钮保持原操作位置。
## 代码变更
- `frontend/platform_admin/src/views/account/AccountProfilePage.vue`
- 新增 `displayProfileValue`,区分查看与编辑渲染。
- 头像显示尺寸由 132px 调整为 120px。
- `frontend/platform_admin/src/views/account/AccountProfilePage.less`
- 重构顶部网格、表单内容宽度、详情文本、编辑控件和必填符号样式。
- `docs/项目文档_账户资料页_v1.1.md`
- 记录 v1.1 页面结构、行为差异和维护边界。
## 验证结果
- `npm run build`:通过。
- 浏览器真实用户资料验证:查看态、编辑态、必填符号、取消恢复均正常。
- 必填符号计算宽度为 8px伪元素内容为 `*`
- 页面控制台无错误;验证过程中未保存或修改测试账户数据。
- `git diff --check`:通过。
- 文件行数:`AccountProfilePage.vue` 455 行,`AccountProfilePage.less` 209 行,符合单文件尽量不超过 500 行的限制。
## 风险评估
- 仅修改共享资料页模板和局部样式,工作人员与用户账户同步生效。
- 没有修改数据、接口、上传逻辑、数据库和其他后台页面。
- 关联字段只读名称依赖已加载的关系选项;加载失败时回退显示原唯一标识。

View File

@@ -0,0 +1,83 @@
# 账户资料页项目文档
## 1. 项目概述
- 项目名称:平台总后台工作人员与用户账户资料页。
- 主要功能:将工作人员、用户账户的详情与编辑从列表抽屉迁移到独立资料页;支持只读/编辑切换、头像本地预览、受控上传和鉴权读取。
- 技术栈Vue 3、TypeScript、Arco Design、Go、Gin、GORM。
- 运行环境Node.js 20.19+、Go 1.26.1、项目现有 PostgreSQL 与 JWT 配置。
- 实施范围:仅 `frontend/platform_admin`(默认开发端口 5173及必要的平台 API气站后台和配送后台保持原行为。
## 2. 目录结构说明
```text
platforms/
├── frontend/platform_admin/src/
│ ├── api/avatar.ts # 头像上传和鉴权读取客户端
│ ├── router/routes/modules/platform.ts # 工作人员、用户账户资料页路由
│ ├── router/typings.d.ts # 资料页返回列表元数据
│ └── views/
│ ├── account/
│ │ ├── AccountProfilePage.vue # 独立资料页与头像交互
│ │ └── AccountProfilePage.less # 资料页布局与响应式样式
│ └── shared/CrudListPage.vue # 目标资源跳转入口
├── backend/api/internal/
│ ├── logic/upload/
│ │ ├── avatar.go # 头像校验、保存和受控读取
│ │ └── avatar_test.go # 图片及路径安全边界测试
│ ├── logic/platform/
│ │ ├── staff/staff.go # 工作人员头像读取与可选头像更新
│ │ └── user/user.go # 用户头像读取与可选头像更新
│ └── routers/
│ ├── platform.go # 账户头像读取路由
│ ├── platform_test.go # 平台路由回归测试
│ ├── upload.go # 专用头像上传路由
│ └── upload_test.go # 上传路由测试
└── docs/ # 需求、安全、项目和操作日志
```
## 3. 核心文件说明
### `AccountProfilePage.vue`
- 职责:加载工作人员或用户详情,渲染顶部身份卡和“基本信息”表单。
- 主要逻辑:`loadProfile` 并行加载详情、关系选项和头像;`handleAvatarChange` 完成本地预览;`save` 在保存时上传新头像并调用原资源更新接口。
- 页面模式:无 `mode` 查询参数时只读,`?mode=edit` 时可编辑;保存后移除参数并留在当前资料页。
### `avatar.go`
- 职责:专用头像文件安全边界。
- 上传限制JPG/PNG、最大 2MB、最大 4096×4096同时验证扩展名、真实 MIME、图片配置和完整解码。
- 读取限制:仅接受 `/uploads/avatars/` 受控 URI拒绝外部 URL 和目录穿越;文件响应设置私有且不缓存。
### `staff.go` 与 `user.go`
- 头像读取方法按账户 `identity` 查询头像 URI再交由上传模块读取文件。
- 更新 DTO 将 `avatar` 改为可选指针:省略字段时保留已有头像,显式空字符串仍可清除,避免普通资料保存误清空头像。
## 4. 变更记录
- 新增安装、配送、运维工作人员及用户账户的隐藏资料路由。
- 详情按钮进入只读资料页,编辑按钮进入同页编辑模式。
- 新增头像本地选择、预览、保存时上传与鉴权读取。
- 新增图片格式、大小、像素尺寸、完整解码和路径穿越测试。
- 未修改数据库结构,`avatar` 仍为 `VARCHAR(512)` 资源 URI。
- 未改变新增账户、审核、归档、钱包及其他资源的弹层行为。
- 未新增第三方依赖。
## 5. 维护指南
- 新增可使用资料页的账户资源时,必须同时配置独立路由、菜单权限、允许编辑字段和受保护头像读取处理器,不能仅在列表中跳转。
- 生产环境应将 `HEQI_UPLOAD_DIR` 映射到受控存储,并在上传落盘前接入病毒/恶意内容扫描;当前完整图片解码不能替代专业扫描。
- 头像上传成功但资料更新失败时可能产生孤立文件,生产环境应增加临时文件标记或周期清理任务。
- 不得把 `/uploads/avatars/` 配置为无需鉴权的公开静态目录。
- 验证命令:
- `go test ./...`
- `npm run build`(目录:`frontend/platform_admin`
- `git diff --check`
## 6. 已知边界
- 本次不提供头像裁剪、历史头像管理或旧头像自动删除。
- 新增工作人员和用户仍使用现有抽屉。
- 气站后台与配送后台未同步独立资料页。

View File

@@ -0,0 +1,87 @@
# 账户资料页项目文档 v1.1
## 1. 项目概述
- 项目名称:平台总后台工作人员与用户账户资料页。
- 主要功能:工作人员和用户账户使用独立资料页查看、编辑基本信息,并支持头像本地预览、受控上传和鉴权读取。
- 本版重点:优化宽屏内容密度、资料卡对齐、查看/编辑状态区分和必填标识位置。
- 技术栈Vue 3、TypeScript、Arco Design、Less、Go、Gin、GORM。
- 运行环境Node.js 20.19+、Go 1.26.1、项目现有 PostgreSQL 与 JWT 配置。
- 实施范围:仅 `frontend/platform_admin` 的工作人员和用户账户共享资料页;气站后台、配送后台及后端接口行为不变。
## 2. 目录结构说明
```text
platforms/
├── frontend/platform_admin/src/
│ ├── api/avatar.ts # 头像上传和鉴权读取客户端
│ ├── router/routes/modules/platform.ts # 工作人员、用户账户资料页路由
│ └── views/
│ ├── account/
│ │ ├── AccountProfilePage.vue # 资料查看、编辑和头像交互
│ │ └── AccountProfilePage.less # 资料页布局、状态和响应式样式
│ └── shared/CrudListPage.vue # 目标资源跳转入口
├── backend/api/internal/
│ ├── logic/upload/avatar.go # 头像校验、保存和受控读取
│ ├── logic/platform/staff/staff.go # 工作人员头像与资料更新
│ ├── logic/platform/user/user.go # 用户头像与资料更新
│ └── routers/ # 平台头像读取与上传路由
└── docs/ # 需求、安全、项目和操作日志
```
## 3. 核心文件说明
### `AccountProfilePage.vue`
- `loadProfile`:加载账户详情、关联选项与受保护头像。
- `displayProfileValue`:将普通字段、枚举、布尔值、金额、日期和关联标识转换为只读文本。
- `startEdit``leaveEditMode`:通过 `?mode=edit` 切换编辑与查看状态。
- `save`:验证表单,在需要时上传头像,再复用原资源更新接口。
- 查看态只渲染纯文本值;编辑态才创建输入、选择和日期控件,避免将详情误呈现为禁用表单。
### `AccountProfilePage.less`
- 顶部使用 `900px` 最大宽度网格,将头像列与身份信息列作为整体居中。
- 基本信息表单使用 `760px` 内容宽度,查看态文本和编辑态控件共享标签基线。
- 必填符号通过页面局部样式压缩为固定 `8px` 宽,不影响其他 Arco 表单。
- 小屏幕下顶部网格切换为单列,表单宽度与标签列同步收缩。
### 头像服务
- 头像内容保持原样展示,本版不进行图片语义判断或自动替换。
- 上传、鉴权读取、文件类型、大小和像素限制均沿用 v1.0,不修改接口或数据库结构。
## 4. 变更记录
### v1.12026-08-10
- 顶部资料卡内容组居中,减少宽屏右侧无效留白。
- 头像缩小为 120px身份标题调整为 20px标签和内容统一左基线。
- 卡片间距调整为 16px资料卡和基本信息卡按内容压缩高度。
- 查看态由禁用输入框改为纯文本,编辑态保留原表单控件。
- 表单内容区扩展并居中,输入框宽度和按钮基线保持一致。
- 修复必填星号与“名称”标签分离问题。
- 工作人员和用户账户共享同一优化结果。
- 未修改头像数据、后端接口、路由、数据库或其他后台页面。
### v1.0
- 新增账户独立资料页、头像上传与鉴权读取能力。
- 新增安装、配送、运维工作人员及用户账户资料路由。
## 5. 维护指南
- 新增字段时必须同时检查 `displayProfileValue` 的只读格式与编辑控件类型。
- 页面布局宽度统一在 `AccountProfilePage.less` 维护,不应在模板中写内联尺寸。
- 必填符号覆盖仅限 `.profile-form`,禁止改动 Arco 全局样式。
- 头像仍是业务资源内容;合法但内容特殊的 PNG/JPG 应原样显示。
- 验证命令:
- `npm run build`(目录:`frontend/platform_admin`
- `git diff --check`
- 浏览器验证:检查用户和工作人员资料页的查看态、编辑态、取消恢复及宽屏/小屏布局。
## 6. 已知边界
- 本版不新增头像裁剪、删除或语义识别。
- 不改变新增账户、审核、归档和钱包操作。
- 气站后台与配送后台仍使用原有页面交互。

View File

@@ -0,0 +1,62 @@
/**
* 功能:平台总后台头像上传与鉴权读取客户端。
* 版本v1.0.0
*/
import { getToken } from '@/utils/auth';
const platformApiBaseURL =
import.meta.env.VITE_API_BASE_URL ||
'http://localhost:12426/heqi/platform/v1';
export type AvatarUploadReply = {
uri: string;
original_name: string;
content_type: string;
size: number;
};
type ApiEnvelope<T> = { code?: number; message?: string; details?: T };
/** 生成服务根路径 URL确保上传请求不会错误拼接平台 API 前缀。 */
function serviceURL(path: string) {
const platformURL = new URL(platformApiBaseURL, window.location.origin);
return new URL(path, platformURL.origin).toString();
}
/** 返回与现有平台请求一致的 JWT 请求头。 */
function authorizationHeaders(): Record<string, string> {
const token = getToken();
return token ? { Authorization: token } : {};
}
/** 上传经过前端预检的头像文件,服务端仍会执行真实内容校验。 */
async function upload(file: File): Promise<AvatarUploadReply> {
const form = new FormData();
form.append('file', file);
const response = await fetch(serviceURL('/upload/avatar'), {
method: 'POST',
headers: authorizationHeaders(),
body: form,
});
const payload = (await response.json()) as ApiEnvelope<AvatarUploadReply>;
if (!response.ok || payload.code !== 0 || !payload.details) {
throw new Error(payload.message || '头像上传失败');
}
return payload.details;
}
/** 读取受保护头像;记录没有头像时返回 undefined 以使用本地默认图。 */
async function load(
resource: string,
identity: string,
): Promise<Blob | undefined> {
const response = await fetch(
`${platformApiBaseURL}${resource}/${encodeURIComponent(identity)}/avatar`,
{ headers: authorizationHeaders() },
);
if (response.status === 404) return undefined;
if (!response.ok) throw new Error('头像读取失败');
return response.blob();
}
export const avatarApi = { upload, load };

View File

@@ -2,6 +2,7 @@ import { DEFAULT_LAYOUT } from '../base';
import type { AppRouteRecordRaw } from '../types';
const resourcePage = () => import('@/views/shared/ResourcePage.vue');
const accountProfilePage = () => import('@/views/account/AccountProfilePage.vue');
function child(
domain: string,
@@ -70,10 +71,14 @@ const routes: AppRouteRecordRaw[] = [
{ ...child('staff', 'installers', 'installers', '安装人员管理', '/staff_account', 'staff_installer'), meta: { title: '安装人员管理', resource: '/staff_account', requiresAuth: true, menuCode: 'staff_installer', staffType: 'installer' } },
{ ...child('staff', 'delivery', 'delivery', '配送人员管理', '/staff_account', 'staff_delivery'), meta: { title: '配送人员管理', resource: '/staff_account', requiresAuth: true, menuCode: 'staff_delivery', staffType: 'delivery' } },
{ ...child('staff', 'operations', 'operations', '运维人员管理', '/staff_account', 'staff_operations'), meta: { title: '运维人员管理', resource: '/staff_account', requiresAuth: true, menuCode: 'staff_operations', staffType: 'operations' } },
{ path: 'installers/:identity', name: 'staff-installers-profile', component: accountProfilePage, meta: { title: '工作人员资料', resource: '/staff_account', requiresAuth: true, menuCode: 'staff_installer', staffType: 'installer', hideInMenu: true, activeMenu: 'staff-installers', listRouteName: 'staff-installers' } },
{ path: 'delivery/:identity', name: 'staff-delivery-profile', component: accountProfilePage, meta: { title: '工作人员资料', resource: '/staff_account', requiresAuth: true, menuCode: 'staff_delivery', staffType: 'delivery', hideInMenu: true, activeMenu: 'staff-delivery', listRouteName: 'staff-delivery' } },
{ path: 'operations/:identity', name: 'staff-operations-profile', component: accountProfilePage, meta: { title: '工作人员资料', resource: '/staff_account', requiresAuth: true, menuCode: 'staff_operations', staffType: 'operations', hideInMenu: true, activeMenu: 'staff-operations', listRouteName: 'staff-operations' } },
child('staff', 'credential', 'credential', '人员资质', '/staff_credential', 'staff', true, 'staff-installers'),
]),
group('user', 'user', '用户管理', 'icon-user', 40, [
child('user', 'user-account', 'account', '用户账户', '/user_account', 'user_account'),
{ path: 'user-account/:identity', name: 'user-account-profile', component: accountProfilePage, meta: { title: '用户资料', resource: '/user_account', requiresAuth: true, menuCode: 'user_account', hideInMenu: true, activeMenu: 'user-account', listRouteName: 'user-account' } },
child('user', 'user-address', 'address', '用户地址', '/user_address', 'user_address'),
child('user', 'service-relation', 'service-relation', '服务关系', '/user_service_relation', 'user_service_relation'),
child('user', 'contracts', 'contracts', '合同管理', '/gasorder_contract', 'gasorder_contract'),

View File

@@ -5,6 +5,7 @@ declare module 'vue-router' {
roles?: string[]; // Controls roles that have access to the page
menuCode?: string; // Server-assigned menu domain required by this route
staffType?: 'installer' | 'delivery' | 'operations';
listRouteName?: string; // 独立资料页返回的列表路由名称
createMode?: boolean;
requiresAuth: boolean; // Whether login is required to access the current page (every route must declare)
icon?: string; // The icon show in the side menu

View File

@@ -0,0 +1,209 @@
/* 功能账户资料页布局、头像和响应式样式。版本v1.0.0 */
.account-profile-page {
min-height: 100%;
padding: 0 20px 28px;
background: var(--color-fill-2);
}
.profile-loading {
display: block;
width: 100%;
}
.summary-card,
.form-card {
margin-top: 16px;
border-radius: 8px;
}
.summary-content {
display: grid;
grid-template-columns: 180px minmax(360px, 1fr);
gap: 44px;
align-items: center;
width: min(900px, 100%);
min-height: 152px;
margin: 0 auto;
padding: 24px 32px;
}
.avatar-column {
display: flex;
flex-direction: column;
align-items: center;
width: 180px;
}
.avatar-control {
position: relative;
padding: 0;
background: transparent;
border: 0;
}
.avatar-control.editable {
cursor: pointer;
}
.profile-avatar {
overflow: hidden;
background: var(--color-fill-3);
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.camera-badge {
position: absolute;
right: 2px;
bottom: 4px;
display: grid;
width: 38px;
height: 38px;
color: rgb(var(--primary-6));
font-size: 20px;
background: var(--color-bg-2);
border: 4px solid var(--color-bg-2);
border-radius: 50%;
place-items: center;
}
.avatar-input {
display: none;
}
.avatar-help {
margin-top: 10px;
color: var(--color-text-3);
font-size: 12px;
}
.identity-summary {
width: 100%;
max-width: 500px;
}
.identity-summary h2 {
margin: 0 0 16px;
color: var(--color-text-1);
font-size: 20px;
line-height: 28px;
}
.identity-summary dl {
margin: 0;
}
.identity-summary dl div {
display: flex;
align-items: center;
min-height: 36px;
font-size: 15px;
}
.identity-summary dt {
width: 88px;
color: var(--color-text-3);
text-align: left;
}
.identity-summary dd {
min-width: 0;
margin-left: 8px;
color: var(--color-text-1);
}
.form-card {
padding-bottom: 12px;
}
.profile-form {
width: min(760px, calc(100% - 48px));
margin: 0 auto;
padding: 20px 0 4px;
}
.profile-form :deep(.arco-form-item-label-col) {
flex: 0 0 116px;
justify-content: flex-start;
}
.profile-form :deep(.arco-form-item-content-flex) {
width: 100%;
max-width: 640px;
}
.profile-form :deep(.arco-form-item-label-required-symbol) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 8px;
margin-right: 4px;
font-size: 0;
}
.profile-form :deep(.arco-form-item-label-required-symbol svg) {
display: none;
}
.profile-form :deep(.arco-form-item-label-required-symbol::before) {
color: rgb(var(--danger-6));
font-size: 14px;
line-height: 1;
content: '*';
}
.profile-form :deep(.arco-input-wrapper),
.profile-form :deep(.arco-input-number),
.profile-form :deep(.arco-select-view),
.profile-form :deep(.arco-picker),
.profile-form :deep(.arco-textarea-wrapper) {
background: var(--color-bg-2);
border-color: var(--color-border-2);
}
.detail-value {
width: 100%;
min-height: 36px;
padding: 7px 0;
overflow: hidden;
color: var(--color-text-1);
line-height: 22px;
text-overflow: ellipsis;
border-bottom: 1px solid var(--color-fill-3);
white-space: nowrap;
}
.form-actions {
margin-top: 12px;
margin-bottom: 4px;
}
@media (max-width: 760px) {
.account-profile-page {
padding: 0 10px 20px;
}
.summary-content {
grid-template-columns: 1fr;
gap: 20px;
justify-items: center;
padding: 24px 16px;
}
.identity-summary {
width: min(420px, 100%);
}
.profile-form {
width: calc(100% - 24px);
padding: 16px 0 0;
}
.profile-form :deep(.arco-form-item-label-col) {
flex-basis: 96px;
}
}

View File

@@ -0,0 +1,455 @@
<!-- 功能工作人员与用户账户独立资料页版本v1.0.0 -->
<template>
<div class="account-profile-page">
<a-page-header
:title="`${definition.title}资料`"
subtitle="查看和维护账户基本信息"
@back="goBack"
>
<template v-if="!editing" #extra>
<a-button type="primary" @click="startEdit">
<template #icon><icon-edit /></template>
编辑资料
</a-button>
</template>
</a-page-header>
<a-spin :loading="loading" class="profile-loading" tip="正在加载资料">
<a-card :bordered="false" class="summary-card">
<div class="summary-content">
<div class="avatar-column">
<button
class="avatar-control"
:class="{ editable: editing }"
type="button"
:disabled="!editing"
aria-label="选择本地头像"
@click="chooseAvatar"
>
<a-avatar :size="120" class="profile-avatar">
<img :src="avatarPreview" alt="账户头像" />
</a-avatar>
<span v-if="editing" class="camera-badge">
<icon-camera />
</span>
</button>
<input
ref="avatarInput"
class="avatar-input"
type="file"
accept="image/jpeg,image/png,.jpg,.jpeg,.png"
@change="handleAvatarChange"
/>
<span v-if="editing" class="avatar-help">JPG/PNG最大 2 MB</span>
</div>
<div class="identity-summary">
<h2>{{ displayName }}</h2>
<dl>
<div>
<dt>用户名</dt>
<dd>{{ String(detail.username ?? '-') }}</dd>
</div>
<div>
<dt>唯一标识</dt>
<dd>
<IdentityText
v-if="detail.identity"
:value="String(detail.identity)"
/>
<template v-else>-</template>
</dd>
</div>
<div>
<dt>创建时间</dt>
<dd>{{ formatDate(detail.created_at) }}</dd>
</div>
</dl>
</div>
</div>
</a-card>
<a-card :bordered="false" class="form-card">
<a-tabs default-active-key="basic">
<a-tab-pane key="basic" title="基本信息">
<a-form :model="form" class="profile-form" layout="horizontal">
<a-form-item
v-for="field in profileFields"
:key="field.key"
:label="field.label"
:required="editing && isResourceFieldRequired(field, 'edit')"
>
<div v-if="!editing" class="detail-value">
{{ displayProfileValue(field) }}
</div>
<template v-else>
<a-switch
v-if="field.type === 'boolean'"
v-model="form[field.key]"
/>
<a-input-number
v-else-if="field.type === 'number' || field.type === 'money'"
v-model="form[field.key]"
:precision="field.type === 'money' ? 2 : 0"
/>
<a-date-picker
v-else-if="field.type === 'date'"
v-model="form[field.key]"
value-format="YYYY-MM-DD"
/>
<a-date-picker
v-else-if="field.type === 'datetime'"
v-model="form[field.key]"
show-time
value-format="YYYY-MM-DDTHH:mm:ssZ"
/>
<a-textarea
v-else-if="field.type === 'textarea'"
v-model="form[field.key]"
:auto-size="{ minRows: 3, maxRows: 8 }"
/>
<a-select
v-else-if="field.type === 'select'"
v-model="form[field.key]"
allow-clear
>
<a-option
v-for="option in field.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
<a-select
v-else-if="field.type === 'identity' || field.type === 'identity-list'"
v-model="form[field.key]"
:multiple="field.type === 'identity-list'"
:loading="relationLoading[field.relation ?? '']"
allow-clear
allow-search
>
<a-option
v-for="option in relationOptions[field.relation ?? ''] ?? []"
:key="String(option.identity)"
:value="String(option.identity)"
>
{{ optionLabel(option) }}
</a-option>
</a-select>
<a-input
v-else
v-model="form[field.key]"
:placeholder="`请输入${field.label}`"
/>
</template>
</a-form-item>
<a-form-item v-if="editing" class="form-actions">
<a-space>
<a-button type="primary" :loading="saving" @click="save">
保存
</a-button>
<a-button :disabled="saving" @click="cancelEdit">取消</a-button>
</a-space>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</a-card>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { IconCamera, IconEdit } from '@arco-design/web-vue/es/icon';
import dayjs from 'dayjs';
import {
computed,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { avatarApi } from '@/api/avatar';
import { resourceApi } from '@/api/resource';
import {
buildResourcePayload,
isMissingField,
isResourceFieldRequired,
} from '@/api/resource-form';
import { getResource, type ResourceField } from '@/api/resources';
import IdentityText from '@/components/IdentityText.vue';
import { DEFAULT_USER_AVATAR } from '@/constants/avatar';
type Row = Record<string, unknown>;
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const saving = ref(false);
const detail = ref<Row>({});
const form = reactive<Record<string, any>>({});
const avatarInput = ref<HTMLInputElement>();
const avatarPreview = ref(DEFAULT_USER_AVATAR);
const selectedAvatar = ref<File>();
const relationOptions = reactive<Record<string, Row[]>>({});
const relationLoading = reactive<Record<string, boolean>>({});
let objectURL = '';
const definition = computed(() => getResource(String(route.meta.resource)));
const identity = computed(() => String(route.params.identity ?? ''));
const editing = computed(() => route.query.mode === 'edit');
const profileFields = computed<ResourceField[]>(() =>
definition.value.fields.filter(
(field) => !['username', 'password', 'avatar'].includes(field.key),
),
);
const displayName = computed(() =>
String(
detail.value.name ??
detail.value.real_name ??
detail.value.username ??
'账户资料',
),
);
/** 返回资料页所属的工作人员或用户列表。 */
function goBack() {
router.push({ name: String(route.meta.listRouteName) });
}
/** 将当前资料页切换为可编辑状态,并保留直达 URL 状态。 */
function startEdit() {
router.replace({ query: { ...route.query, mode: 'edit' } });
}
/** 移除编辑参数,恢复为只读详情状态。 */
async function leaveEditMode() {
const query = { ...route.query };
delete query.mode;
await router.replace({ query });
}
/** 将服务端详情复制到表单,金额字段保持前端元单位。 */
function resetForm() {
for (const field of profileFields.value) {
const value = detail.value[field.key];
form[field.key] =
value == null
? undefined
: field.type === 'money'
? Number(value) / 100
: value;
}
}
/** 加载账户详情、关联下拉选项和受保护头像。 */
async function loadProfile() {
loading.value = true;
try {
detail.value = await resourceApi.detail<Row>(
definition.value.resource,
identity.value,
);
resetForm();
await Promise.all([loadRelations(), loadAvatar()]);
} catch (error) {
Message.error((error as Error).message);
} finally {
loading.value = false;
}
}
/** 加载资料表单所需的关联资源选项。 */
async function loadRelations() {
const resources = new Set(
profileFields.value
.map((field) => field.relation)
.filter((value): value is string => Boolean(value)),
);
await Promise.all(
[...resources].map(async (resource) => {
relationLoading[resource] = true;
try {
relationOptions[resource] = (
await resourceApi.list<Row>(resource, 1, 100)
).list;
} catch {
relationOptions[resource] = [];
} finally {
relationLoading[resource] = false;
}
}),
);
}
/** 加载需要 JWT 的头像并生成仅限当前页面生命周期的 Blob URL。 */
async function loadAvatar() {
revokeObjectURL();
selectedAvatar.value = undefined;
const blob = await avatarApi.load(definition.value.resource, identity.value);
if (!blob) {
avatarPreview.value = DEFAULT_USER_AVATAR;
return;
}
objectURL = URL.createObjectURL(blob);
avatarPreview.value = objectURL;
}
/** 打开浏览器本地图片选择器。 */
function chooseAvatar() {
if (editing.value) avatarInput.value?.click();
}
/** 校验前端图片类型、大小、解码结果和像素尺寸。 */
async function validateAvatarFile(file: File) {
if (!['image/jpeg', 'image/png'].includes(file.type)) {
throw new Error('头像仅支持 JPG 或 PNG 格式');
}
if (file.size <= 0 || file.size > 2 * 1024 * 1024) {
throw new Error('头像大小不能超过 2 MB');
}
const bitmap = await createImageBitmap(file);
try {
if (
bitmap.width <= 0 ||
bitmap.height <= 0 ||
bitmap.width > 4096 ||
bitmap.height > 4096
) {
throw new Error('头像尺寸不能超过 4096×4096 像素');
}
} finally {
bitmap.close();
}
}
/** 选择头像后立即本地预览,文件在点击保存前不会上传。 */
async function handleAvatarChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
try {
await validateAvatarFile(file);
revokeObjectURL();
selectedAvatar.value = file;
objectURL = URL.createObjectURL(file);
avatarPreview.value = objectURL;
} catch (error) {
Message.warning((error as Error).message);
}
}
/** 取消编辑并重新加载服务端头像与表单值。 */
async function cancelEdit() {
resetForm();
await Promise.all([leaveEditMode(), loadAvatar()]);
}
/** 验证表单后保存资料;新头像在资料更新前完成受控上传。 */
async function save() {
if (
profileFields.value.some(
(field) =>
isResourceFieldRequired(field, 'edit') &&
isMissingField(form[field.key]),
)
) {
Message.warning('请填写必填字段');
return;
}
saving.value = true;
try {
const payload = buildResourcePayload(profileFields.value, form, 'edit');
if (selectedAvatar.value) {
const uploaded = await avatarApi.upload(selectedAvatar.value);
payload.avatar = uploaded.uri;
}
await resourceApi.update(
definition.value.resource,
identity.value,
payload,
);
Message.success('资料保存成功');
await leaveEditMode();
await loadProfile();
} catch (error) {
Message.error((error as Error).message);
} finally {
saving.value = false;
}
}
/** 格式化后端时间字段,兼容普通字符串与 GORM 时间对象。 */
function formatDate(value: unknown) {
const raw =
value && typeof value === 'object' && 'Time' in value
? (value as { Time?: unknown }).Time
: value;
const date = dayjs(String(raw ?? ''));
return date.isValid() ? date.format('YYYY-MM-DD HH:mm:ss') : '-';
}
/** 生成人可读的关联资源选项名称。 */
function optionLabel(option: Row) {
return String(
option.name ??
option.title ??
option.code ??
option.username ??
option.identity,
);
}
/** 将只读资料字段转换为适合页面展示的文本。 */
function displayProfileValue(field: ResourceField) {
const value = form[field.key];
if (isMissingField(value)) return field.emptyText ?? '-';
if (field.options) {
const selected = field.options.find(
(option) => String(option.value) === String(value),
);
if (selected) return selected.label;
}
if (field.type === 'boolean') return value === true ? '是' : '否';
if (field.type === 'money') {
const amount = Number(value);
return Number.isFinite(amount) ? `¥${amount.toFixed(2)}` : String(value);
}
if (field.type === 'date' || field.type === 'datetime')
return formatDate(value);
if (field.type === 'identity' && typeof value === 'string') {
const match = (relationOptions[field.relation ?? ''] ?? []).find(
(option) => String(option.identity) === value,
);
return match ? optionLabel(match) : value;
}
if (field.type === 'identity-list' && Array.isArray(value)) {
return value.join('、');
}
return String(value);
}
/** 释放浏览器创建的头像对象 URL避免页面切换后的内存泄漏。 */
function revokeObjectURL() {
if (objectURL) URL.revokeObjectURL(objectURL);
objectURL = '';
}
onMounted(loadProfile);
onBeforeUnmount(revokeObjectURL);
watch(
() => [route.meta.resource, route.params.identity],
([resource, nextIdentity], [previousResource, previousIdentity]) => {
if (resource !== previousResource || nextIdentity !== previousIdentity)
loadProfile();
},
);
</script>
<style scoped lang="less" src="./AccountProfilePage.less"></style>

View File

@@ -832,6 +832,15 @@ function openCreate() {
}
function openEdit(row: Row) {
const profileRoute = accountProfileRouteName();
if (profileRoute) {
router.push({
name: profileRoute,
params: { identity: String(row.identity ?? '') },
query: { mode: 'edit' },
});
return;
}
editingIdentity.value = String(row.identity ?? '');
resetForm(row);
formVisible.value = true;
@@ -842,6 +851,14 @@ function isProtectedRecord(row: Row) {
}
async function openDetail(row: Row) {
const profileRoute = accountProfileRouteName();
if (profileRoute) {
await router.push({
name: profileRoute,
params: { identity: String(row.identity ?? '') },
});
return;
}
try {
detail.value = await resourceApi.detail<Row>(
props.definition.resource,
@@ -1020,6 +1037,16 @@ async function openStatus(row: Row) {
actionForm.status = [1, 2].includes(currentStatus) ? currentStatus : undefined;
}
// 工作人员与用户账户使用独立资料页,其他资源继续沿用现有抽屉。
function accountProfileRouteName() {
if (props.definition.name === 'user_account') return 'user-account-profile';
if (props.definition.name !== 'staff_account') return '';
if (staffType.value === 'installer') return 'staff-installers-profile';
if (staffType.value === 'delivery') return 'staff-delivery-profile';
if (staffType.value === 'operations') return 'staff-operations-profile';
return '';
}
function confirmArchive(row: Row) {
Modal.warning({
title: '确认删除',