refactor file tree

This commit is contained in:
Timi
2026-08-11 15:24:36 +08:00
parent 575d41b2e2
commit 819c1623eb
42 changed files with 86 additions and 86 deletions
+243
View File
@@ -0,0 +1,243 @@
<template>
<div
v-if="managedUser"
v-can="permissionCodes.user.read"
class="user-edit"
>
<t-cell-group v-if="managedUser.user" theme="card">
<t-cell
:title="managedUser.user.name"
:description="getRoleDisplayName(getManagedUserRoleList(managedUser)[0], permissionStore.roleList)"
>
<template #leftIcon>
<t-image
class="avatar"
:src="buildUserAvatar(managedUser.user)"
:alt="managedUser.user.name"
fit="cover"
/>
</template>
</t-cell>
</t-cell-group>
<t-cell-group v-if="managedUser.user" theme="card">
<t-cell title="状态" :note="managedUser.enabled ? '启用' : '禁用'" />
<t-cell title="备注" :note="managedUser.remark" />
<t-cell title="手机号">
<template #note>
<telephone-link :value="managedUser.user.phoneNo" placeholder="未填写" />
</template>
</t-cell>
<t-cell title="上次登录时间" :note="Time.toPassedDateTime(managedUser.user.lastLoginAt)" />
</t-cell-group>
<t-cell-group v-if="currentUser" theme="card">
<t-cell
v-can="permissionCodes.user.update"
title="配置角色"
note="为用户授权业务角色"
arrow
hover
@click="toRoleAssign"
>
<template #leftIcon>
<t-icon name="usergroup" size="1.75rem" />
</template>
</t-cell>
</t-cell-group>
<section v-if="currentUser" class="registration-actions bg-white">
<header>
<h4 class="title">可登记事件</h4>
</header>
<div class="actions">
<t-tag
v-for="item in scopeList"
:key="item"
variant="light-outline"
theme="success"
:content="item"
/>
<div v-if="!scopeList.length" class="empty-card">未配置</div>
</div>
</section>
<t-button v-can="permissionCodes.user.update" class="edit" theme="primary" @click="toEdit">编辑用户</t-button>
</div>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { Time, type User, UserAPI } from "timi-web";
import EventAPI from "@/api/EventAPI";
import ManagedUserAPI from "@/api/ManagedUserAPI";
import type { ManagedUser } from "@/types/ManagedUser";
import type { EventItem } from "@/types/EventItem";
import { usePermissionSettingsStore } from "@/store/permissionSettingsStore";
import { buildFallbackAvatar } from "@/utils/avatar";
import { buildEffectiveRoleCodeSet, getManagedUserRoleList, getRoleDisplayName } from "@/utils/role";
import { permissionCodes } from "@/utils/permission";
defineOptions({
name: "UserDetail"
});
const route = useRoute();
const router = useRouter();
const permissionStore = usePermissionSettingsStore();
// ---------- 用户数据 ----------
const managedUser = ref<ManagedUser>();
const eventList = ref<EventItem[]>([]);
const currentUser = computed(() => {
const item = managedUser.value;
if (!item) {
return null;
}
const user = item.user;
return {
id: item.id || "",
name: user?.nick || user?.name || "未命名用户",
role: getRoleDisplayName(getManagedUserRoleList(item)[0], permissionStore.roleList),
avatar: buildUserAvatar(user),
mobile: user?.phoneNo || "",
lastLoginAt: user?.lastLoginAt
};
});
const scopeList = computed(() => {
const item = managedUser.value;
if (!item) {
return [];
}
return eventList.value
.filter(action => canEventItem(action, item))
.map(item => item.name);
});
// ---------- 导航 ----------
async function toEdit(): Promise<void> {
if (!currentUser.value) {
return;
}
await router.push(`/management/users/${currentUser.value.id}/edit`);
}
async function toRoleAssign(): Promise<void> {
if (!currentUser.value) {
return;
}
await router.push({
path: `/management/users/${currentUser.value.id}/roles`,
query: managedUser.value?.storeId ? { storeId: managedUser.value.storeId } : undefined
});
}
function buildUserAvatar(user?: Partial<User>): string {
const name = user?.nick || user?.name || "用户";
return isUser(user) ? UserAPI.getAvatarURL(user) || buildFallbackAvatar(name) : buildFallbackAvatar(name);
}
function isUser(user?: Partial<User>): user is User {
return typeof user?.name === "string";
}
// ---------- 事件范围 ----------
function canEventItem(action: EventItem, item: ManagedUser): boolean {
const userRoleCodeSet = buildEffectiveRoleCodeSet(item);
const roleCodeList = action.roleCodeList || [];
if (!roleCodeList.length) {
return false;
}
return roleCodeList.some(code => userRoleCodeSet.has(code));
}
// ---------- 页面数据 ----------
async function loadData(): Promise<void> {
const routeId = String(route.params.id || "");
const [detail, actions] = await Promise.all([
ManagedUserAPI.detail(routeId),
EventAPI.listValid(),
permissionStore.loadAll()
]);
managedUser.value = detail;
eventList.value = actions || [];
}
onMounted(async () => {
try {
await loadData();
} catch {
Toast.error("用户详情加载失败");
}
});
</script>
<style scoped lang="less">
.user-edit {
gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
flex-direction: column;
.avatar {
width: 5rem;
height: 5rem;
border-radius: .875rem;
}
.registration-actions {
gap: .5rem;
margin: 0 1rem;
padding: 1rem;
display: flex;
border-radius: 1rem;
flex-direction: column;
.title {
margin: 0;
}
.actions {
gap: .5rem;
display: flex;
.empty-card {
color: #52607a;
}
}
}
.edit {
left: 1rem;
right: 1rem;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
position: fixed;
}
}
:global(html.theme-dark) {
.user-edit {
.panel,
.bottom-bar {
background: rgba(15, 23, 42, .92);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
}
p,
.info-list li span,
.empty-card {
color: #94a3b8;
}
.action {
color: #e2e8f0;
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
}
}
</style>
+244
View File
@@ -0,0 +1,244 @@
<template>
<div v-can="savePermissionCode" class="user-edit">
<t-form class="form" label-align="left">
<t-form-item label="姓名">
<t-input v-model.trim="formModel.name" placeholder="姓名" borderless />
</t-form-item>
<t-form-item v-if="isCreateMode" label="手机号">
<t-input v-model.trim="formModel.mobile" type="tel" placeholder="手机号" borderless />
</t-form-item>
<t-form-item v-if="isCreateMode" label="密码">
<t-input v-model.trim="formModel.password" type="password" placeholder="密码" borderless />
</t-form-item>
<t-form-item v-if="isGlobalManager" label="门店" arrow @click="openStorePicker">
<div class="picker-text" v-text="selectedStoreName" />
</t-form-item>
</t-form>
<t-button class="save" theme="primary" :loading="isSaving" @click="saveUser">保存</t-button>
</div>
<t-popup v-model="storePickerVisible" placement="bottom">
<t-picker
v-model="storePickerDraft"
:columns="storePickerColumns"
title="选择门店"
@cancel="storePickerVisible = false"
@confirm="confirmStorePicker"
/>
</t-popup>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { useUserStore } from "timi-web";
import ManagedUserAPI from "@/api/ManagedUserAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import type { ManagedUser } from "@/types/ManagedUser";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
type PickerValue = string | number;
defineOptions({
name: "UserEdit"
});
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
// ---------- 表单 ----------
const currentManagedUser = ref<ManagedUser>();
const isSaving = ref(false);
const formModel = reactive({
id: "",
userId: "",
storeId: "",
name: "",
mobile: "",
password: ""
});
const isCreateMode = computed(() => !String(route.params.id || ""));
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.user.create : permissionCodes.user.update);
// ---------- 门店选择 ----------
const storePickerVisible = ref(false);
const storePickerDraft = ref<PickerValue[]>([]);
const storePickerColumns = computed(() => [
currentStoreStore.storeList.map(item => ({
label: item.name || item.code || "未命名门店",
value: item.id || ""
})).filter(item => item.value)
]);
const selectedStoreName = computed(() => {
const store = currentStoreStore.storeList.find(item => item.id === formModel.storeId);
return store?.name || store?.code || "请选择门店";
});
function openStorePicker(): void {
if (!currentStoreStore.storeList.length) {
Toast.warning("暂无可选门店");
return;
}
storePickerDraft.value = [formModel.storeId];
storePickerVisible.value = true;
}
function confirmStorePicker(): void {
const storeId = String(storePickerDraft.value[0] || "");
if (!storeId) {
return;
}
formModel.storeId = storeId;
storePickerVisible.value = false;
}
// ---------- 页面数据 ----------
async function prepareStoreScope(): Promise<void> {
if (isGlobalManager.value) {
await currentStoreStore.loadStoreList();
const queryStoreId = String(route.query.storeId || "");
formModel.storeId = currentStoreStore.storeList.some(item => item.id === queryStoreId)
? queryStoreId
: currentStoreStore.currentStoreId;
return;
}
const store = await currentStoreStore.loadLoginStore();
formModel.storeId = store?.id || "";
}
async function fillForm(): Promise<void> {
await prepareStoreScope();
const routeId = String(route.params.id || "");
if (routeId) {
const current = await ManagedUserAPI.detail(routeId);
currentManagedUser.value = current;
Object.assign(formModel, {
id: current.id,
userId: current.userId || "",
storeId: current.storeId || formModel.storeId,
name: current.user?.nick || current.user?.name || "",
mobile: current.user?.phoneNo || "",
password: ""
});
return;
}
Object.assign(formModel, {
id: "",
userId: "",
storeId: formModel.storeId,
name: "",
mobile: "",
password: ""
});
}
// ---------- 保存 ----------
async function saveUser(): Promise<void> {
if (!validateForm()) {
return;
}
isSaving.value = true;
try {
const formData: ManagedUser = {
id: formModel.id || undefined,
userId: formModel.userId || undefined,
storeId: formModel.storeId || undefined,
enabled: currentManagedUser.value?.enabled ?? true,
remark: currentManagedUser.value?.remark || "",
user: {
id: formModel.userId || undefined,
name: formModel.name,
nick: formModel.name,
phoneNo: formModel.mobile || undefined,
password: isCreateMode.value ? formModel.password : undefined
}
};
let saved: ManagedUser;
if (isCreateMode.value) {
saved = await ManagedUserAPI.create(formData);
} else {
saved = await ManagedUserAPI.update(formData);
if (isGlobalManager.value && currentManagedUser.value?.storeId !== formModel.storeId) {
await ManagedUserAPI.transferStore({
id: formModel.id || undefined,
storeId: formModel.storeId || undefined
});
}
}
Toast.success("用户已保存");
const detailPath = `/management/users/${saved.id || formModel.id}`;
if (isCreateMode.value) {
await router.replace(detailPath);
} else {
await router.push(detailPath);
}
} finally {
isSaving.value = false;
}
}
function validateForm(): boolean {
if (!formModel.name) {
Toast.warning("请填写姓名");
return false;
}
if (!formModel.storeId) {
Toast.warning("请选择门店");
return false;
}
if (isCreateMode.value && !formModel.password) {
Toast.warning("请填写密码");
return false;
}
return true;
}
onMounted(async () => {
try {
await fillForm();
} catch {
Toast.error("用户数据加载失败");
}
});
</script>
<style scoped lang="less">
.user-edit {
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
.form {
border-radius: 1rem;
}
.picker-text {
color: var(--td-text-color-primary);
}
.save {
left: 1rem;
right: 1rem;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
position: fixed;
}
}
:global(html.theme-dark) {
.user-edit {
:deep(.form-item .t-form__label) {
color: #94a3b8;
}
}
}
</style>
+299
View File
@@ -0,0 +1,299 @@
<template>
<div v-can="permissionCodes.user.read" class="user-list">
<div class="store-menu">
<t-dropdown-menu v-if="isGlobalManager">
<t-dropdown-item
v-model="selectedStoreId"
:label="storeFilterLabel"
:options="storeOptions"
@change="selectStoreFilter"
/>
</t-dropdown-menu>
</div>
<t-pull-down-refresh
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="refresh"
@scrolltolower="fetch"
>
<t-cell-group theme="card">
<t-cell
class="item"
v-for="item in pageResult.list"
:key="item.id"
:description="`可登记事件:${getActionScope(item)}`"
arrow
@click="toDetail(item)"
>
<template #left-icon>
<t-image :src="buildUserAvatar(item)" :alt="getUserName(item)" fit="cover" class="avatar" />
</template>
<template #title>
<div class="header">
<span class="name" v-text="getUserName(item)" />
<t-tag class="meta" size="small" theme="success" variant="outline" :content="getRoleName(item)" />
</div>
</template>
</t-cell>
</t-cell-group>
<div v-if="!pageResult.list.length" class="load-state" v-text="isLoading ? '正在加载用户' : '暂无用户'" />
<div v-if="pageResult.list.length" class="load-state" v-text="isNoMore ? '已经到底了' : '上拉加载更多'" />
</t-pull-down-refresh>
<t-button v-can="permissionCodes.user.create" class="add" theme="primary" type="button" @click="toCreate">
<template #icon>
<t-icon name="add" />
</template>
新增
</t-button>
</div>
</template>
<script setup lang="ts">
import { Toast, type DropdownValue } from "tdesign-mobile-vue";
import { type Page, type PageResult, UserAPI, type User, useUserStore } from "timi-web";
import EventAPI from "@/api/EventAPI";
import ManagedUserAPI from "@/api/ManagedUserAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import type { ManagedUser } from "@/types/ManagedUser";
import type { EventItem } from "@/types/EventItem";
import { usePermissionSettingsStore } from "@/store/permissionSettingsStore";
import { buildFallbackAvatar } from "@/utils/avatar";
import { buildEffectiveRoleCodeSet, getManagedUserRoleList, getRoleDisplayName } from "@/utils/role";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({
name: "UserList"
});
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const permissionStore = usePermissionSettingsStore();
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
// ---------- 门店筛选 ----------
const allStoreFilterValue = "__ALL_STORES__";
const selectedStoreId = ref(allStoreFilterValue);
const loginStoreId = ref("");
const storeOptions = computed(() => [
{ label: "全部门店", value: allStoreFilterValue },
...currentStoreStore.storeList
.map(item => ({
label: item.name || item.code || "未命名门店",
value: item.id || ""
}))
.filter(item => item.value)
]);
const storeFilterLabel = computed(() => storeOptions.value.find(item => item.value === selectedStoreId.value)?.label || "全部门店");
const userListOptions = computed(() => {
if (isGlobalManager.value) {
return selectedStoreId.value === allStoreFilterValue
? { allStores: true }
: { storeId: selectedStoreId.value };
}
return { storeId: loginStoreId.value };
});
// ---------- 列表数据 ----------
const eventList = ref<EventItem[]>([]);
const isRefreshing = ref(false);
const isLoading = ref(false);
const isNoMore = ref(false);
const page = reactive<Page<ManagedUser>>({
index: 0,
size: 20
});
const pageResult = reactive<PageResult<ManagedUser>>({
total: 0,
list: []
});
// ---------- 展示文案 ----------
function getUserName(item: ManagedUser): string {
return item.user?.nick || item.user?.name || "未命名用户";
}
function getRoleName(item: ManagedUser): string {
return getRoleDisplayName(getManagedUserRoleList(item)[0], permissionStore.roleList);
}
function getActionScope(item: ManagedUser): string {
const nameList = eventList.value
.filter(action => canEventItem(action, item))
.map(action => action.name);
return nameList.join("、") || "未配置";
}
// ---------- 事件范围 ----------
function canEventItem(action: EventItem, item: ManagedUser): boolean {
const userRoleCodeSet = buildEffectiveRoleCodeSet(item);
const roleCodeList = action.roleCodeList || [];
if (!roleCodeList.length) {
return false;
}
return roleCodeList.some(code => userRoleCodeSet.has(code));
}
// ---------- 头像 ----------
function buildUserAvatar(item: ManagedUser): string {
return isUser(item.user) ? UserAPI.getAvatarURL(item.user) || buildFallbackAvatar(getUserName(item)) : buildFallbackAvatar(getUserName(item));
}
function isUser(user?: Partial<User>): user is User {
return typeof user?.name === "string";
}
// ---------- 页面数据 ----------
async function loadStoreScope(): Promise<void> {
if (isGlobalManager.value) {
await currentStoreStore.loadStoreList();
return;
}
const store = await currentStoreStore.loadLoginStore();
loginStoreId.value = store?.id || "";
}
async function loadData(): Promise<void> {
await loadStoreScope();
const [userResult, actions] = await Promise.all([
ManagedUserAPI.list(page, userListOptions.value),
EventAPI.listValid(),
permissionStore.loadAll()
]);
pageResult.total = userResult.total;
pageResult.list = userResult.list || [];
page.index = 1;
isNoMore.value = pageResult.list.length >= pageResult.total || pageResult.list.length < page.size;
eventList.value = actions || [];
}
async function refresh(): Promise<void> {
isRefreshing.value = true;
try {
page.index = 0;
pageResult.total = 0;
pageResult.list = [];
isNoMore.value = false;
await fetch();
} finally {
isRefreshing.value = false;
}
}
async function fetch(): Promise<void> {
if (isLoading.value || isNoMore.value) {
return;
}
isLoading.value = true;
try {
const result = await ManagedUserAPI.list(page, userListOptions.value);
pageResult.total = result.total;
pageResult.list = pageResult.list.concat(result.list || []);
page.index += 1;
isNoMore.value = pageResult.list.length >= pageResult.total || (result.list || []).length < page.size;
} finally {
isLoading.value = false;
}
}
async function selectStoreFilter(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
selectedStoreId.value = String(value);
await refresh();
}
// ---------- 导航 ----------
async function toCreate(): Promise<void> {
await router.push({
path: "/management/users/create",
query: selectedStoreId.value === allStoreFilterValue ? undefined : { storeId: selectedStoreId.value }
});
}
async function toDetail(item: ManagedUser): Promise<void> {
if (!item.id) {
return;
}
await router.push(`/management/users/${item.id}`);
}
onMounted(async () => {
try {
await loadData();
} catch {
Toast.error("用户列表加载失败");
}
});
</script>
<style scoped lang="less">
.user-list {
padding: calc(var(--app-nav-height) + 4rem) 0 6rem 0;
display: grid;
gap: .75rem;
.store-menu {
top: var(--app-nav-height);
left: 0;
right: 0;
z-index: 20;
position: fixed;
background: #fff;
}
.item {
padding: 10px;
.avatar {
width: 3.5rem;
height: 3.5rem;
border-radius: .5rem;
}
.header {
flex: 1;
gap: .5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
}
.add {
left: 1rem;
right: 1rem;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
position: fixed;
}
}
:global(html.theme-dark) {
.user-list {
.item {
background: rgba(30, 41, 59, .9);
.name {
color: #e2e8f0;
}
.meta {
color: #94a3b8;
}
}
}
}
</style>
+91
View File
@@ -0,0 +1,91 @@
<template>
<div class="password-page">
<t-form class="form" label-align="left">
<t-form-item label="旧密码">
<t-input v-model.trim="formModel.oldPassword" type="password" placeholder="旧密码" borderless />
</t-form-item>
<t-form-item label="新密码">
<t-input v-model.trim="formModel.newPassword" type="password" placeholder="新密码" borderless />
</t-form-item>
<t-form-item label="确认密码">
<t-input v-model.trim="formModel.confirmPassword" type="password" placeholder="确认密码" borderless />
</t-form-item>
</t-form>
<t-button class="save" theme="primary" :loading="isSaving" @click="savePassword">保存</t-button>
</div>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { UserAPI, useUserStore } from "timi-web";
defineOptions({
name: "UserPassword"
});
const router = useRouter();
const userStore = useUserStore();
// ---------- 表单 ----------
const isSaving = ref(false);
const formModel = reactive({
oldPassword: "",
newPassword: "",
confirmPassword: ""
});
// ---------- 保存 ----------
async function savePassword(): Promise<void> {
if (!formModel.oldPassword || !formModel.newPassword) {
Toast.warning("请填写旧密码和新密码");
return;
}
if (formModel.newPassword !== formModel.confirmPassword) {
Toast.warning("两次输入的新密码不一致");
return;
}
isSaving.value = true;
try {
await UserAPI.updatePassword({
oldValue: formModel.oldPassword,
newValue: formModel.newPassword
});
userStore.clearLoginUser();
Toast.success("密码已更新,请重新登录");
await router.replace("/login");
} finally {
isSaving.value = false;
}
}
</script>
<style scoped lang="less">
.password-page {
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
.form {
padding: .5rem 0;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1.4rem;
}
.save {
left: 1rem;
right: 1rem;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
position: fixed;
}
}
:global(html.theme-dark) {
.password-page {
.form {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
}
}
}
</style>
+269
View File
@@ -0,0 +1,269 @@
<template>
<div class="profile-page">
<t-form class="form" label-align="left">
<t-form-item label="头像">
<photo-field
v-model="formModel.avatar"
add-text="更换"
alt-text="用户头像"
size="compact"
:loading="isAvatarUploading"
:preview="false"
:show-remove="false"
@add="changeAvatar"
/>
</t-form-item>
<t-form-item label="用户名">
<t-input v-model.trim="formModel.name" placeholder="用户名" borderless />
</t-form-item>
<t-form-item label="昵称">
<t-input v-model.trim="formModel.nickname" placeholder="昵称" borderless />
</t-form-item>
<t-form-item label="性别">
<t-radio-group class="sex" v-model="formModel.gender" variant="default-filled">
<t-radio :block="false" borderless value="MALE">男</t-radio>
<t-radio :block="false" borderless value="FEMALE">女</t-radio>
<t-radio :block="false" borderless value="">未知</t-radio>
</t-radio-group>
</t-form-item>
<t-form-item label="手机号">
<div class="mobile-value">
<div v-if="formModel.mobile" v-text="formModel.mobile" />
<span v-else v-text="'未填写'" />
</div>
</t-form-item>
</t-form>
<input
ref="avatarInputRef"
class="avatar-input"
type="file"
accept="image/*"
@change="handleAvatarSelected"
>
<t-cell-group theme="card">
<t-cell title="修改密码" arrow hover @click="toPassword" />
</t-cell-group>
<t-button class="save" theme="primary" :loading="isSaving" @click="saveProfile">保存</t-button>
<t-button class="logout bg-white" theme="danger" variant="outline" @click="confirmLogout">退出登录</t-button>
</div>
</template>
<script setup lang="ts">
import { DialogPlugin, Toast } from "tdesign-mobile-vue";
import { AttachmentAPI, type Gender, UserAPI, useUserStore } from "timi-web";
import PhotoField from "@/components/PhotoField.vue";
import { buildFallbackAvatar } from "@/utils/avatar";
import { compressUploadImage } from "@/utils/imageCompression";
type UserAvatarSource = NonNullable<Parameters<typeof UserAPI.getAvatarURL>[0]> & {
name?: string;
nick?: string;
};
type ProfileGender = Gender | "";
defineOptions({
name: "UserProfile"
});
const router = useRouter();
const userStore = useUserStore();
// ---------- 头像 ----------
const avatarInputRef = ref<HTMLInputElement>();
const avatarTempFileId = ref("");
const isAvatarUploading = ref(false);
// ---------- 表单 ----------
const isSaving = ref(false);
const formModel = reactive({
id: "",
name: "",
avatar: "",
nickname: "",
role: "",
gender: "" as ProfileGender,
mobile: "",
password: "",
office: "",
scope: "",
status: "",
lastLoginAt: ""
});
// ---------- 页面数据 ----------
function fillForm(): void {
const user = userStore.loginUser.user;
Object.assign(formModel, {
id: user?.id || "",
name: user?.name || "",
avatar: buildUserAvatar(user),
nickname: user?.nick || "",
role: formatRoleCode(userStore.getRoleList()[0]),
gender: user?.gender || "",
mobile: user?.phoneNo || "",
password: "",
office: "",
scope: "",
status: "",
lastLoginAt: user?.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : ""
});
}
// ---------- 头像 ----------
function changeAvatar(): void {
avatarInputRef.value?.click();
}
async function handleAvatarSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
isAvatarUploading.value = true;
try {
const compressedFile = await compressUploadImage(file);
const [tempFile] = await AttachmentAPI.upload(compressedFile);
if (!tempFile?.id) {
Toast.error("头像上传失败");
return;
}
avatarTempFileId.value = tempFile.id;
formModel.avatar = AttachmentAPI.getTempReadURL(tempFile.id);
} finally {
isAvatarUploading.value = false;
input.value = "";
}
}
// ---------- 导航 ----------
async function toPassword(): Promise<void> {
await router.push("/management/profile/password");
}
// ---------- 保存 ----------
async function saveProfile(): Promise<void> {
if (!formModel.name) {
Toast.warning("请填写用户名");
return;
}
isSaving.value = true;
try {
const result = await UserAPI.updateCurrent({
id: formModel.id,
name: formModel.name,
nick: formModel.nickname,
gender: formModel.gender,
tempFileIdList: avatarTempFileId.value ? [avatarTempFileId.value] : undefined
});
await userStore.updateLoginUser(result);
Toast.success("资料已保存");
await router.back();
} finally {
isSaving.value = false;
}
}
// ---------- 退出登录 ----------
async function confirmLogout(): Promise<void> {
const dialog = DialogPlugin.confirm({
title: "退出登录",
content: "确定要退出当前账号吗?",
cancelBtn: "取消",
confirmBtn: "退出",
onCancel() {
dialog.destroy();
},
async onConfirm() {
dialog.destroy();
await logout();
}
});
}
async function logout(): Promise<void> {
await userStore.logout();
await router.replace("/login");
}
// ---------- 格式化 ----------
function formatRoleCode(roleCode?: string): string {
if (!roleCode) {
return "";
}
const codePartList = roleCode.split(":");
return codePartList[codePartList.length - 1] || roleCode;
}
function buildUserAvatar(user?: UserAvatarSource): string {
const name = user?.nick || user?.name || "用户";
return UserAPI.getAvatarURL(user) || buildFallbackAvatar(name, "#0f766e");
}
onMounted(() => {
fillForm();
});
</script>
<style scoped lang="less">
.profile-page {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 6rem 0;
display: flex;
flex-direction: column;
.form {
padding: .5rem 0;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1.4rem;
.sex {
gap: .5rem;
display: flex;
}
}
.avatar-input {
display: none;
}
.mobile-value {
color: var(--td-text-color-primary);
line-height: 1.5rem;
}
.save,
.logout {
left: 1rem;
right: 1rem;
position: fixed;
}
.save {
bottom: calc(3.75rem + env(safe-area-inset-bottom, 0));
}
.logout {
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
}
}
:global(html.theme-dark) {
.profile-page {
.form {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
}
}
}
</style>
+335
View File
@@ -0,0 +1,335 @@
<template>
<div v-can="corePermissionCodes.userRole.read" class="user-role-assign">
<div class="user-menu">
<t-dropdown-menu>
<t-dropdown-item
v-if="isGlobalManager"
v-model="selectedStoreId"
:label="storeFilterLabel"
:options="storeOptions"
@change="handleStoreChange"
/>
<t-dropdown-item
:label="activeUserLabel"
:options="userOptions"
:value="activeManagedUserId"
@change="handleUserChange"
/>
</t-dropdown-menu>
</div>
<t-cell-group v-if="activeUser && roleList.length" theme="card">
<t-cell
v-for="item in roleList"
:key="item.id"
:title="item.name"
:description="item.code"
>
<template #right-icon>
<t-switch
v-can="userRoleWriteAccess"
:model-value="isRoleChecked(item.id)"
@change="value => setRoleChecked(item.id, value === true)"
/>
</template>
</t-cell>
</t-cell-group>
<t-empty v-else-if="!isLoading" description="暂无可授权角色" />
<t-button
v-can="userRoleWriteAccess"
class="save"
theme="primary"
:loading="isSaving"
@click="saveRoleList"
>
保存授权
</t-button>
</div>
</template>
<script setup lang="ts">
import type { DropdownOption, DropdownValue } from "tdesign-mobile-vue";
import { Toast } from "tdesign-mobile-vue";
import { RoleAPI, type ModuleCode, type Role as UserRole, useUserStore } from "timi-web";
import ManagedUserAPI from "@/api/ManagedUserAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import type { Role } from "@/store/permissionSettingsStore";
import type { ManagedUser } from "@/types/ManagedUser";
import { corePermissionCodes, gaoRoleCodes } from "@/utils/permission";
defineOptions({
name: "UserRoleAssign"
});
const route = useRoute();
const moduleCode: ModuleCode = "GAO";
const allStoreFilterValue = "__ALL_STORES__";
// ---------- 用户选择 ----------
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const userList = ref<ManagedUser[]>([]);
const roleList = ref<Role[]>([]);
const routeManagedUserId = computed(() => String(route.params.id || route.query.managedUserId || ""));
const activeManagedUserId = ref(routeManagedUserId.value);
const selectedStoreId = ref(allStoreFilterValue);
const loginStoreId = ref("");
const checkedRoleIds = ref<string[]>([]);
const isLoading = ref(false);
const isSaving = ref(false);
let roleSyncSeq = 0;
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
const storeOptions = computed(() => [
{ label: "全部门店", value: allStoreFilterValue },
...currentStoreStore.storeList
.map(item => ({
label: item.name || item.code || "未命名门店",
value: item.id || ""
}))
.filter(item => item.value)
]);
const storeFilterLabel = computed(() => storeOptions.value.find(item => item.value === selectedStoreId.value)?.label || "全部门店");
const userListOptions = computed(() => {
if (isGlobalManager.value) {
return selectedStoreId.value === allStoreFilterValue
? { allStores: true }
: { storeId: selectedStoreId.value };
}
return { storeId: loginStoreId.value };
});
// ---------- 权限 ----------
const userRoleWriteAccess = {
value: [
corePermissionCodes.userRole.create,
corePermissionCodes.userRole.delete
],
mode: "and"
} as const;
// ---------- 用户状态 ----------
const activeUser = computed(() => userList.value.find(item => item.id === activeManagedUserId.value));
const activeUserLabel = computed(() => activeUser.value ? `当前用户:${getUserName(activeUser.value)}` : "选择用户");
const userOptions = computed<DropdownOption[]>(() => userList.value
.filter(item => !!item.id)
.map(item => ({
label: getUserName(item),
value: item.id as string,
disabled: false
})));
// ---------- 用户切换 ----------
async function handleUserChange(value: DropdownValue): Promise<void> {
activeManagedUserId.value = Array.isArray(value) ? String(value[0] || "") : String(value);
await loadActiveUserRoles();
}
async function handleStoreChange(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
selectedStoreId.value = String(value);
await loadUserList();
}
// ---------- 页面数据 ----------
async function loadStoreScope(): Promise<void> {
if (isGlobalManager.value) {
await currentStoreStore.loadStoreList();
const queryStoreId = String(route.query.storeId || "");
selectedStoreId.value = currentStoreStore.storeList.some(item => item.id === queryStoreId)
? queryStoreId
: allStoreFilterValue;
return;
}
const store = await currentStoreStore.loadLoginStore();
loginStoreId.value = store?.id || "";
}
async function loadData(): Promise<void> {
isLoading.value = true;
try {
await loadStoreScope();
roleList.value = await loadRoleList();
await loadUserList();
} finally {
isLoading.value = false;
}
}
async function loadUserList(): Promise<void> {
const result = await ManagedUserAPI.list({
index: 0,
size: 100
}, userListOptions.value);
userList.value = result.list || [];
if (!userList.value.some(item => item.id === activeManagedUserId.value)) {
activeManagedUserId.value = userList.value[0]?.id || "";
}
await loadActiveUserRoles();
}
// ---------- 角色同步 ----------
async function loadActiveUserRoles(): Promise<void> {
const managedUserId = activeManagedUserId.value;
const managedUser = activeUser.value;
if (!managedUserId || !managedUser?.userId) {
checkedRoleIds.value = [];
return;
}
const currentSeq = roleSyncSeq + 1;
roleSyncSeq = currentSeq;
const roles = await RoleAPI.authorizedList(managedUser.userId, moduleCode);
if (currentSeq !== roleSyncSeq || activeManagedUserId.value !== managedUserId) {
return;
}
const availableRoleIdSet = new Set(roleList.value.map(item => item.id));
checkedRoleIds.value = roles
.map(getRoleId)
.filter((item): item is string => !!item && availableRoleIdSet.has(item));
}
// ---------- 保存 ----------
async function saveRoleList(): Promise<void> {
const current = activeUser.value;
if (!current?.id || !current.userId) {
Toast.warning("请选择用户");
return;
}
isSaving.value = true;
try {
await RoleAPI.authorize({
userId: current.userId,
moduleCode: moduleCode,
roleIdList: [...checkedRoleIds.value]
});
await loadActiveUserRoles();
Toast.success("用户角色已保存");
} catch (error) {
checkedRoleIds.value = [];
try {
await reloadAuthorizationScope();
await loadActiveUserRoles();
} finally {
Toast.error(getErrorMessage(error, "用户角色授权失败"));
}
} finally {
isSaving.value = false;
}
}
// ---------- 展示文案 ----------
function getUserName(item: ManagedUser): string {
return item.user?.nick || item.user?.name || item.user?.phoneNo || "未命名用户";
}
// ---------- 角色列表 ----------
async function loadRoleList(): Promise<Role[]> {
const result = await RoleAPI.grantableList(moduleCode);
return (result || [])
.filter((role): role is UserRole & { id: string } => Boolean(role.id))
.map(role => ({
...role,
id: role.id,
permissionIdList: role.permissionIdList || role.permissionList?.map(item => item.id) || [],
childRoleIdList: role.childRoleIdList || role.childRoleList?.map(item => item.id).filter((id): id is string => Boolean(id)) || [],
delegationPermissionIdList: role.delegationPermissionIdList || role.delegationPermissionList?.map(item => item.id) || []
}));
}
async function reloadAuthorizationScope(): Promise<void> {
roleList.value = await loadRoleList();
}
// ---------- 角色勾选 ----------
function isRoleChecked(roleId: string): boolean {
return checkedRoleIds.value.includes(roleId);
}
function getRoleId(role: UserRole | string): string {
if (typeof role !== "string") {
return role.id || "";
}
const fullRole = roleList.value.find(item => item.code === role || `${item.moduleCode}:${item.code}` === role);
return fullRole?.id || "";
}
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) {
return error.message;
}
if (typeof error === "string" && error) {
return error;
}
return fallback;
}
function setRoleChecked(roleId: string, value: boolean): void {
if (value && !checkedRoleIds.value.includes(roleId)) {
checkedRoleIds.value.push(roleId);
return;
}
if (!value) {
checkedRoleIds.value = checkedRoleIds.value.filter(item => item !== roleId);
}
}
onMounted(async () => {
try {
await loadData();
} catch {
Toast.error("用户角色加载失败");
}
});
</script>
<style scoped lang="less">
.user-role-assign {
gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height) + 4.5rem) 0 6rem 0;
flex-direction: column;
.user-menu {
top: var(--app-nav-height);
left: 0;
right: 0;
z-index: 20;
position: fixed;
background: #fff;
}
.save {
left: 1rem;
right: 1rem;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
position: fixed;
}
}
:global(html.theme-dark) {
.user-role-assign {
.user-menu {
background: #0f172a;
}
}
}
</style>