chore: sync rsf-design from isolated worktree
| New file |
| | |
| | | const CHUNK_GROUPS = [ |
| | | { |
| | | name: 'vendor-echarts', |
| | | packages: ['echarts/'] |
| | | }, |
| | | { |
| | | name: 'vendor-editor', |
| | | packages: ['@wangeditor/', 'highlight.js/'] |
| | | }, |
| | | { |
| | | name: 'vendor-xlsx', |
| | | packages: ['xlsx/'] |
| | | }, |
| | | { |
| | | name: 'vendor-media', |
| | | packages: ['xgplayer/'] |
| | | }, |
| | | { |
| | | name: 'vendor-element-plus', |
| | | packages: ['element-plus/', '@element-plus/'] |
| | | }, |
| | | { |
| | | name: 'vendor-vue', |
| | | packages: ['vue-router/', 'pinia/', '@vueuse/'] |
| | | }, |
| | | { |
| | | name: 'vendor-utils', |
| | | packages: ['@iconify/', 'file-saver/', 'axios/'] |
| | | } |
| | | ] |
| | | |
| | | function createManualChunks(id) { |
| | | if (!id || !id.includes('/node_modules/')) { |
| | | return void 0 |
| | | } |
| | | |
| | | const normalizedId = id.replace(/\\/g, '/') |
| | | const packagePath = normalizedId.split('/node_modules/').pop() || '' |
| | | |
| | | for (const group of CHUNK_GROUPS) { |
| | | if (group.packages.some((pkg) => packagePath.startsWith(pkg))) { |
| | | return group.name |
| | | } |
| | | } |
| | | |
| | | return void 0 |
| | | } |
| | | |
| | | export { createManualChunks } |
| | |
| | | import request from '@/utils/http' |
| | | |
| | | function buildLoginPayload({ username, password }) { |
| | | return { username, password } |
| | | } |
| | | |
| | | function normalizeLoginParams(params) { |
| | | return { |
| | | username: params?.username || params?.userName || '', |
| | | password: params?.password |
| | | } |
| | | } |
| | | |
| | | function normalizeLoginResponse(payload) { |
| | | const data = payload?.data || payload || {} |
| | | return { |
| | | accessToken: data.accessToken || '', |
| | | refreshToken: data.refreshToken || '', |
| | | user: data.user || {} |
| | | } |
| | | } |
| | | |
| | | function normalizeUserInfo(data) { |
| | | const normalizedRoles = normalizeRoleCodes(data?.roles) |
| | | const normalizedButtons = normalizeButtonMarks(data) |
| | | return { |
| | | ...data, |
| | | roles: normalizedRoles, |
| | | buttons: normalizedButtons |
| | | } |
| | | } |
| | | |
| | | function normalizeRoleCodes(roles) { |
| | | if (!Array.isArray(roles)) { |
| | | return [] |
| | | } |
| | | |
| | | return Array.from( |
| | | new Set( |
| | | roles |
| | | .map((item) => { |
| | | if (typeof item === 'string') { |
| | | return item.trim() |
| | | } |
| | | if (item && typeof item === 'object') { |
| | | return item.code || item.name || '' |
| | | } |
| | | return '' |
| | | }) |
| | | .filter(Boolean) |
| | | ) |
| | | ) |
| | | } |
| | | |
| | | function normalizeButtonMarks(data) { |
| | | const directButtons = Array.isArray(data?.buttons) ? data.buttons : [] |
| | | const authorityButtons = Array.isArray(data?.authorities) |
| | | ? data.authorities.map((item) => item?.authority || item?.authMark || '') |
| | | : [] |
| | | |
| | | return Array.from( |
| | | new Set( |
| | | [...directButtons, ...authorityButtons] |
| | | .map((item) => (typeof item === 'string' ? item.trim() : '')) |
| | | .filter(Boolean) |
| | | ) |
| | | ) |
| | | } |
| | | |
| | | function fetchLogin(params) { |
| | | return request.post({ |
| | | url: '/api/auth/login', |
| | | params |
| | | return request |
| | | .post({ |
| | | url: '/login', |
| | | params: buildLoginPayload(normalizeLoginParams(params)) |
| | | // showSuccessMessage: true // 显示成功消息 |
| | | // showErrorMessage: false // 不显示错误消息 |
| | | }) |
| | | .then((response) => { |
| | | const normalized = normalizeLoginResponse(response) |
| | | return { |
| | | token: normalized.accessToken, |
| | | accessToken: normalized.accessToken, |
| | | refreshToken: normalized.refreshToken, |
| | | user: normalized.user |
| | | } |
| | | }) |
| | | } |
| | | function fetchGetUserInfo() { |
| | | return request.get({ |
| | | url: '/api/user/info' |
| | | return request |
| | | .get({ |
| | | url: '/auth/user' |
| | | // 自定义请求头 |
| | | // headers: { |
| | | // 'X-Custom-Header': 'your-custom-value' |
| | | // } |
| | | }) |
| | | .then((response) => normalizeUserInfo(response)) |
| | | } |
| | | export { fetchGetUserInfo, fetchLogin } |
| | | function fetchGetMenuList() { |
| | | return request.get({ |
| | | url: '/auth/menu' |
| | | }) |
| | | } |
| | | export { |
| | | buildLoginPayload, |
| | | fetchGetMenuList, |
| | | fetchGetUserInfo, |
| | | fetchLogin, |
| | | normalizeLoginResponse, |
| | | normalizeUserInfo |
| | | } |
| | |
| | | const appConfig = { |
| | | // 系统信息 |
| | | systemInfo: { |
| | | name: 'Art Design Pro' |
| | | name: 'RSF Design' |
| | | // 系统名称 |
| | | }, |
| | | // 系统主题 |
| | |
| | | import { router } from '@/router' |
| | | import { useUserStore } from '@/store/modules/user' |
| | | import { useAppMode } from '@/hooks/core/useAppMode' |
| | | import { extractRouteAuthMarks, extractUserButtons, hasAuthPermission } from '@/hooks/core/useAuth' |
| | | |
| | | function checkAuthPermission(el, binding) { |
| | | const authList = router.currentRoute.value.meta.authList || [] |
| | | const hasPermission = authList.some((item) => item.authMark === binding.value) |
| | | const authList = extractRouteAuthMarks(router.currentRoute.value.meta.authList) |
| | | const buttons = extractUserButtons(useUserStore().getUserInfo) |
| | | const { isBackendMode } = useAppMode() |
| | | const hasPermission = hasAuthPermission(binding.value, { |
| | | authList, |
| | | buttons, |
| | | isBackendMode: isBackendMode.value, |
| | | routePath: router.currentRoute.value.path |
| | | }) |
| | | if (!hasPermission) { |
| | | removeElement(el) |
| | | } |
| | |
| | | import { useUserStore } from '@/store/modules/user' |
| | | |
| | | function extractRoleCodes(roles) { |
| | | if (!Array.isArray(roles)) { |
| | | return [] |
| | | } |
| | | |
| | | return roles |
| | | .map((item) => { |
| | | if (typeof item === 'string') { |
| | | return item |
| | | } |
| | | if (item && typeof item === 'object') { |
| | | return item.code || item.name || '' |
| | | } |
| | | return '' |
| | | }) |
| | | .filter(Boolean) |
| | | } |
| | | |
| | | function checkRolePermission(el, binding) { |
| | | const userStore = useUserStore() |
| | | const userRoles = userStore.getUserInfo.roles |
| | | const userRoles = extractRoleCodes(userStore.getUserInfo.roles) |
| | | if (!userRoles?.length) { |
| | | removeElement(el) |
| | | return |
| | |
| | | import { storeToRefs } from 'pinia' |
| | | import { useUserStore } from '@/store/modules/user' |
| | | import { useAppMode } from '@/hooks/core/useAppMode' |
| | | const userStore = useUserStore() |
| | | const useAuth = () => { |
| | | const route = useRoute() |
| | | const { isFrontendMode } = useAppMode() |
| | | const { info } = storeToRefs(userStore) |
| | | const frontendAuthList = info.value?.buttons ?? [] |
| | | const backendAuthList = Array.isArray(route.meta.authList) ? route.meta.authList : [] |
| | | const hasAuth = (auth) => { |
| | | if (isFrontendMode.value) { |
| | | return frontendAuthList.includes(auth) |
| | | |
| | | function extractRouteAuthMarks(authList) { |
| | | if (!Array.isArray(authList)) { |
| | | return [] |
| | | } |
| | | return backendAuthList.some((item) => item?.authMark === auth) |
| | | |
| | | return authList |
| | | .map((item) => { |
| | | if (typeof item === 'string') { |
| | | return item |
| | | } |
| | | if (item && typeof item === 'object') { |
| | | return item.authMark || '' |
| | | } |
| | | return '' |
| | | }) |
| | | .filter(Boolean) |
| | | } |
| | | |
| | | function extractUserButtons(info) { |
| | | return Array.isArray(info?.buttons) ? info.buttons : [] |
| | | } |
| | | |
| | | const BUTTON_ACTION_MAP = { |
| | | query: 'list', |
| | | add: 'save', |
| | | edit: 'update', |
| | | delete: 'remove' |
| | | } |
| | | |
| | | function resolveRouteResourceKey(routePath) { |
| | | const pathSegments = String(routePath || '') |
| | | .split('/') |
| | | .filter(Boolean) |
| | | |
| | | const rawSegment = pathSegments[pathSegments.length - 1] |
| | | if (!rawSegment) { |
| | | return '' |
| | | } |
| | | |
| | | return rawSegment.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) |
| | | } |
| | | |
| | | function matchesBackendButton(requiredAuth, buttons, routePath) { |
| | | if (buttons.includes(requiredAuth)) { |
| | | return true |
| | | } |
| | | |
| | | const action = BUTTON_ACTION_MAP[requiredAuth] |
| | | const resourceKey = resolveRouteResourceKey(routePath) |
| | | if (!action || !resourceKey) { |
| | | return false |
| | | } |
| | | |
| | | return buttons.some( |
| | | (item) => |
| | | typeof item === 'string' && item.includes(`:${resourceKey}:`) && item.endsWith(`:${action}`) |
| | | ) |
| | | } |
| | | |
| | | function hasAuthPermission( |
| | | requiredAuth, |
| | | { authList = [], buttons = [], isBackendMode = false, routePath = '' } = {} |
| | | ) { |
| | | const requiredList = Array.isArray(requiredAuth) ? requiredAuth : [requiredAuth] |
| | | |
| | | if (!requiredList.length) { |
| | | return true |
| | | } |
| | | |
| | | if (isBackendMode) { |
| | | return requiredList.some((item) => matchesBackendButton(item, buttons, routePath)) |
| | | } |
| | | |
| | | return requiredList.some((item) => authList.includes(item)) |
| | | } |
| | | |
| | | const useAuth = () => { |
| | | const userStore = useUserStore() |
| | | const route = useRoute() |
| | | const { isBackendMode } = useAppMode() |
| | | const { info } = storeToRefs(userStore) |
| | | const authList = computed(() => extractRouteAuthMarks(route.meta.authList)) |
| | | const buttons = computed(() => extractUserButtons(info.value)) |
| | | const hasAuth = (auth) => { |
| | | return hasAuthPermission(auth, { |
| | | authList: authList.value, |
| | | buttons: buttons.value, |
| | | isBackendMode: isBackendMode.value, |
| | | routePath: route.path |
| | | }) |
| | | } |
| | | return { |
| | | hasAuth |
| | | } |
| | | } |
| | | export { useAuth } |
| | | export { |
| | | extractRouteAuthMarks, |
| | | extractUserButtons, |
| | | hasAuthPermission, |
| | | resolveRouteResourceKey, |
| | | useAuth |
| | | } |
| | |
| | | "notFound": "404", |
| | | "serverError": "500" |
| | | }, |
| | | "userLogin": "Login Logs", |
| | | "system": { |
| | | "title": "System Settings", |
| | | "user": "User Manage", |
| | | "userLogin": "Login Logs", |
| | | "role": "Role Manage", |
| | | "userCenter": "User Center", |
| | | "menu": "Menu Manage" |
| | |
| | | "notFound": "404", |
| | | "serverError": "500" |
| | | }, |
| | | "userLogin": "登录日志", |
| | | "system": { |
| | | "title": "系统管理", |
| | | "user": "用户管理", |
| | | "userLogin": "登录日志", |
| | | "role": "角色管理", |
| | | "userCenter": "个人中心", |
| | | "menu": "菜单管理" |
| | |
| | | import { useUserStore } from '@/store/modules/user' |
| | | import { useAppMode } from '@/hooks/core/useAppMode' |
| | | import { fetchGetMenuList } from '@/api/system-manage' |
| | | import { fetchGetMenuList } from '@/api/auth' |
| | | import { asyncRoutes } from '../routes/asyncRoutes' |
| | | import { RoutesAlias } from '../routesAlias' |
| | | import { adaptBackendMenuTree } from '../adapters/backendMenuAdapter' |
| | | import { formatMenuTitle } from '@/utils' |
| | | class MenuProcessor { |
| | | /** |
| | |
| | | */ |
| | | async processBackendMenu() { |
| | | const list = await fetchGetMenuList() |
| | | return this.filterEmptyMenus(list) |
| | | return adaptBackendMenuTree(list) |
| | | } |
| | | /** |
| | | * 根据角色过滤菜单 |
| | |
| | | import { loadingService } from '@/utils/ui' |
| | | import { useCommon } from '@/hooks/core/useCommon' |
| | | import { useWorktabStore } from '@/store/modules/worktab' |
| | | import { fetchGetUserInfo } from '@/api/auth' |
| | | import { fetchGetUserInfo, normalizeUserInfo } from '@/api/auth' |
| | | import { ApiStatus } from '@/utils/http/status' |
| | | import { isHttpError } from '@/utils/http/error' |
| | | import { RouteRegistry, MenuProcessor, IframeRouteManager, RoutePermissionValidator } from '../core' |
| | |
| | | } |
| | | async function fetchUserInfo() { |
| | | const userStore = useUserStore() |
| | | const data = await fetchGetUserInfo() |
| | | const data = normalizeUserInfo(await fetchGetUserInfo()) |
| | | userStore.setUserInfo(data) |
| | | userStore.checkAndClearWorktabs() |
| | | } |
| | |
| | | const setLockPassword = (password) => { |
| | | lockPassword.value = password |
| | | } |
| | | const setToken = (newAccessToken, newRefreshToken) => { |
| | | accessToken.value = newAccessToken |
| | | if (newRefreshToken) { |
| | | refreshToken.value = newRefreshToken |
| | | } |
| | | const setToken = (newAccessToken, newRefreshToken = '') => { |
| | | accessToken.value = newAccessToken || '' |
| | | refreshToken.value = newRefreshToken || '' |
| | | } |
| | | const logOut = () => { |
| | | const currentUserId = info.value.userId |
| | |
| | | @keyup.enter="handleSubmit" |
| | | style="margin-top: 25px" |
| | | > |
| | | <ElFormItem prop="account"> |
| | | <ElSelect v-model="formData.account" @change="setupAccount"> |
| | | <ElOption |
| | | v-for="account in accounts" |
| | | :key="account.key" |
| | | :label="account.label" |
| | | :value="account.key" |
| | | > |
| | | <span>{{ account.label }}</span> |
| | | </ElOption> |
| | | </ElSelect> |
| | | </ElFormItem> |
| | | <ElFormItem prop="username"> |
| | | <ElInput |
| | | class="custom-height" |
| | |
| | | show-password |
| | | /> |
| | | </ElFormItem> |
| | | |
| | | <!-- 推拽验证 --> |
| | | <div class="relative pb-5 mt-6"> |
| | | <div |
| | | class="relative z-[2] overflow-hidden select-none rounded-lg border border-transparent tad-300" |
| | | :class="{ '!border-[#FF4E4F]': !isPassing && isClickPass }" |
| | | > |
| | | <ArtDragVerify |
| | | ref="dragVerify" |
| | | v-model:value="isPassing" |
| | | :text="$t('login.sliderText')" |
| | | textColor="var(--art-gray-700)" |
| | | :successText="$t('login.sliderSuccessText')" |
| | | progressBarBg="var(--main-color)" |
| | | :background="isDark ? '#26272F' : '#F1F1F4'" |
| | | handlerBg="var(--default-box-color)" |
| | | /> |
| | | </div> |
| | | <p |
| | | class="absolute top-0 z-[1] px-px mt-2 text-xs text-[#f56c6c] tad-300" |
| | | :class="{ 'translate-y-10': !isPassing && isClickPass }" |
| | | > |
| | | {{ $t('login.placeholder.slider') }} |
| | | </p> |
| | | </div> |
| | | |
| | | <div class="flex-cb mt-2 text-sm"> |
| | | <ElCheckbox v-model="formData.rememberPassword">{{ |
| | |
| | | import AppConfig from '@/config' |
| | | import { useUserStore } from '@/store/modules/user' |
| | | import { useI18n } from 'vue-i18n' |
| | | import { HttpError } from '@/utils/http/error' |
| | | import { fetchLogin } from '@/api/auth' |
| | | import { fetchGetUserInfo, fetchLogin, normalizeLoginResponse } from '@/api/auth' |
| | | import { ElNotification } from 'element-plus' |
| | | import { useSettingStore } from '@/store/modules/setting' |
| | | defineOptions({ name: 'Login' }) |
| | | const settingStore = useSettingStore() |
| | | const { isDark } = storeToRefs(settingStore) |
| | | const { t, locale } = useI18n() |
| | | const formKey = ref(0) |
| | | watch(locale, () => { |
| | | formKey.value++ |
| | | }) |
| | | const accounts = computed(() => [ |
| | | { |
| | | key: 'super', |
| | | label: t('login.roles.super'), |
| | | userName: 'Super', |
| | | password: '123456', |
| | | roles: ['R_SUPER'] |
| | | }, |
| | | { |
| | | key: 'admin', |
| | | label: t('login.roles.admin'), |
| | | userName: 'Admin', |
| | | password: '123456', |
| | | roles: ['R_ADMIN'] |
| | | }, |
| | | { |
| | | key: 'user', |
| | | label: t('login.roles.user'), |
| | | userName: 'User', |
| | | password: '123456', |
| | | roles: ['R_USER'] |
| | | } |
| | | ]) |
| | | const dragVerify = ref() |
| | | const userStore = useUserStore() |
| | | const router = useRouter() |
| | | const route = useRoute() |
| | | const isPassing = ref(false) |
| | | const isClickPass = ref(false) |
| | | const systemName = AppConfig.systemInfo.name |
| | | const formRef = ref() |
| | | const formData = reactive({ |
| | | account: '', |
| | | username: '', |
| | | password: '', |
| | | rememberPassword: true |
| | |
| | | password: [{ required: true, message: t('login.placeholder.password'), trigger: 'blur' }] |
| | | })) |
| | | const loading = ref(false) |
| | | onMounted(() => { |
| | | setupAccount('super') |
| | | }) |
| | | const setupAccount = (key) => { |
| | | const selectedAccount = accounts.value.find((account) => account.key === key) |
| | | formData.account = key |
| | | formData.username = selectedAccount?.userName ?? '' |
| | | formData.password = selectedAccount?.password ?? '' |
| | | } |
| | | const handleSubmit = async () => { |
| | | if (!formRef.value) return |
| | | try { |
| | | const valid = await formRef.value.validate() |
| | | if (!valid) return |
| | | if (!isPassing.value) { |
| | | isClickPass.value = true |
| | | return |
| | | } |
| | | loading.value = true |
| | | const { username, password } = formData |
| | | const { token, refreshToken } = await fetchLogin({ |
| | | userName: username, |
| | | password |
| | | }) |
| | | if (!token) { |
| | | throw new Error('Login failed - no token received') |
| | | } |
| | | userStore.setToken(token, refreshToken) |
| | | const payload = normalizeLoginResponse(await fetchLogin(formData)) |
| | | if (!payload.accessToken) return |
| | | userStore.setToken(payload.accessToken, payload.refreshToken) |
| | | userStore.setLoginStatus(true) |
| | | showLoginSuccessNotice() |
| | | const redirect = route.query.redirect |
| | | router.push(redirect || '/') |
| | | } catch (error) { |
| | | if (!(error instanceof HttpError)) { |
| | | console.error('[Login] Unexpected error:', error) |
| | | const userInfo = await fetchGetUserInfo() |
| | | if (userInfo && Object.keys(userInfo).length > 0) { |
| | | userStore.setUserInfo(userInfo) |
| | | } else if (payload.user && Object.keys(payload.user).length > 0) { |
| | | userStore.setUserInfo(payload.user) |
| | | } |
| | | showLoginSuccessNotice() |
| | | router.push(route.query.redirect || '/') |
| | | } finally { |
| | | loading.value = false |
| | | resetDragVerify() |
| | | } |
| | | } |
| | | const resetDragVerify = () => { |
| | | dragVerify.value.reset() |
| | | } |
| | | const showLoginSuccessNotice = () => { |
| | | setTimeout(() => { |
| | |
| | | |
| | | <style scoped> |
| | | @import './style.css'; |
| | | </style> |
| | | |
| | | <style lang="scss" scoped> |
| | | :deep(.el-select__wrapper) { |
| | | height: 40px !important; |
| | | } |
| | | </style> |
| | |
| | | <!-- 工作台页面 --> |
| | | <template> |
| | | <div class="art-full-height flex flex-col gap-5"> |
| | | <section |
| | | class="overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(135deg,var(--art-main-bg-color),var(--art-card-bg-color))] p-6 shadow-[0_24px_80px_rgba(15,23,42,0.08)]" |
| | | > |
| | | <div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between"> |
| | | <div class="max-w-3xl"> |
| | | <div |
| | | class="mb-3 inline-flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-600" |
| | | > |
| | | <span class="size-2 rounded-full bg-emerald-500"></span> |
| | | RSF Phase 1 Landing |
| | | </div> |
| | | <h1 |
| | | class="m-0 text-3xl font-semibold tracking-tight text-[var(--art-gray-900)] md:text-4xl" |
| | | > |
| | | 运行骨架已经切到 `rsf-design` |
| | | </h1> |
| | | <p class="mt-4 max-w-2xl text-sm leading-7 text-[var(--art-gray-600)] md:text-base"> |
| | | 当前入口已经接入真实后端登录、动态菜单和权限链路。这个首页只展示已经可用的 phase-1 |
| | | 能力,不再保留模板里的示例图表和演示数据。 |
| | | </p> |
| | | </div> |
| | | |
| | | <div class="rounded-2xl border border-white/10 bg-white/70 px-4 py-3 backdrop-blur-sm"> |
| | | <p class="text-xs uppercase tracking-[0.24em] text-[var(--art-gray-500)]" |
| | | >Current Entry</p |
| | | > |
| | | <p class="mt-2 text-lg font-semibold text-[var(--art-gray-900)]"> |
| | | {{ currentUserName }} |
| | | </p> |
| | | <p class="mt-1 text-sm text-[var(--art-gray-600)]"> |
| | | {{ currentUserRoleText }} · {{ currentMenuLabel }} |
| | | </p> |
| | | </div> |
| | | </div> |
| | | </section> |
| | | |
| | | <section class="grid gap-4 md:grid-cols-3"> |
| | | <ArtStatsCard |
| | | title="已接入后端" |
| | | :count="backendSwitchCount" |
| | | description="登录、用户信息、菜单全部来自 rsf-server" |
| | | icon="ri:server-line" |
| | | /> |
| | | <ArtStatsCard |
| | | title="动态菜单" |
| | | :count="visibleMenuCount" |
| | | description="仅发布 phase-1 允许进入的新入口" |
| | | icon="ri:route-line" |
| | | /> |
| | | <ArtStatsCard |
| | | title="权限链路" |
| | | :count="permissionSignalCount" |
| | | description="角色与权限节点已从真实用户数据恢复" |
| | | icon="ri:shield-check-line" |
| | | /> |
| | | </section> |
| | | |
| | | <section class="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]"> |
| | | <ElCard |
| | | class="rounded-3xl border border-white/10 shadow-[0_18px_50px_rgba(15,23,42,0.06)]" |
| | | shadow="never" |
| | | > |
| | | <template #header> |
| | | <div class="flex items-center justify-between"> |
| | | <div> |
| | | <CardList></CardList> |
| | | <h2 class="m-0 text-base font-semibold text-[var(--art-gray-900)]">真实运行状态</h2> |
| | | <p class="mt-1 text-xs text-[var(--art-gray-500)]"> |
| | | 这些信息来自当前登录用户和菜单 store |
| | | </p> |
| | | </div> |
| | | <ElTag type="success" effect="light">Backend mode</ElTag> |
| | | </div> |
| | | </template> |
| | | |
| | | <ElRow :gutter="20"> |
| | | <ElCol :sm="24" :md="12" :lg="10"> |
| | | <ActiveUser /> |
| | | </ElCol> |
| | | <ElCol :sm="24" :md="12" :lg="14"> |
| | | <SalesOverview /> |
| | | </ElCol> |
| | | </ElRow> |
| | | <div class="grid gap-4 md:grid-cols-2"> |
| | | <div class="rounded-2xl bg-[var(--art-gray-50)] p-4"> |
| | | <p class="text-xs uppercase tracking-[0.2em] text-[var(--art-gray-500)]">User</p> |
| | | <p class="mt-3 text-xl font-semibold text-[var(--art-gray-900)]"> |
| | | {{ currentUserName }} |
| | | </p> |
| | | <p class="mt-2 text-sm text-[var(--art-gray-600)]"> |
| | | {{ currentUserRoleText }} |
| | | </p> |
| | | <div class="mt-4 flex flex-wrap gap-2"> |
| | | <ElTag v-for="role in currentRoles" :key="role" type="info" effect="plain"> |
| | | {{ role }} |
| | | </ElTag> |
| | | <ElTag v-if="!currentRoles.length" type="info" effect="plain">No roles</ElTag> |
| | | </div> |
| | | </div> |
| | | |
| | | <ElRow :gutter="20"> |
| | | <ElCol :sm="24" :md="24" :lg="12"> |
| | | <NewUser /> |
| | | </ElCol> |
| | | <ElCol :sm="24" :md="12" :lg="6"> |
| | | <Dynamic /> |
| | | </ElCol> |
| | | <ElCol :sm="24" :md="12" :lg="6"> |
| | | <TodoList /> |
| | | </ElCol> |
| | | </ElRow> |
| | | <div class="rounded-2xl bg-[var(--art-gray-50)] p-4"> |
| | | <p class="text-xs uppercase tracking-[0.2em] text-[var(--art-gray-500)]">Permissions</p> |
| | | <p class="mt-3 text-xl font-semibold text-[var(--art-gray-900)]"> |
| | | {{ currentAuthorities.length }} auth nodes |
| | | </p> |
| | | <p class="mt-2 text-sm text-[var(--art-gray-600)]"> |
| | | 权限节点直接来自当前用户的真实 `authorities` 载荷,不再依赖模板演示态。 |
| | | </p> |
| | | <div class="mt-4 flex flex-wrap gap-2"> |
| | | <ElTag v-for="item in previewAuthorities" :key="item" type="warning" effect="plain"> |
| | | {{ item }} |
| | | </ElTag> |
| | | <ElTag v-if="!previewAuthorities.length" type="warning" effect="plain"> |
| | | No authorities |
| | | </ElTag> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </ElCard> |
| | | |
| | | <AboutProject /> |
| | | <ElCard |
| | | class="rounded-3xl border border-white/10 shadow-[0_18px_50px_rgba(15,23,42,0.06)]" |
| | | shadow="never" |
| | | > |
| | | <template #header> |
| | | <div> |
| | | <h2 class="m-0 text-base font-semibold text-[var(--art-gray-900)]">菜单接入清单</h2> |
| | | <p class="mt-1 text-xs text-[var(--art-gray-500)]"> |
| | | 当前菜单树用于验证 `rsf-design` 是否真正接住后端发布 |
| | | </p> |
| | | </div> |
| | | </template> |
| | | |
| | | <div class="space-y-3"> |
| | | <div |
| | | v-for="item in menuPreview" |
| | | :key="item.key" |
| | | class="flex items-center justify-between rounded-2xl border border-[var(--art-gray-200)] px-4 py-3" |
| | | > |
| | | <div> |
| | | <p class="text-sm font-medium text-[var(--art-gray-900)]">{{ item.title }}</p> |
| | | <p class="mt-1 text-xs text-[var(--art-gray-500)]">{{ item.description }}</p> |
| | | </div> |
| | | <ElTag :type="item.type" effect="light"> |
| | | {{ item.value }} |
| | | </ElTag> |
| | | </div> |
| | | </div> |
| | | </ElCard> |
| | | </section> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import CardList from './modules/card-list.vue' |
| | | import ActiveUser from './modules/active-user.vue' |
| | | |
| | | import SalesOverview from './modules/sales-overview.vue' |
| | | import NewUser from './modules/new-user.vue' |
| | | |
| | | import Dynamic from './modules/dynamic-stats.vue' |
| | | import TodoList from './modules/todo-list.vue' |
| | | |
| | | import AboutProject from './modules/about-project.vue' |
| | | import { storeToRefs } from 'pinia' |
| | | import { useUserStore } from '@/store/modules/user' |
| | | import { useMenuStore } from '@/store/modules/menu' |
| | | import { formatMenuTitle } from '@/utils/router' |
| | | |
| | | defineOptions({ name: 'Console' }) |
| | | |
| | | const userStore = useUserStore() |
| | | const menuStore = useMenuStore() |
| | | const { getUserInfo } = storeToRefs(userStore) |
| | | const { menuList } = storeToRefs(menuStore) |
| | | |
| | | const currentUser = computed(() => getUserInfo.value || {}) |
| | | const currentUserName = computed(() => { |
| | | return ( |
| | | currentUser.value.userName || |
| | | currentUser.value.username || |
| | | currentUser.value.nickname || |
| | | 'RSF User' |
| | | ) |
| | | }) |
| | | const currentRoles = computed(() => { |
| | | const roles = currentUser.value.roles |
| | | if (!Array.isArray(roles)) return [] |
| | | return roles |
| | | .map((role) => { |
| | | if (typeof role === 'string') { |
| | | return role |
| | | } |
| | | return role?.code || role?.name || role?.title || '' |
| | | }) |
| | | .filter(Boolean) |
| | | }) |
| | | const currentAuthorities = computed(() => { |
| | | const authorities = currentUser.value.authorities |
| | | if (!Array.isArray(authorities)) return [] |
| | | return authorities.map((item) => item?.authority || '').filter(Boolean) |
| | | }) |
| | | const currentMenuLabel = computed(() => { |
| | | const firstMenu = menuList.value?.[0] |
| | | return formatMenuTitle(firstMenu?.meta?.title || 'menus.dashboard.console') |
| | | }) |
| | | const currentUserRoleText = computed(() => { |
| | | if (!currentRoles.value.length) { |
| | | return 'No role information yet' |
| | | } |
| | | return `Roles: ${currentRoles.value.join(' / ')}` |
| | | }) |
| | | const backendSwitchCount = computed(() => { |
| | | return currentUserName.value !== 'RSF User' || menuList.value.length > 0 ? 1 : 0 |
| | | }) |
| | | const visibleMenuCount = computed(() => countVisibleMenus(menuList.value)) |
| | | const permissionSignalCount = computed(() => { |
| | | return currentRoles.value.length + currentAuthorities.value.length |
| | | }) |
| | | const previewAuthorities = computed(() => currentAuthorities.value.slice(0, 4)) |
| | | const menuPreview = computed(() => { |
| | | const total = visibleMenuCount.value |
| | | const rootCount = Array.isArray(menuList.value) ? menuList.value.length : 0 |
| | | const firstMenu = menuList.value?.[0] |
| | | const firstChildren = Array.isArray(firstMenu?.children) ? firstMenu.children.length : 0 |
| | | |
| | | return [ |
| | | { |
| | | key: 'entry', |
| | | title: '入口模式', |
| | | description: '当前页面以 backend mode 运行,菜单由服务端驱动', |
| | | value: 'backend', |
| | | type: 'success' |
| | | }, |
| | | { |
| | | key: 'menus', |
| | | title: '可见菜单', |
| | | description: '已通过动态路由适配后进入前端菜单树', |
| | | value: `${total}`, |
| | | type: 'primary' |
| | | }, |
| | | { |
| | | key: 'root', |
| | | title: '一级目录', |
| | | description: '根级菜单节点数量', |
| | | value: `${rootCount}`, |
| | | type: 'info' |
| | | }, |
| | | { |
| | | key: 'children', |
| | | title: '首个目录子项', |
| | | description: '用于快速确认菜单树已被正确展开', |
| | | value: `${firstChildren}`, |
| | | type: 'warning' |
| | | } |
| | | ] |
| | | }) |
| | | |
| | | function countVisibleMenus(items) { |
| | | if (!Array.isArray(items)) return 0 |
| | | return items.reduce((total, item) => { |
| | | const current = item?.meta?.isHide ? 0 : 1 |
| | | return total + current + countVisibleMenus(item?.children) |
| | | }, 0) |
| | | } |
| | | </script> |
| | |
| | | v-show="showSearchBar" |
| | | v-model="searchForm" |
| | | @search="handleSearch" |
| | | @reset="resetSearchParams" |
| | | ></RoleSearch> |
| | | @reset="handleReset" |
| | | /> |
| | | |
| | | <ElCard class="art-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }"> |
| | | <ArtTableHeader |
| | |
| | | > |
| | | <template #left> |
| | | <ElSpace wrap> |
| | | <ElButton @click="showDialog('add')" v-ripple>新增角色</ElButton> |
| | | <ElButton v-auth="'add'" @click="showDialog('add')" v-ripple>新增角色</ElButton> |
| | | <ElButton |
| | | v-auth="'delete'" |
| | | :disabled="selectedRows.length === 0" |
| | | @click="handleBatchDelete" |
| | | v-ripple |
| | | > |
| | | 批量删除 |
| | | </ElButton> |
| | | <span v-auth="'query'" class="inline-flex"> |
| | | <ListExportPrint |
| | | :preview-visible="previewVisible" |
| | | @update:previewVisible="handlePreviewVisibleChange" |
| | | :report-title="reportTitle" |
| | | :selected-rows="selectedRows" |
| | | :query-params="reportQueryParams" |
| | | :columns="roleReportColumns" |
| | | :preview-rows="previewRows" |
| | | :preview-meta="resolvedPreviewMeta" |
| | | :total="pagination.total" |
| | | :disabled="loading" |
| | | @export="handleExport" |
| | | @print="handlePrint" |
| | | /> |
| | | </span> |
| | | </ElSpace> |
| | | </template> |
| | | </ArtTableHeader> |
| | | |
| | | <!-- 表格 --> |
| | | <ArtTable |
| | | :loading="loading" |
| | | :data="data" |
| | | :columns="columns" |
| | | :pagination="pagination" |
| | | @selection-change="handleSelectionChange" |
| | | @pagination:size-change="handleSizeChange" |
| | | @pagination:current-change="handleCurrentChange" |
| | | > |
| | | </ArtTable> |
| | | /> |
| | | </ElCard> |
| | | |
| | | <!-- 角色编辑弹窗 --> |
| | | <RoleEditDialog |
| | | v-model="dialogVisible" |
| | | v-model:visible="dialogVisible" |
| | | :dialog-type="dialogType" |
| | | :role-data="currentRoleData" |
| | | @success="refreshData" |
| | | @submit="handleDialogSubmit" |
| | | /> |
| | | |
| | | <!-- 菜单权限弹窗 --> |
| | | <RolePermissionDialog |
| | | v-model="permissionDialog" |
| | | v-model:visible="permissionDialogVisible" |
| | | :role-data="currentRoleData" |
| | | :scope-type="permissionScopeType" |
| | | @success="refreshData" |
| | | /> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useUserStore } from '@/store/modules/user' |
| | | import { |
| | | fetchExportRoleReport, |
| | | fetchDeleteRole, |
| | | fetchGetRoleMany, |
| | | fetchRolePrintPage, |
| | | fetchRolePage, |
| | | fetchSaveRole, |
| | | fetchUpdateRole |
| | | } from '@/api/system-manage' |
| | | import { useTable } from '@/hooks/core/useTable' |
| | | import ListExportPrint from '@/components/biz/list-export-print/index.vue' |
| | | import RoleSearch from './modules/role-search.vue' |
| | | import RoleEditDialog from './modules/role-edit-dialog.vue' |
| | | |
| | | import RolePermissionDialog from './modules/role-permission-dialog.vue' |
| | | |
| | | import { useTable } from '@/hooks/core/useTable' |
| | | import { fetchGetRoleList } from '@/api/system-manage' |
| | | import ArtButtonMore from '@/components/core/forms/art-button-more/index.vue' |
| | | import { ElTag, ElMessageBox } from 'element-plus' |
| | | import { defaultResponseAdapter } from '@/utils/table/tableUtils' |
| | | import { ElMessage, ElMessageBox, ElTag } from 'element-plus' |
| | | import { |
| | | buildRoleDialogModel, |
| | | buildRolePageQueryParams, |
| | | buildRolePrintRows, |
| | | buildRoleReportMeta, |
| | | buildRoleSavePayload, |
| | | buildRoleSearchParams, |
| | | createRoleSearchState, |
| | | getRoleStatusMeta, |
| | | normalizeRoleListRow, |
| | | ROLE_REPORT_STYLE, |
| | | ROLE_REPORT_TITLE, |
| | | resolveRoleReportColumns |
| | | } from './rolePage.helpers' |
| | | |
| | | defineOptions({ name: 'Role' }) |
| | | const searchForm = ref({ |
| | | roleName: void 0, |
| | | roleCode: void 0, |
| | | description: void 0, |
| | | enabled: void 0, |
| | | daterange: void 0 |
| | | }) |
| | | |
| | | const searchForm = ref(createRoleSearchState()) |
| | | const showSearchBar = ref(false) |
| | | const dialogVisible = ref(false) |
| | | const permissionDialog = ref(false) |
| | | const currentRoleData = ref(void 0) |
| | | const dialogType = ref('add') |
| | | const currentRoleData = ref(buildRoleDialogModel()) |
| | | const permissionDialogVisible = ref(false) |
| | | const permissionScopeType = ref('menu') |
| | | const selectedRows = ref([]) |
| | | const previewVisible = ref(false) |
| | | const previewRows = ref([]) |
| | | const previewMeta = ref({}) |
| | | const previewToken = ref(0) |
| | | const activePrintToken = ref(0) |
| | | const userStore = useUserStore() |
| | | const reportTitle = ROLE_REPORT_TITLE |
| | | const reportQueryParams = computed(() => buildRoleSearchParams(searchForm.value)) |
| | | |
| | | const { |
| | | columns, |
| | | columnChecks, |
| | |
| | | resetSearchParams, |
| | | handleSizeChange, |
| | | handleCurrentChange, |
| | | refreshData |
| | | refreshData, |
| | | refreshCreate, |
| | | refreshUpdate, |
| | | refreshRemove |
| | | } = useTable({ |
| | | // 核心配置 |
| | | core: { |
| | | apiFn: fetchGetRoleList, |
| | | apiParams: { |
| | | current: 1, |
| | | size: 20 |
| | | }, |
| | | // 排除 apiParams 中的属性 |
| | | excludeParams: ['daterange'], |
| | | apiFn: fetchRolePage, |
| | | apiParams: buildRolePageQueryParams(searchForm.value), |
| | | columnsFactory: () => [ |
| | | { type: 'selection', width: 52, fixed: 'left' }, |
| | | { |
| | | prop: 'roleId', |
| | | label: '角色ID', |
| | | width: 100 |
| | | }, |
| | | { |
| | | prop: 'roleName', |
| | | prop: 'name', |
| | | label: '角色名称', |
| | | minWidth: 120 |
| | | }, |
| | | { |
| | | prop: 'roleCode', |
| | | label: '角色编码', |
| | | minWidth: 120 |
| | | }, |
| | | { |
| | | prop: 'description', |
| | | label: '角色描述', |
| | | minWidth: 150, |
| | | minWidth: 140, |
| | | showOverflowTooltip: true |
| | | }, |
| | | { |
| | | prop: 'enabled', |
| | | label: '角色状态', |
| | | width: 100, |
| | | prop: 'code', |
| | | label: '角色编码', |
| | | minWidth: 140, |
| | | showOverflowTooltip: true |
| | | }, |
| | | { |
| | | prop: 'memo', |
| | | label: '备注', |
| | | minWidth: 180, |
| | | showOverflowTooltip: true |
| | | }, |
| | | { |
| | | prop: 'status', |
| | | label: '状态', |
| | | width: 120, |
| | | formatter: (row) => { |
| | | const statusConfig = row.enabled |
| | | ? { type: 'success', text: '启用' } |
| | | : { type: 'warning', text: '禁用' } |
| | | return h(ElTag, { type: statusConfig.type }, () => statusConfig.text) |
| | | const statusMeta = getRoleStatusMeta(row.statusBool ?? row.status) |
| | | return h(ElTag, { type: statusMeta.type, effect: 'light' }, () => statusMeta.text) |
| | | } |
| | | }, |
| | | { |
| | | prop: 'createTime', |
| | | label: '创建日期', |
| | | width: 180, |
| | | sortable: true |
| | | prop: 'updateTimeText', |
| | | label: '更新时间', |
| | | minWidth: 180, |
| | | sortable: true, |
| | | formatter: (row) => row.updateTimeText || '-' |
| | | }, |
| | | { |
| | | prop: 'createTimeText', |
| | | label: '创建时间', |
| | | minWidth: 180, |
| | | sortable: true, |
| | | formatter: (row) => row.createTimeText || '-' |
| | | }, |
| | | { |
| | | prop: 'operation', |
| | | label: '操作', |
| | | width: 80, |
| | | width: 120, |
| | | fixed: 'right', |
| | | formatter: (row) => |
| | | h('div', [ |
| | | h(ArtButtonMore, { |
| | | list: [ |
| | | { |
| | | key: 'permission', |
| | | label: '菜单权限', |
| | | icon: 'ri:user-3-line' |
| | | key: 'scope-menu', |
| | | label: '网页权限', |
| | | icon: 'ri:layout-2-line', |
| | | auth: 'edit' |
| | | }, |
| | | { |
| | | key: 'scope-pda', |
| | | label: 'PDA权限', |
| | | icon: 'ri:smartphone-line', |
| | | auth: 'edit' |
| | | }, |
| | | { |
| | | key: 'scope-matnr', |
| | | label: '物料权限', |
| | | icon: 'ri:archive-line', |
| | | auth: 'edit' |
| | | }, |
| | | { |
| | | key: 'scope-warehouse', |
| | | label: '仓库权限', |
| | | icon: 'ri:store-2-line', |
| | | auth: 'edit' |
| | | }, |
| | | { |
| | | key: 'edit', |
| | | label: '编辑角色', |
| | | icon: 'ri:edit-2-line' |
| | | icon: 'ri:edit-2-line', |
| | | auth: 'edit' |
| | | }, |
| | | { |
| | | key: 'delete', |
| | | label: '删除角色', |
| | | icon: 'ri:delete-bin-4-line', |
| | | color: '#f56c6c' |
| | | color: '#f56c6c', |
| | | auth: 'delete' |
| | | } |
| | | ], |
| | | onClick: (item) => buttonMoreClick(item, row) |
| | | onClick: (item) => handleActionClick(item, row) |
| | | }) |
| | | ]) |
| | | } |
| | | ] |
| | | }, |
| | | transform: { |
| | | dataTransformer: (records) => { |
| | | if (!Array.isArray(records)) { |
| | | return [] |
| | | } |
| | | return records.map((item) => normalizeRoleListRow(item)) |
| | | } |
| | | } |
| | | }) |
| | | const dialogType = ref('add') |
| | | const showDialog = (type, row) => { |
| | | dialogVisible.value = true |
| | | dialogType.value = type |
| | | currentRoleData.value = row |
| | | } |
| | | |
| | | const roleReportColumns = computed(() => resolveRoleReportColumns(columns.value)) |
| | | const resolvedPreviewMeta = computed(() => |
| | | buildRoleReportMeta({ |
| | | previewMeta: previewMeta.value, |
| | | count: previewRows.value.length, |
| | | titleAlign: ROLE_REPORT_STYLE.titleAlign, |
| | | titleLevel: ROLE_REPORT_STYLE.titleLevel |
| | | }) |
| | | ) |
| | | |
| | | const handleSearch = (params) => { |
| | | const { daterange, ...filtersParams } = params |
| | | const [startTime, endTime] = Array.isArray(daterange) ? daterange : [null, null] |
| | | replaceSearchParams({ ...filtersParams, startTime, endTime }) |
| | | replaceSearchParams(buildRoleSearchParams(params)) |
| | | getData() |
| | | } |
| | | const buttonMoreClick = (item, row) => { |
| | | |
| | | const handleReset = () => { |
| | | Object.assign(searchForm.value, createRoleSearchState()) |
| | | resetSearchParams() |
| | | } |
| | | |
| | | const handleSelectionChange = (rows) => { |
| | | selectedRows.value = Array.isArray(rows) ? rows : [] |
| | | } |
| | | |
| | | const handlePreviewVisibleChange = (visible) => { |
| | | previewVisible.value = Boolean(visible) |
| | | if (!visible) { |
| | | activePrintToken.value = 0 |
| | | } |
| | | } |
| | | |
| | | const showDialog = (type, row) => { |
| | | dialogType.value = type |
| | | currentRoleData.value = type === 'edit' ? buildRoleDialogModel(row) : buildRoleDialogModel() |
| | | dialogVisible.value = true |
| | | } |
| | | |
| | | const handleActionClick = (item, row) => { |
| | | switch (item.key) { |
| | | case 'permission': |
| | | showPermissionDialog(row) |
| | | case 'scope-menu': |
| | | openScopeDialog('menu', row) |
| | | break |
| | | case 'scope-pda': |
| | | openScopeDialog('pda', row) |
| | | break |
| | | case 'scope-matnr': |
| | | openScopeDialog('matnr', row) |
| | | break |
| | | case 'scope-warehouse': |
| | | openScopeDialog('warehouse', row) |
| | | break |
| | | case 'edit': |
| | | showDialog('edit', row) |
| | | break |
| | | case 'delete': |
| | | deleteRole(row) |
| | | handleDelete(row) |
| | | break |
| | | default: |
| | | break |
| | | } |
| | | } |
| | | const showPermissionDialog = (row) => { |
| | | permissionDialog.value = true |
| | | currentRoleData.value = row |
| | | |
| | | const openScopeDialog = (scopeType, row) => { |
| | | permissionScopeType.value = scopeType |
| | | currentRoleData.value = buildRoleDialogModel(row) |
| | | permissionDialogVisible.value = true |
| | | } |
| | | const deleteRole = (row) => { |
| | | ElMessageBox.confirm(`确定删除角色"${row.roleName}"吗?此操作不可恢复!`, '删除确认', { |
| | | |
| | | const handleDialogSubmit = async (formData) => { |
| | | const payload = buildRoleSavePayload(formData) |
| | | try { |
| | | if (dialogType.value === 'edit') { |
| | | await fetchUpdateRole(payload) |
| | | ElMessage.success('修改成功') |
| | | dialogVisible.value = false |
| | | currentRoleData.value = buildRoleDialogModel() |
| | | await refreshUpdate() |
| | | return |
| | | } |
| | | await fetchSaveRole(payload) |
| | | ElMessage.success('新增成功') |
| | | dialogVisible.value = false |
| | | currentRoleData.value = buildRoleDialogModel() |
| | | await refreshCreate() |
| | | } catch (error) { |
| | | ElMessage.error(error?.message || '提交失败') |
| | | } |
| | | } |
| | | |
| | | const handleDelete = async (row) => { |
| | | try { |
| | | await ElMessageBox.confirm(`确定要删除角色「${row.name || row.code || row.id}」吗?`, '删除确认', { |
| | | confirmButtonText: '确定', |
| | | cancelButtonText: '取消', |
| | | type: 'warning' |
| | | }) |
| | | .then(() => { |
| | | await fetchDeleteRole(row.id) |
| | | ElMessage.success('删除成功') |
| | | refreshData() |
| | | await refreshRemove() |
| | | } catch (error) { |
| | | if (error !== 'cancel') { |
| | | ElMessage.error(error?.message || '删除失败') |
| | | } |
| | | } |
| | | } |
| | | |
| | | const handleBatchDelete = async () => { |
| | | if (!selectedRows.value.length) return |
| | | const ids = selectedRows.value.map((item) => item.id).filter((id) => id !== void 0 && id !== null) |
| | | if (!ids.length) return |
| | | |
| | | try { |
| | | await ElMessageBox.confirm(`确定要批量删除选中的 ${ids.length} 个角色吗?`, '批量删除确认', { |
| | | confirmButtonText: '确定', |
| | | cancelButtonText: '取消', |
| | | type: 'warning' |
| | | }) |
| | | .catch(() => { |
| | | ElMessage.info('已取消删除') |
| | | await fetchDeleteRole(ids.join(',')) |
| | | ElMessage.success('批量删除成功') |
| | | selectedRows.value = [] |
| | | await refreshRemove() |
| | | } catch (error) { |
| | | if (error !== 'cancel') { |
| | | ElMessage.error(error?.message || '批量删除失败') |
| | | } |
| | | } |
| | | } |
| | | |
| | | const handleExport = async (payload) => { |
| | | try { |
| | | const response = await fetchExportRoleReport(payload, { |
| | | headers: { |
| | | Authorization: userStore.accessToken || '' |
| | | } |
| | | }) |
| | | if (!response.ok) { |
| | | throw new Error(`导出失败 (${response.status})`) |
| | | } |
| | | const blob = await response.blob() |
| | | const downloadUrl = window.URL.createObjectURL(blob) |
| | | const link = document.createElement('a') |
| | | link.href = downloadUrl |
| | | link.download = 'role.xlsx' |
| | | document.body.appendChild(link) |
| | | link.click() |
| | | link.remove() |
| | | window.URL.revokeObjectURL(downloadUrl) |
| | | ElMessage.success('导出成功') |
| | | } catch (error) { |
| | | ElMessage.error(error?.message || '导出失败') |
| | | } |
| | | } |
| | | |
| | | const handlePrint = async (payload) => { |
| | | const token = previewToken.value + 1 |
| | | previewToken.value = token |
| | | activePrintToken.value = token |
| | | previewVisible.value = false |
| | | previewRows.value = [] |
| | | previewMeta.value = {} |
| | | |
| | | try { |
| | | const response = Array.isArray(payload?.ids) && payload.ids.length > 0 |
| | | ? await fetchGetRoleMany(payload.ids) |
| | | : await fetchRolePrintPage({ |
| | | ...reportQueryParams.value, |
| | | current: 1, |
| | | pageSize: Number(pagination.total) > 0 ? Number(pagination.total) : Number(payload?.pageSize) || 20 |
| | | }) |
| | | if (activePrintToken.value !== token) { |
| | | return |
| | | } |
| | | const records = defaultResponseAdapter(response).records |
| | | if (activePrintToken.value !== token) { |
| | | return |
| | | } |
| | | |
| | | const rows = buildRolePrintRows(records) |
| | | const now = new Date() |
| | | previewRows.value = rows |
| | | previewMeta.value = { |
| | | reportTitle, |
| | | reportDate: now.toLocaleDateString('zh-CN'), |
| | | printedAt: now.toLocaleString('zh-CN', { hour12: false }), |
| | | operator: userStore.getUserInfo?.name || userStore.getUserInfo?.username || '', |
| | | count: rows.length |
| | | } |
| | | handlePreviewVisibleChange(true) |
| | | } catch (error) { |
| | | if (activePrintToken.value !== token) { |
| | | return |
| | | } |
| | | ElMessage.error(error?.message || '打印失败') |
| | | } |
| | | } |
| | | </script> |
| | |
| | | ref="searchBarRef" |
| | | v-model="formData" |
| | | :items="formItems" |
| | | :rules="rules" |
| | | :showExpand="false" |
| | | @reset="handleReset" |
| | | @search="handleSearch" |
| | | > |
| | | </ArtSearchBar> |
| | | /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { createRoleSearchState } from '../rolePage.helpers' |
| | | |
| | | const props = defineProps({ |
| | | modelValue: { required: true } |
| | | }) |
| | | |
| | | const emit = defineEmits(['update:modelValue', 'search', 'reset']) |
| | | const searchBarRef = ref() |
| | | |
| | | const formData = computed({ |
| | | get: () => props.modelValue, |
| | | set: (val) => emit('update:modelValue', val) |
| | | }) |
| | | const rules = {} |
| | | const statusOptions = ref([ |
| | | { label: '启用', value: true }, |
| | | { label: '禁用', value: false } |
| | | ]) |
| | | |
| | | const formItems = computed(() => [ |
| | | { |
| | | label: '角色名称', |
| | | key: 'roleName', |
| | | key: 'name', |
| | | type: 'input', |
| | | placeholder: '请输入角色名称', |
| | | clearable: true |
| | | }, |
| | | { |
| | | label: '角色编码', |
| | | key: 'roleCode', |
| | | type: 'input', |
| | | placeholder: '请输入角色编码', |
| | | clearable: true |
| | | }, |
| | | { |
| | | label: '角色描述', |
| | | key: 'description', |
| | | type: 'input', |
| | | placeholder: '请输入角色描述', |
| | | clearable: true |
| | | }, |
| | | { |
| | | label: '角色状态', |
| | | key: 'enabled', |
| | | type: 'select', |
| | | props: { |
| | | placeholder: '请选择状态', |
| | | options: statusOptions.value, |
| | | placeholder: '请输入角色名称', |
| | | clearable: true |
| | | } |
| | | }, |
| | | { |
| | | label: '创建日期', |
| | | key: 'daterange', |
| | | type: 'datetime', |
| | | label: '角色编码', |
| | | key: 'code', |
| | | type: 'input', |
| | | props: { |
| | | style: { width: '100%' }, |
| | | placeholder: '请选择日期范围', |
| | | type: 'daterange', |
| | | rangeSeparator: '至', |
| | | startPlaceholder: '开始日期', |
| | | endPlaceholder: '结束日期', |
| | | valueFormat: 'YYYY-MM-DD', |
| | | shortcuts: [ |
| | | { text: '今日', value: [/* @__PURE__ */ new Date(), /* @__PURE__ */ new Date()] }, |
| | | { text: '最近一周', value: [new Date(Date.now() - 6048e5), /* @__PURE__ */ new Date()] }, |
| | | { text: '最近一个月', value: [new Date(Date.now() - 2592e6), /* @__PURE__ */ new Date()] } |
| | | placeholder: '请输入角色编码', |
| | | clearable: true |
| | | } |
| | | }, |
| | | { |
| | | label: '备注', |
| | | key: 'memo', |
| | | type: 'input', |
| | | props: { |
| | | placeholder: '请输入备注', |
| | | clearable: true |
| | | } |
| | | }, |
| | | { |
| | | label: '关键字', |
| | | key: 'condition', |
| | | type: 'input', |
| | | props: { |
| | | placeholder: '输入关键字搜索', |
| | | clearable: true |
| | | } |
| | | }, |
| | | { |
| | | label: '状态', |
| | | key: 'status', |
| | | type: 'select', |
| | | props: { |
| | | placeholder: '请选择状态', |
| | | clearable: true, |
| | | options: [ |
| | | { label: '正常', value: 1 }, |
| | | { label: '禁用', value: 0 } |
| | | ] |
| | | } |
| | | } |
| | | ]) |
| | | const handleReset = () => { |
| | | |
| | | function handleReset() { |
| | | emit('update:modelValue', createRoleSearchState()) |
| | | emit('reset') |
| | | } |
| | | const handleSearch = async (params) => { |
| | | |
| | | async function handleSearch(params) { |
| | | await searchBarRef.value.validate() |
| | | emit('search', params) |
| | | } |
| New file |
| | |
| | | export function createRoleSearchState() { |
| | | return { |
| | | name: '', |
| | | code: '', |
| | | memo: '', |
| | | status: void 0, |
| | | condition: '' |
| | | } |
| | | } |
| | | |
| | | export function createRoleFormState() { |
| | | return { |
| | | id: void 0, |
| | | name: '', |
| | | code: '', |
| | | memo: '', |
| | | status: 1 |
| | | } |
| | | } |
| | | |
| | | export function buildRoleSearchParams(params = {}) { |
| | | const searchParams = { |
| | | name: params.name, |
| | | code: params.code, |
| | | memo: params.memo, |
| | | status: params.status, |
| | | condition: params.condition |
| | | } |
| | | |
| | | return Object.fromEntries( |
| | | Object.entries(searchParams).filter(([, value]) => value !== '' && value !== void 0 && value !== null) |
| | | ) |
| | | } |
| | | |
| | | export function buildRolePageQueryParams(params = {}) { |
| | | const { current, size, pageSize, ...filters } = params |
| | | return { |
| | | current: current || 1, |
| | | pageSize: pageSize || size || 20, |
| | | ...buildRoleSearchParams(filters) |
| | | } |
| | | } |
| | | |
| | | const ROLE_REPORT_COLUMNS = [ |
| | | { source: 'name', label: '角色名称' }, |
| | | { source: 'code', label: '角色编码' }, |
| | | { source: 'statusText', label: '状态' }, |
| | | { source: 'memo', label: '备注' }, |
| | | { source: 'createTimeText', label: '创建时间' }, |
| | | { source: 'updateTimeText', label: '更新时间' } |
| | | ] |
| | | |
| | | const ROLE_REPORT_SOURCE_ALIAS = { |
| | | status: 'statusText' |
| | | } |
| | | |
| | | export const ROLE_REPORT_TITLE = '角色管理报表' |
| | | |
| | | export const ROLE_REPORT_STYLE = { |
| | | titleAlign: 'center', |
| | | titleLevel: 'strong' |
| | | } |
| | | |
| | | export function getRoleReportColumns() { |
| | | return ROLE_REPORT_COLUMNS.map((column) => ({ ...column })) |
| | | } |
| | | |
| | | export function resolveRoleReportColumns(columns = []) { |
| | | if (!Array.isArray(columns)) { |
| | | return [] |
| | | } |
| | | |
| | | const allowedColumns = new Map(ROLE_REPORT_COLUMNS.map((column) => [column.source, column])) |
| | | const seenSources = new Set() |
| | | |
| | | return columns |
| | | .map((column) => { |
| | | if (!column || typeof column !== 'object') { |
| | | return null |
| | | } |
| | | |
| | | const source = ROLE_REPORT_SOURCE_ALIAS[column.source ?? column.prop] ?? column.source ?? column.prop |
| | | if (!source || !allowedColumns.has(source) || seenSources.has(source)) { |
| | | return null |
| | | } |
| | | |
| | | seenSources.add(source) |
| | | const allowedColumn = allowedColumns.get(source) |
| | | return { |
| | | source, |
| | | label: column.label || allowedColumn.label |
| | | } |
| | | }) |
| | | .filter(Boolean) |
| | | } |
| | | |
| | | export function buildRolePrintRows(records = []) { |
| | | if (!Array.isArray(records)) { |
| | | return [] |
| | | } |
| | | |
| | | return records.map((record) => normalizeRoleListRow(record)) |
| | | } |
| | | |
| | | export function buildRoleReportMeta({ |
| | | previewMeta = {}, |
| | | count = 0, |
| | | titleAlign = ROLE_REPORT_STYLE.titleAlign, |
| | | titleLevel = ROLE_REPORT_STYLE.titleLevel |
| | | } = {}) { |
| | | return { |
| | | reportTitle: ROLE_REPORT_TITLE, |
| | | reportDate: previewMeta.reportDate, |
| | | printedAt: previewMeta.printedAt, |
| | | operator: previewMeta.operator, |
| | | count, |
| | | reportStyle: { |
| | | titleAlign, |
| | | titleLevel, |
| | | orientation: 'portrait', |
| | | density: 'compact', |
| | | showSequence: true |
| | | } |
| | | } |
| | | } |
| | | |
| | | export function buildRoleDialogModel(record = {}) { |
| | | return { |
| | | ...createRoleFormState(), |
| | | id: normalizeRoleId(record.id), |
| | | name: record.name || '', |
| | | code: record.code || '', |
| | | memo: record.memo || '', |
| | | status: record.status !== void 0 && record.status !== null ? record.status : 1 |
| | | } |
| | | } |
| | | |
| | | export function buildRoleSavePayload(form = {}) { |
| | | return { |
| | | id: normalizeRoleId(form.id), |
| | | name: form.name || '', |
| | | code: form.code || '', |
| | | memo: form.memo || '', |
| | | status: form.status !== void 0 && form.status !== null ? form.status : 1 |
| | | } |
| | | } |
| | | |
| | | export function normalizeRoleListRow(record = {}) { |
| | | const statusMeta = getRoleStatusMeta(record.statusBool ?? record.status) |
| | | return { |
| | | ...record, |
| | | statusBool: record.statusBool !== void 0 ? Boolean(record.statusBool) : statusMeta.bool, |
| | | statusText: statusMeta.text, |
| | | statusType: statusMeta.type, |
| | | createTimeText: record.createTime$ || record.createTime || '', |
| | | updateTimeText: record.updateTime$ || record.updateTime || '' |
| | | } |
| | | } |
| | | |
| | | export function getRoleStatusMeta(status) { |
| | | if (status === true || status === 1) { |
| | | return { type: 'success', text: '正常', bool: true } |
| | | } |
| | | if (status === false || status === 0) { |
| | | return { type: 'danger', text: '禁用', bool: false } |
| | | } |
| | | return { type: 'info', text: '未知', bool: false } |
| | | } |
| | | |
| | | export function getRoleScopeConfig(scopeType) { |
| | | const configMap = { |
| | | menu: { |
| | | scopeType: 'menu', |
| | | title: '网页权限', |
| | | listUrl: '/role/scope/list', |
| | | treeUrl: '/menu/tree' |
| | | }, |
| | | pda: { |
| | | scopeType: 'pda', |
| | | title: 'PDA权限', |
| | | listUrl: '/rolePda/scope/list', |
| | | treeUrl: '/menuPda/tree' |
| | | }, |
| | | matnr: { |
| | | scopeType: 'matnr', |
| | | title: '物料权限', |
| | | listUrl: '/roleMatnr/scope/list', |
| | | treeUrl: '/menuMatnrGroup/tree' |
| | | }, |
| | | warehouse: { |
| | | scopeType: 'warehouse', |
| | | title: '仓库权限', |
| | | listUrl: '/roleWarehouse/scope/list', |
| | | treeUrl: '/menuWarehouse/tree' |
| | | } |
| | | } |
| | | |
| | | const config = configMap[scopeType] |
| | | if (!config) { |
| | | throw new Error(`Unsupported scope type: ${scopeType}`) |
| | | } |
| | | return config |
| | | } |
| | | |
| | | export function buildRoleScopeSubmitPayload(roleId, checkedKeys = [], halfCheckedKeys = []) { |
| | | return { |
| | | id: normalizeRoleId(roleId), |
| | | menuIds: { |
| | | checked: normalizeScopeKeys(checkedKeys), |
| | | halfChecked: normalizeScopeKeys(halfCheckedKeys) |
| | | } |
| | | } |
| | | } |
| | | |
| | | export function normalizeRoleScopeTreeData(scopeType, treeData = []) { |
| | | if (!Array.isArray(treeData)) { |
| | | return [] |
| | | } |
| | | |
| | | return treeData |
| | | .map((node) => normalizeRoleScopeNode(scopeType, node)) |
| | | .filter(Boolean) |
| | | } |
| | | |
| | | function normalizeRoleScopeNode(scopeType, node) { |
| | | if (!node || typeof node !== 'object') { |
| | | return null |
| | | } |
| | | |
| | | if (scopeType === 'menu' && node.type === 1) { |
| | | return buildScopeAuthNode(node) |
| | | } |
| | | |
| | | const children = Array.isArray(node.children) |
| | | ? normalizeRoleScopeTreeData(scopeType, node.children) |
| | | : [] |
| | | const metaSource = node.meta && typeof node.meta === 'object' ? node.meta : node |
| | | const authNodes = scopeType === 'menu' && Array.isArray(metaSource.authList) && metaSource.authList.length |
| | | ? metaSource.authList.map((auth, index) => ({ |
| | | id: normalizeScopeKey(auth.id ?? auth.authMark ?? `${node.id || 'auth'}-${index}`), |
| | | label: normalizeScopeTitle(auth.title || auth.name || auth.authMark || ''), |
| | | type: 1, |
| | | isAuthButton: true, |
| | | authMark: auth.authMark || auth.authority || auth.code || '', |
| | | children: [] |
| | | })) |
| | | : [] |
| | | |
| | | const mergedChildren = |
| | | authNodes.length > 0 && !children.some((child) => child.isAuthButton) |
| | | ? [...children, ...authNodes] |
| | | : children |
| | | |
| | | return { |
| | | id: normalizeScopeKey(node.id ?? node.value), |
| | | label: normalizeScopeTitle( |
| | | node.label || node.title || node.name || metaSource.title || node.code || '' |
| | | ), |
| | | type: node.type, |
| | | path: node.path || '', |
| | | component: node.component || '', |
| | | isAuthButton: Boolean(node.isAuthButton), |
| | | authMark: node.authMark || metaSource.authMark || metaSource.authority || metaSource.code || '', |
| | | meta: metaSource, |
| | | children: mergedChildren |
| | | } |
| | | } |
| | | |
| | | function buildScopeAuthNode(node) { |
| | | const metaSource = node.meta && typeof node.meta === 'object' ? node.meta : node |
| | | return { |
| | | id: normalizeScopeKey(node.id ?? node.value), |
| | | label: normalizeScopeTitle(node.label || node.title || node.name || metaSource.title || ''), |
| | | type: 1, |
| | | isAuthButton: true, |
| | | authMark: node.authMark || metaSource.authMark || metaSource.authority || metaSource.code || '', |
| | | children: [] |
| | | } |
| | | } |
| | | |
| | | function normalizeScopeKeys(keys = []) { |
| | | if (!Array.isArray(keys)) { |
| | | return [] |
| | | } |
| | | |
| | | return Array.from( |
| | | new Set( |
| | | keys |
| | | .map((key) => normalizeRoleId(key)) |
| | | .filter((key) => key !== void 0) |
| | | ) |
| | | ) |
| | | } |
| | | |
| | | function normalizeScopeKey(value) { |
| | | const normalized = normalizeRoleId(value) |
| | | return normalized === void 0 ? '' : String(normalized) |
| | | } |
| | | |
| | | function normalizeScopeTitle(title) { |
| | | if (typeof title !== 'string') { |
| | | return '' |
| | | } |
| | | const trimmedTitle = title.trim() |
| | | if (trimmedTitle.startsWith('menu.')) { |
| | | return `menus.${trimmedTitle.slice('menu.'.length)}` |
| | | } |
| | | return trimmedTitle |
| | | } |
| | | |
| | | function normalizeRoleId(value) { |
| | | if (value === '' || value === null || value === void 0) { |
| | | return void 0 |
| | | } |
| | | const numeric = Number(value) |
| | | if (Number.isNaN(numeric)) { |
| | | return value |
| | | } |
| | | return numeric |
| | | } |
| | |
| | | <!-- 用户管理页面 --> |
| | | <!-- art-full-height 自动计算出页面剩余高度 --> |
| | | <!-- art-table-card 一个符合系统样式的 class,同时自动撑满剩余高度 --> |
| | | <!-- 更多 useTable 使用示例请移步至 功能示例 下面的高级表格示例或者查看官方文档 --> |
| | | <!-- useTable 文档:https://www.artd.pro/docs/zh/guide/hooks/use-table.html --> |
| | | <template> |
| | | <div class="user-page art-full-height"> |
| | | <!-- 搜索栏 --> |
| | | <UserSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams"></UserSearch> |
| | | <UserSearch |
| | | v-model="searchForm" |
| | | :dept-tree-options="deptTreeOptions" |
| | | :role-options="roleOptions" |
| | | @search="handleSearch" |
| | | @reset="handleReset" |
| | | /> |
| | | |
| | | <ElCard class="art-table-card"> |
| | | <!-- 表格头部 --> |
| | | <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData"> |
| | | <template #left> |
| | | <ElSpace wrap> |
| | | <ElButton @click="showDialog('add')" v-ripple>新增用户</ElButton> |
| | | <ElButton v-auth="'add'" @click="showDialog('add')" v-ripple>新增用户</ElButton> |
| | | </ElSpace> |
| | | </template> |
| | | </ArtTableHeader> |
| | | |
| | | <!-- 表格 --> |
| | | <ArtTable |
| | | :loading="loading" |
| | | :data="data" |
| | | :columns="columns" |
| | | :pagination="pagination" |
| | | @selection-change="handleSelectionChange" |
| | | @pagination:size-change="handleSizeChange" |
| | | @pagination:current-change="handleCurrentChange" |
| | | > |
| | | </ArtTable> |
| | | /> |
| | | |
| | | <!-- 用户弹窗 --> |
| | | <UserDialog |
| | | v-model:visible="dialogVisible" |
| | | :type="dialogType" |
| | | :user-data="currentUserData" |
| | | :role-options="roleOptions" |
| | | :dept-tree-options="deptTreeOptions" |
| | | @submit="handleDialogSubmit" |
| | | /> |
| | | |
| | | <UserDetailDrawer |
| | | v-model:visible="detailDrawerVisible" |
| | | :loading="detailLoading" |
| | | :user-data="detailUserData" |
| | | /> |
| | | </ElCard> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import request from '@/utils/http' |
| | | import { |
| | | fetchDeleteUser, |
| | | fetchGetDeptTree, |
| | | fetchGetRoleOptions, |
| | | fetchGetUserDetail, |
| | | fetchResetUserPassword, |
| | | fetchSaveUser, |
| | | fetchUpdateUser, |
| | | fetchUpdateUserStatus |
| | | } from '@/api/system-manage' |
| | | import { useTable } from '@/hooks/core/useTable' |
| | | import { useAuth } from '@/hooks/core/useAuth' |
| | | import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue' |
| | | import { ElMessage, ElMessageBox, ElSwitch, ElTag } from 'element-plus' |
| | | import UserSearch from './modules/user-search.vue' |
| | | import UserDialog from './modules/user-dialog.vue' |
| | | import UserDetailDrawer from './modules/user-detail-drawer.vue' |
| | | import { |
| | | buildUserDialogModel, |
| | | buildUserPageQueryParams, |
| | | buildUserSavePayload, |
| | | buildUserSearchParams, |
| | | createUserSearchState, |
| | | getUserStatusMeta, |
| | | mergeUserDetailRecord, |
| | | normalizeDeptTreeOptions, |
| | | normalizeRoleOptions, |
| | | normalizeUserListRow, |
| | | formatUserRoleNames |
| | | } from './userPage.helpers' |
| | | |
| | | import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue' |
| | | import { ACCOUNT_TABLE_DATA } from '@/mock/temp/formData' |
| | | import { useTable } from '@/hooks/core/useTable' |
| | | import { fetchGetUserList } from '@/api/system-manage' |
| | | import { ElTag, ElMessageBox, ElImage } from 'element-plus' |
| | | defineOptions({ name: 'User' }) |
| | | |
| | | const searchForm = ref(createUserSearchState()) |
| | | const dialogType = ref('add') |
| | | const dialogVisible = ref(false) |
| | | const currentUserData = ref({}) |
| | | const selectedRows = ref([]) |
| | | const searchForm = ref({ |
| | | userName: void 0, |
| | | userGender: void 0, |
| | | userPhone: void 0, |
| | | userEmail: void 0, |
| | | status: '1' |
| | | const currentUserData = ref(buildUserDialogModel()) |
| | | const detailDrawerVisible = ref(false) |
| | | const detailLoading = ref(false) |
| | | const detailUserData = ref({}) |
| | | const roleOptions = ref([]) |
| | | const deptTreeOptions = ref([]) |
| | | const RESET_PASSWORD = '123456' |
| | | const { hasAuth } = useAuth() |
| | | |
| | | const fetchUserPage = (params = {}) => { |
| | | return request.post({ |
| | | url: '/user/page', |
| | | params: buildUserPageQueryParams(params) |
| | | }) |
| | | const USER_STATUS_CONFIG = { |
| | | 1: { type: 'success', text: '在线' }, |
| | | 2: { type: 'info', text: '离线' }, |
| | | 3: { type: 'warning', text: '异常' }, |
| | | 4: { type: 'danger', text: '注销' } |
| | | } |
| | | const getUserStatusConfig = (status) => { |
| | | return ( |
| | | USER_STATUS_CONFIG[status] || { |
| | | type: 'info', |
| | | text: '未知' |
| | | } |
| | | ) |
| | | } |
| | | |
| | | const { |
| | | columns, |
| | | columnChecks, |
| | |
| | | resetSearchParams, |
| | | handleSizeChange, |
| | | handleCurrentChange, |
| | | refreshData |
| | | refreshData, |
| | | refreshCreate, |
| | | refreshUpdate, |
| | | refreshRemove |
| | | } = useTable({ |
| | | // 核心配置 |
| | | core: { |
| | | apiFn: fetchGetUserList, |
| | | apiParams: { |
| | | current: 1, |
| | | size: 20, |
| | | ...searchForm.value |
| | | }, |
| | | // 自定义分页字段映射,未设置时将使用全局配置 tableConfig.ts 中的 paginationKey |
| | | // paginationKey: { |
| | | // current: 'pageNum', |
| | | // size: 'pageSize' |
| | | // }, |
| | | apiFn: fetchUserPage, |
| | | apiParams: buildUserPageQueryParams(searchForm.value), |
| | | columnsFactory: () => [ |
| | | { type: 'selection' }, |
| | | // 勾选列 |
| | | { type: 'index', width: 60, label: '序号' }, |
| | | // 序号 |
| | | { |
| | | prop: 'userInfo', |
| | | prop: 'username', |
| | | label: '用户名', |
| | | width: 280, |
| | | // visible: false, // 默认是否显示列 |
| | | formatter: (row) => { |
| | | return h('div', { class: 'user flex-c' }, [ |
| | | h(ElImage, { |
| | | class: 'size-9.5 rounded-md', |
| | | src: row.avatar, |
| | | previewSrcList: [row.avatar], |
| | | // 图片预览是否插入至 body 元素上,用于解决表格内部图片预览样式异常 |
| | | previewTeleported: true |
| | | }), |
| | | h('div', { class: 'ml-2' }, [ |
| | | h('p', { class: 'user-name' }, row.userName), |
| | | h('p', { class: 'email' }, row.userEmail) |
| | | ]) |
| | | ]) |
| | | } |
| | | minWidth: 140, |
| | | showOverflowTooltip: true |
| | | }, |
| | | { |
| | | prop: 'userGender', |
| | | label: '性别', |
| | | sortable: true, |
| | | formatter: (row) => row.userGender |
| | | prop: 'nickname', |
| | | label: '昵称', |
| | | minWidth: 120, |
| | | showOverflowTooltip: true |
| | | }, |
| | | { prop: 'userPhone', label: '手机号' }, |
| | | { |
| | | prop: 'deptLabel', |
| | | label: '部门', |
| | | minWidth: 140, |
| | | showOverflowTooltip: true |
| | | }, |
| | | { |
| | | prop: 'phone', |
| | | label: '手机号', |
| | | minWidth: 130 |
| | | }, |
| | | { |
| | | prop: 'email', |
| | | label: '邮箱', |
| | | minWidth: 180, |
| | | showOverflowTooltip: true |
| | | }, |
| | | { |
| | | prop: 'roleNames', |
| | | label: '角色', |
| | | minWidth: 180, |
| | | showOverflowTooltip: true, |
| | | formatter: (row) => formatUserRoleNames(row.roles) || row.roleNames || '-' |
| | | }, |
| | | { |
| | | prop: 'status', |
| | | label: '状态', |
| | | width: 180, |
| | | formatter: (row) => { |
| | | const statusConfig = getUserStatusConfig(row.status) |
| | | return h(ElTag, { type: statusConfig.type }, () => statusConfig.text) |
| | | const statusMeta = getUserStatusMeta(row.statusBool ?? row.status) |
| | | if (!hasAuth('edit')) { |
| | | return h(ElTag, { type: statusMeta.type, effect: 'light' }, () => statusMeta.text) |
| | | } |
| | | return h('div', { class: 'flex items-center gap-2' }, [ |
| | | h(ElSwitch, { |
| | | modelValue: row.statusBool ?? statusMeta.bool, |
| | | loading: row._statusLoading, |
| | | 'onUpdate:modelValue': (value) => handleStatusChange(row, value) |
| | | }), |
| | | h(ElTag, { type: statusMeta.type, effect: 'light' }, () => statusMeta.text) |
| | | ]) |
| | | } |
| | | }, |
| | | { |
| | | prop: 'createTime', |
| | | label: '创建日期', |
| | | sortable: true |
| | | prop: 'updateTimeText', |
| | | label: '更新时间', |
| | | minWidth: 180, |
| | | sortable: true, |
| | | formatter: (row) => row.updateTimeText || '-' |
| | | }, |
| | | { |
| | | prop: 'createTimeText', |
| | | label: '创建时间', |
| | | minWidth: 180, |
| | | sortable: true, |
| | | formatter: (row) => row.createTimeText || '-' |
| | | }, |
| | | { |
| | | prop: 'operation', |
| | | label: '操作', |
| | | width: 120, |
| | | width: 220, |
| | | fixed: 'right', |
| | | // 固定列 |
| | | formatter: (row) => |
| | | h('div', [ |
| | | formatter: (row) => { |
| | | const buttons = [] |
| | | |
| | | if (hasAuth('query')) { |
| | | buttons.push( |
| | | h(ArtButtonTable, { |
| | | type: 'view', |
| | | onClick: () => openDetail(row) |
| | | }) |
| | | ) |
| | | } |
| | | |
| | | if (hasAuth('edit')) { |
| | | buttons.push( |
| | | h(ArtButtonTable, { |
| | | type: 'edit', |
| | | onClick: () => showDialog('edit', row) |
| | | onClick: () => openEditDialog(row) |
| | | }), |
| | | h(ArtButtonTable, { |
| | | type: 'delete', |
| | | onClick: () => deleteUser(row) |
| | | icon: 'ri:key-2-line', |
| | | iconClass: 'bg-warning/12 text-warning', |
| | | onClick: () => handleResetPassword(row) |
| | | }) |
| | | ]) |
| | | ) |
| | | } |
| | | |
| | | if (hasAuth('delete')) { |
| | | buttons.push( |
| | | h(ArtButtonTable, { |
| | | type: 'delete', |
| | | onClick: () => handleDelete(row) |
| | | }) |
| | | ) |
| | | } |
| | | |
| | | return h('div', buttons) |
| | | } |
| | | } |
| | | ] |
| | | }, |
| | | // 数据处理 |
| | | transform: { |
| | | // 数据转换器 - 替换头像 |
| | | dataTransformer: (records) => { |
| | | if (!Array.isArray(records)) { |
| | | console.warn('数据转换器: 期望数组类型,实际收到:', typeof records) |
| | | return [] |
| | | } |
| | | return records.map((item, index) => { |
| | | return { |
| | | ...item, |
| | | avatar: ACCOUNT_TABLE_DATA[index % ACCOUNT_TABLE_DATA.length].avatar |
| | | } |
| | | }) |
| | | return records.map((item) => normalizeUserListRow(item)) |
| | | } |
| | | } |
| | | }) |
| | | |
| | | const loadLookups = async () => { |
| | | try { |
| | | const [roles, depts] = await Promise.all([fetchGetRoleOptions({}), fetchGetDeptTree({})]) |
| | | roleOptions.value = normalizeRoleOptions(roles) |
| | | deptTreeOptions.value = normalizeDeptTreeOptions(depts) |
| | | } catch (error) { |
| | | console.error('加载用户页字典失败', error) |
| | | } |
| | | } |
| | | |
| | | onMounted(() => { |
| | | loadLookups() |
| | | }) |
| | | |
| | | const handleSearch = (params) => { |
| | | replaceSearchParams(params) |
| | | replaceSearchParams(buildUserSearchParams(params)) |
| | | getData() |
| | | } |
| | | const showDialog = (type, row) => { |
| | | console.log('打开弹窗:', { type, row }) |
| | | dialogType.value = type |
| | | currentUserData.value = row || {} |
| | | nextTick(() => { |
| | | dialogVisible.value = true |
| | | }) |
| | | |
| | | const handleReset = () => { |
| | | Object.assign(searchForm.value, createUserSearchState()) |
| | | resetSearchParams() |
| | | } |
| | | const deleteUser = (row) => { |
| | | console.log('删除用户:', row) |
| | | ElMessageBox.confirm(`确定要注销该用户吗?`, '注销用户', { |
| | | |
| | | const showDialog = (type, row) => { |
| | | dialogType.value = type |
| | | currentUserData.value = type === 'edit' ? buildUserDialogModel(row) : buildUserDialogModel() |
| | | dialogVisible.value = true |
| | | } |
| | | |
| | | const loadUserDetail = async (id) => { |
| | | detailLoading.value = true |
| | | try { |
| | | return await fetchGetUserDetail(id) |
| | | } finally { |
| | | detailLoading.value = false |
| | | } |
| | | } |
| | | |
| | | const openEditDialog = async (row) => { |
| | | try { |
| | | const detail = await loadUserDetail(row.id) |
| | | currentUserData.value = buildUserDialogModel(mergeUserDetailRecord(detail, row)) |
| | | dialogType.value = 'edit' |
| | | dialogVisible.value = true |
| | | } catch (error) { |
| | | ElMessage.error(error?.message || '获取用户详情失败') |
| | | } |
| | | } |
| | | |
| | | const openDetail = async (row) => { |
| | | detailDrawerVisible.value = true |
| | | try { |
| | | detailUserData.value = mergeUserDetailRecord(await loadUserDetail(row.id), row) |
| | | } catch (error) { |
| | | detailDrawerVisible.value = false |
| | | detailUserData.value = {} |
| | | ElMessage.error(error?.message || '获取用户详情失败') |
| | | } |
| | | } |
| | | |
| | | const handleDialogSubmit = async (formData) => { |
| | | const payload = buildUserSavePayload(formData) |
| | | try { |
| | | if (dialogType.value === 'edit') { |
| | | await fetchUpdateUser(payload) |
| | | ElMessage.success('修改成功') |
| | | dialogVisible.value = false |
| | | currentUserData.value = buildUserDialogModel() |
| | | await refreshUpdate() |
| | | return |
| | | } |
| | | await fetchSaveUser(payload) |
| | | ElMessage.success('新增成功') |
| | | dialogVisible.value = false |
| | | currentUserData.value = buildUserDialogModel() |
| | | await refreshCreate() |
| | | } catch (error) { |
| | | ElMessage.error(error?.message || '提交失败') |
| | | } |
| | | } |
| | | |
| | | const handleDelete = async (row) => { |
| | | try { |
| | | await ElMessageBox.confirm(`确定要删除用户「${row.username || row.nickname || row.id}」吗?`, '删除确认', { |
| | | confirmButtonText: '确定', |
| | | cancelButtonText: '取消', |
| | | type: 'error' |
| | | }).then(() => { |
| | | ElMessage.success('注销成功') |
| | | type: 'warning' |
| | | }) |
| | | } |
| | | const handleDialogSubmit = async () => { |
| | | try { |
| | | dialogVisible.value = false |
| | | currentUserData.value = {} |
| | | await fetchDeleteUser(row.id) |
| | | ElMessage.success('删除成功') |
| | | await refreshRemove() |
| | | } catch (error) { |
| | | console.error('提交失败:', error) |
| | | if (error !== 'cancel') { |
| | | ElMessage.error(error?.message || '删除失败') |
| | | } |
| | | } |
| | | const handleSelectionChange = (selection) => { |
| | | selectedRows.value = selection |
| | | console.log('选中行数据:', selectedRows.value) |
| | | } |
| | | |
| | | const handleResetPassword = async (row) => { |
| | | try { |
| | | await ElMessageBox.confirm( |
| | | `确定将用户「${row.username || row.nickname || row.id}」的密码重置为 ${RESET_PASSWORD} 吗?`, |
| | | '重置密码', |
| | | { |
| | | confirmButtonText: '确定', |
| | | cancelButtonText: '取消', |
| | | type: 'warning' |
| | | } |
| | | ) |
| | | await fetchResetUserPassword({ |
| | | id: row.id, |
| | | password: RESET_PASSWORD |
| | | }) |
| | | ElMessage.success(`密码已重置为 ${RESET_PASSWORD}`) |
| | | await refreshUpdate() |
| | | } catch (error) { |
| | | if (error !== 'cancel') { |
| | | ElMessage.error(error?.message || '重置密码失败') |
| | | } |
| | | } |
| | | } |
| | | |
| | | const handleStatusChange = async (row, checked) => { |
| | | const previousStatus = row.status |
| | | const previousStatusBool = row.statusBool |
| | | const nextStatus = checked ? 1 : 0 |
| | | row._statusLoading = true |
| | | row.status = nextStatus |
| | | row.statusBool = checked |
| | | |
| | | try { |
| | | await fetchUpdateUserStatus({ |
| | | id: row.id, |
| | | status: nextStatus |
| | | }) |
| | | ElMessage.success('状态已更新') |
| | | await refreshUpdate() |
| | | } catch (error) { |
| | | row.status = previousStatus |
| | | row.statusBool = previousStatusBool |
| | | ElMessage.error(error?.message || '状态更新失败') |
| | | } finally { |
| | | row._statusLoading = false |
| | | } |
| | | } |
| | | </script> |
| New file |
| | |
| | | <template> |
| | | <ElDrawer |
| | | :model-value="visible" |
| | | title="用户详情" |
| | | size="520px" |
| | | @update:model-value="handleVisibleChange" |
| | | > |
| | | <ElSkeleton :loading="loading" animated :rows="10"> |
| | | <ElDescriptions :column="1" border> |
| | | <ElDescriptionsItem label="用户名">{{ displayData.username || '--' }}</ElDescriptionsItem> |
| | | <ElDescriptionsItem label="昵称">{{ displayData.nickname || '--' }}</ElDescriptionsItem> |
| | | <ElDescriptionsItem label="部门">{{ displayData.deptLabel || '--' }}</ElDescriptionsItem> |
| | | <ElDescriptionsItem label="角色">{{ displayData.roleNames || '--' }}</ElDescriptionsItem> |
| | | <ElDescriptionsItem label="状态">{{ statusLabel }}</ElDescriptionsItem> |
| | | <ElDescriptionsItem label="手机号">{{ displayData.phone || '--' }}</ElDescriptionsItem> |
| | | <ElDescriptionsItem label="邮箱">{{ displayData.email || '--' }}</ElDescriptionsItem> |
| | | <ElDescriptionsItem label="真实姓名">{{ displayData.realName || '--' }}</ElDescriptionsItem> |
| | | <ElDescriptionsItem label="身份证号">{{ displayData.idCard || '--' }}</ElDescriptionsItem> |
| | | <ElDescriptionsItem label="工号">{{ displayData.code || '--' }}</ElDescriptionsItem> |
| | | <ElDescriptionsItem label="性别">{{ sexLabel }}</ElDescriptionsItem> |
| | | <ElDescriptionsItem label="创建时间">{{ displayData.createTimeText || '--' }}</ElDescriptionsItem> |
| | | <ElDescriptionsItem label="更新时间">{{ displayData.updateTimeText || '--' }}</ElDescriptionsItem> |
| | | <ElDescriptionsItem label="备注">{{ displayData.memo || '--' }}</ElDescriptionsItem> |
| | | </ElDescriptions> |
| | | </ElSkeleton> |
| | | </ElDrawer> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { getUserStatusMeta, normalizeUserListRow } from '../userPage.helpers' |
| | | |
| | | const props = defineProps({ |
| | | visible: { required: false, default: false }, |
| | | loading: { required: false, default: false }, |
| | | userData: { required: false, default: () => ({}) } |
| | | }) |
| | | |
| | | const emit = defineEmits(['update:visible']) |
| | | |
| | | const displayData = computed(() => normalizeUserListRow(props.userData)) |
| | | const statusLabel = computed(() => getUserStatusMeta(displayData.value.statusBool ?? displayData.value.status).text) |
| | | const sexLabel = computed(() => { |
| | | switch (displayData.value.sex) { |
| | | case 1: |
| | | return '男' |
| | | case 2: |
| | | return '女' |
| | | default: |
| | | return '未知' |
| | | } |
| | | }) |
| | | |
| | | const handleVisibleChange = (visible) => { |
| | | emit('update:visible', visible) |
| | | } |
| | | </script> |
| | |
| | | <template> |
| | | <ElDialog |
| | | v-model="dialogVisible" |
| | | :title="dialogType === 'add' ? '添加用户' : '编辑用户'" |
| | | width="30%" |
| | | :title="dialogTitle" |
| | | :model-value="visible" |
| | | @update:model-value="handleCancel" |
| | | width="960px" |
| | | align-center |
| | | class="user-dialog" |
| | | @closed="handleClosed" |
| | | > |
| | | <ElForm ref="formRef" :model="formData" :rules="rules" label-width="80px"> |
| | | <ElFormItem label="用户名" prop="username"> |
| | | <ElInput v-model="formData.username" placeholder="请输入用户名" /> |
| | | </ElFormItem> |
| | | <ElFormItem label="手机号" prop="phone"> |
| | | <ElInput v-model="formData.phone" placeholder="请输入手机号" /> |
| | | </ElFormItem> |
| | | <ElFormItem label="性别" prop="gender"> |
| | | <ElSelect v-model="formData.gender"> |
| | | <ElOption label="男" value="男" /> |
| | | <ElOption label="女" value="女" /> |
| | | </ElSelect> |
| | | </ElFormItem> |
| | | <ElFormItem label="角色" prop="role"> |
| | | <ElSelect v-model="formData.role" multiple> |
| | | <ElOption |
| | | v-for="role in roleList" |
| | | :key="role.roleCode" |
| | | :value="role.roleCode" |
| | | :label="role.roleName" |
| | | <ArtForm |
| | | ref="formRef" |
| | | v-model="form" |
| | | :items="formItems" |
| | | :rules="rules" |
| | | :span="12" |
| | | :gutter="20" |
| | | label-width="110px" |
| | | :show-reset="false" |
| | | :show-submit="false" |
| | | /> |
| | | </ElSelect> |
| | | </ElFormItem> |
| | | </ElForm> |
| | | |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <ElButton @click="dialogVisible = false">取消</ElButton> |
| | | <ElButton type="primary" @click="handleSubmit">提交</ElButton> |
| | | </div> |
| | | <span class="dialog-footer"> |
| | | <ElButton @click="handleCancel">取消</ElButton> |
| | | <ElButton type="primary" @click="handleSubmit">确定</ElButton> |
| | | </span> |
| | | </template> |
| | | </ElDialog> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ROLE_LIST_DATA } from '@/mock/temp/formData' |
| | | import ArtForm from '@/components/core/forms/art-form/index.vue' |
| | | import { buildUserDialogModel, createUserFormState } from '../userPage.helpers' |
| | | |
| | | const props = defineProps({ |
| | | visible: { required: true }, |
| | | type: { required: true }, |
| | | userData: { required: false } |
| | | visible: { required: false, default: false }, |
| | | type: { required: false, default: 'add' }, |
| | | userData: { required: false, default: () => ({}) }, |
| | | roleOptions: { required: false, default: () => [] }, |
| | | deptTreeOptions: { required: false, default: () => [] } |
| | | }) |
| | | |
| | | const emit = defineEmits(['update:visible', 'submit']) |
| | | const roleList = ref(ROLE_LIST_DATA) |
| | | const dialogVisible = computed({ |
| | | get: () => props.visible, |
| | | set: (value) => emit('update:visible', value) |
| | | }) |
| | | const dialogType = computed(() => props.type) |
| | | const formRef = ref() |
| | | const formData = reactive({ |
| | | username: '', |
| | | phone: '', |
| | | gender: '男', |
| | | role: [] |
| | | }) |
| | | const rules = { |
| | | username: [ |
| | | { required: true, message: '请输入用户名', trigger: 'blur' }, |
| | | { min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' } |
| | | ], |
| | | phone: [ |
| | | { required: true, message: '请输入手机号', trigger: 'blur' }, |
| | | { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' } |
| | | ], |
| | | gender: [{ required: true, message: '请选择性别', trigger: 'blur' }], |
| | | role: [{ required: true, message: '请选择角色', trigger: 'blur' }] |
| | | const form = reactive(createUserFormState()) |
| | | |
| | | const isEdit = computed(() => props.type === 'edit') |
| | | const dialogTitle = computed(() => (isEdit.value ? '编辑用户' : '新增用户')) |
| | | |
| | | const validatePassword = (_rule, value, callback) => { |
| | | if (!isEdit.value && !value) { |
| | | callback(new Error('请输入密码')) |
| | | return |
| | | } |
| | | const initFormData = () => { |
| | | const isEdit = props.type === 'edit' && props.userData |
| | | const row = props.userData |
| | | Object.assign(formData, { |
| | | username: isEdit && row ? row.userName || '' : '', |
| | | phone: isEdit && row ? row.userPhone || '' : '', |
| | | gender: isEdit && row ? row.userGender || '男' : '男', |
| | | role: isEdit && row ? (Array.isArray(row.userRoles) ? row.userRoles : []) : [] |
| | | }) |
| | | if (value && String(value).length < 6) { |
| | | callback(new Error('密码长度至少 6 位')) |
| | | return |
| | | } |
| | | callback() |
| | | } |
| | | |
| | | const validateConfirmPassword = (_rule, value, callback) => { |
| | | if (!form.password && !value && isEdit.value) { |
| | | callback() |
| | | return |
| | | } |
| | | if (!value) { |
| | | callback(new Error('请再次输入密码')) |
| | | return |
| | | } |
| | | if (value !== form.password) { |
| | | callback(new Error('两次输入的密码不一致')) |
| | | return |
| | | } |
| | | callback() |
| | | } |
| | | |
| | | const rules = computed(() => ({ |
| | | username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], |
| | | nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }], |
| | | roleIds: [{ type: 'array', required: true, message: '请选择角色', trigger: 'change' }], |
| | | password: [{ validator: validatePassword, trigger: 'blur' }], |
| | | confirmPassword: [{ validator: validateConfirmPassword, trigger: 'blur' }] |
| | | })) |
| | | |
| | | const formItems = computed(() => [ |
| | | { |
| | | label: '用户名', |
| | | key: 'username', |
| | | type: 'input', |
| | | props: { |
| | | placeholder: '请输入用户名', |
| | | clearable: true |
| | | } |
| | | }, |
| | | { |
| | | label: '昵称', |
| | | key: 'nickname', |
| | | type: 'input', |
| | | props: { |
| | | placeholder: '请输入昵称', |
| | | clearable: true |
| | | } |
| | | }, |
| | | { |
| | | label: '部门', |
| | | key: 'deptId', |
| | | type: 'treeselect', |
| | | props: { |
| | | data: props.deptTreeOptions, |
| | | props: { |
| | | label: 'label', |
| | | value: 'value', |
| | | children: 'children' |
| | | }, |
| | | placeholder: '请选择部门', |
| | | clearable: true, |
| | | checkStrictly: true |
| | | } |
| | | }, |
| | | { |
| | | label: '角色', |
| | | key: 'roleIds', |
| | | type: 'select', |
| | | props: { |
| | | placeholder: '请选择角色', |
| | | clearable: true, |
| | | multiple: true, |
| | | collapseTags: true, |
| | | filterable: true, |
| | | options: props.roleOptions |
| | | } |
| | | }, |
| | | { |
| | | label: '密码', |
| | | key: 'password', |
| | | type: 'input', |
| | | props: { |
| | | type: 'password', |
| | | showPassword: true, |
| | | placeholder: isEdit.value ? '不修改则留空' : '请输入密码', |
| | | clearable: true |
| | | } |
| | | }, |
| | | { |
| | | label: '确认密码', |
| | | key: 'confirmPassword', |
| | | type: 'input', |
| | | props: { |
| | | type: 'password', |
| | | showPassword: true, |
| | | placeholder: '请再次输入密码', |
| | | clearable: true |
| | | } |
| | | }, |
| | | { |
| | | label: '性别', |
| | | key: 'sex', |
| | | type: 'select', |
| | | props: { |
| | | placeholder: '请选择性别', |
| | | clearable: true, |
| | | options: [ |
| | | { label: '未知', value: 0 }, |
| | | { label: '男', value: 1 }, |
| | | { label: '女', value: 2 } |
| | | ] |
| | | } |
| | | }, |
| | | { |
| | | label: '工号', |
| | | key: 'code', |
| | | type: 'input', |
| | | props: { |
| | | placeholder: '请输入工号', |
| | | clearable: true |
| | | } |
| | | }, |
| | | { |
| | | label: '手机号', |
| | | key: 'phone', |
| | | type: 'input', |
| | | props: { |
| | | placeholder: '请输入手机号', |
| | | clearable: true |
| | | } |
| | | }, |
| | | { |
| | | label: '邮箱', |
| | | key: 'email', |
| | | type: 'input', |
| | | props: { |
| | | placeholder: '请输入邮箱', |
| | | clearable: true |
| | | } |
| | | }, |
| | | { |
| | | label: '真实姓名', |
| | | key: 'realName', |
| | | type: 'input', |
| | | props: { |
| | | placeholder: '请输入真实姓名', |
| | | clearable: true |
| | | } |
| | | }, |
| | | { |
| | | label: '身份证号', |
| | | key: 'idCard', |
| | | type: 'input', |
| | | props: { |
| | | placeholder: '请输入身份证号', |
| | | clearable: true |
| | | } |
| | | }, |
| | | { |
| | | label: '状态', |
| | | key: 'status', |
| | | type: 'select', |
| | | props: { |
| | | placeholder: '请选择状态', |
| | | clearable: true, |
| | | options: [ |
| | | { label: '正常', value: 1 }, |
| | | { label: '禁用', value: 0 } |
| | | ] |
| | | } |
| | | }, |
| | | { |
| | | label: '备注', |
| | | key: 'memo', |
| | | type: 'input', |
| | | props: { |
| | | type: 'textarea', |
| | | rows: 3, |
| | | placeholder: '请输入备注', |
| | | clearable: true |
| | | }, |
| | | span: 24 |
| | | } |
| | | ]) |
| | | |
| | | const resetForm = () => { |
| | | Object.assign(form, createUserFormState()) |
| | | formRef.value?.clearValidate?.() |
| | | } |
| | | |
| | | const loadFormData = () => { |
| | | Object.assign(form, buildUserDialogModel(props.userData)) |
| | | } |
| | | |
| | | const handleSubmit = async () => { |
| | | if (!formRef.value) return |
| | | try { |
| | | await formRef.value.validate() |
| | | emit('submit', { ...form }) |
| | | } catch { |
| | | return |
| | | } |
| | | } |
| | | |
| | | const handleCancel = () => { |
| | | emit('update:visible', false) |
| | | } |
| | | |
| | | const handleClosed = () => { |
| | | resetForm() |
| | | } |
| | | |
| | | watch( |
| | | () => [props.visible, props.type, props.userData], |
| | | ([visible]) => { |
| | | () => props.visible, |
| | | (visible) => { |
| | | if (visible) { |
| | | initFormData() |
| | | loadFormData() |
| | | nextTick(() => { |
| | | formRef.value?.clearValidate() |
| | | formRef.value?.clearValidate?.() |
| | | }) |
| | | } |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | const handleSubmit = async () => { |
| | | if (!formRef.value) return |
| | | await formRef.value.validate((valid) => { |
| | | if (valid) { |
| | | ElMessage.success(dialogType.value === 'add' ? '添加成功' : '更新成功') |
| | | dialogVisible.value = false |
| | | emit('submit') |
| | | |
| | | watch( |
| | | () => props.userData, |
| | | () => { |
| | | if (props.visible) { |
| | | loadFormData() |
| | | } |
| | | }) |
| | | }, |
| | | { deep: true } |
| | | ) |
| | | |
| | | watch( |
| | | () => props.type, |
| | | () => { |
| | | if (props.visible) { |
| | | loadFormData() |
| | | } |
| | | } |
| | | ) |
| | | </script> |
| New file |
| | |
| | | import assert from 'node:assert/strict' |
| | | import { register } from 'node:module' |
| | | import test from 'node:test' |
| | | |
| | | register( |
| | | 'data:text/javascript,export function resolve(specifier, context, nextResolve){ if(specifier===\'@/utils/http\'){ return { shortCircuit:true, url:\'data:text/javascript,export default { post(options){ globalThis.__authHttpCalls.push({ method:"post", options }); return Promise.resolve({ accessToken:"abc", refreshToken:"ref", user:{ id:1 } }) }, get(options){ globalThis.__authHttpCalls.push({ method:"get", options }); return Promise.resolve({ userId:1, roles:[{ code:"R_ADMIN", name:"管理员" }], authorities:[{ authority:"system:menu:save" },{ authority:"system:menu:update" }] }) } }\' } } return nextResolve(specifier, context) }', |
| | | import.meta.url |
| | | ) |
| | | |
| | | globalThis.__authHttpCalls = [] |
| | | |
| | | const { |
| | | buildLoginPayload, |
| | | fetchGetUserInfo, |
| | | fetchLogin, |
| | | normalizeLoginResponse, |
| | | normalizeUserInfo |
| | | } = await import('../src/api/auth.js') |
| | | |
| | | test('buildLoginPayload matches the rsf-server login contract', () => { |
| | | assert.deepEqual(buildLoginPayload({ username: 'demo', password: '123456' }), { |
| | | username: 'demo', |
| | | password: '123456' |
| | | }) |
| | | }) |
| | | |
| | | test('normalizeLoginResponse extracts the real token fields', () => { |
| | | assert.deepEqual( |
| | | normalizeLoginResponse({ |
| | | code: 200, |
| | | data: { accessToken: 'abc', user: { id: 1 } } |
| | | }), |
| | | { accessToken: 'abc', refreshToken: '', user: { id: 1 } } |
| | | ) |
| | | }) |
| | | |
| | | test('normalizeLoginResponse also handles an inner data object', () => { |
| | | assert.deepEqual( |
| | | normalizeLoginResponse({ |
| | | accessToken: 'abc', |
| | | refreshToken: 'ref', |
| | | user: { id: 1 } |
| | | }), |
| | | { accessToken: 'abc', refreshToken: 'ref', user: { id: 1 } } |
| | | ) |
| | | }) |
| | | |
| | | test('normalizeLoginResponse accepts the old backend accessToken shape', () => { |
| | | const result = normalizeLoginResponse({ |
| | | code: 200, |
| | | data: { accessToken: 'token-1', refreshToken: '', user: { username: 'admin' } } |
| | | }) |
| | | |
| | | assert.equal(result.accessToken, 'token-1') |
| | | }) |
| | | |
| | | test('normalizeLoginResponse keeps missing accessToken empty', () => { |
| | | const result = normalizeLoginResponse({ |
| | | code: 200, |
| | | data: { refreshToken: 'ref', user: { username: 'admin' } } |
| | | }) |
| | | |
| | | assert.equal(result.accessToken, '') |
| | | }) |
| | | |
| | | test('normalizeUserInfo returns roles and buttons arrays safely', () => { |
| | | assert.deepEqual(normalizeUserInfo({ userId: 1, roles: ['R_ADMIN'], buttons: ['add'] }), { |
| | | userId: 1, |
| | | roles: ['R_ADMIN'], |
| | | buttons: ['add'] |
| | | }) |
| | | }) |
| | | |
| | | test('normalizeUserInfo derives role codes and button aliases from rsf-server auth payloads', () => { |
| | | assert.deepEqual( |
| | | normalizeUserInfo({ |
| | | userId: 1, |
| | | roles: [{ code: 'R_SUPER', name: '超级管理员' }], |
| | | authorities: [{ authority: 'system:menu:save' }, { authority: 'system:menu:update' }] |
| | | }), |
| | | { |
| | | userId: 1, |
| | | roles: ['R_SUPER'], |
| | | authorities: [{ authority: 'system:menu:save' }, { authority: 'system:menu:update' }], |
| | | buttons: ['system:menu:save', 'system:menu:update'] |
| | | } |
| | | ) |
| | | }) |
| | | |
| | | test('fetchLogin keeps supporting legacy userName input at the boundary', async () => { |
| | | const result = await fetchLogin({ userName: 'demo', password: '123456' }) |
| | | |
| | | assert.deepEqual(globalThis.__authHttpCalls.at(-1), { |
| | | method: 'post', |
| | | options: { |
| | | url: '/login', |
| | | params: { username: 'demo', password: '123456' } |
| | | } |
| | | }) |
| | | assert.deepEqual(result, { |
| | | token: 'abc', |
| | | accessToken: 'abc', |
| | | refreshToken: 'ref', |
| | | user: { id: 1 } |
| | | }) |
| | | }) |
| | | |
| | | test('fetchGetUserInfo normalizes the returned user payload', async () => { |
| | | const result = await fetchGetUserInfo() |
| | | |
| | | assert.deepEqual(globalThis.__authHttpCalls.at(-1), { |
| | | method: 'get', |
| | | options: { |
| | | url: '/auth/user' |
| | | } |
| | | }) |
| | | assert.deepEqual(result, { |
| | | userId: 1, |
| | | roles: ['R_ADMIN'], |
| | | authorities: [{ authority: 'system:menu:save' }, { authority: 'system:menu:update' }], |
| | | buttons: ['system:menu:save', 'system:menu:update'] |
| | | }) |
| | | }) |
| New file |
| | |
| | | import assert from 'node:assert/strict' |
| | | import test from 'node:test' |
| | | |
| | | import { |
| | | buildListExportPayload, |
| | | buildPrintPageQuery, |
| | | toExportColumns |
| | | } from '../src/components/biz/list-export-print/list-export-print.helpers.js' |
| | | |
| | | test('selected rows keep export payload on ids only instead of flat query params', () => { |
| | | const payload = buildListExportPayload({ |
| | | reportTitle: '角色管理报表', |
| | | selectedRows: [{ id: 7 }, { id: 9 }], |
| | | queryParams: { current: 1, pageSize: 20, name: '管理员', code: 'R_ADMIN' }, |
| | | columns: [{ prop: 'name', label: '角色名称' }] |
| | | }) |
| | | |
| | | assert.deepEqual(payload.ids, [7, 9]) |
| | | assert.deepEqual(payload.columns, [{ source: 'name', label: '角色名称' }]) |
| | | assert.equal(payload.meta.reportTitle, '角色管理报表') |
| | | assert.deepEqual( |
| | | Object.keys(payload).filter((key) => ['current', 'pageSize', 'name', 'code', 'queryParams'].includes(key)), |
| | | [] |
| | | ) |
| | | }) |
| | | |
| | | test('export columns use ListExportService source/label contract only', () => { |
| | | assert.deepEqual( |
| | | toExportColumns([ |
| | | { prop: 'name', label: '角色名称' }, |
| | | { prop: 'operation', label: '操作' }, |
| | | { prop: 'selection', label: '勾选' } |
| | | ]), |
| | | [{ source: 'name', label: '角色名称' }] |
| | | ) |
| | | }) |
| | | |
| | | test('export payload keeps no-filter searches legal as flat params', () => { |
| | | const payload = buildListExportPayload({ |
| | | reportTitle: '角色管理报表', |
| | | selectedRows: [], |
| | | queryParams: { current: 1, pageSize: 20 }, |
| | | columns: [{ prop: 'name', label: '角色名称' }] |
| | | }) |
| | | |
| | | assert.equal(payload.current, 1) |
| | | assert.equal(payload.pageSize, 20) |
| | | assert.equal(payload.meta.reportTitle, '角色管理报表') |
| | | assert.deepEqual(payload.ids, []) |
| | | assert.equal(Object.prototype.hasOwnProperty.call(payload, 'queryParams'), false) |
| | | }) |
| | | |
| | | test('export payload preserves report style meta and column align without top-level reportStyle', () => { |
| | | const payload = buildListExportPayload({ |
| | | reportTitle: '角色管理报表', |
| | | meta: { |
| | | reportStyle: { |
| | | orientation: 'landscape', |
| | | density: 'comfortable' |
| | | } |
| | | }, |
| | | columns: [{ prop: 'name', label: '角色名称', align: 'right' }] |
| | | }) |
| | | |
| | | assert.deepEqual(payload.meta.reportStyle, { |
| | | orientation: 'landscape', |
| | | density: 'comfortable' |
| | | }) |
| | | assert.equal(payload.columns[0].align, 'right') |
| | | assert.equal(Object.prototype.hasOwnProperty.call(payload, 'reportStyle'), false) |
| | | }) |
| | | |
| | | test('print query expands to the full result set instead of the current page size', () => { |
| | | assert.deepEqual( |
| | | buildPrintPageQuery({ |
| | | queryParams: { current: 3, pageSize: 20, orderBy: 'createTime desc', name: '管理员' }, |
| | | total: 86, |
| | | maxResults: 1000 |
| | | }), |
| | | { |
| | | current: 1, |
| | | pageSize: 86, |
| | | orderBy: 'createTime desc', |
| | | name: '管理员' |
| | | } |
| | | ) |
| | | }) |
| | | |
| | | test('print query caps pageSize at maxResults when total is larger', () => { |
| | | assert.deepEqual( |
| | | buildPrintPageQuery({ |
| | | queryParams: { current: 5, pageSize: 20, orderBy: 'createTime desc', code: 'R_ADMIN' }, |
| | | total: 1500, |
| | | maxResults: 1000 |
| | | }), |
| | | { |
| | | current: 1, |
| | | pageSize: 1000, |
| | | orderBy: 'createTime desc', |
| | | code: 'R_ADMIN' |
| | | } |
| | | ) |
| | | }) |
| New file |
| | |
| | | import assert from 'node:assert/strict' |
| | | import { readFileSync } from 'node:fs' |
| | | import test from 'node:test' |
| | | |
| | | import * as roleHelpers from '../src/views/system/role/rolePage.helpers.js' |
| | | |
| | | test('role report columns are the fixed business columns, not table utility columns', () => { |
| | | assert.deepEqual( |
| | | roleHelpers.getRoleReportColumns().map((column) => column.source), |
| | | ['name', 'code', 'statusText', 'memo', 'createTimeText', 'updateTimeText'] |
| | | ) |
| | | }) |
| | | |
| | | test('report columns keep visible order inside the role allowlist', () => { |
| | | assert.deepEqual( |
| | | roleHelpers.resolveRoleReportColumns([ |
| | | { prop: 'selection', label: '勾选' }, |
| | | { prop: 'status', label: '状态' }, |
| | | { prop: 'name', label: '角色名称' }, |
| | | { prop: 'deptName', label: '部门名称' }, |
| | | { prop: 'operation', label: '操作' }, |
| | | { prop: 'memo', label: '备注' } |
| | | ]), |
| | | [ |
| | | { source: 'statusText', label: '状态' }, |
| | | { source: 'name', label: '角色名称' }, |
| | | { source: 'memo', label: '备注' } |
| | | ] |
| | | ) |
| | | }) |
| | | |
| | | test('role print rows expose formatted status text', () => { |
| | | const rows = roleHelpers.buildRolePrintRows([ |
| | | { name: '管理员', status: 1 }, |
| | | { name: '访客', status: 0 } |
| | | ]) |
| | | |
| | | assert.equal(rows[0].statusText, '正常') |
| | | assert.equal(rows[1].statusText, '禁用') |
| | | }) |
| | | |
| | | test('role report meta applies shared report style and title', () => { |
| | | const meta = roleHelpers.buildRoleReportMeta({ |
| | | previewMeta: { |
| | | reportDate: '2026-03-29', |
| | | printedAt: '2026-03-29 10:57:25', |
| | | operator: 'ROOT' |
| | | }, |
| | | count: 2, |
| | | titleAlign: 'left', |
| | | titleLevel: 'prominent' |
| | | }) |
| | | |
| | | assert.equal(meta.reportTitle, '角色管理报表') |
| | | assert.equal(meta.reportDate, '2026-03-29') |
| | | assert.equal(meta.operator, 'ROOT') |
| | | assert.deepEqual(meta.reportStyle, { |
| | | titleAlign: 'left', |
| | | titleLevel: 'prominent', |
| | | orientation: 'portrait', |
| | | density: 'compact', |
| | | showSequence: true |
| | | }) |
| | | }) |
| | | |
| | | test('role page uses helper report defaults as single source of truth', () => { |
| | | const indexSource = readFileSync(new URL('../src/views/system/role/index.vue', import.meta.url), 'utf8') |
| | | const meta = roleHelpers.buildRoleReportMeta() |
| | | |
| | | assert.equal(roleHelpers.ROLE_REPORT_TITLE, '角色管理报表') |
| | | assert.deepEqual(roleHelpers.ROLE_REPORT_STYLE, { |
| | | titleAlign: 'center', |
| | | titleLevel: 'strong' |
| | | }) |
| | | assert.equal(meta.reportTitle, roleHelpers.ROLE_REPORT_TITLE) |
| | | assert.deepEqual(meta.reportStyle, { |
| | | ...roleHelpers.ROLE_REPORT_STYLE, |
| | | orientation: 'portrait', |
| | | density: 'compact', |
| | | showSequence: true |
| | | }) |
| | | assert.match( |
| | | indexSource, |
| | | /import\s*\{[\s\S]*\bROLE_REPORT_STYLE\b,[\s\S]*\bROLE_REPORT_TITLE\b[\s\S]*\}\s*from '\.\/rolePage\.helpers'/ |
| | | ) |
| | | assert.match( |
| | | indexSource, |
| | | /<ListExportPrint[\s\S]*:report-title="reportTitle"[\s\S]*:preview-meta="resolvedPreviewMeta"[\s\S]*\/>/ |
| | | ) |
| | | assert.match(indexSource, /const reportTitle = ROLE_REPORT_TITLE/) |
| | | assert.doesNotMatch(indexSource, /const reportTitle = '角色管理报表'/) |
| | | assert.doesNotMatch(indexSource, /const ROLE_REPORT_STYLE = \{/) |
| | | assert.match( |
| | | indexSource, |
| | | /const resolvedPreviewMeta = computed\(\(\) =>\s*buildRoleReportMeta\(\{\s*previewMeta: previewMeta\.value,\s*count: previewRows\.value\.length,\s*titleAlign: ROLE_REPORT_STYLE\.titleAlign,\s*titleLevel: ROLE_REPORT_STYLE\.titleLevel\s*\}\)\s*\)/ |
| | | ) |
| | | }) |