完善配送端账户头像管理
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"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/upload"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -41,13 +42,26 @@ func GetStaff(ctx *gin.Context) {
|
||||
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 {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name" binding:"required,max=64"`
|
||||
Phone string `json:"phone" binding:"max=32"`
|
||||
Avatar string `json:"avatar" binding:"max=512"`
|
||||
WorkStatus string `json:"work_status" binding:"required"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name" binding:"required,max=64"`
|
||||
Phone string `json:"phone" binding:"max=32"`
|
||||
Avatar *string `json:"avatar" binding:"omitempty,max=512"`
|
||||
WorkStatus string `json:"work_status" binding:"required"`
|
||||
}
|
||||
|
||||
func CreateStaff(ctx *gin.Context) {
|
||||
@@ -66,9 +80,13 @@ func CreateStaff(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
avatar := ""
|
||||
if request.Avatar != nil {
|
||||
avatar = *request.Avatar
|
||||
}
|
||||
staff := models.StaffAccount{
|
||||
Entity: common.NewEntity(common.StatusEnable), Username: request.Username, PasswordHash: hash,
|
||||
Name: request.Name, Phone: request.Phone, Avatar: request.Avatar, RoleCode: "delivery",
|
||||
Name: request.Name, Phone: request.Phone, Avatar: avatar, RoleCode: "delivery",
|
||||
GasBasicID: point.GasBasicID, DeliveryBasicID: point.ID, WorkStatus: request.WorkStatus,
|
||||
}
|
||||
if err := common.CreateStaffRecord(&staff); err != nil {
|
||||
@@ -92,9 +110,13 @@ func UpdateStaff(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
common.UpdateAllowedByIdentityWithError(ctx, &models.StaffAccount{}, gin.H{
|
||||
"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "work_status": request.WorkStatus,
|
||||
}, []string{"name", "phone", "avatar", "work_status"}, common.StaffWriteError)
|
||||
values := gin.H{"name": request.Name, "phone": request.Phone, "work_status": request.WorkStatus}
|
||||
// 未选择新头像时不提交 avatar,避免编辑基础资料误清空现有头像。
|
||||
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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"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/upload"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -57,13 +58,26 @@ func GetUser(ctx *gin.Context) {
|
||||
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 {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name" binding:"required,max=64"`
|
||||
Phone string `json:"phone" binding:"max=32"`
|
||||
Avatar string `json:"avatar" binding:"max=512"`
|
||||
RealName string `json:"real_name" binding:"max=64"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name" binding:"required,max=64"`
|
||||
Phone string `json:"phone" binding:"max=32"`
|
||||
Avatar *string `json:"avatar" binding:"omitempty,max=512"`
|
||||
RealName string `json:"real_name" binding:"max=64"`
|
||||
}
|
||||
|
||||
func CreateUser(ctx *gin.Context) {
|
||||
@@ -81,9 +95,13 @@ func CreateUser(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
avatar := ""
|
||||
if request.Avatar != nil {
|
||||
avatar = *request.Avatar
|
||||
}
|
||||
user := models.UserAccount{
|
||||
Entity: common.NewEntity(common.StatusEnable), Username: request.Username, PasswordHash: hash,
|
||||
Name: request.Name, Phone: request.Phone, Avatar: request.Avatar, RealName: request.RealName,
|
||||
Name: request.Name, Phone: request.Phone, Avatar: avatar, RealName: request.RealName,
|
||||
}
|
||||
relation := models.UserServiceRelation{
|
||||
Entity: common.NewEntity(common.StatusEnable), GasBasicID: point.GasBasicID, DeliveryBasicID: point.ID,
|
||||
@@ -115,9 +133,12 @@ func UpdateUser(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
if err := db().Model(&user).Updates(map[string]any{
|
||||
"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "real_name": request.RealName,
|
||||
}).Error; err != nil {
|
||||
values := map[string]any{"name": request.Name, "phone": request.Phone, "real_name": request.RealName}
|
||||
// 未选择新头像时不提交 avatar,避免编辑基础资料误清空现有头像。
|
||||
if request.Avatar != nil {
|
||||
values["avatar"] = *request.Avatar
|
||||
}
|
||||
if err := db().Model(&user).Updates(values).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ func RegisterDelivery(serviceKey string, engine *gin.Engine) {
|
||||
staff.GET("", deliverylogic.ListStaff)
|
||||
staff.POST("", deliverylogic.CreateStaff)
|
||||
staff.GET("/:identity", deliverylogic.GetStaff)
|
||||
staff.GET("/:identity/avatar", deliverylogic.GetStaffAvatar)
|
||||
staff.PUT("/:identity", deliverylogic.UpdateStaff)
|
||||
staff.PUT("/:identity/password", deliverylogic.ResetStaffPassword)
|
||||
staff.PATCH("/:identity/status", deliverylogic.UpdateStaffStatus)
|
||||
@@ -44,6 +45,7 @@ func RegisterDelivery(serviceKey string, engine *gin.Engine) {
|
||||
user.GET("", deliverylogic.ListUser)
|
||||
user.POST("", deliverylogic.CreateUser)
|
||||
user.GET("/:identity", deliverylogic.GetUser)
|
||||
user.GET("/:identity/avatar", deliverylogic.GetUserAvatar)
|
||||
user.PUT("/:identity", deliverylogic.UpdateUser)
|
||||
user.PUT("/:identity/password", deliverylogic.ResetUserPassword)
|
||||
user.PATCH("/:identity/status", deliverylogic.UpdateUserStatus)
|
||||
|
||||
@@ -45,6 +45,8 @@ func TestDeliveryOrderActionBoundary(t *testing.T) {
|
||||
"POST /heqi/delivery/v1/gasorder_basic/:identity/adjust-amount",
|
||||
"GET /heqi/delivery/v1/wallet_recharge",
|
||||
"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] {
|
||||
t.Fatalf("missing confirmed delivery action %s", required)
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
5. 将配送端支付、退款资源统一为 `payment_order`、`payment_refund`,同步后端契约快照。
|
||||
6. 增加页面能力矩阵自动检查,并更新项目文档。
|
||||
7. 依据 5173 页面重新对齐灰色页面底、面包屑、分区卡片、详情信息网格、双列表单和底部操作区。
|
||||
8. 将配送人员和用户账户的头像文本框替换为 5173 头像摘要卡片,新增上传、预览、恢复默认头像和配送点范围鉴权读取能力。
|
||||
9. 修正配送人员、用户编辑接口:没有提交头像字段时保留旧头像,避免编辑其他资料时误清空。
|
||||
|
||||
## 操作后状态
|
||||
|
||||
@@ -43,11 +45,12 @@
|
||||
- `contract:check`:通过,18 个资源与后端契约一致。
|
||||
- `profile:check`:通过,资料专用只读页未回退。
|
||||
- `type:check`:通过。
|
||||
- `build`:通过,2622 个模块完成生产构建。
|
||||
- `build`:通过,2628 个模块完成生产构建。
|
||||
- `go test ./internal/logic/delivery`:通过。
|
||||
- `lint`:通过;仅报告项目既有警告,未产生失败项。
|
||||
- 浏览器回归:工作人员列表、详情、编辑、正式新建地址、返回链路、未保存保护、配送订单新建页、支付列表均通过。
|
||||
- 布局回归:工作人员详情、编辑和配送订单新建页已在应用内浏览器逐页截图检查,与 5173 的页面结构和响应式断点一致。
|
||||
- 头像回归:配送人员新建、编辑页已确认不再显示头像文本框,头像选择按钮、格式大小提示、默认头像和身份摘要均正常;后端头像路由测试通过。
|
||||
|
||||
## 风险评估
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ frontend/delivery_admin/
|
||||
│ ├── ResourceDetailContent.vue # 主记录和集合详情展示
|
||||
│ ├── ResourceFieldForm.vue # 通用资源字段表单
|
||||
│ ├── ResourceActionDialog.vue # 详情业务动作与危险确认
|
||||
│ ├── ResourceAccountSummary.vue # 头像选择与账户身份摘要
|
||||
│ ├── use-resource-avatar.ts # 头像预览、上传和清除状态
|
||||
│ └── use-unsaved-record.ts # 未保存离开保护
|
||||
└── views/shared/
|
||||
└── ResourceListPage.vue # 跳转独立页面的标准列表
|
||||
@@ -46,6 +48,8 @@ frontend/delivery_admin/
|
||||
|
||||
页面布局与平台总后台保持一致:使用灰色页面背景、顶部面包屑和操作栏;详情由“基本信息”“关联记录”“业务操作”分区卡片组成;新建和编辑使用双列响应式表单,并将保存、取消操作固定在表单内容底部。
|
||||
|
||||
配送人员和用户账户参考 5173 使用头像身份摘要卡片。新建、编辑时点击头像可选择不超过 2 MB 的 JPG/PNG 文件,保存时先上传到受控头像目录,再把返回 URI 写入资源;详情页通过配送点范围鉴权接口读取头像。未选择新头像时不会清空已有头像。
|
||||
|
||||
`resource-record-form.ts` 为五类可编辑资源声明后端更新字段白名单,防止用户名、合同编号等只读字段出现在编辑页或被无效提交。
|
||||
|
||||
支付与退款资源统一使用后端正式名称 `payment_order`、`payment_refund`,菜单地址仍保持 `/finance/payments`、`/finance/refunds`,避免接口路径不一致导致 404。
|
||||
|
||||
@@ -60,5 +60,10 @@ assert(resourcePage.includes('ResourceListPage'), '标准资源列表尚未切
|
||||
assert(!resourcePage.includes('CrudListPage'), '活动列表仍依赖抽屉式 CrudListPage');
|
||||
assert(definitions.includes("define('payment_order'"), '支付资源必须使用后端名称 payment_order');
|
||||
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}`);
|
||||
|
||||
50
frontend/delivery_admin/src/api/avatar.ts
Normal file
50
frontend/delivery_admin/src/api/avatar.ts
Normal 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
@@ -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>
|
||||
@@ -69,6 +69,9 @@ const entries = computed(() =>
|
||||
Object.entries(record.value).filter(
|
||||
([key, value]) =>
|
||||
key !== 'id' &&
|
||||
key !== 'avatar' &&
|
||||
key !== 'password' &&
|
||||
key !== 'password_hash' &&
|
||||
key !== 'DeletedAt' &&
|
||||
key !== 'deleted_at' &&
|
||||
!key.endsWith('_id') &&
|
||||
|
||||
@@ -29,6 +29,16 @@
|
||||
</a-result>
|
||||
|
||||
<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'">
|
||||
<ResourceDetailContent :definition="definition" :detail="detail" />
|
||||
<a-card
|
||||
@@ -102,9 +112,11 @@ import { primaryRecord, recordEditReason } from '@/api/resource-display';
|
||||
import { recordRouteLocation, returnToList, safeReturnPath } from '@/api/resource-navigation';
|
||||
import { getResource, type DetailAction, type ResourceField } from '@/api/resources';
|
||||
import ResourceActionDialog from './ResourceActionDialog.vue';
|
||||
import ResourceAccountSummary from './ResourceAccountSummary.vue';
|
||||
import ResourceDetailContent from './ResourceDetailContent.vue';
|
||||
import ResourceFieldForm from './ResourceFieldForm.vue';
|
||||
import { useUnsavedRecord } from './use-unsaved-record';
|
||||
import { useResourceAvatar } from './use-resource-avatar';
|
||||
|
||||
type RecordPageMode = 'create' | 'detail' | 'edit';
|
||||
const route = useRoute();
|
||||
@@ -126,11 +138,18 @@ const relationLoading = reactive<Record<string, boolean>>({});
|
||||
const relationTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const actionVisible = ref(false);
|
||||
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(
|
||||
definition.value.name,
|
||||
definition.value.fields,
|
||||
mode.value as 'create' | 'edit',
|
||||
));
|
||||
).filter((field) => field.key !== 'avatar'));
|
||||
const canEditRecord = computed(
|
||||
() => definition.value.canEdit && !recordEditReason(definition.value, record.value),
|
||||
);
|
||||
@@ -142,7 +161,7 @@ const visibleActions = computed(() =>
|
||||
),
|
||||
);
|
||||
const unsaved = useUnsavedRecord(
|
||||
() => JSON.stringify(form),
|
||||
() => JSON.stringify({ form, avatar: avatar.marker() }),
|
||||
() => mode.value !== 'detail',
|
||||
);
|
||||
|
||||
@@ -160,6 +179,10 @@ async function loadRecord() {
|
||||
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') {
|
||||
resetResourceRecordForm(form, formFields.value, record.value, {
|
||||
relationKey: typeof route.query.relation_key === 'string' ? route.query.relation_key : undefined,
|
||||
@@ -181,6 +204,7 @@ async function save() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = buildResourcePayload(formFields.value, form, mode.value as 'create' | 'edit');
|
||||
if (hasAvatarField.value) await avatar.applyToPayload(payload);
|
||||
const saved = mode.value === 'create'
|
||||
? await resourceApi.create<ResourceRow>(definition.value.resource, payload)
|
||||
: await resourceApi.update<ResourceRow>(definition.value.resource, identity.value, payload);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user