feat: 新增账户资料页与头像上传

将工作人员和用户的详情、编辑改为独立账户资料页。

增加受控头像上传与读取、图片安全校验、接口测试,并优化只读及编辑布局。

同步更新平台需求、接口安全说明、项目文档和操作日志。
This commit is contained in:
czl231
2026-08-10 22:35:47 +08:00
parent cef7223841
commit 7242048abf
20 changed files with 1351 additions and 13 deletions

View 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 };

View File

@@ -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'),

View File

@@ -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

View File

@@ -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;
}
}

View 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>

View File

@@ -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: '确认删除',