完善配送端账户头像管理

This commit is contained in:
czl231
2026-08-22 21:10:22 +08:00
parent 25254cb773
commit 2e2c1a35dd
14 changed files with 356 additions and 24 deletions

View File

@@ -6,6 +6,7 @@ import (
"git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/upload"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models" "git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
@@ -41,12 +42,25 @@ func GetStaff(ctx *gin.Context) {
ctx.Param("identity"), point.GasBasicID, point.ID, "delivery"), &staff) ctx.Param("identity"), point.GasBasicID, point.ID, "delivery"), &staff)
} }
// GetStaffAvatar 返回当前配送点范围内配送人员的受保护头像。
func GetStaffAvatar(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
staff, ok := scopedStaff(ctx, ctx.Param("identity"), point)
if !ok {
return
}
upload.ServeAvatar(ctx, staff.Avatar)
}
type staffRequest struct { type staffRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Name string `json:"name" binding:"required,max=64"` Name string `json:"name" binding:"required,max=64"`
Phone string `json:"phone" binding:"max=32"` Phone string `json:"phone" binding:"max=32"`
Avatar string `json:"avatar" binding:"max=512"` Avatar *string `json:"avatar" binding:"omitempty,max=512"`
WorkStatus string `json:"work_status" binding:"required"` WorkStatus string `json:"work_status" binding:"required"`
} }
@@ -66,9 +80,13 @@ func CreateStaff(ctx *gin.Context) {
infra.Response.Error(ctx, err) infra.Response.Error(ctx, err)
return return
} }
avatar := ""
if request.Avatar != nil {
avatar = *request.Avatar
}
staff := models.StaffAccount{ staff := models.StaffAccount{
Entity: common.NewEntity(common.StatusEnable), Username: request.Username, PasswordHash: hash, Entity: common.NewEntity(common.StatusEnable), Username: request.Username, PasswordHash: hash,
Name: request.Name, Phone: request.Phone, Avatar: request.Avatar, RoleCode: "delivery", Name: request.Name, Phone: request.Phone, Avatar: avatar, RoleCode: "delivery",
GasBasicID: point.GasBasicID, DeliveryBasicID: point.ID, WorkStatus: request.WorkStatus, GasBasicID: point.GasBasicID, DeliveryBasicID: point.ID, WorkStatus: request.WorkStatus,
} }
if err := common.CreateStaffRecord(&staff); err != nil { if err := common.CreateStaffRecord(&staff); err != nil {
@@ -92,9 +110,13 @@ func UpdateStaff(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument) infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return return
} }
common.UpdateAllowedByIdentityWithError(ctx, &models.StaffAccount{}, gin.H{ values := gin.H{"name": request.Name, "phone": request.Phone, "work_status": request.WorkStatus}
"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "work_status": request.WorkStatus, // 未选择新头像时不提交 avatar避免编辑基础资料误清空现有头像。
}, []string{"name", "phone", "avatar", "work_status"}, common.StaffWriteError) if request.Avatar != nil {
values["avatar"] = *request.Avatar
}
common.UpdateAllowedByIdentityWithError(ctx, &models.StaffAccount{}, values,
[]string{"name", "phone", "avatar", "work_status"}, common.StaffWriteError)
} }
func ResetStaffPassword(ctx *gin.Context) { func ResetStaffPassword(ctx *gin.Context) {

View File

@@ -6,6 +6,7 @@ import (
"git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/upload"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models" "git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
@@ -57,12 +58,25 @@ func GetUser(ctx *gin.Context) {
infra.Response.Success(ctx, response) infra.Response.Success(ctx, response)
} }
// GetUserAvatar 返回当前配送点存在有效服务关系的用户受保护头像。
func GetUserAvatar(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
if !ok {
return
}
user, _, ok := scopedUser(ctx, ctx.Param("identity"), point.ID)
if !ok {
return
}
upload.ServeAvatar(ctx, user.Avatar)
}
type userRequest struct { type userRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Name string `json:"name" binding:"required,max=64"` Name string `json:"name" binding:"required,max=64"`
Phone string `json:"phone" binding:"max=32"` Phone string `json:"phone" binding:"max=32"`
Avatar string `json:"avatar" binding:"max=512"` Avatar *string `json:"avatar" binding:"omitempty,max=512"`
RealName string `json:"real_name" binding:"max=64"` RealName string `json:"real_name" binding:"max=64"`
} }
@@ -81,9 +95,13 @@ func CreateUser(ctx *gin.Context) {
infra.Response.Error(ctx, err) infra.Response.Error(ctx, err)
return return
} }
avatar := ""
if request.Avatar != nil {
avatar = *request.Avatar
}
user := models.UserAccount{ user := models.UserAccount{
Entity: common.NewEntity(common.StatusEnable), Username: request.Username, PasswordHash: hash, Entity: common.NewEntity(common.StatusEnable), Username: request.Username, PasswordHash: hash,
Name: request.Name, Phone: request.Phone, Avatar: request.Avatar, RealName: request.RealName, Name: request.Name, Phone: request.Phone, Avatar: avatar, RealName: request.RealName,
} }
relation := models.UserServiceRelation{ relation := models.UserServiceRelation{
Entity: common.NewEntity(common.StatusEnable), GasBasicID: point.GasBasicID, DeliveryBasicID: point.ID, Entity: common.NewEntity(common.StatusEnable), GasBasicID: point.GasBasicID, DeliveryBasicID: point.ID,
@@ -115,9 +133,12 @@ func UpdateUser(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument) infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return return
} }
if err := db().Model(&user).Updates(map[string]any{ values := map[string]any{"name": request.Name, "phone": request.Phone, "real_name": request.RealName}
"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "real_name": request.RealName, // 未选择新头像时不提交 avatar避免编辑基础资料误清空现有头像。
}).Error; err != nil { if request.Avatar != nil {
values["avatar"] = *request.Avatar
}
if err := db().Model(&user).Updates(values).Error; err != nil {
infra.Response.Error(ctx, err) infra.Response.Error(ctx, err)
return return
} }

View File

@@ -28,6 +28,7 @@ func RegisterDelivery(serviceKey string, engine *gin.Engine) {
staff.GET("", deliverylogic.ListStaff) staff.GET("", deliverylogic.ListStaff)
staff.POST("", deliverylogic.CreateStaff) staff.POST("", deliverylogic.CreateStaff)
staff.GET("/:identity", deliverylogic.GetStaff) staff.GET("/:identity", deliverylogic.GetStaff)
staff.GET("/:identity/avatar", deliverylogic.GetStaffAvatar)
staff.PUT("/:identity", deliverylogic.UpdateStaff) staff.PUT("/:identity", deliverylogic.UpdateStaff)
staff.PUT("/:identity/password", deliverylogic.ResetStaffPassword) staff.PUT("/:identity/password", deliverylogic.ResetStaffPassword)
staff.PATCH("/:identity/status", deliverylogic.UpdateStaffStatus) staff.PATCH("/:identity/status", deliverylogic.UpdateStaffStatus)
@@ -44,6 +45,7 @@ func RegisterDelivery(serviceKey string, engine *gin.Engine) {
user.GET("", deliverylogic.ListUser) user.GET("", deliverylogic.ListUser)
user.POST("", deliverylogic.CreateUser) user.POST("", deliverylogic.CreateUser)
user.GET("/:identity", deliverylogic.GetUser) user.GET("/:identity", deliverylogic.GetUser)
user.GET("/:identity/avatar", deliverylogic.GetUserAvatar)
user.PUT("/:identity", deliverylogic.UpdateUser) user.PUT("/:identity", deliverylogic.UpdateUser)
user.PUT("/:identity/password", deliverylogic.ResetUserPassword) user.PUT("/:identity/password", deliverylogic.ResetUserPassword)
user.PATCH("/:identity/status", deliverylogic.UpdateUserStatus) user.PATCH("/:identity/status", deliverylogic.UpdateUserStatus)

View File

@@ -45,6 +45,8 @@ func TestDeliveryOrderActionBoundary(t *testing.T) {
"POST /heqi/delivery/v1/gasorder_basic/:identity/adjust-amount", "POST /heqi/delivery/v1/gasorder_basic/:identity/adjust-amount",
"GET /heqi/delivery/v1/wallet_recharge", "GET /heqi/delivery/v1/wallet_recharge",
"GET /heqi/delivery/v1/wallet_recharge/:identity", "GET /heqi/delivery/v1/wallet_recharge/:identity",
"GET /heqi/delivery/v1/staff_account/:identity/avatar",
"GET /heqi/delivery/v1/user_account/:identity/avatar",
} { } {
if !routes[required] { if !routes[required] {
t.Fatalf("missing confirmed delivery action %s", required) t.Fatalf("missing confirmed delivery action %s", required)

View File

@@ -17,6 +17,8 @@
5. 将配送端支付、退款资源统一为 `payment_order``payment_refund`,同步后端契约快照。 5. 将配送端支付、退款资源统一为 `payment_order``payment_refund`,同步后端契约快照。
6. 增加页面能力矩阵自动检查,并更新项目文档。 6. 增加页面能力矩阵自动检查,并更新项目文档。
7. 依据 5173 页面重新对齐灰色页面底、面包屑、分区卡片、详情信息网格、双列表单和底部操作区。 7. 依据 5173 页面重新对齐灰色页面底、面包屑、分区卡片、详情信息网格、双列表单和底部操作区。
8. 将配送人员和用户账户的头像文本框替换为 5173 头像摘要卡片,新增上传、预览、恢复默认头像和配送点范围鉴权读取能力。
9. 修正配送人员、用户编辑接口:没有提交头像字段时保留旧头像,避免编辑其他资料时误清空。
## 操作后状态 ## 操作后状态
@@ -43,11 +45,12 @@
- `contract:check`通过18 个资源与后端契约一致。 - `contract:check`通过18 个资源与后端契约一致。
- `profile:check`:通过,资料专用只读页未回退。 - `profile:check`:通过,资料专用只读页未回退。
- `type:check`:通过。 - `type:check`:通过。
- `build`通过2622 个模块完成生产构建。 - `build`通过2628 个模块完成生产构建。
- `go test ./internal/logic/delivery`:通过。 - `go test ./internal/logic/delivery`:通过。
- `lint`:通过;仅报告项目既有警告,未产生失败项。 - `lint`:通过;仅报告项目既有警告,未产生失败项。
- 浏览器回归:工作人员列表、详情、编辑、正式新建地址、返回链路、未保存保护、配送订单新建页、支付列表均通过。 - 浏览器回归:工作人员列表、详情、编辑、正式新建地址、返回链路、未保存保护、配送订单新建页、支付列表均通过。
- 布局回归:工作人员详情、编辑和配送订单新建页已在应用内浏览器逐页截图检查,与 5173 的页面结构和响应式断点一致。 - 布局回归:工作人员详情、编辑和配送订单新建页已在应用内浏览器逐页截图检查,与 5173 的页面结构和响应式断点一致。
- 头像回归:配送人员新建、编辑页已确认不再显示头像文本框,头像选择按钮、格式大小提示、默认头像和身份摘要均正常;后端头像路由测试通过。
## 风险评估 ## 风险评估

View File

@@ -33,6 +33,8 @@ frontend/delivery_admin/
│ ├── ResourceDetailContent.vue # 主记录和集合详情展示 │ ├── ResourceDetailContent.vue # 主记录和集合详情展示
│ ├── ResourceFieldForm.vue # 通用资源字段表单 │ ├── ResourceFieldForm.vue # 通用资源字段表单
│ ├── ResourceActionDialog.vue # 详情业务动作与危险确认 │ ├── ResourceActionDialog.vue # 详情业务动作与危险确认
│ ├── ResourceAccountSummary.vue # 头像选择与账户身份摘要
│ ├── use-resource-avatar.ts # 头像预览、上传和清除状态
│ └── use-unsaved-record.ts # 未保存离开保护 │ └── use-unsaved-record.ts # 未保存离开保护
└── views/shared/ └── views/shared/
└── ResourceListPage.vue # 跳转独立页面的标准列表 └── ResourceListPage.vue # 跳转独立页面的标准列表
@@ -46,6 +48,8 @@ frontend/delivery_admin/
页面布局与平台总后台保持一致:使用灰色页面背景、顶部面包屑和操作栏;详情由“基本信息”“关联记录”“业务操作”分区卡片组成;新建和编辑使用双列响应式表单,并将保存、取消操作固定在表单内容底部。 页面布局与平台总后台保持一致:使用灰色页面背景、顶部面包屑和操作栏;详情由“基本信息”“关联记录”“业务操作”分区卡片组成;新建和编辑使用双列响应式表单,并将保存、取消操作固定在表单内容底部。
配送人员和用户账户参考 5173 使用头像身份摘要卡片。新建、编辑时点击头像可选择不超过 2 MB 的 JPG/PNG 文件,保存时先上传到受控头像目录,再把返回 URI 写入资源;详情页通过配送点范围鉴权接口读取头像。未选择新头像时不会清空已有头像。
`resource-record-form.ts` 为五类可编辑资源声明后端更新字段白名单,防止用户名、合同编号等只读字段出现在编辑页或被无效提交。 `resource-record-form.ts` 为五类可编辑资源声明后端更新字段白名单,防止用户名、合同编号等只读字段出现在编辑页或被无效提交。
支付与退款资源统一使用后端正式名称 `payment_order``payment_refund`,菜单地址仍保持 `/finance/payments``/finance/refunds`,避免接口路径不一致导致 404。 支付与退款资源统一使用后端正式名称 `payment_order``payment_refund`,菜单地址仍保持 `/finance/payments``/finance/refunds`,避免接口路径不一致导致 404。

View File

@@ -60,5 +60,10 @@ assert(resourcePage.includes('ResourceListPage'), '标准资源列表尚未切
assert(!resourcePage.includes('CrudListPage'), '活动列表仍依赖抽屉式 CrudListPage'); assert(!resourcePage.includes('CrudListPage'), '活动列表仍依赖抽屉式 CrudListPage');
assert(definitions.includes("define('payment_order'"), '支付资源必须使用后端名称 payment_order'); assert(definitions.includes("define('payment_order'"), '支付资源必须使用后端名称 payment_order');
assert(definitions.includes("define('payment_refund'"), '退款资源必须使用后端名称 payment_refund'); assert(definitions.includes("define('payment_refund'"), '退款资源必须使用后端名称 payment_refund');
assert(routeKeys.has('GET /staff_account/:identity/avatar'), '配送人员缺少受保护头像读取接口');
assert(routeKeys.has('GET /user_account/:identity/avatar'), '用户账户缺少受保护头像读取接口');
const recordPage = read('src/views/resource/ResourceRecordPage.vue');
assert(recordPage.includes('ResourceAccountSummary'), '账户资源页尚未接入 5173 头像摘要卡片');
assert(recordPage.includes("field.key !== 'avatar'"), '头像字段仍可能显示为普通文本框');
console.log(`独立资源页面契约通过:详情 ${listResources.length},新建 ${creatable.size},编辑 ${editable.size}`); console.log(`独立资源页面契约通过:详情 ${listResources.length},新建 ${creatable.size},编辑 ${editable.size}`);

View File

@@ -0,0 +1,50 @@
/**
* 功能描述:提供配送点资源头像上传与鉴权读取。
* 版本v1.0.0。
*/
import { getToken } from '@/utils/auth';
const deliveryApiBaseURL =
import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/heqi/delivery/v1';
export type AvatarUploadReply = {
uri: string;
original_name: string;
content_type: string;
size: number;
};
type ApiEnvelope<T> = { code?: number; message?: string; details?: T };
/** 返回与现有配送点请求一致的 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 baseURL = new URL(deliveryApiBaseURL, window.location.origin);
const response = await fetch(new URL('/upload/avatar', baseURL.origin), {
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;
}
/** 读取配送点权限范围内的受保护头像。 */
async function load(resource: string, identity: string): Promise<Blob | undefined> {
const response = await fetch(
`${deliveryApiBaseURL}${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 };

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,98 @@
<!-- 功能描述参考 5173 展示账户头像和身份摘要版本v1.0.0 -->
<template>
<a-card class="account-card" :bordered="false">
<div class="account-summary">
<div class="avatar-column">
<button
class="avatar-button"
type="button"
:disabled="mode === 'detail'"
:aria-label="mode === 'detail' ? '账户头像' : '选择账户头像'"
@click="chooseAvatar"
>
<a-avatar :size="120" class="account-avatar">
<img :src="avatarUrl" alt="账户头像" />
</a-avatar>
<span v-if="mode !== 'detail'" class="camera-badge"><icon-camera /></span>
</button>
<input ref="avatarInput" class="avatar-input" type="file" accept="image/jpeg,image/png" @change="onSelected" />
<span v-if="mode !== 'detail'" class="avatar-help">JPG/PNG最大 2 MB</span>
<a-button v-if="mode !== 'detail' && canClear" type="text" size="mini" @click="emit('clear-avatar')">
恢复默认头像
</a-button>
</div>
<div class="identity-summary">
<h2>{{ summaryTitle }}</h2>
<dl>
<div><dt>用户名</dt><dd>{{ String(record.username ?? '保存后确定') }}</dd></div>
<div><dt>唯一标识</dt><dd class="identity-value">{{ String(record.identity ?? '保存后生成') }}</dd></div>
<div><dt>创建时间</dt><dd>{{ createdAt }}</dd></div>
</dl>
</div>
</div>
</a-card>
</template>
<script setup lang="ts">
import dayjs from 'dayjs';
import { IconCamera } from '@arco-design/web-vue/es/icon';
import { computed, ref } from 'vue';
import type { ResourceRow } from '@/api/resource-record-form';
const props = defineProps<{
mode: 'create' | 'detail' | 'edit'; title: string; record: ResourceRow;
avatarUrl: string; canClear: boolean;
}>();
const emit = defineEmits<{ 'select-avatar': [file: File]; 'clear-avatar': [] }>();
const avatarInput = ref<HTMLInputElement>();
const summaryTitle = computed(() => String(
props.record.name ?? props.record.real_name ?? props.record.username ??
(props.mode === 'create' ? `新建${props.title}` : props.title),
));
const createdAt = computed(() => {
if (!props.record.created_at) return '保存后生成';
const value = dayjs(String(props.record.created_at));
return value.isValid() ? value.format('YYYY-MM-DD HH:mm:ss') : String(props.record.created_at);
});
function chooseAvatar() {
if (props.mode !== 'detail') avatarInput.value?.click();
}
function onSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (file) emit('select-avatar', file);
input.value = '';
}
</script>
<style scoped lang="less">
.account-card { border-radius: 10px; }
.account-summary {
display: grid; grid-template-columns: 180px minmax(360px, 500px);
justify-content: center; align-items: center; gap: 44px; min-height: 168px; padding: 18px 32px;
}
.avatar-column { display: flex; flex-direction: column; align-items: center; gap: 8px; }
.avatar-button { position: relative; padding: 0; background: transparent; border: 0; cursor: pointer; }
.avatar-button:disabled { cursor: default; }
.account-avatar { background: var(--color-fill-3); }
.account-avatar img { width: 100%; height: 100%; object-fit: cover; }
.camera-badge {
position: absolute; right: 2px; bottom: 2px; display: grid; width: 34px; height: 34px;
place-items: center; color: rgb(var(--primary-6)); background: var(--color-bg-2);
border: 3px solid var(--color-bg-2); border-radius: 50%; box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}
.avatar-input { display: none; }
.avatar-help { color: var(--color-text-3); font-size: 12px; }
.identity-summary h2 { margin: 0 0 14px; color: var(--color-text-1); font-size: 20px; }
.identity-summary dl, .identity-summary dd { margin: 0; }
.identity-summary dl > div { display: grid; grid-template-columns: 92px minmax(0, 1fr); align-items: center; min-height: 38px; }
.identity-summary dt { color: var(--color-text-3); text-align: right; }
.identity-summary dd { color: var(--color-text-1); font-size: 15px; }
.identity-value { color: rgb(var(--primary-6)) !important; word-break: break-all; }
@media (max-width: 760px) {
.account-summary { grid-template-columns: 1fr; gap: 20px; padding: 20px 8px; }
.identity-summary { width: min(100%, 440px); margin: 0 auto; }
}
</style>

View File

@@ -69,6 +69,9 @@ const entries = computed(() =>
Object.entries(record.value).filter( Object.entries(record.value).filter(
([key, value]) => ([key, value]) =>
key !== 'id' && key !== 'id' &&
key !== 'avatar' &&
key !== 'password' &&
key !== 'password_hash' &&
key !== 'DeletedAt' && key !== 'DeletedAt' &&
key !== 'deleted_at' && key !== 'deleted_at' &&
!key.endsWith('_id') && !key.endsWith('_id') &&

View File

@@ -29,6 +29,16 @@
</a-result> </a-result>
<template v-else> <template v-else>
<ResourceAccountSummary
v-if="hasAvatarField"
:mode="mode"
:title="definition.title"
:record="summaryRecord"
:avatar-url="avatar.url.value"
:can-clear="avatar.canClear.value"
@select-avatar="avatar.select"
@clear-avatar="avatar.clear"
/>
<template v-if="mode === 'detail'"> <template v-if="mode === 'detail'">
<ResourceDetailContent :definition="definition" :detail="detail" /> <ResourceDetailContent :definition="definition" :detail="detail" />
<a-card <a-card
@@ -102,9 +112,11 @@ import { primaryRecord, recordEditReason } from '@/api/resource-display';
import { recordRouteLocation, returnToList, safeReturnPath } from '@/api/resource-navigation'; import { recordRouteLocation, returnToList, safeReturnPath } from '@/api/resource-navigation';
import { getResource, type DetailAction, type ResourceField } from '@/api/resources'; import { getResource, type DetailAction, type ResourceField } from '@/api/resources';
import ResourceActionDialog from './ResourceActionDialog.vue'; import ResourceActionDialog from './ResourceActionDialog.vue';
import ResourceAccountSummary from './ResourceAccountSummary.vue';
import ResourceDetailContent from './ResourceDetailContent.vue'; import ResourceDetailContent from './ResourceDetailContent.vue';
import ResourceFieldForm from './ResourceFieldForm.vue'; import ResourceFieldForm from './ResourceFieldForm.vue';
import { useUnsavedRecord } from './use-unsaved-record'; import { useUnsavedRecord } from './use-unsaved-record';
import { useResourceAvatar } from './use-resource-avatar';
type RecordPageMode = 'create' | 'detail' | 'edit'; type RecordPageMode = 'create' | 'detail' | 'edit';
const route = useRoute(); const route = useRoute();
@@ -126,11 +138,18 @@ const relationLoading = reactive<Record<string, boolean>>({});
const relationTimers = new Map<string, ReturnType<typeof setTimeout>>(); const relationTimers = new Map<string, ReturnType<typeof setTimeout>>();
const actionVisible = ref(false); const actionVisible = ref(false);
const activeAction = ref<DetailAction>(); const activeAction = ref<DetailAction>();
const avatar = useResourceAvatar();
const hasAvatarField = computed(() =>
definition.value.fields.some((field) => field.key === 'avatar'),
);
const summaryRecord = computed(() =>
mode.value === 'detail' ? record.value : { ...record.value, ...form },
);
const formFields = computed(() => recordFormFields( const formFields = computed(() => recordFormFields(
definition.value.name, definition.value.name,
definition.value.fields, definition.value.fields,
mode.value as 'create' | 'edit', mode.value as 'create' | 'edit',
)); ).filter((field) => field.key !== 'avatar'));
const canEditRecord = computed( const canEditRecord = computed(
() => definition.value.canEdit && !recordEditReason(definition.value, record.value), () => definition.value.canEdit && !recordEditReason(definition.value, record.value),
); );
@@ -142,7 +161,7 @@ const visibleActions = computed(() =>
), ),
); );
const unsaved = useUnsavedRecord( const unsaved = useUnsavedRecord(
() => JSON.stringify(form), () => JSON.stringify({ form, avatar: avatar.marker() }),
() => mode.value !== 'detail', () => mode.value !== 'detail',
); );
@@ -160,6 +179,10 @@ async function loadRecord() {
if (reason) throw new Error(reason); if (reason) throw new Error(reason);
} }
} }
if (hasAvatarField.value && mode.value !== 'create') {
// 头像属于附加信息,读取失败不能阻断基础资料页面。
await avatar.load(definition.value.resource, identity.value).catch(() => undefined);
}
if (mode.value !== 'detail') { if (mode.value !== 'detail') {
resetResourceRecordForm(form, formFields.value, record.value, { resetResourceRecordForm(form, formFields.value, record.value, {
relationKey: typeof route.query.relation_key === 'string' ? route.query.relation_key : undefined, relationKey: typeof route.query.relation_key === 'string' ? route.query.relation_key : undefined,
@@ -181,6 +204,7 @@ async function save() {
saving.value = true; saving.value = true;
try { try {
const payload = buildResourcePayload(formFields.value, form, mode.value as 'create' | 'edit'); const payload = buildResourcePayload(formFields.value, form, mode.value as 'create' | 'edit');
if (hasAvatarField.value) await avatar.applyToPayload(payload);
const saved = mode.value === 'create' const saved = mode.value === 'create'
? await resourceApi.create<ResourceRow>(definition.value.resource, payload) ? await resourceApi.create<ResourceRow>(definition.value.resource, payload)
: await resourceApi.update<ResourceRow>(definition.value.resource, identity.value, payload); : await resourceApi.update<ResourceRow>(definition.value.resource, identity.value, payload);

View File

@@ -0,0 +1,26 @@
/** 功能描述缓存单次保存流程中已上传的头像地址。版本v1.0.0。 */
export type AvatarUpload = (file: File) => Promise<{ uri: string }>;
/** 创建可重试的头像上传结果缓存。 */
export function createAvatarUploadCache(upload: AvatarUpload) {
let cachedFile: File | undefined;
let cachedURI: Promise<string> | undefined;
async function resolve(file: File) {
if (cachedFile !== file || !cachedURI) {
cachedFile = file;
cachedURI = upload(file).then((result) => result.uri).catch((error) => {
if (cachedFile === file) cachedURI = undefined;
throw error;
});
}
return cachedURI;
}
function reset() {
cachedFile = undefined;
cachedURI = undefined;
}
return { resolve, reset };
}

View File

@@ -0,0 +1,72 @@
/** 功能描述管理资源头像的读取、预览、清除与保存前上传。版本v1.0.0。 */
import { Message } from '@arco-design/web-vue';
import { onBeforeUnmount, ref } from 'vue';
import { avatarApi } from '@/api/avatar';
import { DEFAULT_USER_AVATAR } from '@/constants/avatar';
import { createAvatarUploadCache } from './avatar-upload-cache';
/** 创建资源头像状态。 */
export function useResourceAvatar() {
const url = ref(DEFAULT_USER_AVATAR);
const file = ref<File>();
const cleared = ref(false);
const canClear = ref(false);
const uploadCache = createAvatarUploadCache(avatarApi.upload);
let objectURL = '';
function revoke() {
if (objectURL) URL.revokeObjectURL(objectURL);
objectURL = '';
}
async function load(resource: string, identity: string) {
revoke();
uploadCache.reset();
url.value = DEFAULT_USER_AVATAR;
file.value = undefined;
cleared.value = false;
canClear.value = false;
if (!identity) return;
const blob = await avatarApi.load(resource, identity);
if (!blob) return;
objectURL = URL.createObjectURL(blob);
url.value = objectURL;
canClear.value = true;
}
function select(next: File) {
if (!['image/jpeg', 'image/png'].includes(next.type) || next.size <= 0 || next.size > 2 * 1024 * 1024) {
Message.warning('请选择不超过 2 MB 的 JPG 或 PNG 图片');
return;
}
revoke();
uploadCache.reset();
objectURL = URL.createObjectURL(next);
url.value = objectURL;
file.value = next;
cleared.value = false;
canClear.value = true;
}
function clear() {
revoke();
uploadCache.reset();
url.value = DEFAULT_USER_AVATAR;
file.value = undefined;
cleared.value = true;
canClear.value = false;
}
async function applyToPayload(payload: Record<string, unknown>) {
if (file.value) payload.avatar = await uploadCache.resolve(file.value);
else if (cleared.value) payload.avatar = '';
}
function marker() {
return file.value ? `${file.value.name}:${file.value.size}:${file.value.lastModified}`
: cleared.value ? 'clear' : 'unchanged';
}
onBeforeUnmount(revoke);
return { url, canClear, load, select, clear, applyToPayload, marker };
}