diff --git a/backend/api/internal/logic/platform/staff/staff.go b/backend/api/internal/logic/platform/staff/staff.go index 237d0a8..83aa625 100644 --- a/backend/api/internal/logic/platform/staff/staff.go +++ b/backend/api/internal/logic/platform/staff/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" ) @@ -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" } diff --git a/backend/api/internal/logic/platform/user/user.go b/backend/api/internal/logic/platform/user/user.go index e60aa57..91fe79c 100644 --- a/backend/api/internal/logic/platform/user/user.go +++ b/backend/api/internal/logic/platform/user/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" ) @@ -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"}) } diff --git a/backend/api/internal/logic/upload/avatar.go b/backend/api/internal/logic/upload/avatar.go new file mode 100644 index 0000000..3828740 --- /dev/null +++ b/backend/api/internal/logic/upload/avatar.go @@ -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 +} diff --git a/backend/api/internal/logic/upload/avatar_test.go b/backend/api/internal/logic/upload/avatar_test.go new file mode 100644 index 0000000..ea917f3 --- /dev/null +++ b/backend/api/internal/logic/upload/avatar_test.go @@ -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) + } + } +} diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index 7199a87..3239aea 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -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{}) } diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index 396c908..d22d94a 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -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) } diff --git a/backend/api/internal/routers/upload.go b/backend/api/internal/routers/upload.go index 555b6c3..f824080 100644 --- a/backend/api/internal/routers/upload.go +++ b/backend/api/internal/routers/upload.go @@ -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) } diff --git a/backend/api/internal/routers/upload_test.go b/backend/api/internal/routers/upload_test.go new file mode 100644 index 0000000..8d51ab6 --- /dev/null +++ b/backend/api/internal/routers/upload_test.go @@ -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) +} diff --git a/docs/05-平台总后台需求.md b/docs/05-平台总后台需求.md index a1c92c7..0798397 100644 --- a/docs/05-平台总后台需求.md +++ b/docs/05-平台总后台需求.md @@ -130,6 +130,8 @@ 工作人员支持安装人员 `installer`、配送人员 `delivery`、运维人员 `operations` 三种角色,可关联气站或配送点,并记录在岗/离岗状态。前端按角色提供独立列表和新增入口;后端按菜单和人员实际角色校验详情、修改及资质访问。 +工作人员详情与编辑使用独立资料页,不再使用列表抽屉。资料页默认只读,可切换编辑状态;顶部展示头像、用户名、唯一标识和创建时间,下方维护现有基本信息。头像仅支持 JPG/PNG、本地预览和保存时上传,读取继续受平台 JWT、菜单及人员角色权限保护。 + ### 6.3 用户管理 | 资源 | 路径 | 模式 | 已实现能力 | @@ -140,6 +142,8 @@ 用户页面同时提供配送合同入口。当前后台可直接维护用户、地址和服务关系,但尚未实现邀请二维码注册、服务关系审批和完整历史时间线。 +用户账户详情与编辑复用工作人员资料页布局,默认只读并支持页内编辑。用户头像通过受保护资源接口读取,不在通用详情响应中暴露头像 URI;新增用户仍沿用现有新增抽屉。 + ### 6.4 智能气阀管理 | 资源 | 路径 | 模式 | 已实现能力 | diff --git a/docs/11-数据接口与安全.md b/docs/11-数据接口与安全.md index d4ba438..1bddf2d 100644 --- a/docs/11-数据接口与安全.md +++ b/docs/11-数据接口与安全.md @@ -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 <= 可提现余额 <= 总余额`。普通消费扣减总余额后,必须同步把可提现余额限制在剩余总余额以内。 - 提现申请在同一数据库事务内锁定钱包、同时预扣总余额和可提现余额并写入不可变流水;驳回只返还该申请实际预扣的两类余额,完成打款只确认外部结果,不得再次扣款。 diff --git a/docs/操作日志_账户资料页与头像上传_20260810.md b/docs/操作日志_账户资料页与头像上传_20260810.md new file mode 100644 index 0000000..956c90c --- /dev/null +++ b/docs/操作日志_账户资料页与头像上传_20260810.md @@ -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 存储尚无孤立文件自动清理;通过保存时才上传降低无效文件数量,生产环境仍需清理机制。 +- 完整图片解码可以阻止伪造或损坏图片,但不能替代病毒扫描;生产发布前需接入受控对象存储和恶意内容扫描。 +- 影响范围限定为平台总后台,气站后台和配送后台没有行为变化。 diff --git a/docs/操作日志_账户资料页视觉优化_20260810.md b/docs/操作日志_账户资料页视觉优化_20260810.md new file mode 100644 index 0000000..1052b0d --- /dev/null +++ b/docs/操作日志_账户资料页视觉优化_20260810.md @@ -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 行的限制。 + +## 风险评估 + +- 仅修改共享资料页模板和局部样式,工作人员与用户账户同步生效。 +- 没有修改数据、接口、上传逻辑、数据库和其他后台页面。 +- 关联字段只读名称依赖已加载的关系选项;加载失败时回退显示原唯一标识。 diff --git a/docs/项目文档_账户资料页_v1.0.md b/docs/项目文档_账户资料页_v1.0.md new file mode 100644 index 0000000..579737e --- /dev/null +++ b/docs/项目文档_账户资料页_v1.0.md @@ -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. 已知边界 + +- 本次不提供头像裁剪、历史头像管理或旧头像自动删除。 +- 新增工作人员和用户仍使用现有抽屉。 +- 气站后台与配送后台未同步独立资料页。 diff --git a/docs/项目文档_账户资料页_v1.1.md b/docs/项目文档_账户资料页_v1.1.md new file mode 100644 index 0000000..fc4f922 --- /dev/null +++ b/docs/项目文档_账户资料页_v1.1.md @@ -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.1(2026-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. 已知边界 + +- 本版不新增头像裁剪、删除或语义识别。 +- 不改变新增账户、审核、归档和钱包操作。 +- 气站后台与配送后台仍使用原有页面交互。 diff --git a/frontend/platform_admin/src/api/avatar.ts b/frontend/platform_admin/src/api/avatar.ts new file mode 100644 index 0000000..b75b696 --- /dev/null +++ b/frontend/platform_admin/src/api/avatar.ts @@ -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 = { 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 { + const token = getToken(); + return token ? { Authorization: token } : {}; +} + +/** 上传经过前端预检的头像文件,服务端仍会执行真实内容校验。 */ +async function upload(file: File): Promise { + 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; + 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 { + 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 }; diff --git a/frontend/platform_admin/src/router/routes/modules/platform.ts b/frontend/platform_admin/src/router/routes/modules/platform.ts index 61626fe..da336c5 100644 --- a/frontend/platform_admin/src/router/routes/modules/platform.ts +++ b/frontend/platform_admin/src/router/routes/modules/platform.ts @@ -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'), diff --git a/frontend/platform_admin/src/router/typings.d.ts b/frontend/platform_admin/src/router/typings.d.ts index 1f85192..1f18575 100644 --- a/frontend/platform_admin/src/router/typings.d.ts +++ b/frontend/platform_admin/src/router/typings.d.ts @@ -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 diff --git a/frontend/platform_admin/src/views/account/AccountProfilePage.less b/frontend/platform_admin/src/views/account/AccountProfilePage.less new file mode 100644 index 0000000..2abc2b4 --- /dev/null +++ b/frontend/platform_admin/src/views/account/AccountProfilePage.less @@ -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; + } +} diff --git a/frontend/platform_admin/src/views/account/AccountProfilePage.vue b/frontend/platform_admin/src/views/account/AccountProfilePage.vue new file mode 100644 index 0000000..89e81bb --- /dev/null +++ b/frontend/platform_admin/src/views/account/AccountProfilePage.vue @@ -0,0 +1,455 @@ + + + + + + diff --git a/frontend/platform_admin/src/views/shared/CrudListPage.vue b/frontend/platform_admin/src/views/shared/CrudListPage.vue index 48da7a1..4aac14e 100644 --- a/frontend/platform_admin/src/views/shared/CrudListPage.vue +++ b/frontend/platform_admin/src/views/shared/CrudListPage.vue @@ -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( 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: '确认删除',