feat: 新增账户资料页与头像上传
将工作人员和用户的详情、编辑改为独立账户资料页。 增加受控头像上传与读取、图片安全校验、接口测试,并优化只读及编辑布局。 同步更新平台需求、接口安全说明、项目文档和操作日志。
This commit is contained in:
@@ -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" }
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
171
backend/api/internal/logic/upload/avatar.go
Normal file
171
backend/api/internal/logic/upload/avatar.go
Normal 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
|
||||
}
|
||||
70
backend/api/internal/logic/upload/avatar_test.go
Normal file
70
backend/api/internal/logic/upload/avatar_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
25
backend/api/internal/routers/upload_test.go
Normal file
25
backend/api/internal/routers/upload_test.go
Normal 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)
|
||||
}
|
||||
@@ -130,6 +130,8 @@
|
||||
|
||||
工作人员支持安装人员 `installer`、配送人员 `delivery`、运维人员 `operations` 三种角色,可关联气站或配送点,并记录在岗/离岗状态。前端按角色提供独立列表和新增入口;后端按菜单和人员实际角色校验详情、修改及资质访问。
|
||||
|
||||
工作人员详情与编辑使用独立资料页,不再使用列表抽屉。资料页默认只读,可切换编辑状态;顶部展示头像、用户名、唯一标识和创建时间,下方维护现有基本信息。头像仅支持 JPG/PNG、本地预览和保存时上传,读取继续受平台 JWT、菜单及人员角色权限保护。
|
||||
|
||||
### 6.3 用户管理
|
||||
|
||||
| 资源 | 路径 | 模式 | 已实现能力 |
|
||||
@@ -140,6 +142,8 @@
|
||||
|
||||
用户页面同时提供配送合同入口。当前后台可直接维护用户、地址和服务关系,但尚未实现邀请二维码注册、服务关系审批和完整历史时间线。
|
||||
|
||||
用户账户详情与编辑复用工作人员资料页布局,默认只读并支持页内编辑。用户头像通过受保护资源接口读取,不在通用详情响应中暴露头像 URI;新增用户仍沿用现有新增抽屉。
|
||||
|
||||
### 6.4 智能气阀管理
|
||||
|
||||
| 资源 | 路径 | 模式 | 已实现能力 |
|
||||
|
||||
@@ -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 <= 可提现余额 <= 总余额`。普通消费扣减总余额后,必须同步把可提现余额限制在剩余总余额以内。
|
||||
- 提现申请在同一数据库事务内锁定钱包、同时预扣总余额和可提现余额并写入不可变流水;驳回只返还该申请实际预扣的两类余额,完成打款只确认外部结果,不得再次扣款。
|
||||
|
||||
52
docs/操作日志_账户资料页与头像上传_20260810.md
Normal file
52
docs/操作日志_账户资料页与头像上传_20260810.md
Normal 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 存储尚无孤立文件自动清理;通过保存时才上传降低无效文件数量,生产环境仍需清理机制。
|
||||
- 完整图片解码可以阻止伪造或损坏图片,但不能替代病毒扫描;生产发布前需接入受控对象存储和恶意内容扫描。
|
||||
- 影响范围限定为平台总后台,气站后台和配送后台没有行为变化。
|
||||
49
docs/操作日志_账户资料页视觉优化_20260810.md
Normal file
49
docs/操作日志_账户资料页视觉优化_20260810.md
Normal 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 行的限制。
|
||||
|
||||
## 风险评估
|
||||
|
||||
- 仅修改共享资料页模板和局部样式,工作人员与用户账户同步生效。
|
||||
- 没有修改数据、接口、上传逻辑、数据库和其他后台页面。
|
||||
- 关联字段只读名称依赖已加载的关系选项;加载失败时回退显示原唯一标识。
|
||||
83
docs/项目文档_账户资料页_v1.0.md
Normal file
83
docs/项目文档_账户资料页_v1.0.md
Normal 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. 已知边界
|
||||
|
||||
- 本次不提供头像裁剪、历史头像管理或旧头像自动删除。
|
||||
- 新增工作人员和用户仍使用现有抽屉。
|
||||
- 气站后台与配送后台未同步独立资料页。
|
||||
87
docs/项目文档_账户资料页_v1.1.md
Normal file
87
docs/项目文档_账户资料页_v1.1.md
Normal 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.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. 已知边界
|
||||
|
||||
- 本版不新增头像裁剪、删除或语义识别。
|
||||
- 不改变新增账户、审核、归档和钱包操作。
|
||||
- 气站后台与配送后台仍使用原有页面交互。
|
||||
62
frontend/platform_admin/src/api/avatar.ts
Normal file
62
frontend/platform_admin/src/api/avatar.ts
Normal 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 };
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
455
frontend/platform_admin/src/views/account/AccountProfilePage.vue
Normal file
455
frontend/platform_admin/src/views/account/AccountProfilePage.vue
Normal 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>
|
||||
@@ -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: '确认删除',
|
||||
|
||||
Reference in New Issue
Block a user