feat: 新增账户资料页与头像上传
将工作人员和用户的详情、编辑改为独立账户资料页。 增加受控头像上传与读取、图片安全校验、接口测试,并优化只读及编辑布局。 同步更新平台需求、接口安全说明、项目文档和操作日志。
This commit is contained in:
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