Compare commits
7 Commits
387eb41412
...
29ef03c40f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29ef03c40f | ||
|
|
480714172e | ||
|
|
0c6ed249ee | ||
|
|
3ad30b4f39 | ||
|
|
14e0502d16 | ||
|
|
d43f999b96 | ||
|
|
8b34147868 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,3 +43,4 @@ yarn.lock
|
||||
|
||||
# Temp
|
||||
/codeTemp/*
|
||||
SKILL.md
|
||||
|
||||
@@ -19,6 +19,25 @@ interface BackendUserInfoDTO {
|
||||
buttons?: string[] | null;
|
||||
}
|
||||
|
||||
interface BackendMyProfileDetailDTO {
|
||||
id?: string | number | null;
|
||||
userId?: string | number | null;
|
||||
username?: string | null;
|
||||
userName?: string | null;
|
||||
nickname?: string | null;
|
||||
company?: string | null;
|
||||
email?: string | null;
|
||||
mobile?: string | null;
|
||||
sex?: Api.SystemManage.UserGender | null;
|
||||
avatar?: string | null;
|
||||
loginIp?: string | null;
|
||||
loginDate?: string | null;
|
||||
createTime?: string | null;
|
||||
roles?: Api.SystemManage.RoleSimple[] | null;
|
||||
dept?: Api.SystemManage.DeptSimple | null;
|
||||
position?: Api.SystemManage.PostSimple | null;
|
||||
}
|
||||
|
||||
let userInfoPromise: Promise<ServiceRequestResult<BackendUserInfoDTO>> | null = null;
|
||||
|
||||
/** 将后端 token 结构转换成前端现有结构 */
|
||||
@@ -39,6 +58,42 @@ function mapUserInfo(data: BackendUserInfoDTO): Api.Auth.UserInfo {
|
||||
};
|
||||
}
|
||||
|
||||
function safeStringId(value: string | number | null | undefined): string | null {
|
||||
return value === null || value === undefined ? null : String(value);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
function mapMyProfileDetail(data: BackendMyProfileDetailDTO, fallbackUserId = ''): Api.Auth.MyProfileDetail {
|
||||
const baseInfo = {
|
||||
userId: String(data.id ?? data.userId ?? fallbackUserId ?? ''),
|
||||
username: data.username ?? data.userName ?? '',
|
||||
nickname: data.nickname ?? '',
|
||||
deptId: safeStringId(data.dept?.id),
|
||||
deptName: data.dept?.name ?? '',
|
||||
positionId: safeStringId(data.position?.id),
|
||||
positionName: data.position?.name ?? ''
|
||||
};
|
||||
|
||||
const contactInfo = {
|
||||
company: data.company ?? null,
|
||||
email: data.email ?? '',
|
||||
mobile: data.mobile ?? '',
|
||||
sex: data.sex ?? 0,
|
||||
avatar: data.avatar ?? ''
|
||||
};
|
||||
|
||||
const extraInfo = {
|
||||
roles: data.roles ?? [],
|
||||
dept: data.dept ?? null,
|
||||
position: data.position ?? null,
|
||||
loginIp: data.loginIp ?? '',
|
||||
loginDate: data.loginDate ?? null,
|
||||
createTime: data.createTime ?? null
|
||||
};
|
||||
|
||||
return { ...baseInfo, ...contactInfo, ...extraInfo };
|
||||
}
|
||||
|
||||
export function clearUserInfoCache() {
|
||||
userInfoPromise = null;
|
||||
}
|
||||
@@ -101,6 +156,45 @@ export async function fetchGetUserInfo(force = false): Promise<ServiceRequestRes
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取当前登录人资料详情 */
|
||||
export async function fetchGetMyProfileDetail(
|
||||
options: {
|
||||
userId?: string;
|
||||
} = {}
|
||||
): Promise<ServiceRequestResult<Api.Auth.MyProfileDetail>> {
|
||||
const result = await request<BackendMyProfileDetailDTO>({
|
||||
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/get`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
if (result.error || !result.data) {
|
||||
return result as ServiceRequestResult<Api.Auth.MyProfileDetail>;
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: mapMyProfileDetail(result.data, options.userId ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
/** 更新当前登录人基础资料 */
|
||||
export function fetchUpdateMyProfile(data: Api.Auth.UpdateMyProfileParams) {
|
||||
return request<boolean>({
|
||||
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/update`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 修改当前登录人密码 */
|
||||
export function fetchUpdateMyPassword(data: Api.Auth.UpdateMyPasswordParams) {
|
||||
return request<boolean>({
|
||||
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/update-password`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 token
|
||||
*
|
||||
|
||||
@@ -183,6 +183,18 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function refreshUserInfo() {
|
||||
const { data: info, error } = await fetchGetUserInfo(true);
|
||||
|
||||
if (!error) {
|
||||
Object.assign(userInfo, info);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function initUserInfo() {
|
||||
const hasToken = getToken();
|
||||
|
||||
@@ -205,6 +217,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
loginLoading,
|
||||
resetStore,
|
||||
login,
|
||||
initUserInfo
|
||||
initUserInfo,
|
||||
refreshUserInfo
|
||||
};
|
||||
});
|
||||
|
||||
34
src/typings/api/auth.d.ts
vendored
34
src/typings/api/auth.d.ts
vendored
@@ -17,5 +17,39 @@ declare namespace Api {
|
||||
roles: string[];
|
||||
buttons: string[];
|
||||
}
|
||||
|
||||
interface MyProfileDetail {
|
||||
userId: string;
|
||||
username: string;
|
||||
nickname?: string | null;
|
||||
deptId?: string | null;
|
||||
deptName?: string | null;
|
||||
positionId?: string | null;
|
||||
positionName?: string | null;
|
||||
company?: string | null;
|
||||
email?: string | null;
|
||||
mobile?: string | null;
|
||||
sex?: Api.SystemManage.UserGender | null;
|
||||
avatar?: string | null;
|
||||
roles: Api.SystemManage.RoleSimple[];
|
||||
dept?: Api.SystemManage.DeptSimple | null;
|
||||
position?: Api.SystemManage.PostSimple | null;
|
||||
loginIp?: string | null;
|
||||
loginDate?: string | null;
|
||||
createTime?: string | null;
|
||||
}
|
||||
|
||||
interface UpdateMyProfileParams {
|
||||
nickname?: string | null;
|
||||
email?: string | null;
|
||||
mobile?: string | null;
|
||||
sex?: Api.SystemManage.UserGender | null;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
interface UpdateMyPasswordParams {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,436 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref } from 'vue';
|
||||
import { userGenderRecord } from '@/constants/business';
|
||||
import { fetchGetMyProfileDetail, fetchUpdateMyProfile } from '@/service/api';
|
||||
import { buildFileProxyUrl, uploadFile } from '@/service/api/file';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { $t } from '@/locales';
|
||||
import ProfileInfoDialog from './modules/profile-info-dialog.vue';
|
||||
import ProfilePasswordDialog from './modules/profile-password-dialog.vue';
|
||||
import { buildProfileUpdatePayload, formatProfileDateTime, resolveProfileRoleLabels } from './modules/profile-model';
|
||||
|
||||
defineOptions({ name: 'MyProfile' });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const avatarSubmitting = ref(false);
|
||||
const profile = ref<Api.Auth.MyProfileDetail | null>(null);
|
||||
const profileInfoVisible = ref(false);
|
||||
const passwordVisible = ref(false);
|
||||
const avatarInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const descriptionColumns = computed(() => (appStore.isMobile ? 1 : 2));
|
||||
const displayName = computed(() => profile.value?.nickname?.trim() || profile.value?.username || '--');
|
||||
const displayUsername = computed(() => profile.value?.username?.trim() || '--');
|
||||
const companyText = computed(() => profile.value?.company?.trim() || '--');
|
||||
const deptText = computed(() => profile.value?.dept?.name?.trim() || profile.value?.deptName?.trim() || '--');
|
||||
const positionText = computed(
|
||||
() => profile.value?.position?.name?.trim() || profile.value?.positionName?.trim() || '--'
|
||||
);
|
||||
const mobileText = computed(() => profile.value?.mobile?.trim() || '--');
|
||||
const emailText = computed(() => profile.value?.email?.trim() || '--');
|
||||
const genderText = computed(() => {
|
||||
const value = profile.value?.sex;
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return $t(userGenderRecord[value]);
|
||||
});
|
||||
const roleLabels = computed(() => {
|
||||
const roles = profile.value?.roles ?? [];
|
||||
|
||||
if (roles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return resolveProfileRoleLabels(roles);
|
||||
});
|
||||
|
||||
function getAvatarText() {
|
||||
const name = displayName.value;
|
||||
|
||||
return name === '--' ? 'CN' : name.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function getEditableProfileValues(current: Api.Auth.MyProfileDetail): Api.Auth.UpdateMyProfileParams {
|
||||
return {
|
||||
nickname: current.nickname ?? '',
|
||||
email: current.email ?? '',
|
||||
mobile: current.mobile ?? '',
|
||||
sex: current.sex ?? 1,
|
||||
avatar: current.avatar ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
const userId = authStore.userInfo.userId;
|
||||
|
||||
if (!userId) {
|
||||
profile.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await fetchGetMyProfileDetail({
|
||||
userId
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
profile.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
async function initPage() {
|
||||
loading.value = true;
|
||||
|
||||
await authStore.initUserInfo();
|
||||
await loadProfile();
|
||||
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function triggerAvatarSelect() {
|
||||
if (!profile.value || avatarSubmitting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
avatarInputRef.value?.click();
|
||||
}
|
||||
|
||||
async function handleAvatarChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
|
||||
if (!file || !profile.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
window.$message?.error('请上传图片文件');
|
||||
return;
|
||||
}
|
||||
|
||||
avatarSubmitting.value = true;
|
||||
|
||||
const uploadResult = await uploadFile(file, 'avatar');
|
||||
|
||||
if (uploadResult.error) {
|
||||
avatarSubmitting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const avatar = buildFileProxyUrl(uploadResult.data.configId, uploadResult.data.path);
|
||||
const payload = buildProfileUpdatePayload({
|
||||
...getEditableProfileValues(profile.value),
|
||||
avatar
|
||||
});
|
||||
const updateResult = await fetchUpdateMyProfile(payload);
|
||||
|
||||
avatarSubmitting.value = false;
|
||||
|
||||
if (updateResult.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('头像更新成功');
|
||||
await Promise.all([loadProfile(), authStore.refreshUserInfo()]);
|
||||
}
|
||||
|
||||
async function handleProfileSubmitted() {
|
||||
await Promise.all([loadProfile(), authStore.refreshUserInfo()]);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initPage();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
initPage();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LookForward title="个人信息" subtitle="功能建设中,敬请期待" />
|
||||
<div v-loading="loading" class="my-profile-page">
|
||||
<template v-if="profile">
|
||||
<ElCard class="my-profile-hero-card" shadow="never">
|
||||
<div class="my-profile-hero">
|
||||
<div class="my-profile-hero__identity">
|
||||
<button
|
||||
class="my-profile-hero__avatar-button"
|
||||
type="button"
|
||||
:disabled="avatarSubmitting"
|
||||
@click="triggerAvatarSelect"
|
||||
>
|
||||
<ElAvatar v-if="profile.avatar" :src="profile.avatar" :size="88" class="my-profile-hero__avatar" />
|
||||
<div v-else class="my-profile-hero__avatar-fallback">{{ getAvatarText() }}</div>
|
||||
<div class="my-profile-hero__avatar-mask">
|
||||
<span>{{ avatarSubmitting ? '上传中...' : '更换头像' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref="avatarInputRef"
|
||||
class="my-profile-hero__avatar-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@change="handleAvatarChange"
|
||||
/>
|
||||
|
||||
<div class="my-profile-hero__summary">
|
||||
<div class="my-profile-hero__title-row">
|
||||
<h1 class="my-profile-hero__title">{{ displayName }}</h1>
|
||||
<ElTag type="info" effect="plain">个人中心</ElTag>
|
||||
</div>
|
||||
<p class="my-profile-hero__subtitle">@{{ displayUsername }}</p>
|
||||
<div class="my-profile-hero__meta">
|
||||
<ElTag effect="plain">{{ companyText }}</ElTag>
|
||||
<ElTag effect="plain">{{ deptText }}</ElTag>
|
||||
<ElTag effect="plain">{{ positionText }}</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-profile-hero__actions">
|
||||
<ElButton type="primary" @click="profileInfoVisible = true">编辑基本信息</ElButton>
|
||||
<ElButton @click="passwordVisible = true">修改密码</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<div class="my-profile-content">
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="my-profile-card__header">
|
||||
<span class="my-profile-card__title">基本资料</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElDescriptions :column="descriptionColumns" border>
|
||||
<ElDescriptionsItem label="用户名">{{ displayUsername }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="昵称">{{ displayName }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="手机号">{{ mobileText }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="邮箱">{{ emailText }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="性别">{{ genderText }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="所属公司">{{ companyText }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="所属部门">{{ deptText }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="所属岗位">{{ positionText }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="角色" :span="descriptionColumns">
|
||||
<div v-if="roleLabels.length" class="my-profile-role-list">
|
||||
<ElTag v-for="roleLabel in roleLabels" :key="roleLabel" type="primary" effect="plain">
|
||||
{{ roleLabel }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<span v-else>--</span>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="my-profile-card__header">
|
||||
<span class="my-profile-card__title">登录信息</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElDescriptions :column="descriptionColumns" border>
|
||||
<ElDescriptionsItem label="最近登录 IP">{{ profile.loginIp?.trim() || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="最近登录时间">{{ formatProfileDateTime(profile.loginDate) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="账号创建时间" :span="descriptionColumns">
|
||||
{{ formatProfileDateTime(profile.createTime) }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElEmpty v-else description="未获取到个人信息" />
|
||||
|
||||
<ProfileInfoDialog v-model:visible="profileInfoVisible" :profile="profile" @submitted="handleProfileSubmitted" />
|
||||
<ProfilePasswordDialog v-model:visible="passwordVisible" :username="profile?.username" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.my-profile-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.my-profile-hero-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(14 116 144 / 12%), transparent 32%),
|
||||
radial-gradient(circle at bottom right, rgb(16 185 129 / 10%), transparent 26%),
|
||||
linear-gradient(135deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 98%));
|
||||
}
|
||||
|
||||
.my-profile-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.my-profile-hero__identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.my-profile-hero__avatar-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.my-profile-hero__avatar-button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.my-profile-hero__avatar,
|
||||
.my-profile-hero__avatar-fallback {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
}
|
||||
|
||||
.my-profile-hero__avatar-fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, rgb(14 116 144 / 92%), rgb(15 118 110 / 84%));
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.my-profile-hero__avatar-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgb(15 23 42 / 52%);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.my-profile-hero__avatar-button:hover .my-profile-hero__avatar-mask,
|
||||
.my-profile-hero__avatar-button:focus-visible .my-profile-hero__avatar-mask {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.my-profile-hero__avatar-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.my-profile-hero__summary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.my-profile-hero__title-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.my-profile-hero__title {
|
||||
margin: 0;
|
||||
color: rgb(15 23 42 / 98%);
|
||||
font-size: 28px;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.my-profile-hero__subtitle {
|
||||
margin: 0;
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.my-profile-hero__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.my-profile-hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.my-profile-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.my-profile-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.my-profile-card__title {
|
||||
color: rgb(15 23 42 / 98%);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.my-profile-role-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.my-profile-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.my-profile-hero__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.my-profile-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.my-profile-hero__identity {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.my-profile-hero__title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { userGenderOptions } from '@/constants/business';
|
||||
import { fetchUpdateMyProfile } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { translateOptions } from '@/utils/common';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { buildProfileUpdatePayload } from './profile-model';
|
||||
|
||||
defineOptions({ name: 'ProfileInfoDialog' });
|
||||
|
||||
interface Props {
|
||||
profile?: Api.Auth.MyProfileDetail | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule, patternRules } = useFormRules();
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
const genderOptions = computed(() =>
|
||||
translateOptions(userGenderOptions).map(item => ({
|
||||
...item,
|
||||
value: Number(item.value) as Api.SystemManage.UserGender
|
||||
}))
|
||||
);
|
||||
|
||||
const model = ref<Api.Auth.UpdateMyProfileParams>({
|
||||
nickname: '',
|
||||
email: '',
|
||||
mobile: '',
|
||||
sex: 1,
|
||||
avatar: ''
|
||||
});
|
||||
|
||||
const rules = computed<Record<string, App.Global.FormRule[]>>(() => ({
|
||||
nickname: [createRequiredRule('请输入昵称')],
|
||||
mobile: model.value.mobile?.trim() ? [patternRules.phone] : [],
|
||||
email: model.value.email?.trim() ? [patternRules.email] : []
|
||||
}));
|
||||
|
||||
function initModel() {
|
||||
model.value = {
|
||||
nickname: props.profile?.nickname ?? '',
|
||||
email: props.profile?.email ?? '',
|
||||
mobile: props.profile?.mobile ?? '',
|
||||
sex: props.profile?.sex ?? 1,
|
||||
avatar: props.profile?.avatar ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.profile) {
|
||||
return;
|
||||
}
|
||||
|
||||
await validate();
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const { error } = await fetchUpdateMyProfile(buildProfileUpdatePayload(model.value));
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('个人信息更新成功');
|
||||
closeDialog();
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
initModel();
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="编辑基本信息"
|
||||
preset="sm"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" autocomplete="off">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="昵称" prop="nickname">
|
||||
<ElInput v-model="model.nickname" maxlength="30" placeholder="请输入昵称" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="手机号" prop="mobile">
|
||||
<ElInput v-model="model.mobile" maxlength="20" placeholder="请输入手机号" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="邮箱" prop="email">
|
||||
<ElInput v-model="model.email" maxlength="100" placeholder="请输入邮箱" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="性别" prop="sex">
|
||||
<ElSelect v-model="model.sex" placeholder="请选择性别">
|
||||
<ElOption v-for="{ label, value } in genderOptions" :key="value" :label="label" :value="value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
export function formatProfileDateTime(value?: string | number | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
export function resolveProfileRoleLabels(roles: Api.SystemManage.RoleSimple[]) {
|
||||
return roles.map(role => role.name?.trim() || role.code || role.id);
|
||||
}
|
||||
|
||||
export function buildProfileUpdatePayload(form: Api.Auth.UpdateMyProfileParams): Api.Auth.UpdateMyProfileParams {
|
||||
return {
|
||||
nickname: getNullableText(form.nickname),
|
||||
email: getNullableText(form.email),
|
||||
mobile: getNullableText(form.mobile),
|
||||
sex: form.sex ?? null,
|
||||
avatar: getNullableText(form.avatar)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { fetchUpdateMyPassword } from '@/service/api';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'ProfilePasswordDialog' });
|
||||
|
||||
interface Props {
|
||||
username?: string | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule, createConfirmPwdRule, patternRules } = useFormRules();
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
const model = ref({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
const confirmDisabled = computed(() => {
|
||||
return (
|
||||
submitting.value ||
|
||||
!model.value.oldPassword.trim() ||
|
||||
!model.value.newPassword.trim() ||
|
||||
!model.value.confirmPassword.trim()
|
||||
);
|
||||
});
|
||||
|
||||
const rules = computed<Record<string, App.Global.FormRule[]>>(() => ({
|
||||
oldPassword: [createRequiredRule('请输入旧密码')],
|
||||
newPassword: [
|
||||
createRequiredRule('请输入新密码'),
|
||||
patternRules.pwd,
|
||||
{
|
||||
asyncValidator: (_rule, value: string) => {
|
||||
if (value.trim() !== '' && value === model.value.oldPassword) {
|
||||
return Promise.reject(new Error('新密码不能与旧密码相同'));
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
confirmPassword: createConfirmPwdRule(model.value.newPassword)
|
||||
}));
|
||||
|
||||
const displayUsername = computed(() => props.username?.trim() || '--');
|
||||
|
||||
function initModel() {
|
||||
model.value.oldPassword = '';
|
||||
model.value.newPassword = '';
|
||||
model.value.confirmPassword = '';
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (confirmDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await validate();
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const { error } = await fetchUpdateMyPassword({
|
||||
oldPassword: model.value.oldPassword.trim(),
|
||||
newPassword: model.value.newPassword.trim()
|
||||
});
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('密码修改成功,请重新登录');
|
||||
closeDialog();
|
||||
emit('submitted');
|
||||
await authStore.resetStore();
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
initModel();
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="修改密码"
|
||||
preset="sm"
|
||||
:confirm-loading="submitting"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" autocomplete="off">
|
||||
<input class="business-form-autofill-guard" type="text" name="fake-username" autocomplete="username" />
|
||||
<input class="business-form-autofill-guard" type="password" name="fake-password" autocomplete="new-password" />
|
||||
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="用户名">
|
||||
<ElInput :model-value="displayUsername" disabled />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElAlert title="密码修改后会退出当前登录态,请使用新密码重新登录。" type="info" :closable="false" show-icon />
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="旧密码" prop="oldPassword">
|
||||
<ElInput
|
||||
v-model="model.oldPassword"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入旧密码"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="新密码" prop="newPassword">
|
||||
<ElInput v-model="model.newPassword" show-password autocomplete="new-password" placeholder="请输入新密码" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="确认新密码" prop="confirmPassword">
|
||||
<ElInput
|
||||
v-model="model.confirmPassword"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
placeholder="请再次输入新密码"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.business-form-autofill-guard {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import type { TreeInstance } from 'element-plus';
|
||||
import { menuTypeRecord } from '@/constants/business';
|
||||
import { fetchAssignRoleMenus, fetchGetRoleMenuIds } from '@/service/api';
|
||||
import { $t } from '@/locales';
|
||||
import { normalizeRoleMenuCheckedIds, resolveRoleMenuSubmitIds } from './role-resource-tree';
|
||||
|
||||
defineOptions({ name: 'RoleResourcePanel' });
|
||||
|
||||
@@ -26,6 +27,8 @@ const permissionLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const filterKeyword = ref('');
|
||||
const checkedKeys = ref<string[]>([]);
|
||||
const baselineMenuIds = ref<string[]>([]);
|
||||
const dirtyMenuIds = ref<Set<string>>(new Set());
|
||||
|
||||
const disabled = computed(() => !props.role || props.role.status === 1);
|
||||
const checkedCount = computed(() => checkedKeys.value.length);
|
||||
@@ -37,9 +40,24 @@ const treeProps = {
|
||||
label: 'name'
|
||||
} as const;
|
||||
|
||||
function applyCheckedKeys(keys: string[]) {
|
||||
checkedKeys.value = [...keys];
|
||||
treeRef.value?.setCheckedKeys(keys);
|
||||
function syncCheckedKeys() {
|
||||
checkedKeys.value = (treeRef.value?.getCheckedKeys(false) as string[]) ?? [];
|
||||
}
|
||||
|
||||
async function applyCheckedKeys(keys: string[]) {
|
||||
checkedKeys.value = [];
|
||||
|
||||
await nextTick();
|
||||
|
||||
const tree = treeRef.value;
|
||||
|
||||
if (!tree) {
|
||||
return;
|
||||
}
|
||||
|
||||
tree.setCheckedKeys(keys);
|
||||
|
||||
syncCheckedKeys();
|
||||
}
|
||||
|
||||
function getTagType(type: Api.SystemManage.MenuType): UI.ThemeColor {
|
||||
@@ -88,7 +106,9 @@ function collectExpandableNodeIds(nodes: Api.SystemManage.MenuSimple[]) {
|
||||
|
||||
async function loadRoleMenus() {
|
||||
if (!props.role) {
|
||||
applyCheckedKeys([]);
|
||||
baselineMenuIds.value = [];
|
||||
dirtyMenuIds.value = new Set();
|
||||
await applyCheckedKeys([]);
|
||||
treeRef.value?.filter(filterKeyword.value);
|
||||
return;
|
||||
}
|
||||
@@ -100,19 +120,27 @@ async function loadRoleMenus() {
|
||||
permissionLoading.value = false;
|
||||
|
||||
if (error) {
|
||||
applyCheckedKeys([]);
|
||||
baselineMenuIds.value = [];
|
||||
dirtyMenuIds.value = new Set();
|
||||
await applyCheckedKeys([]);
|
||||
treeRef.value?.filter(filterKeyword.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Role-menu bindings are exact IDs from the backend, so tree echo must not
|
||||
// cascade parent checks down to unrelated descendants.
|
||||
applyCheckedKeys(data);
|
||||
const normalizedMenuIds = normalizeRoleMenuCheckedIds({
|
||||
menuTree: props.menuTree,
|
||||
checkedIds: data
|
||||
});
|
||||
|
||||
baselineMenuIds.value = normalizedMenuIds;
|
||||
dirtyMenuIds.value = new Set();
|
||||
await applyCheckedKeys(normalizedMenuIds);
|
||||
treeRef.value?.filter(filterKeyword.value);
|
||||
}
|
||||
|
||||
function handleCheck() {
|
||||
checkedKeys.value = (treeRef.value?.getCheckedKeys(false) as string[]) ?? [];
|
||||
function handleCheck(data: Api.SystemManage.MenuSimple) {
|
||||
dirtyMenuIds.value = new Set([...dirtyMenuIds.value, data.id]);
|
||||
syncCheckedKeys();
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
@@ -120,7 +148,13 @@ async function handleSave() {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuIds = (treeRef.value?.getCheckedKeys(false) as string[]) ?? [];
|
||||
const checkedMenuIds = (treeRef.value?.getCheckedKeys(false) as string[]) ?? [];
|
||||
const menuIds = resolveRoleMenuSubmitIds({
|
||||
menuTree: props.menuTree,
|
||||
baselineIds: baselineMenuIds.value,
|
||||
dirtyIds: [...dirtyMenuIds.value],
|
||||
checkedIds: checkedMenuIds
|
||||
});
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
@@ -135,7 +169,9 @@ async function handleSave() {
|
||||
return;
|
||||
}
|
||||
|
||||
checkedKeys.value = [...menuIds];
|
||||
baselineMenuIds.value = [...menuIds];
|
||||
dirtyMenuIds.value = new Set();
|
||||
syncCheckedKeys();
|
||||
|
||||
window.$message?.success($t('common.modifySuccess'));
|
||||
emit('saved');
|
||||
@@ -155,9 +191,15 @@ watch(
|
||||
|
||||
watch(
|
||||
() => props.menuTree.length,
|
||||
value => {
|
||||
async value => {
|
||||
if (value && props.role) {
|
||||
applyCheckedKeys(checkedKeys.value);
|
||||
const normalizedMenuIds = normalizeRoleMenuCheckedIds({
|
||||
menuTree: props.menuTree,
|
||||
checkedIds: baselineMenuIds.value
|
||||
});
|
||||
|
||||
baselineMenuIds.value = normalizedMenuIds;
|
||||
await applyCheckedKeys(normalizedMenuIds);
|
||||
treeRef.value?.filter(filterKeyword.value);
|
||||
}
|
||||
}
|
||||
@@ -207,7 +249,6 @@ watch(
|
||||
ref="treeRef"
|
||||
node-key="id"
|
||||
show-checkbox
|
||||
check-strictly
|
||||
:default-expanded-keys="defaultExpandedKeys"
|
||||
:data="menuTree"
|
||||
:props="treeProps"
|
||||
|
||||
170
src/views/system/role/modules/role-resource-tree.ts
Normal file
170
src/views/system/role/modules/role-resource-tree.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
export type RoleResourceTreeNode = {
|
||||
id: string;
|
||||
children?: RoleResourceTreeNode[] | null;
|
||||
};
|
||||
|
||||
type ResolveRoleMenuSubmitIdsInput = {
|
||||
menuTree: RoleResourceTreeNode[];
|
||||
baselineIds: string[];
|
||||
dirtyIds: string[];
|
||||
checkedIds: string[];
|
||||
};
|
||||
|
||||
type NormalizeRoleMenuCheckedIdsInput = {
|
||||
menuTree: RoleResourceTreeNode[];
|
||||
checkedIds: string[];
|
||||
};
|
||||
|
||||
type TreeIndex = {
|
||||
orderedIds: string[];
|
||||
parentById: Map<string, string | null>;
|
||||
subtreeIdsById: Map<string, string[]>;
|
||||
};
|
||||
|
||||
export function normalizeRoleMenuCheckedIds(input: NormalizeRoleMenuCheckedIdsInput) {
|
||||
const checkedIds = normalizeIds(input.checkedIds);
|
||||
|
||||
if (!checkedIds.length) {
|
||||
return checkedIds;
|
||||
}
|
||||
|
||||
const treeIndex = buildTreeIndex(input.menuTree);
|
||||
const normalizedIdSet = new Set(checkedIds);
|
||||
|
||||
treeIndex.orderedIds.forEach(id => {
|
||||
if (!normalizedIdSet.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const descendantIds = (treeIndex.subtreeIdsById.get(id) ?? [id]).slice(1);
|
||||
|
||||
if (!descendantIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedDescendantCount = descendantIds.reduce((count, descendantId) => {
|
||||
return normalizedIdSet.has(descendantId) ? count + 1 : count;
|
||||
}, 0);
|
||||
|
||||
if (selectedDescendantCount > 0 && selectedDescendantCount < descendantIds.length) {
|
||||
normalizedIdSet.delete(id);
|
||||
}
|
||||
});
|
||||
|
||||
return sortIdsByTreeOrder(treeIndex.orderedIds, normalizedIdSet);
|
||||
}
|
||||
|
||||
export function resolveRoleMenuSubmitIds(input: ResolveRoleMenuSubmitIdsInput) {
|
||||
const baselineIds = normalizeIds(input.baselineIds);
|
||||
|
||||
if (!input.dirtyIds.length) {
|
||||
return baselineIds;
|
||||
}
|
||||
|
||||
const treeIndex = buildTreeIndex(input.menuTree);
|
||||
const affectedIds = collectAffectedIds(treeIndex, baselineIds, normalizeIds(input.dirtyIds));
|
||||
|
||||
if (!affectedIds.size) {
|
||||
return baselineIds;
|
||||
}
|
||||
|
||||
const nextIdSet = new Set<string>();
|
||||
|
||||
baselineIds.forEach(id => {
|
||||
if (!affectedIds.has(id)) {
|
||||
nextIdSet.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
// 半选父节点只用于树态展示,提交它会把整棵子树误当成完整授权。
|
||||
normalizeIds(input.checkedIds).forEach(id => {
|
||||
if (affectedIds.has(id)) {
|
||||
nextIdSet.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
return sortIdsByTreeOrder(treeIndex.orderedIds, nextIdSet);
|
||||
}
|
||||
|
||||
function normalizeIds(ids: string[]) {
|
||||
return [...new Set(ids.map(id => String(id).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function buildTreeIndex(nodes: RoleResourceTreeNode[]) {
|
||||
const orderedIds: string[] = [];
|
||||
const parentById = new Map<string, string | null>();
|
||||
const subtreeIdsById = new Map<string, string[]>();
|
||||
|
||||
const walk = (items: RoleResourceTreeNode[], parentId: string | null) => {
|
||||
items.forEach(item => {
|
||||
orderedIds.push(item.id);
|
||||
parentById.set(item.id, parentId);
|
||||
|
||||
const childIds = item.children?.length ? walk(item.children, item.id) : [];
|
||||
subtreeIdsById.set(item.id, [item.id, ...childIds]);
|
||||
});
|
||||
|
||||
return items.flatMap(item => subtreeIdsById.get(item.id) ?? [item.id]);
|
||||
};
|
||||
|
||||
walk(nodes, null);
|
||||
|
||||
return {
|
||||
orderedIds,
|
||||
parentById,
|
||||
subtreeIdsById
|
||||
} satisfies TreeIndex;
|
||||
}
|
||||
|
||||
function collectAffectedIds(treeIndex: TreeIndex, baselineIds: string[], dirtyIds: string[]) {
|
||||
const affectedIds = new Set<string>();
|
||||
const baselineIdSet = new Set(baselineIds);
|
||||
|
||||
dirtyIds.forEach(dirtyId => {
|
||||
const subtreeIds = treeIndex.subtreeIdsById.get(dirtyId) ?? [dirtyId];
|
||||
subtreeIds.forEach(id => affectedIds.add(id));
|
||||
|
||||
const ancestors = collectAncestorIds(treeIndex.parentById, dirtyId);
|
||||
|
||||
ancestors.forEach(ancestorId => {
|
||||
if (!baselineIdSet.has(ancestorId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ancestorSubtreeIds = treeIndex.subtreeIdsById.get(ancestorId) ?? [ancestorId];
|
||||
ancestorSubtreeIds.forEach(id => affectedIds.add(id));
|
||||
});
|
||||
});
|
||||
|
||||
return affectedIds;
|
||||
}
|
||||
|
||||
function collectAncestorIds(parentById: Map<string, string | null>, nodeId: string) {
|
||||
const ancestorIds: string[] = [];
|
||||
|
||||
let currentId: string | null | undefined = nodeId;
|
||||
|
||||
while (currentId) {
|
||||
ancestorIds.push(currentId);
|
||||
currentId = parentById.get(currentId) ?? null;
|
||||
}
|
||||
|
||||
return ancestorIds;
|
||||
}
|
||||
|
||||
function sortIdsByTreeOrder(orderedIds: string[], idSet: Set<string>) {
|
||||
const sortedIds: string[] = [];
|
||||
|
||||
orderedIds.forEach(id => {
|
||||
if (idSet.has(id)) {
|
||||
sortedIds.push(id);
|
||||
idSet.delete(id);
|
||||
}
|
||||
});
|
||||
|
||||
idSet.forEach(id => {
|
||||
sortedIds.push(id);
|
||||
});
|
||||
|
||||
return sortedIds;
|
||||
}
|
||||
116
tests/role-resource-tree.test.ts
Normal file
116
tests/role-resource-tree.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import * as roleResourceTree from '../src/views/system/role/modules/role-resource-tree';
|
||||
|
||||
type MenuNode = {
|
||||
id: string;
|
||||
children?: MenuNode[];
|
||||
};
|
||||
|
||||
type NormalizeRoleMenuCheckedIds = (input: { menuTree: MenuNode[]; checkedIds: string[] }) => string[];
|
||||
|
||||
const menuTree: MenuNode[] = [
|
||||
{
|
||||
id: 'personal',
|
||||
children: [
|
||||
{
|
||||
id: 'weekly',
|
||||
children: [{ id: 'weeklyDetail' }]
|
||||
},
|
||||
{
|
||||
id: 'monthly',
|
||||
children: [{ id: 'monthlyDetail' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'infra',
|
||||
children: [{ id: 'stateMachine' }, { id: 'cmd' }]
|
||||
}
|
||||
];
|
||||
|
||||
const normalizeRoleMenuCheckedIds = (roleResourceTree as { normalizeRoleMenuCheckedIds?: NormalizeRoleMenuCheckedIds })
|
||||
.normalizeRoleMenuCheckedIds;
|
||||
|
||||
const { resolveRoleMenuSubmitIds } = roleResourceTree;
|
||||
|
||||
describe('resolveRoleMenuSubmitIds', () => {
|
||||
it('keeps original ids when there is no user interaction', () => {
|
||||
const result = resolveRoleMenuSubmitIds({
|
||||
menuTree,
|
||||
baselineIds: ['weekly', 'monthly'],
|
||||
dirtyIds: [],
|
||||
checkedIds: ['personal', 'weekly', 'monthly']
|
||||
});
|
||||
|
||||
assert.deepEqual(result, ['weekly', 'monthly']);
|
||||
});
|
||||
|
||||
it('preserves untouched branches when another branch changes', () => {
|
||||
const result = resolveRoleMenuSubmitIds({
|
||||
menuTree,
|
||||
baselineIds: ['weekly', 'monthly'],
|
||||
dirtyIds: ['stateMachine'],
|
||||
checkedIds: ['personal', 'weekly', 'monthly', 'stateMachine']
|
||||
});
|
||||
|
||||
assert.deepEqual(result, ['weekly', 'monthly', 'stateMachine']);
|
||||
});
|
||||
|
||||
it('recomputes the whole dirty branch instead of expanding unrelated baseline ids', () => {
|
||||
const result = resolveRoleMenuSubmitIds({
|
||||
menuTree,
|
||||
baselineIds: ['personal'],
|
||||
dirtyIds: ['weekly'],
|
||||
checkedIds: ['personal', 'weekly', 'weeklyDetail']
|
||||
});
|
||||
|
||||
assert.deepEqual(result, ['personal', 'weekly', 'weeklyDetail']);
|
||||
});
|
||||
|
||||
it('does not expand untouched sibling branches under the same ancestor', () => {
|
||||
const result = resolveRoleMenuSubmitIds({
|
||||
menuTree,
|
||||
baselineIds: ['monthly'],
|
||||
dirtyIds: ['weeklyDetail'],
|
||||
checkedIds: ['personal', 'weekly', 'weeklyDetail', 'monthly', 'monthlyDetail']
|
||||
});
|
||||
|
||||
assert.deepEqual(result, ['weeklyDetail', 'monthly']);
|
||||
});
|
||||
|
||||
it('does not submit half-checked parent ids when a fully authorized branch becomes partial', () => {
|
||||
const result = resolveRoleMenuSubmitIds({
|
||||
menuTree,
|
||||
baselineIds: ['personal'],
|
||||
dirtyIds: ['monthlyDetail'],
|
||||
checkedIds: ['weekly', 'weeklyDetail']
|
||||
});
|
||||
|
||||
assert.deepEqual(result, ['weekly', 'weeklyDetail']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRoleMenuCheckedIds', () => {
|
||||
it('removes partially covered parent ids before tree rendering', () => {
|
||||
assert.equal(typeof normalizeRoleMenuCheckedIds, 'function');
|
||||
|
||||
const result = normalizeRoleMenuCheckedIds?.({
|
||||
menuTree,
|
||||
checkedIds: ['personal', 'weekly', 'weeklyDetail']
|
||||
});
|
||||
|
||||
assert.deepEqual(result, ['weekly', 'weeklyDetail']);
|
||||
});
|
||||
|
||||
it('keeps parent ids when the backend uses a parent-only full-branch representation', () => {
|
||||
assert.equal(typeof normalizeRoleMenuCheckedIds, 'function');
|
||||
|
||||
const result = normalizeRoleMenuCheckedIds?.({
|
||||
menuTree,
|
||||
checkedIds: ['personal']
|
||||
});
|
||||
|
||||
assert.deepEqual(result, ['personal']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user