zhou zhou
19 小时以前 e9283ffe6822b12ec5dd2ccf4dc13a369b227a61
chore: sync rsf-design from isolated worktree
6个文件已添加
16个文件已修改
2739 ■■■■ 已修改文件
rsf-design/build/manualChunks.js 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/auth.js 116 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/config/index.js 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/directives/core/auth.js 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/directives/core/roles.js 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/hooks/core/useAuth.js 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/locales/langs/en.json 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/locales/langs/zh.json 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/router/core/MenuProcessor.js 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/router/guards/beforeEach.js 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/store/modules/user.js 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/auth/login/index.vue 118 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/dashboard/console/index.vue 271 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/role/index.vue 428 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/role/modules/role-search.vue 97 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/role/rolePage.helpers.js 320 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/user/index.vue 444 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/user/modules/user-detail-drawer.vue 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/user/modules/user-dialog.vue 355 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/auth-contract.test.mjs 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/list-export-print-contract.test.mjs 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/system-role-print-export-page.test.mjs 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/build/manualChunks.js
New file
@@ -0,0 +1,49 @@
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 }
rsf-design/src/api/auth.js
@@ -1,19 +1,111 @@
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
    // showSuccessMessage: true // 显示成功消息
    // showErrorMessage: false // 不显示错误消息
  })
  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: '/auth/user'
      // 自定义请求头
      // headers: {
      //   'X-Custom-Header': 'your-custom-value'
      // }
    })
    .then((response) => normalizeUserInfo(response))
}
function fetchGetMenuList() {
  return request.get({
    url: '/api/user/info'
    // 自定义请求头
    // headers: {
    //   'X-Custom-Header': 'your-custom-value'
    // }
    url: '/auth/menu'
  })
}
export { fetchGetUserInfo, fetchLogin }
export {
  buildLoginPayload,
  fetchGetMenuList,
  fetchGetUserInfo,
  fetchLogin,
  normalizeLoginResponse,
  normalizeUserInfo
}
rsf-design/src/config/index.js
@@ -5,7 +5,7 @@
const appConfig = {
  // 系统信息
  systemInfo: {
    name: 'Art Design Pro'
    name: 'RSF Design'
    // 系统名称
  },
  // 系统主题
rsf-design/src/directives/core/auth.js
@@ -1,7 +1,18 @@
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)
  }
rsf-design/src/directives/core/roles.js
@@ -1,7 +1,26 @@
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
rsf-design/src/hooks/core/useAuth.js
@@ -2,21 +2,106 @@
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/store/modules/user'
import { useAppMode } from '@/hooks/core/useAppMode'
const userStore = useUserStore()
function extractRouteAuthMarks(authList) {
  if (!Array.isArray(authList)) {
    return []
  }
  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 { isFrontendMode } = useAppMode()
  const { isBackendMode } = useAppMode()
  const { info } = storeToRefs(userStore)
  const frontendAuthList = info.value?.buttons ?? []
  const backendAuthList = Array.isArray(route.meta.authList) ? route.meta.authList : []
  const authList = computed(() => extractRouteAuthMarks(route.meta.authList))
  const buttons = computed(() => extractUserButtons(info.value))
  const hasAuth = (auth) => {
    if (isFrontendMode.value) {
      return frontendAuthList.includes(auth)
    }
    return backendAuthList.some((item) => item?.authMark === 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
}
rsf-design/src/locales/langs/en.json
@@ -257,9 +257,11 @@
      "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"
rsf-design/src/locales/langs/zh.json
@@ -257,9 +257,11 @@
      "notFound": "404",
      "serverError": "500"
    },
    "userLogin": "登录日志",
    "system": {
      "title": "系统管理",
      "user": "用户管理",
      "userLogin": "登录日志",
      "role": "角色管理",
      "userCenter": "个人中心",
      "menu": "菜单管理"
rsf-design/src/router/core/MenuProcessor.js
@@ -1,8 +1,9 @@
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 {
  /**
@@ -36,7 +37,7 @@
   */
  async processBackendMenu() {
    const list = await fetchGetMenuList()
    return this.filterEmptyMenus(list)
    return adaptBackendMenuTree(list)
  }
  /**
   * 根据角色过滤菜单
rsf-design/src/router/guards/beforeEach.js
@@ -10,7 +10,7 @@
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'
@@ -185,7 +185,7 @@
}
async function fetchUserInfo() {
  const userStore = useUserStore()
  const data = await fetchGetUserInfo()
  const data = normalizeUserInfo(await fetchGetUserInfo())
  userStore.setUserInfo(data)
  userStore.checkAndClearWorktabs()
}
rsf-design/src/store/modules/user.js
@@ -41,11 +41,9 @@
    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
rsf-design/src/views/auth/login/index.vue
@@ -18,18 +18,6 @@
            @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"
@@ -47,31 +35,6 @@
                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">{{
@@ -111,51 +74,20 @@
  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
@@ -165,49 +97,27 @@
    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(() => {
@@ -224,10 +134,4 @@
<style scoped>
  @import './style.css';
</style>
<style lang="scss" scoped>
  :deep(.el-select__wrapper) {
    height: 40px !important;
  }
</style>
rsf-design/src/views/dashboard/console/index.vue
@@ -1,44 +1,249 @@
<!-- 工作台页面 -->
<template>
  <div>
    <CardList></CardList>
  <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>
    <ElRow :gutter="20">
      <ElCol :sm="24" :md="12" :lg="10">
        <ActiveUser />
      </ElCol>
      <ElCol :sm="24" :md="12" :lg="14">
        <SalesOverview />
      </ElCol>
    </ElRow>
        <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>
    <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>
    <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>
    <AboutProject />
    <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>
              <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>
        <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>
          <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>
      <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>
rsf-design/src/views/system/role/index.vue
@@ -5,8 +5,8 @@
      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
@@ -17,62 +17,115 @@
      >
        <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,
@@ -84,130 +137,313 @@
    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}"吗?此操作不可恢复!`, '删除确认', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    })
      .then(() => {
        ElMessage.success('删除成功')
        refreshData()
  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'
      })
      .catch(() => {
        ElMessage.info('已取消删除')
      await fetchDeleteRole(row.id)
      ElMessage.success('删除成功')
      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'
      })
      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>
rsf-design/src/views/system/role/modules/role-search.vue
@@ -3,84 +3,85 @@
    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)
  }
rsf-design/src/views/system/role/rolePage.helpers.js
New file
@@ -0,0 +1,320 @@
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
}
rsf-design/src/views/system/user/index.vue
@@ -1,81 +1,104 @@
<!-- 用户管理页面 -->
<!-- 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 USER_STATUS_CONFIG = {
    1: { type: 'success', text: '在线' },
    2: { type: 'info', text: '离线' },
    3: { type: 'warning', text: '异常' },
    4: { type: 'danger', text: '注销' }
  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 getUserStatusConfig = (status) => {
    return (
      USER_STATUS_CONFIG[status] || {
        type: 'info',
        text: '未知'
      }
    )
  }
  const {
    columns,
    columnChecks,
@@ -87,136 +110,283 @@
    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, // 默认是否显示列
          minWidth: 140,
          showOverflowTooltip: true
        },
        {
          prop: 'nickname',
          label: '昵称',
          minWidth: 120,
          showOverflowTooltip: true
        },
        {
          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) => {
            return h('div', { class: 'user flex-c' }, [
              h(ElImage, {
                class: 'size-9.5 rounded-md',
                src: row.avatar,
                previewSrcList: [row.avatar],
                // 图片预览是否插入至 body 元素上,用于解决表格内部图片预览样式异常
                previewTeleported: true
            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('div', { class: 'ml-2' }, [
                h('p', { class: 'user-name' }, row.userName),
                h('p', { class: 'email' }, row.userEmail)
              ])
              h(ElTag, { type: statusMeta.type, effect: 'light' }, () => statusMeta.text)
            ])
          }
        },
        {
          prop: 'userGender',
          label: '性别',
          prop: 'updateTimeText',
          label: '更新时间',
          minWidth: 180,
          sortable: true,
          formatter: (row) => row.userGender
        },
        { prop: 'userPhone', label: '手机号' },
        {
          prop: 'status',
          label: '状态',
          formatter: (row) => {
            const statusConfig = getUserStatusConfig(row.status)
            return h(ElTag, { type: statusConfig.type }, () => statusConfig.text)
          }
          formatter: (row) => row.updateTimeText || '-'
        },
        {
          prop: 'createTime',
          label: '创建日期',
          sortable: true
          prop: 'createTimeText',
          label: '创建时间',
          minWidth: 180,
          sortable: true,
          formatter: (row) => row.createTimeText || '-'
        },
        {
          prop: 'operation',
          label: '操作',
          width: 120,
          width: 220,
          fixed: 'right',
          // 固定列
          formatter: (row) =>
            h('div', [
              h(ArtButtonTable, {
                type: 'edit',
                onClick: () => showDialog('edit', row)
              }),
              h(ArtButtonTable, {
                type: 'delete',
                onClick: () => deleteUser(row)
              })
            ])
          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: () => openEditDialog(row)
                }),
                h(ArtButtonTable, {
                  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 handleSearch = (params) => {
    replaceSearchParams(params)
    getData()
  }
  const showDialog = (type, row) => {
    console.log('打开弹窗:', { type, row })
    dialogType.value = type
    currentUserData.value = row || {}
    nextTick(() => {
      dialogVisible.value = true
    })
  }
  const deleteUser = (row) => {
    console.log('删除用户:', row)
    ElMessageBox.confirm(`确定要注销该用户吗?`, '注销用户', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'error'
    }).then(() => {
      ElMessage.success('注销成功')
    })
  }
  const handleDialogSubmit = async () => {
  const loadLookups = async () => {
    try {
      dialogVisible.value = false
      currentUserData.value = {}
      const [roles, depts] = await Promise.all([fetchGetRoleOptions({}), fetchGetDeptTree({})])
      roleOptions.value = normalizeRoleOptions(roles)
      deptTreeOptions.value = normalizeDeptTreeOptions(depts)
    } catch (error) {
      console.error('提交失败:', error)
      console.error('加载用户页字典失败', error)
    }
  }
  const handleSelectionChange = (selection) => {
    selectedRows.value = selection
    console.log('选中行数据:', selectedRows.value)
  onMounted(() => {
    loadLookups()
  })
  const handleSearch = (params) => {
    replaceSearchParams(buildUserSearchParams(params))
    getData()
  }
  const handleReset = () => {
    Object.assign(searchForm.value, createUserSearchState())
    resetSearchParams()
  }
  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: 'warning'
      })
      await fetchDeleteUser(row.id)
      ElMessage.success('删除成功')
      await refreshRemove()
    } catch (error) {
      if (error !== 'cancel') {
        ElMessage.error(error?.message || '删除失败')
      }
    }
  }
  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>
rsf-design/src/views/system/user/modules/user-detail-drawer.vue
New file
@@ -0,0 +1,56 @@
<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>
rsf-design/src/views/system/user/modules/user-dialog.vue
@@ -1,106 +1,301 @@
<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"
          />
        </ElSelect>
      </ElFormItem>
    </ElForm>
    <ArtForm
      ref="formRef"
      v-model="form"
      :items="formItems"
      :rules="rules"
      :span="12"
      :gutter="20"
      label-width="110px"
      :show-reset="false"
      :show-submit="false"
    />
    <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
    }
    if (value && String(value).length < 6) {
      callback(new Error('密码长度至少 6 位'))
      return
    }
    callback()
  }
  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 : []) : []
    })
  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>
rsf-design/tests/auth-contract.test.mjs
New file
@@ -0,0 +1,123 @@
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']
  })
})
rsf-design/tests/list-export-print-contract.test.mjs
New file
@@ -0,0 +1,103 @@
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'
    }
  )
})
rsf-design/tests/system-role-print-export-page.test.mjs
New file
@@ -0,0 +1,97 @@
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*\)/
  )
})