zhou zhou
23 小时以前 40905cbd04c2e332cd4bc2b9e0c5b3e1da9cccfa
feat: complete rsf-design phase 1 integration
16个文件已添加
22个文件已修改
5118 ■■■■ 已修改文件
rsf-design/.env.development 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/.env.production 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/package.json 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/scripts/build-local-iconify-collections.mjs 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/system-manage.js 391 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/biz/list-export-print/index.vue 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/biz/list-export-print/list-export-print.helpers.js 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/biz/list-export-print/list-print-document.js 282 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/biz/list-export-print/list-print-preview-dialog.vue 199 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/core/layouts/art-work-tab/index.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/plugins/iconify.collections.js 328 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/router/adapters/backendMenuAdapter.js 240 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/router/core/MenuProcessor.js 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/store/modules/worktab.js 28 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/utils/backend-menu-title.js 153 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/utils/navigation/route.js 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/menu/index.vue 335 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/menu/modules/menu-dialog.vue 412 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/role/modules/role-edit-dialog.vue 206 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/role/modules/role-permission-dialog.vue 413 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/user-login/index.vue 164 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/user-login/userLoginTable.config.js 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/user/modules/user-search.vue 160 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/user/userPage.helpers.js 287 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/backend-api-base.test.mjs 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/backend-menu-adapter.test.mjs 208 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/iconify-local-minimal.test.mjs 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/iconify-local-prefixes.test.mjs 21 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/list-print-preview-style-contract.test.mjs 202 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/navigation-home-path.test.mjs 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/system-manage-contract.test.mjs 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/system-menu-page-contract.test.mjs 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/system-role-scope-contract.test.mjs 218 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/system-user-page-contract.test.mjs 209 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/user-login-page-contract.test.mjs 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/work-tab-icon-contract.test.mjs 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/worktab-icon-normalization-contract.test.mjs 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/vite.config.js 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/.env.development
@@ -3,11 +3,11 @@
# 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/)
VITE_BASE_URL = /
# API 请求基础路径(开发环境设置为 / 使用代理,生产环境设置为完整后端地址)
VITE_API_URL = /
# API 请求基础路径(开发环境通过 rsf-server 上下文路径走代理)
VITE_API_URL = /rsf-server
# 代理目标地址(开发环境通过 Vite 代理转发请求到此地址,解决跨域问题)
VITE_API_PROXY_URL = http://127.0.0.1:8085/ref-server
VITE_API_PROXY_URL = http://127.0.0.1:8085
# Delete console
VITE_DROP_CONSOLE = false
rsf-design/.env.production
@@ -4,7 +4,7 @@
VITE_BASE_URL = /
# API 地址前缀
VITE_API_URL = https://m1.apifoxmock.com/m1/6400575-6097373-default
VITE_API_URL = /rsf-server
# Delete console
VITE_DROP_CONSOLE = true
rsf-design/package.json
@@ -7,6 +7,7 @@
    "pnpm": ">=8.8.0"
  },
  "scripts": {
    "test": "node --test tests/auth-contract.test.mjs tests/backend-menu-adapter.test.mjs tests/clean-dev-helpers.test.mjs tests/iconify-local-minimal.test.mjs tests/iconify-local-prefixes.test.mjs tests/manual-chunks.test.mjs tests/repo-hygiene.test.mjs tests/system-manage-contract.test.mjs tests/system-role-scope-contract.test.mjs tests/system-user-page-contract.test.mjs tests/work-tab-icon-contract.test.mjs tests/worktab-icon-normalization-contract.test.mjs",
    "dev": "vite --open",
    "build": "vite build",
    "serve": "vite preview",
rsf-design/scripts/build-local-iconify-collections.mjs
@@ -45,13 +45,18 @@
}
function collectUsedIconsByPrefix() {
  const iconPattern = /icon\s*[:=]\s*["']([a-z0-9-]+):([a-z0-9-]+)["']/g
  const iconPattern = /["']([a-z0-9-]+):([a-z0-9-]+)["']/g
  const knownPrefixes = new Set(Object.keys(iconCollections))
  const usedIconsByPrefix = new Map()
  for (const filePath of collectSourceFiles(srcRoot)) {
    const content = fs.readFileSync(filePath, 'utf8')
    for (const [, prefix, name] of content.matchAll(iconPattern)) {
      if (!knownPrefixes.has(prefix)) {
        continue
      }
      const names = usedIconsByPrefix.get(prefix) || new Set()
      names.add(name)
      usedIconsByPrefix.set(prefix, names)
rsf-design/src/api/system-manage.js
@@ -4,11 +4,12 @@
  return {
    current: params.current || 1,
    pageSize: params.pageSize || params.size || 20,
    username: params.username,
    nickname: params.nickname,
    phone: params.phone,
    status: params.status,
    deptId: params.deptId
    ...(params.username !== undefined ? { username: params.username } : {}),
    ...(params.nickname !== undefined ? { nickname: params.nickname } : {}),
    ...(params.phone !== undefined ? { phone: params.phone } : {}),
    ...(params.email !== undefined ? { email: params.email } : {}),
    ...(params.status !== undefined ? { status: params.status } : {}),
    ...(params.deptId !== undefined ? { deptId: params.deptId } : {})
  }
}
@@ -16,10 +17,10 @@
  return {
    current: params.current || 1,
    pageSize: params.pageSize || params.size || 20,
    name: params.name,
    code: params.code,
    memo: params.memo,
    status: params.status
    ...(params.name !== undefined ? { name: params.name } : {}),
    ...(params.code !== undefined ? { code: params.code } : {}),
    ...(params.memo !== undefined ? { memo: params.memo } : {}),
    ...(params.status !== undefined ? { status: params.status } : {})
  }
}
@@ -40,7 +41,8 @@
}
function fetchResetUserPassword(params) {
  return request.post({ url: '/auth/reset/password', params })
  const normalizedParams = normalizeAdminPasswordUpdateParams(params)
  return request.post({ url: '/user/update', params: normalizedParams })
}
function fetchUpdateUserStatus(params) {
@@ -71,32 +73,145 @@
  return request.post({ url: '/role/list', params })
}
function buildRolePageParams(params = {}) {
  return {
    current: params.current || 1,
    pageSize: params.pageSize || params.size || 20,
    ...(params.name !== undefined ? { name: params.name } : {}),
    ...(params.code !== undefined ? { code: params.code } : {}),
    ...(params.memo !== undefined ? { memo: params.memo } : {}),
    ...(params.status !== undefined ? { status: params.status } : {}),
    ...(params.condition !== undefined ? { condition: params.condition } : {})
  }
}
function fetchRolePage(params = {}) {
  return request.post({ url: '/role/page', params: buildRolePageParams(params) })
}
const fetchRolePrintPage = fetchRolePage
function normalizeRoleManyIds(ids) {
  if (Array.isArray(ids)) {
    return ids
      .map((id) => normalizeLegacyId(id))
      .filter((id) => id !== '')
      .join(',')
  }
  return normalizeLegacyId(ids)
}
function fetchGetRoleMany(ids) {
  const normalizedIds = normalizeRoleManyIds(ids)
  return request.post({ url: `/role/many/${normalizedIds}` })
}
async function fetchExportRoleReport(payload = {}, options = {}) {
  return fetch(`${import.meta.env.VITE_API_URL}/role/export`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(options.headers || {})
    },
    body: JSON.stringify(payload)
  })
}
function fetchGetDeptTree(params) {
  return request.post({ url: '/dept/tree', params })
}
function fetchGetMenuTree(params) {
function fetchGetMenuTree(params = {}) {
  return request.post({ url: '/menu/tree', params })
}
function fetchGetRoleScopeList(scopeType, roleId) {
function fetchSaveMenu(params) {
  return request.post({ url: '/menu/save', params })
}
function fetchUpdateMenu(params) {
  return request.post({ url: '/menu/update', params })
}
function fetchDeleteMenu(id) {
  return request.post({ url: `/menu/remove/${id}` })
}
function assertAdminPasswordUpdatePayload(params) {
  if (!isPlainObject(params)) {
    throw new Error('fetchResetUserPassword requires an object payload')
  }
  if (params.id === undefined || params.id === null || params.id === '') {
    throw new Error('fetchResetUserPassword requires a user id')
  }
}
function normalizeAdminPasswordUpdateParams(params) {
  assertAdminPasswordUpdatePayload(params)
  if (params.password !== undefined) {
    return params
  }
  if (params.newPassword !== undefined) {
    return {
      ...params,
      password: params.newPassword
    }
  }
  throw new Error('fetchResetUserPassword requires a password payload')
}
function getScopeListUrl(scopeType) {
  const urlMap = {
    menu: '/role/scope/list',
    pda: '/rolePda/scope/list',
    matnr: '/roleMatnr/scope/list',
    warehouse: '/roleWarehouse/scope/list'
  }
  return request.get({ url: urlMap[scopeType], params: { roleId } })
  const url = urlMap[scopeType]
  if (!url) {
    throw new Error(`Unsupported scope type: ${scopeType}`)
  }
  return url
}
function fetchUpdateRoleScope(scopeType, params) {
function getScopeUpdateUrl(scopeType) {
  const urlMap = {
    menu: '/role/scope/update',
    pda: '/rolePda/scope/update',
    matnr: '/roleMatnr/scope/update',
    warehouse: '/roleWarehouse/scope/update'
  }
  return request.post({ url: urlMap[scopeType], params })
  const url = urlMap[scopeType]
  if (!url) {
    throw new Error(`Unsupported scope type: ${scopeType}`)
  }
  return url
}
function getScopeTreeUrl(scopeType) {
  const urlMap = {
    menu: '/menu/tree',
    pda: '/menuPda/tree',
    matnr: '/menuMatnrGroup/tree',
    warehouse: '/menuWarehouse/tree'
  }
  const url = urlMap[scopeType]
  if (!url) {
    throw new Error(`Unsupported scope type: ${scopeType}`)
  }
  return url
}
function fetchGetRoleScopeList(scopeType, roleId) {
  return request.get({ url: getScopeListUrl(scopeType), params: { roleId } })
}
function fetchUpdateRoleScope(scopeType, params) {
  return request.post({ url: getScopeUpdateUrl(scopeType), params })
}
function fetchGetRoleScopeTree(scopeType, params = {}) {
  return request.post({ url: getScopeTreeUrl(scopeType), params })
}
function fetchGetUserLoginList(params) {
@@ -106,8 +221,242 @@
  })
}
function fetchGetMenuList(params) {
  return fetchGetMenuTree(params)
function fetchGetMenuList(params = {}) {
  return request.post({ url: '/menu/list', params }).then((menuList) => adaptLegacyMenuTree(buildLegacyMenuTree(menuList)))
}
function adaptLegacyMenuTree(menuTree) {
  if (!Array.isArray(menuTree)) {
    return []
  }
  return menuTree
    .map((node) => adaptLegacyMenuNode(node))
    .filter(Boolean)
}
function buildLegacyMenuTree(menuList) {
  if (!Array.isArray(menuList)) {
    return []
  }
  const nodeMap = new Map()
  const roots = []
  menuList.forEach((node) => {
    if (!node || typeof node !== 'object') {
      return
    }
    nodeMap.set(normalizeLegacyId(node.id), {
      ...node,
      children: []
    })
  })
  nodeMap.forEach((node) => {
    const parentId = normalizeLegacyId(node.parentId ?? 0)
    if (parentId && parentId !== '0' && nodeMap.has(parentId)) {
      nodeMap.get(parentId).children.push(node)
      return
    }
    roots.push(node)
  })
  return sortLegacyMenuTree(roots)
}
function sortLegacyMenuTree(nodes) {
  return nodes
    .sort((left, right) => {
      const leftSort = Number(left?.sort ?? 0)
      const rightSort = Number(right?.sort ?? 0)
      if (leftSort !== rightSort) {
        return leftSort - rightSort
      }
      return normalizeLegacyId(left?.id).localeCompare(normalizeLegacyId(right?.id))
    })
    .map((node) => ({
      ...node,
      children: Array.isArray(node.children) ? sortLegacyMenuTree(node.children) : []
    }))
}
function adaptLegacyMenuNode(node) {
  if (!node || typeof node !== 'object' || node.type === 1) {
    return null
  }
  const menuChildren = []
  const authList = []
  const rawChildren = Array.isArray(node.children) ? node.children : []
  rawChildren.forEach((child) => {
    if (!child || typeof child !== 'object') {
      return
    }
    if (child.type === 1) {
      authList.push(buildLegacyAuthItem(child))
      return
    }
    const adaptedChild = adaptLegacyMenuNode(child)
    if (adaptedChild) {
      menuChildren.push(adaptedChild)
    }
  })
  const adapted = {
    id: normalizeLegacyId(node.id),
    parentId: normalizeLegacyId(node.parentId ?? 0),
    parentName: node.parentName || '',
    name: buildLegacyRouteName(node),
    route: typeof node.route === 'string' ? node.route : '',
    path: buildLegacyMenuPath(node),
    component: buildLegacyMenuComponent(node),
    authority: node.authority || '',
    icon: node.icon || '',
    sort: node.sort ?? 0,
    status: node.status ?? 1,
    memo: node.memo || '',
    type: node.type ?? 0,
    meta: buildLegacyMenuMeta(node),
    children: menuChildren
  }
  if (authList.length > 0) {
    adapted.meta.authList = authList
  }
  return adapted
}
function buildLegacyMenuMeta(node) {
  const metaSource = node.meta && typeof node.meta === 'object' ? node.meta : node
  const meta = {
    title: normalizeLegacyTitle(node.name || metaSource.title || '')
  }
  const supportedKeys = [
    'icon',
    'sort',
    'keepAlive',
    'isHide',
    'isHideTab',
    'fixedTab',
    'link',
    'isIframe',
    'roles',
    'showBadge',
    'showTextBadge',
    'activePath',
    'isFullPage'
  ]
  supportedKeys.forEach((key) => {
    if (metaSource[key] !== undefined) {
      meta[key] = metaSource[key]
    }
  })
  meta.isEnable = normalizeLegacyEnableState(metaSource)
  return meta
}
function buildLegacyAuthItem(node) {
  const metaSource = node.meta && typeof node.meta === 'object' ? node.meta : node
  return {
    id: normalizeLegacyId(node.id),
    parentId: normalizeLegacyId(node.parentId ?? 0),
    parentName: node.parentName || '',
    name: node.name || metaSource.title || '',
    title: normalizeLegacyTitle(node.name || metaSource.title || ''),
    route: typeof node.route === 'string' ? node.route : '',
    component: buildLegacyMenuComponent(node),
    authMark: metaSource.authMark || metaSource.authority || metaSource.code || '',
    authority: metaSource.authority || metaSource.authMark || metaSource.code || '',
    icon: metaSource.icon || '',
    sort: metaSource.sort ?? 0,
    status: metaSource.status ?? 1,
    memo: metaSource.memo || '',
    type: node.type ?? 1
  }
}
function buildLegacyMenuPath(node) {
  const rawPath = typeof node.route === 'string' && node.route.trim()
    ? node.route
    : typeof node.path === 'string'
      ? node.path
      : ''
  return normalizeLegacyPath(rawPath)
}
function buildLegacyMenuComponent(node) {
  if (typeof node.component === 'string') {
    return node.component
  }
  return ''
}
function buildLegacyRouteName(node) {
  if (typeof node.name === 'string' && node.name.trim()) {
    return node.name.trim()
  }
  if (typeof node.component === 'string' && node.component.trim()) {
    return node.component.trim()
  }
  const path = buildLegacyMenuPath(node)
  if (path) {
    return path
      .split('/')
      .filter(Boolean)
      .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
      .join('')
  }
  return `Menu${normalizeLegacyId(node.id)}`
}
function normalizeLegacyTitle(title) {
  if (typeof title !== 'string') {
    return ''
  }
  const trimmedTitle = title.trim()
  if (trimmedTitle.startsWith('menu.')) {
    return `menus.${trimmedTitle.slice('menu.'.length)}`
  }
  return trimmedTitle
}
function normalizeLegacyPath(path) {
  if (typeof path !== 'string') {
    return ''
  }
  return path.replace(/^\/+/, '').replace(/\/+$/, '')
}
function normalizeLegacyId(id) {
  if (id === null || id === undefined || id === '') {
    return ''
  }
  return String(id)
}
function normalizeLegacyEnableState(metaSource) {
  if (metaSource.isEnable !== undefined) {
    return Boolean(metaSource.isEnable)
  }
  if (metaSource.enabled !== undefined) {
    return Boolean(metaSource.enabled)
  }
  if (metaSource.statusBool !== undefined) {
    return Boolean(metaSource.statusBool)
  }
  if (metaSource.status !== undefined) {
    return Number(metaSource.status) === 1
  }
  return true
}
function isPlainObject(value) {
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
export {
@@ -123,9 +472,17 @@
  fetchUpdateRole,
  fetchDeleteRole,
  fetchGetRoleOptions,
  fetchRolePrintPage,
  fetchRolePage,
  fetchExportRoleReport,
  fetchGetRoleMany,
  fetchGetDeptTree,
  fetchGetMenuTree,
  fetchSaveMenu,
  fetchUpdateMenu,
  fetchDeleteMenu,
  fetchGetRoleScopeList,
  fetchGetRoleScopeTree,
  fetchUpdateRoleScope,
  fetchGetUserLoginList,
  fetchGetMenuList
rsf-design/src/components/biz/list-export-print/index.vue
New file
@@ -0,0 +1,80 @@
<template>
  <ElSpace wrap>
    <ElButton :disabled="disabled" @click="handleExport">导出</ElButton>
    <ElButton :disabled="disabled" @click="handlePrint">打印</ElButton>
  </ElSpace>
  <ListPrintPreviewDialog
    v-model:visible="previewVisibleModel"
    :title="normalizedMeta.reportTitle"
    :meta="normalizedMeta"
    :rows="previewRows"
    :columns="normalizedPreviewColumns"
    :max-preview-rows="previewMaxRows"
  />
</template>
<script setup>
  import { computed } from 'vue'
  import ListPrintPreviewDialog from './list-print-preview-dialog.vue'
  import {
    buildListExportPayload,
    buildListPrintPayload,
    buildPreviewColumns,
    buildReportStyleMeta
  } from './list-export-print.helpers.js'
  defineOptions({ name: 'ListExportPrint' })
  const props = defineProps({
    reportTitle: { type: String, default: '报表' },
    selectedRows: { type: Array, default: () => [] },
    queryParams: { type: Object, default: () => ({}) },
    columns: { type: Array, default: () => [] },
    previewRows: { type: Array, default: () => [] },
    previewMeta: { type: Object, default: () => ({}) },
    previewMaxRows: { type: Number, default: 50 },
    total: { type: Number, default: 0 },
    maxResults: { type: Number, default: 1000 },
    disabled: { type: Boolean, default: false }
  })
  const emit = defineEmits(['export', 'print'])
  const normalizedMeta = computed(() =>
    buildReportStyleMeta({
      reportTitle: props.reportTitle,
      meta: props.previewMeta
    })
  )
  const normalizedPreviewColumns = computed(() =>
    buildPreviewColumns({
      columns: props.columns,
      reportStyle: normalizedMeta.value.reportStyle
    })
  )
  const previewVisibleModel = defineModel('previewVisible', {
    type: Boolean,
    default: false
  })
  const handleExport = () => {
    const payload = buildListExportPayload({
      reportTitle: props.reportTitle,
      selectedRows: props.selectedRows,
      queryParams: props.queryParams,
      columns: props.columns,
      meta: normalizedMeta.value
    })
    emit('export', payload)
  }
  const handlePrint = () => {
    const payload = buildListPrintPayload({
      selectedRows: props.selectedRows,
      queryParams: props.queryParams,
      total: props.total,
      maxResults: props.maxResults
    })
    emit('print', payload)
  }
</script>
rsf-design/src/components/biz/list-export-print/list-export-print.helpers.js
New file
@@ -0,0 +1,155 @@
const DEFAULT_MAX_RESULTS = 1000
const DEFAULT_PRINT_PAGE_SIZE = 20
const HIDDEN_EXPORT_COLUMNS = new Set(['selection', 'operation'])
const DEFAULT_REPORT_STYLE = {
  titleAlign: 'center',
  titleLevel: 'strong',
  orientation: 'portrait',
  density: 'compact',
  showSequence: true,
  showBorder: true
}
function normalizeSelectedIds(selectedRows = []) {
  if (!Array.isArray(selectedRows)) {
    return []
  }
  return selectedRows
    .map((row) => row?.id)
    .filter((id) => id !== void 0 && id !== null)
}
function normalizeFlatQueryParams(queryParams = {}) {
  if (!queryParams || typeof queryParams !== 'object') {
    return {}
  }
  const { current, pageSize, size, ...rest } = queryParams
  const normalizedPageSize = pageSize ?? size
  return {
    ...(current !== void 0 ? { current } : {}),
    ...(normalizedPageSize !== void 0 ? { pageSize: normalizedPageSize } : {}),
    ...rest
  }
}
export function toExportColumns(columns = []) {
  if (!Array.isArray(columns)) {
    return []
  }
  return columns
    .map((column) => {
      if (!column || typeof column !== 'object') {
        return null
      }
      const source = column.source ?? column.prop
      const label = column.label
      if (!source || !label || HIDDEN_EXPORT_COLUMNS.has(source)) {
        return null
      }
      return {
        source,
        label,
        ...(column.align !== void 0 ? { align: column.align } : {})
      }
    })
    .filter(Boolean)
}
export function buildReportStyleMeta({ reportTitle = '', meta = {} } = {}) {
  const reportStyle = {
    ...DEFAULT_REPORT_STYLE,
    ...(meta?.reportStyle ?? {})
  }
  return {
    ...meta,
    reportTitle,
    reportStyle
  }
}
export function buildPreviewColumns({ columns = [], reportStyle = {} } = {}) {
  const normalizedColumns = toExportColumns(columns)
  if (reportStyle?.showSequence === false) {
    return normalizedColumns
  }
  return [
    { source: '__sequence__', label: '序号', align: 'center' },
    ...normalizedColumns
  ]
}
export function buildListExportPayload({
  reportTitle = '',
  selectedRows = [],
  queryParams = {},
  columns = [],
  meta = {}
} = {}) {
  const ids = normalizeSelectedIds(selectedRows)
  const normalizedColumns = toExportColumns(columns)
  const reportMeta = {
    reportTitle,
    ...meta
  }
  if (ids.length > 0) {
    return {
      ids,
      columns: normalizedColumns,
      meta: reportMeta
    }
  }
  return {
    ...normalizeFlatQueryParams(queryParams),
    ids,
    columns: normalizedColumns,
    meta: reportMeta
  }
}
export function buildPrintPageQuery({ queryParams = {}, total = 0, maxResults = DEFAULT_MAX_RESULTS } = {}) {
  const normalizedQueryParams = normalizeFlatQueryParams(queryParams)
  const normalizedTotal = Number(total)
  const parsedMaxResults = Number(maxResults)
  const normalizedMaxResults =
    Number.isInteger(parsedMaxResults) && parsedMaxResults > 0 ? parsedMaxResults : DEFAULT_MAX_RESULTS
  const fallbackPageSize = Number(normalizedQueryParams.pageSize)
  let pageSize = DEFAULT_PRINT_PAGE_SIZE
  if (Number.isFinite(normalizedTotal) && normalizedTotal > 0) {
    pageSize = Math.min(normalizedTotal, normalizedMaxResults)
  } else if (Number.isFinite(fallbackPageSize) && fallbackPageSize > 0) {
    pageSize = Math.min(fallbackPageSize, normalizedMaxResults)
  }
  return {
    ...normalizedQueryParams,
    current: 1,
    pageSize
  }
}
export function buildListPrintPayload({
  selectedRows = [],
  queryParams = {},
  total = 0,
  maxResults = DEFAULT_MAX_RESULTS
} = {}) {
  const ids = normalizeSelectedIds(selectedRows)
  if (ids.length > 0) {
    return { ids }
  }
  return buildPrintPageQuery({ queryParams, total, maxResults })
}
export { DEFAULT_MAX_RESULTS }
rsf-design/src/components/biz/list-export-print/list-print-document.js
New file
@@ -0,0 +1,282 @@
function escapeHtml(value) {
  return String(value ?? '')
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;')
}
function getReportStyle(meta = {}) {
  return meta?.reportStyle && typeof meta.reportStyle === 'object' ? meta.reportStyle : {}
}
function getTitleAlignClass(titleAlign) {
  const alignMap = {
    left: 'title-left',
    center: 'title-center',
    right: 'title-right'
  }
  return alignMap[titleAlign] ?? alignMap.center
}
function getTitleLevelClass(titleLevel) {
  const levelMap = {
    normal: 'title-normal',
    strong: 'title-strong',
    prominent: 'title-prominent'
  }
  return levelMap[titleLevel] ?? levelMap.strong
}
function getPageOrientation(reportStyle = {}) {
  return reportStyle.orientation === 'landscape' ? 'landscape' : 'portrait'
}
function getMetaItems(meta = {}) {
  return [
    { key: 'reportDate', label: '报表日期', value: meta.reportDate ?? '--' },
    { key: 'operator', label: '打印人', value: meta.operator ?? '--' },
    { key: 'printedAt', label: '打印时间', value: meta.printedAt ?? '--' },
    { key: 'count', label: '记录数', value: meta.count ?? '--' }
  ]
}
function getCellText(row, column, index) {
  if (column?.source === '__sequence__') {
    return index + 1
  }
  return row?.[column?.source] ?? '--'
}
function getAlignClass(column = {}) {
  const alignMap = {
    left: 'cell-left',
    center: 'cell-center',
    right: 'cell-right'
  }
  return alignMap[column.align] ?? alignMap.left
}
export function buildPrintDocumentHtml({ title = '报表', meta = {}, rows = [], columns = [] } = {}) {
  const reportStyle = getReportStyle(meta)
  const reportTitle = meta.reportTitle || title
  const titleClass = `${getTitleAlignClass(reportStyle.titleAlign)} ${getTitleLevelClass(reportStyle.titleLevel)}`
  const orientation = getPageOrientation(reportStyle)
  const showBorder = reportStyle.showBorder !== false
  const metaItems = getMetaItems(meta)
  const tableWrapClass = showBorder ? 'report-table-wrap' : 'report-table-wrap report-table-wrap-borderless'
  const cellClassName = showBorder ? 'cell-bordered' : 'cell-borderless'
  const headerHtml = columns
    .map(
      (column) =>
        `<th class="${getAlignClass(column)} ${cellClassName}">${escapeHtml(column.label ?? '--')}</th>`
    )
    .join('')
  const bodyHtml =
    rows.length > 0
      ? rows
          .map((row, index) => {
            const cells = columns
              .map(
                (column) =>
                  `<td class="${getAlignClass(column)} ${cellClassName}">${escapeHtml(getCellText(row, column, index))}</td>`
              )
              .join('')
            return `<tr>${cells}</tr>`
          })
          .join('')
      : `<tr><td colspan="${Math.max(columns.length, 1)}" class="empty-cell">暂无打印数据</td></tr>`
  const metaHtml = metaItems
    .map(
      (item) =>
        `<div class="report-meta-item"><span class="report-meta-label">${escapeHtml(item.label)}</span><span class="report-meta-value">${escapeHtml(item.value)}</span></div>`
    )
    .join('')
  return `<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>${escapeHtml(reportTitle)}</title>
    <style>
      :root {
        color-scheme: light;
      }
      * {
        box-sizing: border-box;
      }
      @page {
        size: A4 ${orientation};
      }
      html,
      body {
        margin: 0;
        padding: 0;
        background: #ffffff;
        color: #0f172a;
        font-family: "Microsoft YaHei", "PingFang SC", "Helvetica Neue", Arial, sans-serif;
        -webkit-print-color-adjust: exact;
        print-color-adjust: exact;
      }
      body {
        padding: 0;
      }
      .report-page {
        width: 100%;
      }
      .report-title-wrap {
        border-bottom: 1px solid #cbd5e1;
        padding-bottom: 16px;
      }
      .report-title {
        line-height: 1.2;
        color: #0f172a;
      }
      .title-left {
        text-align: left;
      }
      .title-center {
        text-align: center;
      }
      .title-right {
        text-align: right;
      }
      .title-normal {
        font-size: 18px;
        font-weight: 500;
      }
      .title-strong {
        font-size: 22px;
        font-weight: 600;
      }
      .title-prominent {
        font-size: 26px;
        font-weight: 700;
      }
      .report-meta {
        display: grid;
        grid-template-columns: repeat(4, minmax(0, 1fr));
        gap: 10px;
        margin-top: 12px;
        font-size: 11px;
        color: #475569;
      }
      .report-meta-item {
        min-width: 0;
      }
      .report-meta-label {
        color: #94a3b8;
      }
      .report-meta-value {
        margin-left: 8px;
        color: #334155;
        word-break: break-word;
      }
      .report-table-wrap {
        margin-top: 16px;
        border: 1px solid #cbd5e1;
        overflow: hidden;
      }
      .report-table-wrap-borderless {
        border: 0;
      }
      table {
        width: 100%;
        border-collapse: collapse;
        table-layout: auto;
        font-size: 11px;
      }
      th,
      td {
        padding: 4px 6px;
        vertical-align: top;
        word-break: break-all;
      }
      .cell-bordered {
        border: 1px solid #cbd5e1;
      }
      .cell-borderless {
        border: 0;
      }
      th {
        background: #f1f5f9;
        color: #475569;
        font-weight: 600;
      }
      td {
        color: #334155;
      }
      .cell-left {
        text-align: left;
      }
      .cell-center {
        text-align: center;
      }
      .cell-right {
        text-align: right;
      }
      .empty-cell {
        padding: 32px 12px;
        text-align: center;
        color: #94a3b8;
      }
      @media print {
        html,
        body {
          background: #ffffff;
        }
        body {
          padding: 0;
        }
      }
    </style>
  </head>
  <body>
    <div class="report-page">
      <div class="report-title-wrap">
        <div class="report-title ${titleClass}">${escapeHtml(reportTitle)}</div>
      </div>
      <div class="report-meta">${metaHtml}</div>
      <div class="${tableWrapClass}">
        <table>
          <thead>
            <tr>${headerHtml}</tr>
          </thead>
          <tbody>${bodyHtml}</tbody>
        </table>
      </div>
    </div>
    <script>
      window.addEventListener('load', () => {
        window.focus();
        setTimeout(() => {
          window.print();
        }, 50);
      });
      window.addEventListener('afterprint', () => {
        window.close();
      });
    </script>
  </body>
</html>`
}
export function printReportDocument(payload = {}) {
  if (typeof window === 'undefined') {
    return
  }
  const printWindow = window.open('', '_blank')
  if (!printWindow) {
    return
  }
  printWindow.document.open()
  printWindow.document.write(buildPrintDocumentHtml(payload))
  printWindow.document.close()
}
rsf-design/src/components/biz/list-export-print/list-print-preview-dialog.vue
New file
@@ -0,0 +1,199 @@
<template>
  <ElDialog
    v-model="visible"
    :title="title"
    width="min(96vw, 1100px)"
    top="4vh"
    class="max-h-[88vh] overflow-hidden"
  >
    <div class="flex max-h-[calc(88vh-160px)] min-h-0 flex-col bg-slate-100 px-4 py-4 md:px-6 md:py-6">
      <div
        :class="paperClass"
        :style="paperStyle"
        class="mx-auto flex min-h-0 flex-1 flex-col overflow-hidden bg-white px-7 py-6 text-slate-800 shadow-sm md:px-8 md:py-7"
      >
        <div class="flex flex-wrap items-center justify-end gap-2 pb-2">
          <ElRadioGroup v-model="currentShowBorder" size="small">
            <ElRadioButton :label="true">边框开</ElRadioButton>
            <ElRadioButton :label="false">边框关</ElRadioButton>
          </ElRadioGroup>
          <ElRadioGroup v-model="currentOrientation" size="small">
            <ElRadioButton label="portrait">竖版</ElRadioButton>
            <ElRadioButton label="landscape">横版</ElRadioButton>
          </ElRadioGroup>
        </div>
        <div class="border-b border-slate-300 pb-3">
          <div :class="titleClass">{{ meta.reportTitle ?? '--' }}</div>
        </div>
        <div class="mt-3 grid grid-cols-4 gap-2 text-[11px] leading-tight text-slate-600">
          <div v-for="item in metaItems" :key="item.key" class="min-w-0">
            <span class="text-slate-400">{{ item.label }}</span>
            <span class="ml-2 break-words text-slate-700">{{ item.value ?? '--' }}</span>
          </div>
        </div>
        <div
          v-if="hiddenRowCount > 0"
          class="mt-3 rounded-md border border-amber-200 bg-amber-50 px-2.5 py-1.5 text-[11px] leading-tight text-amber-700"
        >
          预览仅展示前 {{ previewRows.length }} 条,点击打印将输出全部 {{ rows.length }} 条数据。
        </div>
        <div :class="tableWrapClass" class="mt-4 min-h-0 flex-1 overflow-auto">
          <table class="w-full table-auto border-collapse text-left text-[11px] leading-tight">
            <thead class="bg-slate-100 text-slate-600">
              <tr>
                <th
                  v-for="column in columns"
                  :key="column.source"
                  :class="headerCellClass"
                >
                  {{ column.label }}
                </th>
              </tr>
            </thead>
            <tbody>
              <tr v-if="previewRows.length === 0">
                <td
                  :colspan="Math.max(columns.length, 1)"
                  :class="emptyCellClass"
                >
                  暂无打印数据
                </td>
              </tr>
              <tr v-for="(row, index) in previewRows" :key="row?.id ?? index">
                <td
                  v-for="column in columns"
                  :key="column.source"
                  :class="bodyCellClass"
                >
                  {{ column.source === '__sequence__' ? index + 1 : row?.[column.source] ?? '--' }}
                </td>
              </tr>
            </tbody>
          </table>
        </div>
      </div>
    </div>
    <template #footer>
      <div class="flex items-center justify-end gap-2 print:hidden">
        <ElButton @click="visible = false">关闭</ElButton>
        <ElButton type="primary" @click="handlePrint">打印</ElButton>
      </div>
    </template>
  </ElDialog>
</template>
<script setup>
  import { computed, ref, watch } from 'vue'
  import { printReportDocument } from './list-print-document.js'
  defineOptions({ name: 'ListPrintPreviewDialog' })
  const visible = defineModel('visible', { type: Boolean, default: false })
  const props = defineProps({
    title: { type: String, default: '打印预览' },
    meta: { type: Object, default: () => ({}) },
    rows: { type: Array, default: () => [] },
    columns: { type: Array, default: () => [] },
    maxPreviewRows: { type: Number, default: 50 }
  })
  const meta = computed(() => (props.meta && typeof props.meta === 'object' ? props.meta : {}))
  const reportStyleSource = computed(() =>
    meta.value.reportStyle && typeof meta.value.reportStyle === 'object'
      ? meta.value.reportStyle
      : {}
  )
  const currentOrientation = ref('portrait')
  const currentShowBorder = ref(true)
  const metaItems = computed(() => {
    return [
      { key: 'reportDate', label: '报表日期', value: meta.value.reportDate },
      { key: 'operator', label: '打印人', value: meta.value.operator },
      { key: 'printedAt', label: '打印时间', value: meta.value.printedAt },
      { key: 'count', label: '记录数', value: meta.value.count }
    ]
  })
  const previewRows = computed(() => props.rows.slice(0, props.maxPreviewRows))
  const hiddenRowCount = computed(() => Math.max(props.rows.length - previewRows.value.length, 0))
  const effectiveMeta = computed(() => ({
    ...meta.value,
    reportStyle: {
      ...reportStyleSource.value,
      orientation: currentOrientation.value,
      showBorder: currentShowBorder.value
    }
  }))
  const paperClass = computed(() =>
    currentOrientation.value === 'landscape'
      ? 'w-full max-w-[980px]'
      : 'w-full max-w-[760px]'
  )
  const paperStyle = computed(() => ({
    aspectRatio: currentOrientation.value === 'landscape' ? '297 / 210' : '210 / 297',
    width:
      currentOrientation.value === 'landscape'
        ? 'min(100%, calc((88vh - 240px) * 297 / 210))'
        : 'min(100%, calc((88vh - 240px) * 210 / 297))'
  }))
  const titleClass = computed(() => {
    const alignMap = {
      left: 'text-left',
      center: 'text-center',
      right: 'text-right'
    }
    const levelMap = {
      normal: 'text-[18px] font-medium',
      strong: 'text-[22px] font-semibold',
      prominent: 'text-[26px] font-bold'
    }
    const reportStyle = reportStyleSource.value
    const alignClass = alignMap[reportStyle.titleAlign] ?? alignMap.center
    const levelClass = levelMap[reportStyle.titleLevel] ?? levelMap.strong
    return `${alignClass} ${levelClass} leading-tight text-slate-900`
  })
  const tableWrapClass = computed(() =>
    currentShowBorder.value ? 'border border-slate-300' : 'border border-transparent'
  )
  const headerCellClass = computed(() =>
    currentShowBorder.value
      ? 'border border-slate-300 px-1.5 py-1.5 font-medium break-all'
      : 'border-0 px-1.5 py-1.5 font-medium break-all'
  )
  const bodyCellClass = computed(() =>
    currentShowBorder.value
      ? 'border border-slate-300 px-1.5 py-1.5 text-slate-700 break-all align-top'
      : 'border-0 px-1.5 py-1.5 text-slate-700 break-all align-top'
  )
  const emptyCellClass = computed(() =>
    currentShowBorder.value
      ? 'border border-slate-300 px-3 py-6 text-center text-slate-400'
      : 'border-0 px-3 py-6 text-center text-slate-400'
  )
  watch(
    reportStyleSource,
    (reportStyle) => {
      currentOrientation.value = reportStyle.orientation === 'landscape' ? 'landscape' : 'portrait'
      currentShowBorder.value = reportStyle.showBorder !== false
    },
    { immediate: true, deep: true }
  )
  const handlePrint = () => {
    printReportDocument({
      title: props.title,
      meta: effectiveMeta.value,
      rows: props.rows,
      columns: props.columns
    })
  }
</script>
rsf-design/src/components/core/layouts/art-work-tab/index.vue
@@ -39,7 +39,7 @@
          @contextmenu.prevent="(e) => showMenu(e, item.path)"
        >
          <ArtSvgIcon
            v-show="item.icon"
            v-if="item.icon"
            :icon="item.icon"
            class="text-base mr-1 group-hover:text-theme"
            :class="item.path === activeTab ? 'text-theme' : 'text-g-600'"
rsf-design/src/plugins/iconify.collections.js
@@ -12,68 +12,6 @@
    prefix: 'icon-park-outline',
    width: 48
  },
  'line-md': {
    height: 24,
    icons: {
      'coffee-half-empty-filled-loop': {
        body: '<defs><mask id="SVG5AkzhcyZ"><path fill="none" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M12 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M16 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4"><animate attributeName="d" dur="3s" repeatCount="indefinite" values="M8 0c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M12 0c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M16 0c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4;M8 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M12 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4M16 -8c0 2 -2 2 -2 4s2 2 2 4s-2 2 -2 4s2 2 2 4"/></path><path d="M4 7h16v0h-16v12h16v-32h-16Z"><animate fill="freeze" attributeName="d" begin="1s" dur="0.6s" to="M4 2h16v5h-16v12h16v-24h-16Z"/></path></mask></defs><path fill="currentColor" fill-opacity="0" d="M17 14v4c0 1.66 -1.34 3 -3 3h-6c-1.66 0 -3 -1.34 -3 -3v-4Z"><animate fill="freeze" attributeName="fill-opacity" begin="1.6s" dur="0.4s" to="1"/></path><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path stroke-dasharray="48" d="M17 9v9c0 1.66 -1.34 3 -3 3h-6c-1.66 0 -3 -1.34 -3 -3v-9Z"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.6s" values="48;0"/></path><path stroke-dasharray="16" stroke-dashoffset="16" d="M17 9h3c0.55 0 1 0.45 1 1v3c0 0.55 -0.45 1 -1 1h-3"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.6s" dur="0.3s" to="0"/></path></g><path fill="currentColor" d="M0 0h24v24H0z" mask="url(#SVG5AkzhcyZ)"/>'
      },
      'github-twotone': {
        body: '<path fill="currentColor" fill-opacity="0" d="M15 4.5c-0.39 -0.1 -1.33 -0.5 -3 -0.5c-1.67 0 -2.61 0.4 -3 0.5c-0.53 -0.43 -1.94 -1.5 -3.5 -1.5c-0.34 1 -0.29 2.22 0 3c-0.75 1 -1 2 -1 3.5c0 2.19 0.48 3.58 1.5 4.5c1.02 0.92 2.11 1.37 3.5 1.5c-0.65 0.54 -0.5 1.87 -0.5 2.5v4h6v-4c0 -0.63 0.15 -1.96 -0.5 -2.5c1.39 -0.13 2.48 -0.58 3.5 -1.5c1.02 -0.92 1.5 -2.31 1.5 -4.5c0 -1.5 -0.25 -2.5 -1 -3.5c0.29 -0.78 0.34 -2 0 -3c-1.56 0 -2.97 1.07 -3.5 1.5Z"><animate fill="freeze" attributeName="fill-opacity" begin="0.9s" dur="0.15s" to=".3"/></path><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path stroke-dasharray="32" d="M12 4c1.67 0 2.61 0.4 3 0.5c0.53 -0.43 1.94 -1.5 3.5 -1.5c0.34 1 0.29 2.22 0 3c0.75 1 1 2 1 3.5c0 2.19 -0.48 3.58 -1.5 4.5c-1.02 0.92 -2.11 1.37 -3.5 1.5c0.65 0.54 0.5 1.87 0.5 2.5c0 0.73 0 3 0 3M12 4c-1.67 0 -2.61 0.4 -3 0.5c-0.53 -0.43 -1.94 -1.5 -3.5 -1.5c-0.34 1 -0.29 2.22 0 3c-0.75 1 -1 2 -1 3.5c0 2.19 0.48 3.58 1.5 4.5c1.02 0.92 2.11 1.37 3.5 1.5c-0.65 0.54 -0.5 1.87 -0.5 2.5c0 0.73 0 3 0 3"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.6s" values="32;0"/></path><path stroke-dasharray="10" stroke-dashoffset="10" d="M9 19c-1.41 0 -2.84 -0.56 -3.69 -1.19c-0.84 -0.63 -1.09 -1.66 -2.31 -2.31"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.7s" dur="0.2s" to="0"/></path></g>'
      },
      'phone-call-twotone-loop': {
        body: '<g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path fill="currentColor" fill-opacity="0" stroke-dasharray="62" d="M8 3c0.5 0 2.5 4.5 2.5 5c0 1 -1.5 2 -2 3c-0.5 1 0.5 2 1.5 3c0.39 0.39 2 2 3 1.5c1 -0.5 2 -2 3 -2c0.5 0 5 2 5 2.5c0 2 -1.5 3.5 -3 4c-1.5 0.5 -2.5 0.5 -4.5 0c-2 -0.5 -3.5 -1 -6 -3.5c-2.5 -2.5 -3 -4 -3.5 -6c-0.5 -2 -0.5 -3 0 -4.5c0.5 -1.5 2 -3 4 -3Z"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.6s" values="62;0"/><animateTransform attributeName="transform" dur="2.7s" keyTimes="0;0.035;0.07;0.105;0.14;0.175;0.21;0.245;0.28;1" repeatCount="indefinite" type="rotate" values="0 12 12;15 12 12;0 12 12;-12 12 12;0 12 12;12 12 12;0 12 12;-15 12 12;0 12 12;0 12 12"/><animate fill="freeze" attributeName="fill-opacity" begin="1.3s" dur="0.15s" to=".3"/></path><g fill="none"><path stroke-dasharray="6" stroke-dashoffset="6" d="M15.76 8.28c-0.5 -0.51 -1.1 -0.93 -1.76 -1.24M15.76 8.28c0.49 0.49 0.9 1.08 1.2 1.72"><animate attributeName="stroke-dashoffset" begin="0.7s" dur="2.7s" keyTimes="0;0.15;0.3;1" repeatCount="indefinite" values="6;0;6;6"/></path><path stroke-dasharray="8" stroke-dashoffset="8" d="M18.67 5.35c-1 -1 -2.26 -1.73 -3.67 -2.1M18.67 5.35c0.99 1 1.72 2.25 2.08 3.65"><animate attributeName="stroke-dashoffset" begin="1s" dur="2.7s" keyTimes="0;0.15;0.3;1" repeatCount="indefinite" values="8;0;8;8"/></path></g></g>'
      },
      'reddit-loop': {
        body: '<defs><mask id="SVGC2McScuF"><g fill="#fff"><path fill-opacity="0" stroke="#fff" stroke-dasharray="46" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9.42c4.42 0 8 2.37 8 5.29c0 2.92 -3.58 5.29 -8 5.29c-4.42 0 -8 -2.37 -8 -5.29c0 -2.92 3.58 -5.29 8 -5.29Z"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.6s" values="46;0"/><animate fill="freeze" attributeName="fill-opacity" begin="0.6s" dur="0.4s" to="1"/></path><path d="M3.94 9.73c1.24 0 2.24 1 2.24 2.24c0 1.24 -1 2.24 -2.24 2.24c-1.24 0 -2.24 -1 -2.24 -2.24c0 -1.24 1 -2.24 2.24 -2.24ZM20.06 9.73c1.24 0 2.24 1 2.24 2.24c0 1.24 -1 2.24 -2.24 2.24c-1.24 0 -2.24 -1 -2.24 -2.24c0 -1.24 1 -2.24 2.24 -2.24Z" opacity="0"><set fill="freeze" attributeName="opacity" begin="1s" to="1"/><animate fill="freeze" attributeName="d" begin="1s" dur="0.2s" values="M7.24 9.73c1.24 0 2.24 1 2.24 2.24c0 1.24 -1 2.24 -2.24 2.24c-1.24 0 -2.24 -1 -2.24 -2.24c0 -1.24 1 -2.24 2.24 -2.24ZM16.76 9.73c1.24 0 2.24 1 2.24 2.24c0 1.24 -1 2.24 -2.24 2.24c-1.24 0 -2.24 -1 -2.24 -2.24c0 -1.24 1 -2.24 2.24 -2.24Z;M3.94 9.73c1.24 0 2.24 1 2.24 2.24c0 1.24 -1 2.24 -2.24 2.24c-1.24 0 -2.24 -1 -2.24 -2.24c0 -1.24 1 -2.24 2.24 -2.24ZM20.06 9.73c1.24 0 2.24 1 2.24 2.24c0 1.24 -1 2.24 -2.24 2.24c-1.24 0 -2.24 -1 -2.24 -2.24c0 -1.24 1 -2.24 2.24 -2.24Z"/></path><circle cx="18.45" cy="4.23" r="1.61" opacity="0"><animate attributeName="cx" begin="2s" dur="6s" keyTimes="0;0.5;1" repeatCount="indefinite" values="18.45;5.75;18.45"/><set fill="freeze" attributeName="opacity" begin="2.2s" to="1"/></circle></g><circle cx="8.45" cy="13.59" r="1.61" opacity="0"><animate fill="freeze" attributeName="opacity" begin="1.2s" dur="0.4s" to="1"/></circle><circle cx="15.55" cy="13.59" r="1.61" opacity="0"><animate fill="freeze" attributeName="opacity" begin="1.6s" dur="0.4s" to="1"/></circle><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width=".8"><path stroke="#fff" stroke-dasharray="14" stroke-dashoffset="14" d="M12 8.75l1.18 -5.64l5.03 1.07"><animate attributeName="d" begin="2s" dur="6s" keyTimes="0;0.25;0.5;0.75;1" repeatCount="indefinite" values="M12 8.75l1.18 -5.64l5.03 1.07;M12 8.75l0 -6.75l0 2.18;M12 8.75l-1.18 -5.64l-5.03 1.07;M12 8.75l0 -6.75l0 2.18;M12 8.75l1.18 -5.64l5.03 1.07"/><animate fill="freeze" attributeName="stroke-dashoffset" begin="2s" dur="0.2s" to="0"/></path><path stroke="#000" stroke-dasharray="10" stroke-dashoffset="10" d="M8.47 17.52c0 0 0.94 1.06 3.53 1.06c2.58 0 3.53 -1.06 3.53 -1.06"><animate fill="freeze" attributeName="stroke-dashoffset" begin="2s" dur="0.2s" to="0"/></path></g></mask></defs><path fill="currentColor" d="M0 0h24v24H0z" mask="url(#SVGC2McScuF)"/>'
      },
      'sun-rising-filled-loop': {
        body: '<g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path fill="currentColor" d="M12 6c3.31 0 6 2.69 6 6c0 3.31 -2.69 6 -6 6c-3.31 0 -6 -2.69 -6 -6c0 -3.31 2.69 -6 6 -6Z"><animate fill="freeze" attributeName="d" dur="0.6s" values="M12 26c3.31 0 6 2.69 6 6c0 3.31 -2.69 6 -6 6c-3.31 0 -6 -2.69 -6 -6c0 -3.31 2.69 -6 6 -6Z;M12 6c3.31 0 6 2.69 6 6c0 3.31 -2.69 6 -6 6c-3.31 0 -6 -2.69 -6 -6c0 -3.31 2.69 -6 6 -6Z"/></path><g fill="none"><path d="M12 21v1M21 12h1M12 3v-1M3 12h-1" opacity="0"><animateTransform attributeName="transform" dur="30s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/><set fill="freeze" attributeName="opacity" begin="0.7s" to="1"/><animate fill="freeze" attributeName="d" begin="0.7s" dur="0.2s" values="M12 19v1M19 12h1M12 5v-1M5 12h-1;M12 21v1M21 12h1M12 3v-1M3 12h-1"/></path><path d="M18.5 18.5l0.5 0.5M18.5 5.5l0.5 -0.5M5.5 5.5l-0.5 -0.5M5.5 18.5l-0.5 0.5" opacity="0"><animateTransform attributeName="transform" dur="30s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/><set fill="freeze" attributeName="opacity" begin="0.9s" to="1"/><animate fill="freeze" attributeName="d" begin="0.9s" dur="0.2s" values="M17 17l0.5 0.5M17 7l0.5 -0.5M7 7l-0.5 -0.5M7 17l-0.5 0.5;M18.5 18.5l0.5 0.5M18.5 5.5l0.5 -0.5M5.5 5.5l-0.5 -0.5M5.5 18.5l-0.5 0.5"/></path></g></g>'
      },
      'switch-off': {
        body: '<path fill="none" stroke="currentColor" stroke-dasharray="54" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 7h5c2.76 0 5 2.24 5 5c0 2.76 -2.24 5 -5 5h-10c-2.76 0 -5 -2.24 -5 -5c0 -2.76 2.24 -5 5 -5Z"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.6s" values="54;0"/></path><circle cx="7" cy="12" r="3" fill="currentColor" opacity="0"><animate fill="freeze" attributeName="opacity" begin="0.6s" dur="0.2s" to="1"/></circle>'
      },
      'volume-high-filled': {
        body: '<g fill="currentColor"><path fill-opacity="0" stroke="currentColor" stroke-dasharray="34" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 10h3.5l3.5 -3.5v10.5l-3.5 -3.5h-3.5Z"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.4s" values="34;0"/><animate fill="freeze" attributeName="fill-opacity" begin="0.8s" dur="0.4s" to="1"/></path><path d="M14 12c0 0 0 0 0 0c0 0 0 0 0 0Z"><animate fill="freeze" attributeName="d" begin="0.4s" dur="0.2s" to="M14 16c1.5 -0.71 2.5 -2.24 2.5 -4c0 -1.77 -1 -3.26 -2.5 -4Z"/></path><path d="M14 12c0 0 0 0 0 0c0 0 0 0 0 0v0c0 0 0 0 0 0c0 0 0 0 0 0Z"><animate fill="freeze" attributeName="d" begin="0.4s" dur="0.4s" to="M14 3.23c4 0.91 7 4.49 7 8.77c0 4.28 -3 7.86 -7 8.77v-2.07c2.89 -0.86 5 -3.53 5 -6.7c0 -3.17 -2.11 -5.85 -5 -6.71Z"/></path></g>'
      },
      telegram: {
        body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path stroke-dasharray="18" d="M21 5l-2.5 15M21 5l-12 8.5"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.4s" values="18;0"/></path><path stroke-dasharray="24" d="M21 5l-19 7.5"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.4s" values="24;0"/></path><path stroke-dasharray="14" stroke-dashoffset="14" d="M18.5 20l-9.5 -6.5"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.4s" dur="0.3s" to="0"/></path><path stroke-dasharray="10" stroke-dashoffset="10" d="M2 12.5l7 1"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.4s" dur="0.3s" to="0"/></path><path stroke-dasharray="8" stroke-dashoffset="8" d="M12 16l-3 3M9 13.5l0 5.5"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.7s" dur="0.3s" to="0"/></path></g>'
      }
    },
    prefix: 'line-md',
    width: 24
  },
  'svg-spinners': {
    height: 24,
    icons: {
      '3-dots-bounce': {
        body: '<circle cx="4" cy="12" r="3" fill="currentColor"><animate id="SVGKiXXedfO" attributeName="cy" begin="0;SVGgLulOGrw.end+0.25s" calcMode="spline" dur="0.6s" keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12"/></circle><circle cx="12" cy="12" r="3" fill="currentColor"><animate attributeName="cy" begin="SVGKiXXedfO.begin+0.1s" calcMode="spline" dur="0.6s" keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12"/></circle><circle cx="20" cy="12" r="3" fill="currentColor"><animate id="SVGgLulOGrw" attributeName="cy" begin="SVGKiXXedfO.begin+0.2s" calcMode="spline" dur="0.6s" keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12"/></circle>'
      },
      '3-dots-fade': {
        body: '<circle cx="4" cy="12" r="3" fill="currentColor"><animate id="SVG7x14Dcom" fill="freeze" attributeName="opacity" begin="0;SVGqSjG0dUp.end-0.25s" dur="0.75s" values="1;.2"/></circle><circle cx="12" cy="12" r="3" fill="currentColor" opacity=".4"><animate fill="freeze" attributeName="opacity" begin="SVG7x14Dcom.begin+0.15s" dur="0.75s" values="1;.2"/></circle><circle cx="20" cy="12" r="3" fill="currentColor" opacity=".3"><animate id="SVGqSjG0dUp" fill="freeze" attributeName="opacity" begin="SVG7x14Dcom.begin+0.3s" dur="0.75s" values="1;.2"/></circle>'
      },
      '3-dots-move': {
        body: '<circle cx="4" cy="12" r="0" fill="currentColor"><animate fill="freeze" attributeName="r" begin="0;SVGUppsBdVN.end" calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="0;3"/><animate fill="freeze" attributeName="cx" begin="SVGqCgsydxJ.end" calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="4;12"/><animate fill="freeze" attributeName="cx" begin="SVG3PwDNd6F.end" calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="12;20"/><animate id="SVG3V8yEdYE" fill="freeze" attributeName="r" begin="SVG6wCQhd9Q.end" calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="3;0"/><animate id="SVGUppsBdVN" fill="freeze" attributeName="cx" begin="SVG3V8yEdYE.end" dur="0.001s" values="20;4"/></circle><circle cx="4" cy="12" r="3" fill="currentColor"><animate fill="freeze" attributeName="cx" begin="0;SVGUppsBdVN.end" calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="4;12"/><animate fill="freeze" attributeName="cx" begin="SVGqCgsydxJ.end" calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="12;20"/><animate id="SVG4PgJdbds" fill="freeze" attributeName="r" begin="SVG3PwDNd6F.end" calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="3;0"/><animate id="SVG6wCQhd9Q" fill="freeze" attributeName="cx" begin="SVG4PgJdbds.end" dur="0.001s" values="20;4"/><animate fill="freeze" attributeName="r" begin="SVG6wCQhd9Q.end" calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="0;3"/></circle><circle cx="12" cy="12" r="3" fill="currentColor"><animate fill="freeze" attributeName="cx" begin="0;SVGUppsBdVN.end" calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="12;20"/><animate id="SVG38aCdcdI" fill="freeze" attributeName="r" begin="SVGqCgsydxJ.end" calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="3;0"/><animate id="SVG3PwDNd6F" fill="freeze" attributeName="cx" begin="SVG38aCdcdI.end" dur="0.001s" values="20;4"/><animate fill="freeze" attributeName="r" begin="SVG3PwDNd6F.end" calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="0;3"/><animate fill="freeze" attributeName="cx" begin="SVG6wCQhd9Q.end" calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="4;12"/></circle><circle cx="20" cy="12" r="3" fill="currentColor"><animate id="SVGwaWzveSq" fill="freeze" attributeName="r" begin="0;SVGUppsBdVN.end" calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="3;0"/><animate id="SVGqCgsydxJ" fill="freeze" attributeName="cx" begin="SVGwaWzveSq.end" dur="0.001s" values="20;4"/><animate fill="freeze" attributeName="r" begin="SVGqCgsydxJ.end" calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="0;3"/><animate fill="freeze" attributeName="cx" begin="SVG3PwDNd6F.end" calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="4;12"/><animate fill="freeze" attributeName="cx" begin="SVG6wCQhd9Q.end" calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="12;20"/></circle>'
      },
      '3-dots-rotate': {
        body: '<circle cx="12" cy="12" r="3" fill="currentColor"/><g><circle cx="4" cy="12" r="3" fill="currentColor"/><circle cx="20" cy="12" r="3" fill="currentColor"/><animateTransform attributeName="transform" calcMode="spline" dur="1s" keySplines=".36,.6,.31,1;.36,.6,.31,1" repeatCount="indefinite" type="rotate" values="0 12 12;180 12 12;360 12 12"/></g>'
      },
      'blocks-shuffle-2': {
        body: '<rect width="10" height="10" x="1" y="1" fill="currentColor" rx="1"><animate id="SVG7JagGz2Y" fill="freeze" attributeName="x" begin="0;SVGgDT19bUV.end" dur="0.2s" values="1;13"/><animate id="SVGpS1BddYk" fill="freeze" attributeName="y" begin="SVGc7yq8dne.end" dur="0.2s" values="1;13"/><animate id="SVGboa7EdFl" fill="freeze" attributeName="x" begin="SVG0ZX9C6Fa.end" dur="0.2s" values="13;1"/><animate id="SVG6rrusL2C" fill="freeze" attributeName="y" begin="SVGTOnnO5Dr.end" dur="0.2s" values="13;1"/></rect><rect width="10" height="10" x="1" y="13" fill="currentColor" rx="1"><animate id="SVGc7yq8dne" fill="freeze" attributeName="y" begin="SVG7JagGz2Y.end" dur="0.2s" values="13;1"/><animate id="SVG0ZX9C6Fa" fill="freeze" attributeName="x" begin="SVGpS1BddYk.end" dur="0.2s" values="1;13"/><animate id="SVGTOnnO5Dr" fill="freeze" attributeName="y" begin="SVGboa7EdFl.end" dur="0.2s" values="1;13"/><animate id="SVGgDT19bUV" fill="freeze" attributeName="x" begin="SVG6rrusL2C.end" dur="0.2s" values="13;1"/></rect>'
      },
      'blocks-wave': {
        body: '<rect width="7.33" height="7.33" x="1" y="1" fill="currentColor"><animate id="SVGzjrPLenI" attributeName="x" begin="0;SVGXAURnSRI.end+0.2s" dur="0.6s" values="1;4;1"/><animate attributeName="y" begin="0;SVGXAURnSRI.end+0.2s" dur="0.6s" values="1;4;1"/><animate attributeName="width" begin="0;SVGXAURnSRI.end+0.2s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="0;SVGXAURnSRI.end+0.2s" dur="0.6s" values="7.33;1.33;7.33"/></rect><rect width="7.33" height="7.33" x="8.33" y="1" fill="currentColor"><animate attributeName="x" begin="SVGzjrPLenI.begin+0.1s" dur="0.6s" values="8.33;11.33;8.33"/><animate attributeName="y" begin="SVGzjrPLenI.begin+0.1s" dur="0.6s" values="1;4;1"/><animate attributeName="width" begin="SVGzjrPLenI.begin+0.1s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="SVGzjrPLenI.begin+0.1s" dur="0.6s" values="7.33;1.33;7.33"/></rect><rect width="7.33" height="7.33" x="1" y="8.33" fill="currentColor"><animate attributeName="x" begin="SVGzjrPLenI.begin+0.1s" dur="0.6s" values="1;4;1"/><animate attributeName="y" begin="SVGzjrPLenI.begin+0.1s" dur="0.6s" values="8.33;11.33;8.33"/><animate attributeName="width" begin="SVGzjrPLenI.begin+0.1s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="SVGzjrPLenI.begin+0.1s" dur="0.6s" values="7.33;1.33;7.33"/></rect><rect width="7.33" height="7.33" x="15.66" y="1" fill="currentColor"><animate attributeName="x" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="15.66;18.66;15.66"/><animate attributeName="y" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="1;4;1"/><animate attributeName="width" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="7.33;1.33;7.33"/></rect><rect width="7.33" height="7.33" x="8.33" y="8.33" fill="currentColor"><animate attributeName="x" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="8.33;11.33;8.33"/><animate attributeName="y" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="8.33;11.33;8.33"/><animate attributeName="width" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="7.33;1.33;7.33"/></rect><rect width="7.33" height="7.33" x="1" y="15.66" fill="currentColor"><animate attributeName="x" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="1;4;1"/><animate attributeName="y" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="15.66;18.66;15.66"/><animate attributeName="width" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="7.33;1.33;7.33"/></rect><rect width="7.33" height="7.33" x="15.66" y="8.33" fill="currentColor"><animate attributeName="x" begin="SVGzjrPLenI.begin+0.3s" dur="0.6s" values="15.66;18.66;15.66"/><animate attributeName="y" begin="SVGzjrPLenI.begin+0.3s" dur="0.6s" values="8.33;11.33;8.33"/><animate attributeName="width" begin="SVGzjrPLenI.begin+0.3s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="SVGzjrPLenI.begin+0.3s" dur="0.6s" values="7.33;1.33;7.33"/></rect><rect width="7.33" height="7.33" x="8.33" y="15.66" fill="currentColor"><animate attributeName="x" begin="SVGzjrPLenI.begin+0.3s" dur="0.6s" values="8.33;11.33;8.33"/><animate attributeName="y" begin="SVGzjrPLenI.begin+0.3s" dur="0.6s" values="15.66;18.66;15.66"/><animate attributeName="width" begin="SVGzjrPLenI.begin+0.3s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="SVGzjrPLenI.begin+0.3s" dur="0.6s" values="7.33;1.33;7.33"/></rect><rect width="7.33" height="7.33" x="15.66" y="15.66" fill="currentColor"><animate id="SVGXAURnSRI" attributeName="x" begin="SVGzjrPLenI.begin+0.4s" dur="0.6s" values="15.66;18.66;15.66"/><animate attributeName="y" begin="SVGzjrPLenI.begin+0.4s" dur="0.6s" values="15.66;18.66;15.66"/><animate attributeName="width" begin="SVGzjrPLenI.begin+0.4s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="SVGzjrPLenI.begin+0.4s" dur="0.6s" values="7.33;1.33;7.33"/></rect>'
      },
      clock: {
        body: '<path fill="currentColor" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z"/><rect width="2" height="7" x="11" y="6" fill="currentColor" rx="1"><animateTransform attributeName="transform" dur="9s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></rect><rect width="2" height="9" x="11" y="11" fill="currentColor" rx="1"><animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></rect>'
      },
      tadpole: {
        body: '<path fill="currentColor" d="M12,23a9.63,9.63,0,0,1-8-9.5,9.51,9.51,0,0,1,6.79-9.1A1.66,1.66,0,0,0,12,2.81h0a1.67,1.67,0,0,0-1.94-1.64A11,11,0,0,0,12,23Z"><animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></path>'
      }
    },
    prefix: 'svg-spinners',
    width: 24
  },
  'system-uicons': {
    height: 21,
    icons: {
@@ -117,35 +55,14 @@
  ri: {
    height: 24,
    icons: {
      'account-box-2-line': {
        body: '<path fill="currentColor" d="M4.995 3A1.995 1.995 0 0 0 3 4.995v14.01C3 20.107 3.893 21 4.995 21h14.01A1.995 1.995 0 0 0 21 19.005V4.995A1.995 1.995 0 0 0 19.005 3zM5 19V5h14v14zm7-11a1 1 0 1 1 0 2a1 1 0 0 1 0-2m0 4a3 3 0 1 0 0-6a3 3 0 0 0 0 6m0 3a2 2 0 0 0-2 2H8a4 4 0 0 1 8 0h-2a2 2 0 0 0-2-2"/>'
      },
      'account-circle-line': {
        body: '<path fill="currentColor" d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2m.16 14a6.98 6.98 0 0 0-5.147 2.256A7.97 7.97 0 0 0 12 20a7.97 7.97 0 0 0 5.167-1.892A6.98 6.98 0 0 0 12.16 16M12 4a8 8 0 0 0-6.384 12.821A8.98 8.98 0 0 1 12.16 14a8.97 8.97 0 0 1 6.362 2.634A8 8 0 0 0 12 4m0 1a4 4 0 1 1 0 8a4 4 0 0 1 0-8m0 2a2 2 0 1 0 0 4a2 2 0 0 0 0-4"/>'
      },
      'add-fill': {
        body: '<path fill="currentColor" d="M11 11V5h2v6h6v2h-6v6h-2v-6H5v-2z"/>'
      },
      'align-item-bottom-line': {
        body: '<path fill="currentColor" d="M9 5v10H6V5zM5 3a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zm10 6v6h3V9zm-2-1a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-5a1 1 0 0 1-1-1zm8 11H3v2h18z"/>'
      },
      'align-justify': {
        body: '<path fill="currentColor" d="M3 4h18v2H3zm0 15h18v2H3zm0-5h18v2H3zm0-5h18v2H3z"/>'
      },
      'align-right': {
        body: '<path fill="currentColor" d="M3 4h18v2H3zm4 15h14v2H7zm-4-5h18v2H3zm4-5h14v2H7z"/>'
      },
      'anthropic-line': {
        body: '<path fill="currentColor" d="M14.122 5h2.146L22.1 20h-2.146zM7.66 5h2.681l5.77 15h-2.144l-1.538-4H5.572l-1.539 4H1.891zm4 9L9 7.086L6.341 14z"/>'
      },
      'apple-line': {
        body: '<path fill="currentColor" d="M15.778 8.208c-.473-.037-.98.076-1.758.373c.065-.025-.742.29-.969.37c-.502.175-.915.271-1.378.271c-.458 0-.88-.092-1.365-.255a11 11 0 0 1-.505-.186l-.449-.177c-.648-.254-1.012-.35-1.315-.342c-1.153.014-2.243.68-2.877 1.782c-1.292 2.243-.576 6.299 1.313 9.031c1.005 1.444 1.556 1.96 1.777 1.953c.222-.01.386-.057.784-.225l.166-.071c1.006-.429 1.71-.618 2.771-.618c1.021 0 1.703.186 2.669.602l.168.072c.397.17.54.208.792.202c.357-.005.798-.417 1.777-1.854c.268-.391.505-.803.71-1.22a7 7 0 0 1-.391-.347c-1.29-1.228-2.087-2.884-2.109-4.93A6.63 6.63 0 0 1 17 8.458a4.1 4.1 0 0 0-1.221-.25m.155-1.994c.708.048 2.736.264 4.056 2.196c-.108.06-2.424 1.404-2.4 4.212c.036 3.36 2.94 4.476 2.976 4.488c-.024.084-.468 1.596-1.536 3.156c-.924 1.356-1.884 2.7-3.396 2.724c-1.488.036-1.968-.876-3.66-.876c-1.704 0-2.232.852-3.636.912c-1.464.048-2.568-1.464-3.504-2.808c-1.908-2.76-3.36-7.776-1.404-11.172c.972-1.692 2.7-2.76 4.584-2.784c1.428-.036 2.784.96 3.66.96c.864 0 2.412-1.152 4.26-1.008m-1.14-1.824c-.78.936-2.052 1.668-3.288 1.572c-.168-1.272.456-2.604 1.176-3.432c.804-.936 2.148-1.632 3.264-1.68c.144 1.296-.372 2.604-1.152 3.54"/>'
      },
      'apps-2-add-line': {
        body: '<path fill="currentColor" d="M2.5 7a4.5 4.5 0 1 0 9 0a4.5 4.5 0 0 0-9 0m0 10a4.5 4.5 0 1 0 9 0a4.5 4.5 0 0 0-9 0m10 0a4.5 4.5 0 1 0 9 0a4.5 4.5 0 0 0-9 0m-3-10a2.5 2.5 0 1 1-5 0a2.5 2.5 0 0 1 5 0m0 10a2.5 2.5 0 1 1-5 0a2.5 2.5 0 0 1 5 0m10 0a2.5 2.5 0 1 1-5 0a2.5 2.5 0 0 1 5 0M16 11V8h-3V6h3V3h2v3h3v2h-3v3z"/>'
      },
      'apps-2-line': {
        body: '<path fill="currentColor" d="M7 11.5a4.5 4.5 0 1 1 0-9a4.5 4.5 0 0 1 0 9m0 10a4.5 4.5 0 1 1 0-9a4.5 4.5 0 0 1 0 9m10-10a4.5 4.5 0 1 1 0-9a4.5 4.5 0 0 1 0 9m0 10a4.5 4.5 0 1 1 0-9a4.5 4.5 0 0 1 0 9M7 9.5a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5m0 10a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5m10-10a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5m0 10a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5"/>'
      'archive-line': {
        body: '<path fill="currentColor" d="M3 10H2V4.003C2 3.449 2.455 3 2.992 3h18.016A.99.99 0 0 1 22 4.003V10h-1v10.002a.996.996 0 0 1-.993.998H3.993A.996.996 0 0 1 3 20.002zm16 0H5v9h14zM4 5v3h16V5zm5 7h6v2H9z"/>'
      },
      'arrow-down-wide-fill': {
        body: '<path fill="currentColor" d="m12 15.632l8.968-4.748l-.936-1.768L12 13.368L3.968 9.116l-.936 1.768z"/>'
@@ -156,23 +73,20 @@
      'arrow-left-s-line': {
        body: '<path fill="currentColor" d="m10.828 12l4.95 4.95l-1.414 1.415L8 12l6.364-6.364l1.414 1.414z"/>'
      },
      'arrow-left-wide-fill': {
        body: '<path fill="currentColor" d="m8.369 12l4.747-8.968l1.768.936L10.632 12l4.252 8.032l-1.768.936z"/>'
      },
      'arrow-right-circle-line': {
        body: '<path fill="currentColor" d="M12 11V8l4 4l-4 4v-3H8v-2zm0-9c5.52 0 10 4.48 10 10s-4.48 10-10 10S2 17.52 2 12S6.48 2 12 2m0 18c4.42 0 8-3.58 8-8s-3.58-8-8-8s-8 3.58-8 8s3.58 8 8 8"/>'
      },
      'arrow-right-s-line': {
        body: '<path fill="currentColor" d="m13.172 12l-4.95-4.95l1.414-1.413L16 12l-6.364 6.364l-1.414-1.415z"/>'
      },
      'arrow-right-up-line': {
        body: '<path fill="currentColor" d="m16.004 9.414l-8.607 8.607l-1.414-1.414L14.59 8H7.003V6h11v11h-2z"/>'
      },
      'arrow-up-circle-line': {
        body: '<path fill="currentColor" d="M12 2c5.52 0 10 4.48 10 10s-4.48 10-10 10S2 17.52 2 12S6.48 2 12 2m0 18c4.42 0 8-3.58 8-8s-3.58-8-8-8s-8 3.58-8 8s3.58 8 8 8m1-8v4h-2v-4H8l4-4l4 4z"/>'
      'arrow-right-wide-fill': {
        body: '<path fill="currentColor" d="m15.632 12l-4.748-8.968l-1.768.936L13.368 12l-4.252 8.032l1.768.936z"/>'
      },
      'arrow-up-down-fill': {
        body: '<path fill="currentColor" d="M12 8H8.001L8 20H6V8H2l5-5zm10 8l-5 5l-5-5h4V4h2v12z"/>'
      },
      'arrow-up-line': {
        body: '<path fill="currentColor" d="M13 7.828V20h-2V7.828l-5.364 5.364l-1.414-1.414L12 4l7.778 7.778l-1.414 1.414z"/>'
      },
      'arrow-up-wide-fill': {
        body: '<path fill="currentColor" d="m12 8.369l8.968 4.747l-.936 1.768L12 10.632l-8.032 4.252l-.936-1.768z"/>'
@@ -180,20 +94,8 @@
      'arrow-up-wide-line': {
        body: '<path fill="currentColor" d="m12 8.369l8.968 4.747l-.936 1.768L12 10.632l-8.032 4.252l-.936-1.768z"/>'
      },
      'article-line': {
        body: '<path fill="currentColor" d="M20 22H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1m-1-2V4H5v16zM7 6h4v4H7zm0 6h10v2H7zm0 4h10v2H7zm6-9h4v2h-4z"/>'
      },
      'bar-chart-2-line': {
        body: '<path fill="currentColor" d="M2 13h6v8H2zm14-5h6v13h-6zM9 3h6v18H9zM4 15v4h2v-4zm7-10v14h2V5zm7 5v9h2v-9z"/>'
      },
      'bar-chart-box-ai-line': {
        body: '<path fill="currentColor" d="m20.713 8.128l-.246.566a.506.506 0 0 1-.934 0l-.246-.566a4.36 4.36 0 0 0-2.22-2.25l-.759-.339a.53.53 0 0 1 0-.963l.717-.319a4.37 4.37 0 0 0 2.251-2.326l.253-.611a.506.506 0 0 1 .942 0l.253.61a4.37 4.37 0 0 0 2.25 2.327l.718.32a.53.53 0 0 1 0 .962l-.76.338a4.36 4.36 0 0 0-2.219 2.251M2 4a1 1 0 0 1 1-1h11v2H4v14h16v-8h2v9a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1zm5 9h2v4H7zm4-6h2v10h-2zm4 3h2v7h-2z"/>'
      },
      'bar-chart-box-line': {
        body: '<path fill="currentColor" d="M3 3h18a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1m1 2v14h16V5zm3 8h2v4H7zm4-6h2v10h-2zm4 3h2v7h-2z"/>'
      },
      'bar-chart-grouped-line': {
        body: '<path fill="currentColor" d="M2 12h2v9H2zm3 2h2v7H5zm11-6h2v13h-2zm3 2h2v11h-2zM9 2h2v19H9zm3 2h2v17h-2z"/>'
      },
      'bilibili-line': {
        body: '<path fill="currentColor" d="M7.172 2.757L10.414 6h3.171l3.243-3.242a1 1 0 1 1 1.415 1.415L16.414 6H18.5A3.5 3.5 0 0 1 22 9.5v8a3.5 3.5 0 0 1-3.5 3.5h-13A3.5 3.5 0 0 1 2 17.5v-8A3.5 3.5 0 0 1 5.5 6h2.085L5.757 4.171a1 1 0 0 1 1.415-1.415M18.5 8h-13a1.5 1.5 0 0 0-1.493 1.356L4 9.5v8a1.5 1.5 0 0 0 1.356 1.493L5.5 19h13a1.5 1.5 0 0 0 1.493-1.355L20 17.5v-8A1.5 1.5 0 0 0 18.5 8M8 11a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1m8 0a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1"/>'
@@ -204,29 +106,14 @@
      'book-2-line': {
        body: '<path fill="currentColor" d="M21 18H6a1 1 0 1 0 0 2h15v2H6a3 3 0 0 1-3-3V4a2 2 0 0 1 2-2h16zM5 16.05q.243-.05.5-.05H19V4H5zM16 9H8V7h8z"/>'
      },
      'box-1-line': {
        body: '<path fill="currentColor" d="m12 1l9.5 5.5v11L12 23l-9.5-5.5v-11zM5.494 7.078L13 11.423v8.687l6.5-3.763V7.653L12 3.311zM4.5 8.813v7.534L11 20.11v-7.533z"/>'
      },
      'bus-2-line': {
        body: '<path fill="currentColor" d="M17 20H7v1a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-9H2V8h1V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v3h1v4h-1v9a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1zM5 5v6h14V5zm14 8H5v5h14zM7.5 17a1.5 1.5 0 1 1 0-3a1.5 1.5 0 0 1 0 3m9 0a1.5 1.5 0 1 1 0-3a1.5 1.5 0 0 1 0 3"/>'
      },
      'calendar-2-line': {
        body: '<path fill="currentColor" d="M9 1v2h6V1h2v2h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h4V1zm11 10H4v8h16zM8 13v2H6v-2zm5 0v2h-2v-2zm5 0v2h-2v-2zM7 5H4v4h16V5h-3v2h-2V5H9v2H7z"/>'
      },
      'camera-4-line': {
        body: '<path fill="currentColor" d="M14.434 3a2 2 0 0 1 1.714.97l.773 1.287a.5.5 0 0 0 .429.243H19a3 3 0 0 1 3 3V18a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3V8.5a3 3 0 0 1 3-3h1.65a.5.5 0 0 0 .43-.243l.772-1.286A2 2 0 0 1 9.566 3zm-5.64 3.286A2.5 2.5 0 0 1 6.65 7.5H5a1 1 0 0 0-1 1V18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V8.5a1 1 0 0 0-1-1h-1.65a2.5 2.5 0 0 1-2.145-1.214L14.434 5H9.566zM12 8.5a4.5 4.5 0 1 1 0 9a4.5 4.5 0 0 1 0-9m0 2a2.5 2.5 0 1 0 0 5a2.5 2.5 0 0 0 0-5"/>'
      },
      'capsule-line': {
        body: '<path fill="currentColor" d="M19.779 4.222a6 6 0 0 1 0 8.485l-7.071 7.071a6 6 0 0 1-8.486-8.485l7.071-7.071a6 6 0 0 1 8.486 0m-5.657 11.313L8.466 9.878l-2.83 2.83a4 4 0 0 0 5.657 5.656zm4.242-9.899a4 4 0 0 0-5.657 0L9.88 8.464l5.657 5.657l2.827-2.828a4 4 0 0 0 0-5.657"/>'
      },
      'check-fill': {
        body: '<path fill="currentColor" d="m10 15.17l9.192-9.191l1.414 1.414L10 17.999l-6.364-6.364l1.414-1.414z"/>'
      },
      'checkbox-blank-circle-line': {
        body: '<path fill="currentColor" d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10m0-2a8 8 0 1 0 0-16a8 8 0 0 0 0 16"/>'
      },
      'checkbox-circle-line': {
        body: '<path fill="currentColor" d="M4 12a8 8 0 1 1 16 0a8 8 0 0 1-16 0m8-10C6.477 2 2 6.477 2 12s4.477 10 10 10s10-4.477 10-10S17.523 2 12 2m5.457 7.457l-1.414-1.414L11 13.086l-2.793-2.793l-1.414 1.414L11 15.914z"/>'
      },
      'clipboard-line': {
        body: '<path fill="currentColor" d="M7 4V2h10v2h3.007c.548 0 .993.445.993.993v16.014a.994.994 0 0 1-.993.993H3.993A.993.993 0 0 1 3 21.007V4.993C3 4.445 3.445 4 3.993 4zm0 2H5v14h14V6h-2v2H7zm2-2v2h6V4z"/>'
      },
      'close-circle-line': {
        body: '<path fill="currentColor" d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10m0-2a8 8 0 1 0 0-16a8 8 0 0 0 0 16m0-9.414l2.828-2.829l1.415 1.415L13.414 12l2.829 2.828l-1.415 1.415L12 13.414l-2.828 2.829l-1.415-1.415L10.586 12L7.757 9.172l1.415-1.415z"/>'
@@ -240,14 +127,8 @@
      'command-fill': {
        body: '<path fill="currentColor" d="M10 8h4V6.5a3.5 3.5 0 1 1 3.5 3.5H16v4h1.5a3.5 3.5 0 1 1-3.5 3.5V16h-4v1.5A3.5 3.5 0 1 1 6.5 14H8v-4H6.5A3.5 3.5 0 1 1 10 6.5zM8 8V6.5A1.5 1.5 0 1 0 6.5 8zm0 8H6.5A1.5 1.5 0 1 0 8 17.5zm8-8h1.5A1.5 1.5 0 1 0 16 6.5zm0 8v1.5a1.5 1.5 0 1 0 1.5-1.5zm-6-6v4h4v-4z"/>'
      },
      'contacts-line': {
        body: '<path fill="currentColor" d="M19 7h5v2h-5zm-2 5h7v2h-7zm3 5h4v2h-4zM2 22a8 8 0 1 1 16 0h-2a6 6 0 0 0-12 0zm8-9c-3.315 0-6-2.685-6-6s2.685-6 6-6s6 2.685 6 6s-2.685 6-6 6m0-2c2.21 0 4-1.79 4-4s-1.79-4-4-4s-4 1.79-4 4s1.79 4 4 4"/>'
      },
      'copilot-line': {
        body: '<path fill="currentColor" d="M5.4 7.8c0-2.088 1.178-3 3.172-3c1.196 0 2.129.264 2.129 1.6c0 1.814-.575 3.75-2.7 3.75c-1.229 0-1.798-.176-2.09-.424c-.247-.21-.51-.67-.51-1.926m3.172-5C5.497 2.8 3.4 4.626 3.4 7.8c0 .999.137 1.89.53 2.605l-.183.364a6.3 6.3 0 0 0-1.425 1.107c-1.061 1.126-.973 2.389-.973 3.824c0 2.267 2.512 3.62 4.315 4.373c2.133.89 4.677 1.427 6.336 1.427c1.658 0 4.202-.537 6.335-1.427c1.803-.753 4.315-2.106 4.315-4.373c0-1.435.088-2.698-.973-3.824a6.3 6.3 0 0 0-1.425-1.107l-.182-.364c.392-.716.53-1.606.53-2.605c0-3.174-2.097-5-5.172-5c-1.24 0-2.618.259-3.428 1.283C11.19 3.059 9.813 2.8 8.57 2.8M8 12.15c1.692 0 3.224-.815 4-2.334c.775 1.519 2.307 2.334 4 2.334c.894 0 1.769-.074 2.517-.38c.511.596 1.17.911 1.705 1.478c.639.678.428 1.585.428 2.452c0 1.272-2.166 2.143-3.086 2.527c-1.942.81-4.223 1.273-5.565 1.273c-1.341 0-3.623-.463-5.565-1.273c-.919-.384-3.085-1.255-3.085-2.527c0-.867-.21-1.774.428-2.452c.56-.594 1.341-.75 1.705-1.478c.748.306 1.623.38 2.518.38m5.3-5.75c0-1.336.932-1.6 2.128-1.6c1.994 0 3.172.912 3.172 3c0 1.257-.264 1.715-.511 1.926c-.292.248-.861.424-2.09.424c-2.125 0-2.7-1.936-2.7-3.75m-4.638 8.084a1.001 1.001 0 1 1 2.002 0v1.997a1.001 1.001 0 1 1-2.002 0zm6.675 0a1.001 1.001 0 1 0-2.003 0v1.997a1.001 1.001 0 1 0 2.003 0z"/>'
      },
      'customer-service-2-line': {
        body: '<path fill="currentColor" d="M19.938 8H21a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-1.062A8 8 0 0 1 12 23v-2a6 6 0 0 0 6-6V9A6 6 0 0 0 6 9v7H3a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h1.062a8.001 8.001 0 0 1 15.876 0M3 10v4h1v-4zm17 0v4h1v-4zM7.76 15.785l1.06-1.696A5.97 5.97 0 0 0 12 15a5.97 5.97 0 0 0 3.18-.911l1.06 1.696A7.96 7.96 0 0 1 12 17a7.96 7.96 0 0 1-4.24-1.215"/>'
      'computer-line': {
        body: '<path fill="currentColor" d="M4 16h16V5H4zm9 2v2h4v2H7v-2h4v-2H2.992A1 1 0 0 1 2 16.992V4.008C2 3.451 2.455 3 2.992 3h18.016c.548 0 .992.449.992 1.007v12.985c0 .557-.455 1.008-.992 1.008z"/>'
      },
      'delete-bin-4-line': {
        body: '<path fill="currentColor" d="M20 7v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7H2V5h20v2zM6 7v13h12V7zm1-5h10v2H7zm4 8h2v7h-2z"/>'
@@ -255,20 +136,11 @@
      'delete-bin-5-line': {
        body: '<path fill="currentColor" d="M4 8h16v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zm2 2v10h12V10zm3 2h2v6H9zm4 0h2v6h-2zM7 5V3a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v2h5v2H2V5zm2-1v1h6V4z"/>'
      },
      'delete-bin-line': {
        body: '<path fill="currentColor" d="M17 6h5v2h-2v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V8H2V6h5V3a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1zm1 2H6v12h12zm-9 3h2v6H9zm4 0h2v6h-2zM9 4v2h6V4z"/>'
      },
      'download-2-line': {
        body: '<path fill="currentColor" d="M13 10h5l-6 6l-6-6h5V3h2zm-9 9h16v-7h2v8a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-8h2z"/>'
      },
      'drag-move-fill': {
        body: '<path fill="currentColor" d="m12 22l-4-4h8zm0-20l4 4H8zm0 12a2 2 0 1 1 0-4a2 2 0 0 1 0 4M2 12l4-4v8zm20 0l-4 4V8z"/>'
      'drag-move-2-fill': {
        body: '<path fill="currentColor" d="M18 11V8l4 4l-4 4v-3h-5v5h3l-4 4l-4-4h3v-5H6v3l-4-4l4-4v3h5V6H8l4-4l4 4h-3v5z"/>'
      },
      'dribbble-fill': {
        body: '<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10c5.51 0 10-4.48 10-10S17.51 2 12 2m6.605 4.61a8.5 8.5 0 0 1 1.93 5.314c-.281-.054-3.101-.629-5.943-.271c-.065-.141-.12-.293-.184-.445a25 25 0 0 0-.564-1.236c3.145-1.28 4.577-3.124 4.761-3.362M12 3.475c2.17 0 4.154.814 5.662 2.148c-.152.216-1.443 1.941-4.48 3.08c-1.399-2.57-2.95-4.675-3.189-5A8.7 8.7 0 0 1 12 3.475m-3.633.803a54 54 0 0 1 3.167 4.935c-3.992 1.063-7.517 1.04-7.896 1.04a8.58 8.58 0 0 1 4.729-5.975M3.453 12.01v-.26c.37.01 4.512.065 8.775-1.215c.25.477.477.965.694 1.453c-.109.033-.228.065-.336.098c-4.404 1.42-6.747 5.303-6.942 5.629a8.52 8.52 0 0 1-2.19-5.705M12 20.547a8.48 8.48 0 0 1-5.239-1.8c.152-.315 1.888-3.656 6.703-5.337c.022-.01.033-.01.054-.022a35.3 35.3 0 0 1 1.823 6.475a8.4 8.4 0 0 1-3.341.684m4.761-1.465c-.086-.52-.542-3.015-1.66-6.084c2.68-.423 5.023.271 5.315.369a8.47 8.47 0 0 1-3.655 5.715"/>'
      },
      'edge-line': {
        body: '<path fill="currentColor" d="M8.008 14.001A5 5 0 0 0 8 14.25C8 16.632 9.753 19 13 19c2.373 0 4.528-.655 6-1.553v3.35C17.211 21.564 15.112 22 13 22c-5.502 0-8-3.47-8-7.75c0-3.231 2.041-6 4.943-7.164C8.54 8.663 8 10.341 8 10.996L18 11c0-3.406-2.548-6-6-6c-5 0-8.001 3.988-9 5.999C3.29 6.237 7.01 2 12 2c5.2 0 9 4.03 9 9v3H8z"/>'
      },
      'edit-2-line': {
        body: '<path fill="currentColor" d="M5 18.89h1.414l9.314-9.314l-1.414-1.414L5 17.476zm16 2H3v-4.243L16.435 3.212a1 1 0 0 1 1.414 0l2.829 2.829a1 1 0 0 1 0 1.414L9.243 18.89H21zM15.728 6.748l1.414 1.414l1.414-1.414l-1.414-1.414z"/>'
@@ -279,44 +151,26 @@
      'error-warning-line': {
        body: '<path fill="currentColor" d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10m0-2a8 8 0 1 0 0-16a8 8 0 0 0 0 16m-1-5h2v2h-2zm0-8h2v6h-2z"/>'
      },
      'export-line': {
        body: '<path fill="currentColor" d="M22 4a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1zM4 15h3.416a5.001 5.001 0 0 0 9.168 0H20v4H4zM4 5h16v8h-5a3 3 0 1 1-6 0H4zm12 6h-3v3h-2v-3H8l4-4.5z"/>'
      },
      'eye-line': {
        body: '<path fill="currentColor" d="M12 3c5.392 0 9.878 3.88 10.819 9c-.94 5.12-5.427 9-10.819 9s-9.878-3.88-10.818-9C2.122 6.88 6.608 3 12 3m0 16a9.005 9.005 0 0 0 8.778-7a9.005 9.005 0 0 0-17.555 0A9.005 9.005 0 0 0 12 19m0-2.5a4.5 4.5 0 1 1 0-9a4.5 4.5 0 0 1 0 9m0-2a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5"/>'
      },
      'file-copy-line': {
        body: '<path fill="currentColor" d="M7 6V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-3v3c0 .552-.45 1-1.007 1H4.007A1 1 0 0 1 3 21l.003-14c0-.552.45-1 1.006-1zM5.002 8L5 20h10V8zM9 6h8v10h2V4H9z"/>'
      },
      'file-excel-2-line': {
        body: '<path fill="currentColor" d="m2.859 2.877l12.57-1.795a.5.5 0 0 1 .571.494v20.848a.5.5 0 0 1-.57.494L2.858 21.123a1 1 0 0 1-.859-.99V3.867a1 1 0 0 1 .859-.99M4 4.735v14.53l10 1.429V3.306zM17 19h3V5h-3V3h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4zm-6.8-7l2.8 4h-2.4L9 13.714L7.4 16H5l2.8-4L5 8h2.4L9 10.286L10.6 8H13z"/>'
      },
      'file-pdf-2-line': {
        body: '<path fill="currentColor" d="M5 4h10v4h4v12H5zM3.999 2A.995.995 0 0 0 3 2.992v18.016a1 1 0 0 0 .993.992h16.014A1 1 0 0 0 21 20.992V7l-5-5zm6.5 5.5c0 1.577-.455 3.437-1.224 5.153c-.772 1.723-1.814 3.197-2.9 4.066l1.18 1.613c2.927-1.952 6.168-3.29 9.304-2.842l.457-1.939C14.644 12.661 12.5 9.99 12.5 7.5zm.6 5.972c.268-.597.505-1.216.705-1.843a9.7 9.7 0 0 0 1.706 1.966c-.982.176-1.944.465-2.875.833q.248-.471.465-.956"/>'
      },
      'fingerprint-line': {
        body: '<path fill="currentColor" d="M17 13v1c0 2.77-.664 5.445-1.915 7.846l-.227.42l-1.746-.974a14.9 14.9 0 0 0 1.881-6.836L15 14v-1zm-6-3h2v4l-.005.379a12.94 12.94 0 0 1-2.691 7.549l-.231.29l-1.549-1.264a10.94 10.94 0 0 0 2.47-6.588L11 14zm1-4a5 5 0 0 1 5 5h-2a3 3 0 0 0-6 0v3c0 2.235-.82 4.344-2.27 5.977l-.212.23l-1.448-1.38a6.97 6.97 0 0 0 1.924-4.524L7 14v-3a5 5 0 0 1 5-5m0-4a9 9 0 0 1 9 9v3c0 1.698-.201 3.37-.596 4.99l-.14.539l-1.93-.526c.392-1.437.614-2.922.658-4.435L19 14v-3A7 7 0 0 0 7.808 5.394L6.383 3.968A8.96 8.96 0 0 1 12 2M4.968 5.383l1.426 1.425a6.97 6.97 0 0 0-1.39 3.951L5 11l.004 2c0 1.12-.264 2.203-.761 3.177l-.157.29l-1.736-.992c.379-.665.6-1.407.645-2.183L3.004 13v-2a8.94 8.94 0 0 1 1.964-5.617"/>'
      'file-list-3-line': {
        body: '<path fill="currentColor" d="M19 22H5a3 3 0 0 1-3-3V3a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v12h4v4a3 3 0 0 1-3 3m-1-5v2a1 1 0 1 0 2 0v-2zm-2 3V4H4v15a1 1 0 0 0 1 1zM6 7h8v2H6zm0 4h8v2H6zm0 4h5v2H6z"/>'
      },
      'fire-line': {
        body: '<path fill="currentColor" d="M12 23a7.5 7.5 0 0 0 7.5-7.5c0-.866-.23-1.697-.5-2.47q-2.5 2.47-3.8 2.47c3.995-7 1.8-10-4.2-14c.5 5-2.796 7.274-4.138 8.537A7.5 7.5 0 0 0 12 23m.71-17.765c3.241 2.75 3.257 4.887.753 9.274c-.761 1.333.202 2.991 1.737 2.991c.688 0 1.384-.2 2.119-.595a5.5 5.5 0 1 1-9.087-5.412c.126-.118.765-.685.793-.71c.424-.38.773-.717 1.118-1.086c1.23-1.318 2.114-2.78 2.566-4.462"/>'
      },
      'flag-2-line': {
        body: '<path fill="currentColor" d="M21.138 3a.5.5 0 0 1 .434.748L18 10l3.573 6.252a.5.5 0 0 1-.435.748H4v5H2V3zm-2.584 2H4v10h14.554l-2.857-5z"/>'
      'fullscreen-exit-line': {
        body: '<path fill="currentColor" d="M18 7h4v2h-6V3h2zM8 9H2V7h4V3h2zm10 8v4h-2v-6h6v2zM8 15v6H6v-4H2v-2z"/>'
      },
      'fullscreen-fill': {
        body: '<path fill="currentColor" d="M16 3h6v6h-2V5h-4zM2 3h6v2H4v4H2zm18 16v-4h2v6h-6v-2zM4 19h4v2H2v-6h2z"/>'
      },
      'fullscreen-line': {
        body: '<path fill="currentColor" d="M8 3v2H4v4H2V3zM2 21v-6h2v4h4v2zm20 0h-6v-2h4v-4h2zm0-12h-2V5h-4V3h6z"/>'
      },
      'function-line': {
        body: '<path fill="currentColor" d="M3 4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1zm0 10a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1zM13 4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1zm0 10a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1zm2-9v4h4V5zm0 10v4h4v-4zM5 5v4h4V5zm0 10v4h4v-4z"/>'
      },
      'game-line': {
        body: '<path fill="currentColor" d="M12 2a9.98 9.98 0 0 1 7.743 3.671L13.414 12l6.329 6.329A9.98 9.98 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2m0 2a8 8 0 1 0 4.697 14.477l.208-.157l-6.32-6.32l6.32-6.321l-.208-.156a7.97 7.97 0 0 0-4.394-1.517zm0 1a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3"/>'
      },
      'gamepad-line': {
        body: '<path fill="currentColor" d="M17 4a6 6 0 0 1 6 6v4a6 6 0 0 1-6 6H7a6 6 0 0 1-6-6v-4a6 6 0 0 1 6-6zm0 2H7a4 4 0 0 0-3.995 3.8L3 10v4a4 4 0 0 0 3.8 3.995L7 18h10a4 4 0 0 0 3.995-3.8L21 14v-4a4 4 0 0 0-3.8-3.995zm-7 3v2h2v2H9.999L10 15H8l-.001-2H6v-2h2V9zm8 4v2h-2v-2zm-2-4v2h-2V9z"/>'
      },
      'gift-2-line': {
        body: '<path fill="currentColor" d="M14.505 2.003a3.5 3.5 0 0 1 3.163 5h3.337a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-1v8a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-8h-1a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3.337a3.5 3.5 0 0 1 5.664-3.95a3.48 3.48 0 0 1 2.499-1.05m3.5 11h-12v7h12zm2-4h-16v2h16zm-10.5-5a1.5 1.5 0 0 0-.145 2.993l.145.007h1.5v-1.5A1.5 1.5 0 0 0 9.649 4.01zm5 0l-.145.007a1.5 1.5 0 0 0-1.348 1.348l-.007.145v1.5h1.5l.144-.007a1.5 1.5 0 0 0 0-2.986z"/>'
      },
      'github-fill': {
        body: '<path fill="currentColor" d="M12.001 2c-5.525 0-10 4.475-10 10a9.99 9.99 0 0 0 6.837 9.488c.5.087.688-.213.688-.476c0-.237-.013-1.024-.013-1.862c-2.512.463-3.162-.612-3.362-1.175c-.113-.288-.6-1.175-1.025-1.413c-.35-.187-.85-.65-.013-.662c.788-.013 1.35.725 1.538 1.025c.9 1.512 2.337 1.087 2.912.825c.088-.65.35-1.087.638-1.337c-2.225-.25-4.55-1.113-4.55-4.938c0-1.088.387-1.987 1.025-2.687c-.1-.25-.45-1.275.1-2.65c0 0 .837-.263 2.75 1.024a9.3 9.3 0 0 1 2.5-.337c.85 0 1.7.112 2.5.337c1.913-1.3 2.75-1.024 2.75-1.024c.55 1.375.2 2.4.1 2.65c.637.7 1.025 1.587 1.025 2.687c0 3.838-2.337 4.688-4.562 4.938c.362.312.675.912.675 1.85c0 1.337-.013 2.412-.013 2.75c0 .262.188.574.688.474A10.02 10.02 0 0 0 22 12c0-5.525-4.475-10-10-10"/>'
      },
      'github-line': {
        body: '<path fill="currentColor" d="M5.884 18.653c-.3-.2-.558-.455-.86-.816a51 51 0 0 1-.466-.579c-.463-.575-.755-.841-1.056-.95a1 1 0 1 1 .675-1.882c.752.27 1.261.735 1.947 1.588c-.094-.117.34.427.433.539c.19.227.33.365.44.438c.204.137.588.196 1.15.14c.024-.382.094-.753.202-1.095c-2.968-.726-4.648-2.64-4.648-6.396c0-1.24.37-2.356 1.058-3.292c-.218-.894-.185-1.975.302-3.192a1 1 0 0 1 .63-.582c.081-.024.127-.035.208-.047c.803-.124 1.937.17 3.415 1.096a11.7 11.7 0 0 1 2.687-.308c.912 0 1.819.104 2.684.308c1.477-.933 2.614-1.227 3.422-1.096q.128.02.218.05a1 1 0 0 1 .616.58c.487 1.216.52 2.296.302 3.19c.691.936 1.058 2.045 1.058 3.293c0 3.757-1.674 5.665-4.642 6.392c.125.415.19.878.19 1.38c0 .665-.002 1.299-.007 2.01c0 .19-.002.394-.005.706a1 1 0 0 1-.018 1.958c-1.14.227-1.984-.532-1.984-1.525l.002-.447l.005-.705c.005-.707.008-1.337.008-1.997c0-.697-.184-1.152-.426-1.361c-.661-.57-.326-1.654.541-1.751c2.966-.333 4.336-1.482 4.336-4.66c0-.955-.312-1.744-.913-2.404A1 1 0 0 1 17.2 6.19c.166-.414.236-.957.095-1.614l-.01.003c-.491.139-1.11.44-1.858.949a1 1 0 0 1-.833.135a9.6 9.6 0 0 0-2.592-.349c-.89 0-1.772.118-2.592.35a1 1 0 0 1-.829-.134c-.753-.507-1.374-.807-1.87-.947c-.143.653-.072 1.194.093 1.607a1 1 0 0 1-.189 1.045c-.597.655-.913 1.458-.913 2.404c0 3.172 1.371 4.328 4.322 4.66c.865.097 1.202 1.177.545 1.748c-.193.168-.43.732-.43 1.364v3.15c0 .985-.834 1.725-1.96 1.528a1 1 0 0 1-.04-1.962v-.99c-.91.061-1.661-.088-2.254-.485"/>'
@@ -324,20 +178,11 @@
      'group-line': {
        body: '<path fill="currentColor" d="M2 22a8 8 0 1 1 16 0h-2a6 6 0 0 0-12 0zm8-9c-3.315 0-6-2.685-6-6s2.685-6 6-6s6 2.685 6 6s-2.685 6-6 6m0-2c2.21 0 4-1.79 4-4s-1.79-4-4-4s-4 1.79-4 4s1.79 4 4 4m8.284 3.703A8 8 0 0 1 23 22h-2a6 6 0 0 0-3.537-5.473zm-.688-11.29A5.5 5.5 0 0 1 21 8.5a5.5 5.5 0 0 1-5 5.478v-2.013a3.5 3.5 0 0 0 1.041-6.609z"/>'
      },
      'hard-drive-3-line': {
        body: '<path fill="currentColor" d="M4.508 2.876A1 1 0 0 1 5.5 2h13a1 1 0 0 1 .992.876l1.5 12Q21 14.938 21 15v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 .008-.124zM6.383 4l-1.25 10h13.734l-1.25-10zM19 16H5v4h14zm-4 1h2v2h-2zm-2 0h-2v2h2z"/>'
      },
      'heart-3-line': {
        body: '<path fill="currentColor" d="M16.5 3C19.538 3 22 5.5 22 9c0 7-7.5 11-10 12.5C9.5 20 2 16 2 9c0-3.5 2.5-6 5.5-6C9.36 3 11 4 12 5c1-1 2.64-2 4.5-2m-3.566 15.604a27 27 0 0 0 2.42-1.701C18.335 14.533 20 11.943 20 9c0-2.36-1.537-4-3.5-4c-1.076 0-2.24.57-3.086 1.414L12 7.828l-1.414-1.414C9.74 5.57 8.576 5 7.5 5C5.56 5 4 6.657 4 9c0 2.944 1.666 5.533 4.645 7.903c.745.593 1.54 1.146 2.421 1.7c.299.189.595.37.934.572c.339-.202.635-.383.934-.571"/>'
      },
      'heart-fill': {
        body: '<path fill="currentColor" d="M12.001 4.529a6 6 0 0 1 8.242.228a6 6 0 0 1 .236 8.236l-8.48 8.492l-8.478-8.492a6 6 0 0 1 8.48-8.464"/>'
      },
      'heart-line': {
        body: '<path fill="currentColor" d="M12.001 4.529a6 6 0 0 1 8.242.228a6 6 0 0 1 .236 8.236l-8.48 8.492l-8.478-8.492a6 6 0 0 1 8.48-8.464m6.826 1.641a4 4 0 0 0-5.49-.153l-1.335 1.198l-1.336-1.197a4 4 0 0 0-5.686 5.605L12 18.654l7.02-7.03a4 4 0 0 0-.193-5.454"/>'
      },
      'home-line': {
        body: '<path fill="currentColor" d="M21 20a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9.49a1 1 0 0 1 .386-.79l8-6.223a1 1 0 0 1 1.228 0l8 6.223a1 1 0 0 1 .386.79zm-2-1V9.978l-7-5.444l-7 5.444V19z"/>'
      'history-line': {
        body: '<path fill="currentColor" d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12h2a8 8 0 1 0 1.385-4.5H8v2H2v-6h2V6a9.99 9.99 0 0 1 8-4m1 5v4.585l3.243 3.243l-1.415 1.415L11 12.413V7z"/>'
      },
      'home-smile-2-line': {
        body: '<path fill="currentColor" d="M19 19V9.799l-7-5.522l-7 5.522V19zm2 1a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9.314a1 1 0 0 1 .38-.785l8-6.311a1 1 0 0 1 1.24 0l8 6.31a1 1 0 0 1 .38.786zM7 12h2a3 3 0 1 0 6 0h2a5 5 0 0 1-10 0"/>'
@@ -345,8 +190,8 @@
      'image-line': {
        body: '<path fill="currentColor" d="M2.992 21A.993.993 0 0 1 2 20.007V3.993A1 1 0 0 1 2.992 3h18.016c.548 0 .992.445.992.993v16.014a1 1 0 0 1-.992.993zM20 15V5H4v14L14 9zm0 2.828l-6-6L6.828 19H20zM8 11a2 2 0 1 1 0-4a2 2 0 0 1 0 4"/>'
      },
      'input-method-line': {
        body: '<path fill="currentColor" d="M5 5v14h14V5zM4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1m5.869 12l-.82 2H6.833L11 7h2l4.167 10H14.95l-.82-2zm.82-2h2.623L12 9.8z"/>'
      'key-2-line': {
        body: '<path fill="currentColor" d="m10.758 11.828l7.849-7.849l1.414 1.414l-1.414 1.415l2.474 2.474l-1.414 1.415l-2.475-2.475l-1.414 1.414l2.121 2.121l-1.414 1.415l-2.121-2.122l-2.192 2.192a5.002 5.002 0 0 1-7.708 6.293a5 5 0 0 1 6.294-7.707m-.637 6.293A3 3 0 1 0 5.88 13.88a3 3 0 0 0 4.242 4.242"/>'
      },
      'layout-2-line': {
        body: '<path fill="currentColor" d="M21 20a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1zM11 5H5v14h6zm8 8h-6v6h6zm0-8h-6v6h6z"/>'
@@ -354,14 +199,14 @@
      'layout-grid-line': {
        body: '<path fill="currentColor" d="M21 3a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM11 13H4v6h7zm9 0h-7v6h7zm-9-8H4v6h7zm9 0h-7v6h7z"/>'
      },
      'loader-line': {
        body: '<path fill="currentColor" d="M12 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1m0 15a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1m8.66-10a1 1 0 0 1-.366 1.366l-2.598 1.5a1 1 0 1 1-1-1.732l2.598-1.5A1 1 0 0 1 20.66 7M7.67 14.5a1 1 0 0 1-.367 1.366l-2.598 1.5a1 1 0 1 1-1-1.732l2.598-1.5a1 1 0 0 1 1.366.366M20.66 17a1 1 0 0 1-1.366.366l-2.598-1.5a1 1 0 0 1 1-1.732l2.598 1.5A1 1 0 0 1 20.66 17M7.67 9.5a1 1 0 0 1-1.367.366l-2.598-1.5a1 1 0 1 1 1-1.732l2.598 1.5A1 1 0 0 1 7.67 9.5"/>'
      'lightbulb-flash-line': {
        body: '<path fill="currentColor" d="M9.973 18h4.054c.132-1.202.745-2.193 1.74-3.277c.113-.122.832-.867.917-.973a6 6 0 1 0-9.37-.002c.086.107.807.853.918.974c.996 1.084 1.609 2.076 1.741 3.278M14 20h-4v1h4zm-8.246-5a8 8 0 1 1 12.49.002C17.624 15.774 16 17 16 18.5V21a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.5C8 17 6.375 15.774 5.754 15M13 10.004h2.5l-4.5 6v-4H8.5L13 6z"/>'
      },
      'line-chart-line': {
        body: '<path fill="currentColor" d="M5 3v16h16v2H3V3zm15.293 3.293l1.414 1.414L16 13.414l-3-2.999l-4.293 4.292l-1.414-1.414L13 7.586l3 2.999z"/>'
      },
      'lock-line': {
        body: '<path fill="currentColor" d="M19 10h1a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V11a1 1 0 0 1 1-1h1V9a7 7 0 0 1 14 0zM5 12v8h14v-8zm6 2h2v4h-2zm6-4V9A5 5 0 0 0 7 9v1z"/>'
      },
      'magic-line': {
        body: '<path fill="currentColor" d="M15.199 9.944a2.6 2.6 0 0 1-.79-1.55l-.403-3.083l-2.731 1.486a2.6 2.6 0 0 1-1.719.272L6.5 6.5l.57 3.056a2.6 2.6 0 0 1-.273 1.72l-1.486 2.73l3.083.403a2.6 2.6 0 0 1 1.55.79l2.138 2.257l1.336-2.807a2.6 2.6 0 0 1 1.23-1.231l2.808-1.336zm.025 5.564l-2.213 4.65a.6.6 0 0 1-.977.155l-3.542-3.739a.6.6 0 0 0-.358-.182l-5.106-.668a.6.6 0 0 1-.45-.881l2.462-4.524a.6.6 0 0 0 .063-.396L4.16 4.86a.6.6 0 0 1 .7-.7l5.062.943a.6.6 0 0 0 .397-.063l4.523-2.46a.6.6 0 0 1 .882.448l.668 5.107a.6.6 0 0 0 .182.357l3.739 3.542a.6.6 0 0 1-.155.977l-4.65 2.213a.6.6 0 0 0-.284.284m.797 1.927l1.414-1.414l4.243 4.242l-1.415 1.415z"/>'
      },
      'mail-line': {
        body: '<path fill="currentColor" d="M3 3h18a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1m17 4.238l-7.928 7.1L4 7.216V19h16zM4.511 5l7.55 6.662L19.502 5z"/>'
@@ -370,9 +215,6 @@
        body: '<path fill="currentColor" d="m12 20.9l4.95-4.95a7 7 0 1 0-9.9 0zm0 2.828l-6.364-6.364a9 9 0 1 1 12.728 0zM12 13a2 2 0 1 0 0-4a2 2 0 0 0 0 4m0 2a4 4 0 1 1 0-8a4 4 0 0 1 0 8"/>'
      },
      'menu-2-fill': {
        body: '<path fill="currentColor" d="M3 4h18v2H3zm0 7h12v2H3zm0 7h18v2H3z"/>'
      },
      'menu-2-line': {
        body: '<path fill="currentColor" d="M3 4h18v2H3zm0 7h12v2H3zm0 7h18v2H3z"/>'
      },
      'menu-line': {
@@ -384,20 +226,11 @@
      'message-3-line': {
        body: '<path fill="currentColor" d="M2 8.994A5.99 5.99 0 0 1 8 3h8c3.313 0 6 2.695 6 5.994V21H8c-3.313 0-6-2.695-6-5.994zM20 19V8.994A4.004 4.004 0 0 0 16 5H8a3.99 3.99 0 0 0-4 3.994v6.012A4.004 4.004 0 0 0 8 19zm-6-8h2v2h-2zm-6 0h2v2H8z"/>'
      },
      'money-cny-box-line': {
        body: '<path fill="currentColor" d="M3.005 3.003h18a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-18a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1m1 2v14h16v-14zm9 8h3v2h-3v2h-2v-2h-3v-2h3v-1h-3v-2h2.586L8.469 7.88l1.415-1.414l2.12 2.122l2.122-2.122L15.54 7.88l-2.12 2.122h2.585v2h-3z"/>'
      },
      'money-cny-circle-line': {
        body: '<path fill="currentColor" d="M12.005 22.003c-5.523 0-10-4.477-10-10s4.477-10 10-10s10 4.477 10 10s-4.477 10-10 10m0-2a8 8 0 1 0 0-16a8 8 0 0 0 0 16m1-7h3v2h-3v2h-2v-2h-3v-2h3v-1h-3v-2h2.586L8.469 7.88l1.415-1.414l2.12 2.122l2.122-2.122L15.54 7.88l-2.12 2.122h2.585v2h-3z"/>'
      },
      'money-dollar-circle-line': {
        body: '<path fill="currentColor" d="M12.005 22.003c-5.523 0-10-4.477-10-10s4.477-10 10-10s10 4.477 10 10s-4.477 10-10 10m0-2a8 8 0 1 0 0-16a8 8 0 0 0 0 16m-3.5-6h5.5a.5.5 0 1 0 0-1h-4a2.5 2.5 0 1 1 0-5h1v-2h2v2h2.5v2h-5.5a.5.5 0 0 0 0 1h4a2.5 2.5 0 0 1 0 5h-1v2h-2v-2h-2.5z"/>'
      'moon-line': {
        body: '<path fill="currentColor" d="M10 7a7 7 0 0 0 12 4.9v.1c0 5.523-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2h.1A6.98 6.98 0 0 0 10 7m-6 5a8 8 0 0 0 15.062 3.762A9 9 0 0 1 8.238 4.938A8 8 0 0 0 4 12"/>'
      },
      'more-2-fill': {
        body: '<path fill="currentColor" d="M12 3c-1.1 0-2 .9-2 2s.9 2 2 2s2-.9 2-2s-.9-2-2-2m0 14c-1.1 0-2 .9-2 2s.9 2 2 2s2-.9 2-2s-.9-2-2-2m0-7c-1.1 0-2 .9-2 2s.9 2 2 2s2-.9 2-2s-.9-2-2-2"/>'
      },
      'mouse-line': {
        body: '<path fill="currentColor" d="M11.141 4c-1.582 0-2.387.169-3.128.565a3.45 3.45 0 0 0-1.448 1.448C6.169 6.753 6 7.559 6 9.14v5.718c0 1.582.169 2.387.565 3.128q.504.944 1.448 1.448c.74.396 1.546.565 3.128.565h1.718c1.582 0 2.387-.169 3.128-.565a3.45 3.45 0 0 0 1.448-1.448c.396-.74.565-1.546.565-3.128V9.14c0-1.582-.169-2.387-.565-3.128a3.45 3.45 0 0 0-1.448-1.448C15.247 4.169 14.441 4 12.86 4zm0-2h1.718c2.014 0 3.094.278 4.071.801A5.45 5.45 0 0 1 19.2 5.07c.522.978.801 2.058.801 4.072v5.718c0 2.014-.279 3.094-.801 4.071a5.45 5.45 0 0 1-2.27 2.269c-.977.522-2.057.801-4.071.801H11.14c-2.014 0-3.094-.279-4.072-.801A5.45 5.45 0 0 1 4.8 18.931c-.522-.977-.8-2.057-.8-4.071V9.14c0-2.014.278-3.094.801-4.072A5.45 5.45 0 0 1 7.07 2.801C8.047 2.278 9.127 2 11.141 2M11 6h2v5h-2z"/>'
      },
      'notification-2-line': {
        body: '<path fill="currentColor" d="M22 20H2v-2h1v-6.969C3 6.043 7.03 2 12 2s9 4.043 9 9.031V18h1zM5 18h14v-6.969C19 7.148 15.866 4 12 4s-7 3.148-7 7.031zm4.5 3h5a2.5 2.5 0 0 1-5 0"/>'
@@ -411,20 +244,14 @@
      'pencil-line': {
        body: '<path fill="currentColor" d="m15.728 9.576l-1.414-1.414L5 17.476v1.414h1.414zm1.414-1.414l1.414-1.414l-1.414-1.414l-1.414 1.414zm-9.9 12.728H3v-4.243L16.435 3.212a1 1 0 0 1 1.414 0l2.829 2.829a1 1 0 0 1 0 1.414z"/>'
      },
      'phone-line': {
        body: '<path fill="currentColor" d="M9.366 10.682a10.56 10.56 0 0 0 3.952 3.952l.884-1.238a1 1 0 0 1 1.294-.296a11.4 11.4 0 0 0 4.583 1.364a1 1 0 0 1 .921.997v4.462a1 1 0 0 1-.898.995Q19.307 21 18.5 21C9.94 21 3 14.06 3 5.5q0-.807.082-1.602A1 1 0 0 1 4.077 3h4.462a1 1 0 0 1 .997.921A11.4 11.4 0 0 0 10.9 8.504a1 1 0 0 1-.296 1.294zm-2.522-.657l1.9-1.357A13.4 13.4 0 0 1 7.647 5H5.01Q5 5.25 5 5.5C5 12.956 11.044 19 18.5 19q.25 0 .5-.01v-2.637a13.4 13.4 0 0 1-3.668-1.097l-1.357 1.9a12.5 12.5 0 0 1-1.588-.75l-.058-.033a12.56 12.56 0 0 1-4.702-4.702l-.033-.058a12.4 12.4 0 0 1-.75-1.588"/>'
      },
      'pie-chart-line': {
        body: '<path fill="currentColor" d="M9 2.458v2.124A8.003 8.003 0 0 0 12 20a8 8 0 0 0 7.419-5h2.123c-1.274 4.057-5.064 7-9.542 7c-5.523 0-10-4.477-10-10c0-4.478 2.943-8.268 7-9.542M12 2c5.523 0 10 4.477 10 10q0 .507-.05 1H11V2.05Q11.493 2 12 2m1 2.062V11h6.938A8.004 8.004 0 0 0 13 4.062"/>'
      },
      'planet-line': {
        body: '<path fill="currentColor" d="M3.918 8.037A9 9 0 0 0 15.966 20.08c.873.373 1.719.618 2.49.681c.902.074 1.844-.095 2.526-.777c.752-.752.88-1.816.746-2.812c-.123-.91-.48-1.92-1.002-2.961A9 9 0 0 0 9.791 3.274c-1.044-.524-2.055-.882-2.965-1.006c-.997-.135-2.062-.007-2.815.746c-.682.683-.851 1.626-.777 2.528c.064.773.31 1.62.684 2.495m1.404-2.071a4 4 0 0 1-.095-.587c-.048-.586.09-.842.198-.95c.12-.12.423-.275 1.132-.179q.298.04.643.136a9 9 0 0 0-1.878 1.58m14.29 10.837a5 5 0 0 1 .134.637c.096.709-.06 1.012-.178 1.13c-.109.109-.364.247-.95.199a4 4 0 0 1-.581-.094a9 9 0 0 0 1.575-1.872m-3.73 1.023c-1.677-.878-3.625-2.323-5.507-4.205c-1.88-1.88-3.324-3.825-4.203-5.5A7.02 7.02 0 0 1 9.97 5.298a7 7 0 0 1 5.912 12.528m-2.277.99a7 7 0 0 1-8.42-8.419c.964 1.516 2.25 3.112 3.776 4.638c1.528 1.528 3.126 2.815 4.644 3.78"/>'
      'plug-2-line': {
        body: '<path fill="currentColor" d="M13 18v2h6v2h-6a2 2 0 0 1-2-2v-2H8a4 4 0 0 1-4-4V7a1 1 0 0 1 1-1h2V2h2v4h6V2h2v4h2a1 1 0 0 1 1 1v7a4 4 0 0 1-4 4zm-5-2h8a2 2 0 0 0 2-2v-3H6v3a2 2 0 0 0 2 2m10-8H6v1h12zm-6 6.5a1 1 0 1 1 0-2a1 1 0 0 1 0 2M11 2h2v3h-2z"/>'
      },
      'price-tag-line': {
        body: '<path fill="currentColor" d="m3.005 7l8.445-5.63a1 1 0 0 1 1.11 0L21.005 7v14a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1zm2 1.07V20h14V8.07l-7-4.667zm7 2.93a2 2 0 1 1 0-4a2 2 0 0 1 0 4"/>'
      },
      'profile-line': {
        body: '<path fill="currentColor" d="M21.008 3c.548 0 .992.445.992.993v16.014a1 1 0 0 1-.992.993H2.992A.993.993 0 0 1 2 20.007V3.993A1 1 0 0 1 2.992 3zM20 5H4v14h16zm-2 10v2H6v-2zm-6-8v6H6V7zm6 4v2h-4v-2zm-8-2H8v2h2zm8-2v2h-4V7z"/>'
      },
      'progress-2-line': {
        body: '<path fill="currentColor" d="M2 12c0 5.523 4.477 10 10 10s10-4.477 10-10S17.523 2 12 2S2 6.477 2 12m18 0a8 8 0 1 1-16 0a8 8 0 0 1 16 0m-8 0V6a6 6 0 0 1 6 6z"/>'
@@ -432,77 +259,47 @@
      'pushpin-2-line': {
        body: '<path fill="currentColor" d="M18 3v2h-1v6l2 3v2h-6v7h-2v-7H5v-2l2-3V5H6V3zM9 5v6.606L7.404 14h9.192L15 11.606V5z"/>'
      },
      'qr-code-line': {
        body: '<path fill="currentColor" d="M16 17v-1h-3v-3h3v2h2v2h-1v2h-2v2h-2v-3h2v-1zm5 4h-4v-2h2v-2h2zM3 3h8v8H3zm2 2v4h4V5zm8-2h8v8h-8zm2 2v4h4V5zM3 13h8v8H3zm2 2v4h4v-4zm13-2h3v2h-3zM6 6h2v2H6zm0 10h2v2H6zM16 6h2v2h-2z"/>'
      },
      'rectangle-line': {
        body: '<path fill="currentColor" d="M3 4h18a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1m1 2v12h16V6z"/>'
      },
      'refresh-line': {
        body: '<path fill="currentColor" d="M5.463 4.433A9.96 9.96 0 0 1 12 2c5.523 0 10 4.477 10 10c0 2.136-.67 4.116-1.81 5.74L17 12h3A8 8 0 0 0 6.46 6.228zm13.074 15.134A9.96 9.96 0 0 1 12 22C6.477 22 2 17.523 2 12c0-2.136.67-4.116 1.81-5.74L7 12H4a8 8 0 0 0 13.54 5.772z"/>'
      },
      'screenshot-line': {
        body: '<path fill="currentColor" d="m11.993 14.407l-1.552 1.552a4 4 0 1 1-1.418-1.41l1.555-1.556l-4.185-4.185l1.415-1.415l4.185 4.185l4.189-4.189l1.414 1.414l-4.19 4.19l1.562 1.56a4 4 0 1 1-1.414 1.414zM7 20a2 2 0 1 0 0-4a2 2 0 0 0 0 4m10 0a2 2 0 1 0 0-4a2 2 0 0 0 0 4m2-7V5H5v8H3V4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v9z"/>'
      'robot-2-line': {
        body: '<path fill="currentColor" d="M13.5 2c0 .444-.193.843-.5 1.118V5h5a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3h5V3.118A1.5 1.5 0 1 1 13.5 2M6 7a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1zm-4 3H0v6h2zm20 0h2v6h-2zM9 14.5a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3m6 0a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3"/>'
      },
      'route-line': {
        body: '<path fill="currentColor" d="M4 15V8.5a4.5 4.5 0 0 1 9 0v7a2.5 2.5 0 0 0 5 0V8.83a3.001 3.001 0 1 1 2 0v6.67a4.5 4.5 0 1 1-9 0v-7a2.5 2.5 0 0 0-5 0V15h3l-4 5l-4-5zm15-8a1 1 0 1 0 0-2a1 1 0 0 0 0 2"/>'
      },
      'search-line': {
        body: '<path fill="currentColor" d="m18.031 16.617l4.283 4.282l-1.415 1.415l-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9s9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617m-2.006-.742A6.98 6.98 0 0 0 18 11c0-3.867-3.133-7-7-7s-7 3.133-7 7s3.133 7 7 7a6.98 6.98 0 0 0 4.875-1.975z"/>'
      },
      'server-line': {
        body: '<path fill="currentColor" d="M5 11h14V5H5zm16-7v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1m-2 9H5v6h14zM7 15h3v2H7zm0-8h3v2H7z"/>'
      },
      'settings-line': {
        body: '<path fill="currentColor" d="m12 1l9.5 5.5v11L12 23l-9.5-5.5v-11zm0 2.311L4.5 7.653v8.694l7.5 4.342l7.5-4.342V7.653zM12 16a4 4 0 1 1 0-8a4 4 0 0 1 0 8m0-2a2 2 0 1 0 0-4a2 2 0 0 0 0 4"/>'
      },
      'shake-hands-line': {
        body: '<path fill="currentColor" d="M11.861 2.39a3 3 0 0 1 3.275-.034L19.29 5H21a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-1.52a2.65 2.65 0 0 1-1.285 2.449l-5.093 3.056a2 2 0 0 1-2.07-.008a2 2 0 0 1-2.561.073l-5.14-4.039a2 2 0 0 1-.565-2.446A2 2 0 0 1 2 13.51V6a1 1 0 0 1 1-1h4.947zM4.173 13.646l.692-.605a3 3 0 0 1 4.195.24l2.702 2.972a3 3 0 0 1 .396 3.487l5.009-3.005a.66.66 0 0 0 .278-.79l-4.427-6.198a1 1 0 0 0-1.101-.377l-2.486.745a3 3 0 0 1-2.983-.752l-.293-.292A2 2 0 0 1 5.68 7H4v6.51zm9.89-9.602a1 1 0 0 0-1.093.012l-5.4 3.6l.292.293a1 1 0 0 0 .995.25l2.485-.745a3 3 0 0 1 3.303 1.13L18.515 14H20V7h-.709a2 2 0 0 1-1.074-.313zM6.181 14.545l-1.616 1.414l5.14 4.039l.705-1.232a1 1 0 0 0-.129-1.169L7.58 14.625a1 1 0 0 0-1.398-.08"/>'
      },
      'share-forward-line': {
        body: '<path fill="currentColor" d="M13 14h-2a9 9 0 0 0-7.968 4.81A10 10 0 0 1 3 18C3 12.477 7.477 8 13 8V2.5L23.5 11L13 19.5zm-2-2h4v3.308L20.321 11L15 6.692V10h-2a7.98 7.98 0 0 0-6.057 2.774A11 11 0 0 1 11 12"/>'
      },
      'shield-check-line': {
        body: '<path fill="currentColor" d="m12 1l8.217 1.826a1 1 0 0 1 .783.976v9.987a6 6 0 0 1-2.672 4.992L12 23l-6.328-4.219A6 6 0 0 1 3 13.79V3.802a1 1 0 0 1 .783-.976zm0 2.049L5 4.604v9.185a4 4 0 0 0 1.781 3.328L12 20.597l5.219-3.48A4 4 0 0 0 19 13.79V4.604zm4.452 5.173l1.415 1.414L11.503 16L7.26 11.757l1.414-1.414l2.828 2.828z"/>'
      },
      'shopping-bag-3-line': {
        body: '<path fill="currentColor" d="M6.505 2h11a1 1 0 0 1 .8.4l2.7 3.6v15a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1V6l2.7-3.6a1 1 0 0 1 .8-.4m12.5 6h-14v12h14zm-.5-2l-1.5-2h-10l-1.5 2zm-9.5 4v2a3 3 0 1 0 6 0v-2h2v2a5 5 0 0 1-10 0v-2z"/>'
      'smartphone-line': {
        body: '<path fill="currentColor" d="M7 4v16h10V4zM6 2h12a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1m6 15a1 1 0 1 1 0 2a1 1 0 0 1 0-2"/>'
      },
      'shopping-bag-4-line': {
        body: '<path fill="currentColor" d="M9 6h6a3 3 0 1 0-6 0M7 6a5 5 0 0 1 10 0h3a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1zM5 8v12h14V8zm4 2a3 3 0 1 0 6 0h2a5 5 0 0 1-10 0z"/>'
      'store-2-line': {
        body: '<path fill="currentColor" d="M21 13.242V20h1v2H2v-2h1v-6.758A4.5 4.5 0 0 1 1 9.5c0-.827.224-1.624.633-2.303L4.345 2.5a1 1 0 0 1 .866-.5H18.79a1 1 0 0 1 .866.5l2.703 4.682c.418.694.642 1.49.642 2.318c0 1.56-.794 2.935-2 3.742m-2 .73a4.5 4.5 0 0 1-3.75-1.36A4.5 4.5 0 0 1 12 14.001a4.5 4.5 0 0 1-3.25-1.387A4.5 4.5 0 0 1 5 13.973V20h14zM5.789 4L3.356 8.213a2.5 2.5 0 1 0 4.466 2.216c.335-.837 1.52-.837 1.856 0a2.5 2.5 0 0 0 4.644 0c.335-.837 1.52-.837 1.856 0a2.5 2.5 0 1 0 4.457-2.232L18.21 4z"/>'
      },
      'shopping-bag-line': {
        body: '<path fill="currentColor" d="M7.005 8V6a5 5 0 0 1 10 0v2h3a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1zm0 2h-2v10h14V10h-2v2h-2v-2h-6v2h-2zm2-2h6V6a3 3 0 0 0-6 0z"/>'
      },
      'sparkling-line': {
        body: '<path fill="currentColor" d="M14 4.438A2.437 2.437 0 0 0 16.438 2h1.125A2.437 2.437 0 0 0 20 4.438v1.125A2.437 2.437 0 0 0 17.563 8h-1.125A2.437 2.437 0 0 0 14 5.563zM1 11a6 6 0 0 0 6-6h2a6 6 0 0 0 6 6v2a6 6 0 0 0-6 6H7a6 6 0 0 0-6-6zm3.876 1A8.04 8.04 0 0 1 8 15.124A8.04 8.04 0 0 1 11.124 12A8.04 8.04 0 0 1 8 8.876A8.04 8.04 0 0 1 4.876 12m12.374 2A3.25 3.25 0 0 1 14 17.25v1.5A3.25 3.25 0 0 1 17.25 22h1.5A3.25 3.25 0 0 1 22 18.75v-1.5A3.25 3.25 0 0 1 18.75 14z"/>'
      },
      'star-fill': {
        body: '<path fill="currentColor" d="m12 18.26l-7.053 3.948l1.575-7.928L.588 8.792l8.027-.952L12 .5l3.385 7.34l8.027.952l-5.934 5.488l1.575 7.928z"/>'
      },
      'subway-line': {
        body: '<path fill="currentColor" d="m17.2 20l1.8 1.5v.5H5v-.5L6.8 20H5a2 2 0 0 1-2-2V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4v11a2 2 0 0 1-2 2zM13 5v6h6V7a2 2 0 0 0-2-2zm-2 0H7a2 2 0 0 0-2 2v4h6zm8 8H5v5h14zM7.5 17a1.5 1.5 0 1 1 0-3a1.5 1.5 0 0 1 0 3m9 0a1.5 1.5 0 1 1 0-3a1.5 1.5 0 0 1 0 3"/>'
      },
      't-box-line': {
        body: '<path fill="currentColor" d="M5 5v14h14V5zM4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1m9 7v7h-2v-7H7V8h10v2z"/>'
      },
      'table-3': {
        body: '<path fill="currentColor" d="M3 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zm8 2v3H4V5zm-7 9v-4h7v4zm0 2h7v3H4zm9 0h7v3h-7zm7-2h-7v-4h7zm0-9v3h-7V5z"/>'
      'sun-fill': {
        body: '<path fill="currentColor" d="M12 18a6 6 0 1 1 0-12a6 6 0 0 1 0 12M11 1h2v3h-2zm0 19h2v3h-2zM3.515 4.929l1.414-1.414L7.05 5.636L5.636 7.05zM16.95 18.364l1.414-1.414l2.121 2.121l-1.414 1.414zm2.121-14.85l1.414 1.415l-2.121 2.121l-1.414-1.414zM5.636 16.95l1.414 1.414l-2.121 2.121l-1.414-1.414zM23 11v2h-3v-2zM4 11v2H1v-2z"/>'
      },
      'table-line': {
        body: '<path fill="currentColor" d="M4 8h16V5H4zm10 11v-9h-4v9zm2 0h4v-9h-4zm-8 0v-9H4v9zM3 3h18a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1"/>'
      },
      'table-view': {
        body: '<path fill="currentColor" d="M3 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zm5 2v3H4V5zm-4 9v-4h4v4zm0 2h4v3H4zm6 0h10v3H10zm10-2H10v-4h10zm0-9v3H10V5z"/>'
      },
      'telegram-2-line': {
        body: '<path fill="currentColor" d="M17.094 7.146c.593-.215.888-.292 1.05-.32q.002.08-.002.122c-.232 2.444-1.251 8.457-1.775 11.255c-.122.655-.216.967-.85.595c-.416-.245-.792-.553-1.196-.817c-1.325-.869-3.221-2.162-3.065-2.084c-1.304-.86-.758-1.386-.03-2.088c.117-.113.24-.231.36-.356c.054-.056.317-.3.687-.645c1.188-1.104 3.484-3.239 3.542-3.486c.01-.04.018-.192-.071-.271c-.09-.08-.223-.053-.318-.031q-.203.046-6.474 4.279q-.918.63-1.664.614l.005.003c-.655-.231-1.308-.43-1.964-.63a66 66 0 0 1-1.3-.405l-.308-.098c4.527-1.972 7.542-3.27 9.053-3.899c2.194-.913 3.496-1.438 4.32-1.738m2.423-1.928a1.8 1.8 0 0 0-.726-.346c-.2-.048-.39-.063-.533-.06c-.477.008-.988.143-1.846.454c-.875.318-2.219.862-4.406 1.771Q9.691 8 2.804 11.001c-.404.161-.773.344-1.065.56c-.27.201-.647.56-.716 1.11c-.052.416.069.8.315 1.103c.214.263.488.423.697.524c.31.15.728.281 1.095.396c.573.18 1.144.363 1.719.539c1.778.544 3.242.992 4.852 2.054c1.181.778 2.34 1.59 3.523 2.366c.432.283.835.608 1.28.87c.488.285 1.106.546 1.86.477c1.138-.105 1.73-1.152 1.97-2.43c.521-2.79 1.557-8.886 1.8-11.432a3.8 3.8 0 0 0-.037-.885a1.66 1.66 0 0 0-.58-1.035"/>'
      },
      'thumb-up-line': {
        body: '<path fill="currentColor" d="M14.6 8H21a2 2 0 0 1 2 2v2.105c0 .26-.051.52-.15.761l-3.095 7.515a1 1 0 0 1-.925.62H2a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1h3.482a1 1 0 0 0 .817-.424L11.752.851a.5.5 0 0 1 .632-.159l1.814.908a2.5 2.5 0 0 1 1.305 2.852zM7 10.588V19h11.16L21 12.105V10h-6.4a2 2 0 0 1-1.938-2.493l.903-3.548a.5.5 0 0 0-.261-.57l-.661-.331l-4.71 6.672c-.25.354-.57.645-.933.858M5 11H3v8h2z"/>'
      },
      'time-line': {
        body: '<path fill="currentColor" d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10m0-2a8 8 0 1 0 0-16a8 8 0 0 0 0 16m1-8h4v2h-6V7h2z"/>'
      'tools-line': {
        body: '<path fill="currentColor" d="M5.33 3.272a3.5 3.5 0 0 1 4.254 4.962l10.709 10.71l-1.414 1.414l-10.71-10.71a3.502 3.502 0 0 1-4.962-4.255L5.444 7.63a1.5 1.5 0 0 0 2.121-2.121zm10.367 1.883l3.182-1.768l1.414 1.415l-1.768 3.182l-1.768.353l-2.12 2.121l-1.415-1.414l2.121-2.121zm-6.718 8.132l1.415 1.414l-5.304 5.303a1 1 0 0 1-1.492-1.327l.078-.087z"/>'
      },
      'translate-2': {
        body: '<path fill="currentColor" d="m18.5 10l4.4 11h-2.155l-1.201-3h-4.09l-1.199 3h-2.154L16.5 10zM10 2v2h6v2h-1.968a18.2 18.2 0 0 1-3.62 6.301a15 15 0 0 0 2.335 1.707l-.75 1.878A17 17 0 0 1 9 13.725a16.7 16.7 0 0 1-6.201 3.548l-.536-1.929a14.7 14.7 0 0 0 5.327-3.042A18 18 0 0 1 4.767 8h2.24A16 16 0 0 0 9 10.877a16.2 16.2 0 0 0 2.91-4.876L2 6V4h6V2zm7.5 10.885L16.253 16h2.492z"/>'
      },
      'twitch-line': {
        body: '<path fill="currentColor" d="M4.301 3h16.7v11.7l-4.7 4.7h-3.9l-2.5 2.4h-2.9v-2.4h-4V6.2zm.7 14.4h4v2.4h.095l2.5-2.4h3.877L19 13.872V5H5zm10-9.4h2v4.7h-2zm0 0h2v4.7h-2zm-5 0h2v4.7h-2z"/>'
      'unpin-line': {
        body: '<path fill="currentColor" d="m20.97 17.172l-1.414 1.414l-3.535-3.535l-.073.074l-.707 3.536l-1.415 1.414l-4.242-4.243l-4.95 4.95l-1.414-1.414l4.95-4.95l-4.243-4.243L5.34 8.761l3.536-.707l.073-.074l-3.536-3.536L6.828 3.03zM10.365 9.394l-.502.502l-2.822.565l6.5 6.5l.564-2.822l.502-.502zm8.411.074l-1.34 1.34l1.414 1.415l1.34-1.34l.707.707l1.415-1.415l-8.486-8.485l-1.414 1.414l.707.707l-1.34 1.34l1.414 1.415l1.34-1.34z"/>'
      },
      'user-3-line': {
        body: '<path fill="currentColor" d="M20 22h-2v-2a3 3 0 0 0-3-3H9a3 3 0 0 0-3 3v2H4v-2a5 5 0 0 1 5-5h6a5 5 0 0 1 5 5zm-8-9a6 6 0 1 1 0-12a6 6 0 0 1 0 12m0-2a4 4 0 1 0 0-8a4 4 0 0 0 0 8"/>'
@@ -519,23 +316,8 @@
      'user-settings-line': {
        body: '<path fill="currentColor" d="M12 14v2a6 6 0 0 0-6 6H4a8 8 0 0 1 8-8m0-1c-3.315 0-6-2.685-6-6s2.685-6 6-6s6 2.685 6 6s-2.685 6-6 6m0-2c2.21 0 4-1.79 4-4s-1.79-4-4-4s-4 1.79-4 4s1.79 4 4 4m2.595 7.811a3.5 3.5 0 0 1 0-1.622l-.992-.573l1-1.732l.992.573A3.5 3.5 0 0 1 17 14.645V13.5h2v1.145c.532.158 1.012.44 1.405.812l.992-.573l1 1.732l-.991.573a3.5 3.5 0 0 1 0 1.622l.991.573l-1 1.732l-.992-.573a3.5 3.5 0 0 1-1.405.812V22.5h-2v-1.145a3.5 3.5 0 0 1-1.405-.812l-.992.573l-1-1.732zM18 19.5a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3"/>'
      },
      'video-on-line': {
        body: '<path fill="currentColor" d="m17 9.2l5.213-3.65a.5.5 0 0 1 .787.41v12.08a.5.5 0 0 1-.787.41L17 14.8V19a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1zm0 3.159l4 2.8V8.84l-4 2.8zM3 6v12h12V6z"/>'
      },
      'vidicon-line': {
        body: '<path fill="currentColor" d="m17 9.2l5.213-3.65a.5.5 0 0 1 .787.41v12.08a.5.5 0 0 1-.787.41L17 14.8V19a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1zm0 3.159l4 2.8V8.84l-4 2.8zM3 6v12h12V6zm2 2h2v2H5z"/>'
      },
      'volume-down-line': {
        body: '<path fill="currentColor" d="M13 7.22L9.603 10H6v4h3.603L13 16.78zM8.889 16H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3.889l5.294-4.332a.5.5 0 0 1 .817.387v15.89a.5.5 0 0 1-.817.387zm9.974.591l-1.422-1.422A4 4 0 0 0 19 12c0-1.43-.75-2.685-1.88-3.392l1.439-1.439A5.99 5.99 0 0 1 21 12c0 1.842-.83 3.49-2.137 4.591"/>'
      },
      'wallet-line': {
        body: '<path fill="currentColor" d="M18.005 7h3a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h15zm-14 2v10h16V9zm0-4v2h12V5zm11 8h3v2h-3z"/>'
      },
      'water-flash-line': {
        body: '<path fill="currentColor" d="m12.005 3.103l-4.95 4.95a7 7 0 1 0 9.9 0zm0-2.828l6.364 6.364A9 9 0 1 1 5.64 19.367a9 9 0 0 1 0-12.728zm1 10.728h2.5l-4.5 6.5v-4.5h-2.5l4.5-6.5z"/>'
      },
      'windows-line': {
        body: '<path fill="currentColor" d="M21.001 2.5v19l-18-2v-15zm-2 10.499l-7 .001v5.487l7 .779zm-14 4.71l5 .556V13l-5-.001zm14-6.71V4.735l-7 .777V11zm-9-5.265l-5 .556V11l5 .001z"/>'
      }
    },
    prefix: 'ri',
rsf-design/src/router/adapters/backendMenuAdapter.js
New file
@@ -0,0 +1,240 @@
import { RoutesAlias } from '../routesAlias.js'
import { resolveBackendMenuTitle } from '../../utils/backend-menu-title.js'
const PHASE_1_COMPONENTS = {
  console: '/dashboard/console',
  user: '/system/user',
  role: '/system/role',
  menu: '/system/menu',
  userLogin: '/system/user-login'
}
const LEGACY_BACKEND_ICON_MAP = Object.freeze({
  SmartToy: 'ri:robot-2-line',
  Psychology: 'ri:lightbulb-flash-line',
  History: 'ri:history-line',
  Cable: 'ri:plug-2-line',
  StorageSharp: 'ri:server-line',
  Warehouse: 'ri:store-2-line',
  Inventory2Outlined: 'ri:archive-line',
  DeckOutlined: 'ri:layout-grid-line',
  FormatListNumberedOutlined: 'ri:file-list-3-line',
  Beenhere: 'ri:checkbox-circle-line',
  ManageHistoryOutlined: 'ri:history-line',
  AssessmentOutlined: 'ri:bar-chart-box-line',
  QueryStats: 'ri:line-chart-line',
  ScreenSearchDesktop: 'ri:computer-line',
  Dvr: 'ri:file-list-3-line',
  Token: 'ri:key-2-line',
  GroupAddOutlined: 'ri:user-add-line',
  People: 'ri:group-line',
  Style: 'ri:price-tag-line',
  Groups: 'ri:group-line',
  SettingsOutlined: 'ri:settings-line',
  MenuOpen: 'ri:menu-unfold-3-line',
  Straighten: 'ri:table-line',
  MenuBook: 'ri:book-2-line',
  ConfirmationNumber: 'ri:price-tag-line',
  Engineering: 'ri:tools-line',
  TripOrigin: 'ri:checkbox-blank-circle-line'
})
function adaptBackendMenuTree(menuTree) {
  if (!Array.isArray(menuTree)) {
    return []
  }
  return menuTree
    .map((item) => adaptMenuNode(item, { depth: 0, parentRoutePath: '' }))
    .filter(Boolean)
}
function adaptMenuNode(node, context) {
  if (!node || typeof node !== 'object') {
    return null
  }
  if (node.type !== void 0 && node.type !== 0) {
    return null
  }
  const { depth, parentRoutePath } = context
  const rawRoute = typeof node.route === 'string' ? node.route.trim() : ''
  const fullRoutePath = buildFullRoutePath(rawRoute, parentRoutePath)
  const children = Array.isArray(node.children)
    ? node.children
        .map((item) =>
          adaptMenuNode(item, {
            depth: depth + 1,
            parentRoutePath: fullRoutePath || parentRoutePath
          })
        )
        .filter(Boolean)
    : []
  const hasChildren = children.length > 0
  const component = resolveComponent(node.component, fullRoutePath, hasChildren)
  const isFirstLevel = depth === 0
  if (!hasChildren && !component) {
    return null
  }
  const path = resolvePath(node, { component, isFirstLevel, hasChildren, rawRoute })
  const meta = buildMeta(node)
  const adapted = {
    id: normalizeId(node.id),
    name: buildRouteName(node, path),
    path,
    meta
  }
  if (hasChildren) {
    adapted.children = children
  }
  if (component) {
    adapted.component = component
  } else if (isFirstLevel) {
    adapted.component = RoutesAlias.Layout
  }
  return adapted
}
function resolveComponent(componentKey, fullRoutePath, hasChildren) {
  const normalizedKey = typeof componentKey === 'string' ? componentKey.trim() : ''
  if (!normalizedKey && hasChildren) {
    return ''
  }
  if (!normalizedKey) {
    return normalizeComponentPath(fullRoutePath)
  }
  return PHASE_1_COMPONENTS[normalizedKey] || normalizeComponentPath(fullRoutePath)
}
function resolvePath(node, { component, isFirstLevel, hasChildren, rawRoute }) {
  if (rawRoute) {
    if (!isFirstLevel && rawRoute.startsWith('/')) {
      return normalizeComponentPath(rawRoute)
    }
    return sanitizePath(rawRoute)
  }
  if (component) {
    const componentPath = component.replace(/^\//, '')
    return isFirstLevel ? `/${componentPath}` : componentPath.split('/').pop() || ''
  }
  const rawPath = typeof node.path === 'string' ? node.path.trim() : ''
  if (rawPath) {
    return sanitizePath(rawPath)
  }
  return ''
}
function buildMeta(node) {
  const meta = {
    title: normalizeTitle(node.name || node.meta?.title || '')
  }
  const metaSource = node.meta && typeof node.meta === 'object' ? node.meta : node
  const supportedKeys = [
    'icon',
    'keepAlive',
    'isHide',
    'isHideTab',
    'fixedTab',
    'link',
    'isIframe',
    'authList',
    'roles'
  ]
  supportedKeys.forEach((key) => {
    if (metaSource[key] !== void 0) {
      meta[key] = key === 'icon' ? normalizeIcon(metaSource[key]) : metaSource[key]
    }
  })
  return meta
}
function normalizeTitle(title) {
  if (typeof title !== 'string') {
    return ''
  }
  return resolveBackendMenuTitle(title)
}
function normalizeIcon(icon) {
  if (typeof icon !== 'string') {
    return icon
  }
  const normalizedIcon = icon.trim()
  if (!normalizedIcon) {
    return ''
  }
  if (normalizedIcon.includes(':')) {
    return normalizedIcon
  }
  return LEGACY_BACKEND_ICON_MAP[normalizedIcon] || normalizedIcon
}
function buildRouteName(node, path) {
  if (typeof node.name === 'string' && node.name.trim()) {
    return node.name.trim()
  }
  if (typeof node.component === 'string' && node.component.trim()) {
    return node.component.trim()
  }
  if (path) {
    return path
      .split('/')
      .filter(Boolean)
      .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
      .join('')
  }
  return `Menu${normalizeId(node.id)}`
}
function normalizeId(id) {
  if (id === null || id === void 0 || id === '') {
    return ''
  }
  return String(id)
}
function sanitizePath(path) {
  return path.replace(/^\/+/, '').replace(/\/+$/, '')
}
function normalizeComponentPath(fullRoutePath) {
  if (!fullRoutePath) {
    return ''
  }
  return `/${sanitizePath(fullRoutePath)}`
}
function buildFullRoutePath(rawRoute, parentRoutePath) {
  if (!rawRoute) {
    return normalizeComponentPath(parentRoutePath)
  }
  if (rawRoute.startsWith('/')) {
    return normalizeComponentPath(rawRoute)
  }
  const normalizedParent = sanitizePath(parentRoutePath || '')
  const normalizedRoute = sanitizePath(rawRoute)
  if (!normalizedRoute) {
    return normalizeComponentPath(normalizedParent)
  }
  if (!normalizedParent) {
    return `/${normalizedRoute}`
  }
  return `/${normalizedParent}/${normalizedRoute}`
}
export { LEGACY_BACKEND_ICON_MAP, PHASE_1_COMPONENTS, adaptBackendMenuTree, normalizeIcon }
rsf-design/src/router/core/MenuProcessor.js
@@ -166,6 +166,7 @@
   */
  isValidAbsolutePath(path) {
    return (
      path.startsWith('/') ||
      path.startsWith('http://') ||
      path.startsWith('https://') ||
      path.startsWith('/outside/iframe/')
rsf-design/src/store/modules/worktab.js
@@ -2,6 +2,19 @@
import { ref, computed } from 'vue'
import { router } from '@/router'
import { useCommon } from '@/hooks/core/useCommon'
import { normalizeIcon } from '@/router/adapters/backendMenuAdapter.js'
const normalizeTabState = (tab) => {
  if (!tab || typeof tab !== 'object') {
    return tab
  }
  return {
    ...tab,
    icon: normalizeIcon(tab.icon)
  }
}
const useWorktabStore = defineStore(
  'worktabStore',
  () => {
@@ -53,7 +66,7 @@
      }
      if (existingIndex === -1) {
        const insertIndex = tab.fixedTab ? findFixedTabInsertIndex() : opened.value.length
        const newTab = { ...tab }
        const newTab = normalizeTabState(tab)
        if (tab.fixedTab) {
          opened.value.splice(insertIndex, 0, newTab)
        } else {
@@ -62,7 +75,7 @@
        current.value = newTab
      } else {
        const existingTab = opened.value[existingIndex]
        opened.value[existingIndex] = {
        opened.value[existingIndex] = normalizeTabState({
          ...existingTab,
          path: tab.path,
          params: tab.params,
@@ -72,7 +85,7 @@
          keepAlive: tab.keepAlive ?? existingTab.keepAlive,
          name: tab.name || existingTab.name,
          icon: tab.icon || existingTab.icon
        }
        })
        current.value = opened.value[existingIndex]
      }
    }
@@ -257,15 +270,22 @@
            return false
          }
        }
        const validTabs = opened.value.filter((tab) => isTabRouteValid(tab))
        const validTabs = opened.value.filter((tab) => isTabRouteValid(tab)).map(normalizeTabState)
        if (validTabs.length !== opened.value.length) {
          console.warn('发现无效的标签页路由,已自动清理')
        }
        if (
          validTabs.length !== opened.value.length ||
          validTabs.some((tab, index) => tab.icon !== opened.value[index]?.icon)
        ) {
          opened.value = validTabs
        }
        const isCurrentValid = current.value && isTabRouteValid(current.value)
        if (!isCurrentValid && validTabs.length > 0) {
          console.warn('当前激活标签无效,已自动切换')
          current.value = validTabs[0]
        } else if (isCurrentValid) {
          current.value = normalizeTabState(current.value)
        } else if (!isCurrentValid) {
          current.value = {}
        }
rsf-design/src/utils/backend-menu-title.js
New file
@@ -0,0 +1,153 @@
const LEGACY_BACKEND_MENU_TITLES = {
  'menu.abnormal': '异常管理',
  'menu.aiCallLog': 'AI 观测',
  'menu.aiMcpMount': 'MCP 挂载',
  'menu.aiParam': 'AI 参数',
  'menu.aiPrompt': 'Prompt 管理',
  'menu.asnOrder': '入库通知单',
  'menu.asnOrderItem': '收货明细',
  'menu.asnOrderItemLog': '收货历史明细',
  'menu.asnOrderLog': '历史通知单',
  'menu.basContainer': '容器规则',
  'menu.basStation': '站点管理',
  'menu.basStationArea': '站点区域',
  'menu.basicInfo': '基础信息',
  'menu.check': '盘点管理',
  'menu.checkDiff': '盘点差异单',
  'menu.checkItem': '盘点单明细',
  'menu.checkOrder': '盘点单',
  'menu.checkOutBound': '盘点出库',
  'menu.companys': '往来企业',
  'menu.config': '配置参数',
  'menu.container': '容器管理(废)',
  'menu.contract': '合同信息(废)',
  'menu.customer': '客户表',
  'menu.dashboard': '控制台',
  'menu.delivery': 'DO单',
  'menu.deliveryItem': 'DO单明细',
  'menu.department': '部门管理',
  'menu.deviceBind': '设备绑定',
  'menu.deviceSite': '路径管理',
  'menu.dictData': '字典数据集',
  'menu.dictType': '数据字典',
  'menu.fields': '扩展字段',
  'menu.fieldsItem': '扩展字段明细',
  'menu.flowInstance': '流程实例',
  'menu.flowStepInstance': '流程步骤实例',
  'menu.flowStepLog': '流程步骤日志',
  'menu.flowStepTemplate': '流程步骤模板',
  'menu.freeze': '库存冻结',
  'menu.histories': '历史档',
  'menu.host': '机构管理',
  'menu.inStatistic': '日入库汇总查询',
  'menu.inStatisticItem': '日入库明细查询',
  'menu.inStockPoces': '入库管理',
  'menu.loc': '库位',
  'menu.locArea': '逻辑分区(废)',
  'menu.locAreaMat': '逻辑分区',
  'menu.locAreaMatRela': '库区物料关系',
  'menu.locDeadReport': '库存停滞报表',
  'menu.locItem': '库存明细',
  'menu.locPreview': '库位明细',
  'menu.locRevise': '库存调整',
  'menu.locType': '库位类型(废)',
  'menu.logs': '日志',
  'menu.matnr': '物料',
  'menu.matnrGroup': '物料分组',
  'menu.matnrRoleMenu': '物料权限',
  'menu.menu': '菜单管理',
  'menu.menuPda': 'PDA菜单',
  'menu.missionFlowStepInstance': '任务流程步骤',
  'menu.operation': '操作日志',
  'menu.outBound': '出库作业',
  'menu.outStatistic': '日出库汇总查询',
  'menu.outStatisticItem': '日出库明细查询',
  'menu.outStock': '出库通知单',
  'menu.outStockItem': '出库单明细',
  'menu.outStockPoces': '出库管理',
  'menu.pdaRoleMenu': 'PDA权限',
  'menu.permissions': '权限管理',
  'menu.platform': '平台管理',
  'menu.preparation': '备料单',
  'menu.purchase': 'PO单',
  'menu.purchaseItem': 'PO单明细',
  'menu.qlyInspect': '质检信息',
  'menu.qlyIsptItem': '质检信息明细',
  'menu.role': '角色管理',
  'menu.serialRule': '编码规则',
  'menu.serialRuleItem': '编码规则子表',
  'menu.settings': '个人设置',
  'menu.shipper': '货主信息',
  'menu.statisticCount': '日出入库汇总统计',
  'menu.statisticReport': '报表管理',
  'menu.statistics': '库存查询',
  'menu.stock': '入出库历史',
  'menu.stockItem': '单据明细',
  'menu.stockManage': '库存管理',
  'menu.stockStatistic': '日入库汇总查询',
  'menu.stockTransfer': '库位转移',
  'menu.subsystemFlowTemplate': '子系统流程模板',
  'menu.supplier': '供应商',
  'menu.system': '系统设置',
  'menu.task': '任务管理',
  'menu.taskInstance': '任务实例',
  'menu.taskInstanceNode': '任务实例节点',
  'menu.taskItem': '任务档明细',
  'menu.taskItemLog': '任务明细历史档',
  'menu.taskLog': '任务历史档',
  'menu.taskPathTemplate': '任务路径模板',
  'menu.taskPathTemplateMerge': '任务路径模板合并',
  'menu.taskPathTemplateNode': '任务路径模板节点',
  'menu.tasks': '任务管理',
  'menu.tenant': '租户管理',
  'menu.token': '登录日志',
  'menu.transfer': '调拔单',
  'menu.transferItem': '调拔单明细',
  'menu.transferPoces': '调拨管理',
  'menu.user': '用户管理',
  'menu.userCenter': '个人中心',
  'menu.userLogin': '登录日志',
  'menu.waitPakin': '组托档',
  'menu.waitPakinItem': '组托档明细',
  'menu.waitPakinItemLog': '组托历史档明细',
  'menu.waitPakinLog': '组托历史档',
  'menu.wareWork': '仓库作业',
  'menu.warehouse': '仓库',
  'menu.warehouseAreas': '库区',
  'menu.warehouseAreasItem': '收货库存',
  'menu.warehouseRoleMenu': '仓库权限',
  'menu.warehouseStock': '即时库存',
  'menu.wave': '波次管理',
  'menu.waveItem': '波次明细',
  'menu.waveRule': '波次策略',
  'menu.whMat': '库区物料关系'
}
export function resolveBackendMenuTitle(title) {
  if (typeof title !== 'string') {
    return ''
  }
  const trimmedTitle = title.trim()
  if (!trimmedTitle) {
    return ''
  }
  if (LEGACY_BACKEND_MENU_TITLES[trimmedTitle]) {
    return LEGACY_BACKEND_MENU_TITLES[trimmedTitle]
  }
  if (trimmedTitle.startsWith('menus.')) {
    const legacyMenuKey = `menu.${trimmedTitle.slice('menus.'.length)}`
    if (LEGACY_BACKEND_MENU_TITLES[legacyMenuKey]) {
      return LEGACY_BACKEND_MENU_TITLES[legacyMenuKey]
    }
    return trimmedTitle.split('.').pop() || trimmedTitle
  }
  if (trimmedTitle.startsWith('menu.')) {
    return trimmedTitle.split('.').pop() || trimmedTitle
  }
  return trimmedTitle
}
rsf-design/src/utils/navigation/route.js
@@ -1,6 +1,11 @@
import { PHASE_1_COMPONENTS } from '../../router/adapters/backendMenuAdapter.js'
const RELEASED_COMPONENT_PATHS = new Set(Object.values(PHASE_1_COMPONENTS))
function isIframe(url) {
  return url.startsWith('/outside/iframe/')
}
const isNavigableMenuItem = (menuItem) => {
  if (!menuItem.path || !menuItem.path.trim()) {
    return false
@@ -13,6 +18,15 @@
const normalizePath = (path) => {
  return path.startsWith('/') ? path : `/${path}`
}
const hasReleasedComponent = (menuItem) => {
  if (!menuItem || typeof menuItem !== 'object') {
    return false
  }
  const componentPath = typeof menuItem.component === 'string' ? menuItem.component.trim() : ''
  return RELEASED_COMPONENT_PATHS.has(componentPath)
}
const getFirstMenuPath = (menuList) => {
  if (!Array.isArray(menuList) || menuList.length === 0) {
    return ''
@@ -27,7 +41,9 @@
        return childPath
      }
    }
    return normalizePath(menuItem.path)
    if (hasReleasedComponent(menuItem)) {
      return normalizePath(menuItem.path)
    }
  }
  return ''
}
rsf-design/src/views/system/menu/index.vue
@@ -1,7 +1,6 @@
<!-- 菜单管理页面 -->
<template>
  <div class="menu-page art-full-height">
    <!-- 搜索栏 -->
    <ArtSearchBar
      v-model="formFilters"
      :items="formItems"
@@ -11,7 +10,6 @@
    />
    <ElCard class="art-table-card">
      <!-- 表格头部 -->
      <ArtTableHeader
        :showZebra="false"
        :loading="loading"
@@ -19,7 +17,7 @@
        @refresh="handleRefresh"
      >
        <template #left>
          <ElButton v-auth="'add'" @click="handleAddMenu" v-ripple> 添加菜单 </ElButton>
          <ElButton v-auth="'add'" @click="handleAddMenu" v-ripple>添加菜单</ElButton>
          <ElButton @click="toggleExpand" v-ripple>
            {{ isExpanded ? '收起' : '展开' }}
          </ElButton>
@@ -28,7 +26,7 @@
      <ArtTable
        ref="tableRef"
        rowKey="path"
        rowKey="id"
        :loading="loading"
        :columns="columns"
        :data="filteredTableData"
@@ -37,12 +35,12 @@
        :default-expand-all="false"
      />
      <!-- 菜单弹窗 -->
      <MenuDialog
        v-model:visible="dialogVisible"
        :type="dialogType"
        :editData="editData"
        :lockType="lockMenuType"
        :menuTreeOptions="menuTreeOptions"
        @submit="handleSubmit"
      />
    </ElCard>
@@ -56,22 +54,34 @@
  import ArtSvgIcon from '@/components/core/base/art-svg-icon/index.vue'
  import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue'
  import { useTableColumns } from '@/hooks/core/useTableColumns'
  import { fetchGetMenuList } from '@/api/system-manage'
  import { ElTag, ElMessageBox } from 'element-plus'
  import {
    fetchDeleteMenu,
    fetchGetMenuList,
    fetchSaveMenu,
    fetchUpdateMenu
  } from '@/api/system-manage'
  import { ElTag, ElMessage, ElMessageBox } from 'element-plus'
  defineOptions({ name: 'Menus' })
  const loading = ref(false)
  const isExpanded = ref(false)
  const tableRef = ref()
  const dialogVisible = ref(false)
  const dialogType = ref('menu')
  const editData = ref(null)
  const lockMenuType = ref(false)
  const lockMenuType = ref(true)
  const tableData = ref([])
  const menuTreeOptions = ref([])
  const initialSearchState = {
    name: '',
    route: ''
  }
  const formFilters = reactive({ ...initialSearchState })
  const appliedFilters = reactive({ ...initialSearchState })
  const formItems = computed(() => [
    {
      label: '菜单名称',
@@ -86,44 +96,88 @@
      props: { clearable: true }
    }
  ])
  onMounted(() => {
    getMenuList()
  })
  const getMenuList = async () => {
  const normalizeNumber = (value, fallback = 0) => {
    if (value === '' || value === null || value === undefined) {
      return fallback
    }
    const normalized = Number(value)
    return Number.isNaN(normalized) ? fallback : normalized
  }
  const normalizeMenuTreeOptions = (nodes = []) => {
    if (!Array.isArray(nodes)) {
      return []
    }
    return nodes
      .map((node) => ({
        label: formatMenuTitle(node.meta?.title || node.name || ''),
        value: normalizeNumber(node.id, 0),
        children: normalizeMenuTreeOptions(node.children)
      }))
  }
  const loadMenuResources = async () => {
    loading.value = true
    try {
      const list = await fetchGetMenuList()
      tableData.value = list
      const list = await fetchGetMenuList({})
      tableData.value = Array.isArray(list) ? list : []
      menuTreeOptions.value = [
        {
          label: '顶级菜单',
          value: 0,
          children: normalizeMenuTreeOptions(tableData.value)
        }
      ]
    } catch (error) {
      throw error instanceof Error ? error : new Error('获取菜单失败')
      ElMessage.error(error?.message || '获取菜单失败')
    } finally {
      loading.value = false
    }
  }
  onMounted(() => {
    loadMenuResources()
  })
  const hasNestedMenus = (row) => Array.isArray(row.children) && row.children.some((child) => !child.meta?.isAuthButton)
  const getMenuTypeTag = (row) => {
    if (row.meta?.isAuthButton) return 'danger'
    if (row.children?.length) return 'info'
    if (row.meta?.link && row.meta?.isIframe) return 'success'
    if (row.path) return 'primary'
    if (row.meta?.link) return 'warning'
    return 'info'
    if (row.meta?.isAuthButton || Number(row.type) === 1) return 'danger'
    if (hasNestedMenus(row)) return 'info'
    return 'primary'
  }
  const getMenuTypeText = (row) => {
    if (row.meta?.isAuthButton) return '按钮'
    if (row.children?.length) return '目录'
    if (row.meta?.link && row.meta?.isIframe) return '内嵌'
    if (row.path) return '菜单'
    if (row.meta?.link) return '外链'
    return '未知'
    if (row.meta?.isAuthButton || Number(row.type) === 1) return '按钮'
    if (hasNestedMenus(row)) return '目录'
    return '菜单'
  }
  const getStatusMeta = (status) => {
    return normalizeNumber(status, 1) === 1
      ? { text: '启用', type: 'success' }
      : { text: '禁用', type: 'danger' }
  }
  const getMenuDisplayTitle = (row) => {
    const titleKey = row.meta?.title || row.name || ''
    const normalizedTitleKey =
      titleKey && !String(titleKey).includes('.') ? `menu.${titleKey}` : titleKey
    return formatMenuTitle(normalizedTitleKey)
  }
  const getMenuDisplayIcon = (row) => row.meta?.icon || row.icon || ''
  const { columnChecks, columns } = useTableColumns(() => [
    {
      prop: 'meta.title',
      label: '菜单名称',
      minWidth: 180,
      formatter: (row) => getMenuDisplayTitle(row)
    },
    {
      prop: 'meta.icon',
      label: '图标预览',
@@ -134,52 +188,64 @@
        if (!icon) return h('span', { class: 'text-g-400' }, '-')
        return h('div', { class: 'flex items-center justify-center' }, [
          h(ArtSvgIcon, { icon, class: 'text-base text-g-700' })
        ])
        return h(
          'div',
          {
            class:
              'mx-auto flex h-8 w-8 items-center justify-center rounded-md border border-[var(--art-border-color)] bg-[var(--art-main-bg-color)]'
          },
          [h(ArtSvgIcon, { icon, class: 'text-base text-g-700' })]
        )
      }
    },
    {
      prop: 'meta.title',
      label: '菜单名称',
      minWidth: 120,
      formatter: (row) => getMenuDisplayTitle(row)
    },
    {
      prop: 'type',
      label: '菜单类型',
      formatter: (row) => {
        return h(ElTag, { type: getMenuTypeTag(row) }, () => getMenuTypeText(row))
      }
      width: 110,
      formatter: (row) =>
        h(ElTag, { type: getMenuTypeTag(row), effect: 'light' }, () => getMenuTypeText(row))
    },
    {
      prop: 'path',
      prop: 'route',
      label: '路由',
      minWidth: 180,
      formatter: (row) => {
        if (row.meta?.isAuthButton) return ''
        return row.meta?.link || row.path || ''
        return row.route || ''
      }
    },
    {
      prop: 'meta.authList',
      prop: 'authority',
      label: '权限标识',
      minWidth: 180,
      formatter: (row) => {
        if (row.meta?.isAuthButton) {
          return row.meta?.authMark || ''
          return row.authority || row.meta?.authMark || ''
        }
        if (!row.meta?.authList?.length) return ''
        if (!row.meta?.authList?.length) return row.authority || ''
        return `${row.meta.authList.length} 个权限标识`
      }
    },
    {
      prop: 'date',
      label: '编辑时间',
      formatter: () => '2022-3-12 12:00:00'
      prop: 'sort',
      label: '排序',
      width: 90
    },
    {
      prop: 'status',
      label: '状态',
      formatter: () => h(ElTag, { type: 'success' }, () => '启用')
      width: 100,
      formatter: (row) => {
        const statusMeta = getStatusMeta(row.status)
        return h(ElTag, { type: statusMeta.type, effect: 'light' }, () => statusMeta.text)
      }
    },
    {
      prop: 'memo',
      label: '备注',
      minWidth: 180,
      showOverflowTooltip: true,
      formatter: (row) => row.memo || '-'
    },
    {
      prop: 'operation',
@@ -187,7 +253,7 @@
      width: 180,
      align: 'right',
      formatter: (row) => {
        const buttonStyle = { style: 'text-align: right' }
        const buttonStyle = { class: 'flex justify-end' }
        if (row.meta?.isAuthButton) {
          return h('div', buttonStyle, [
            h(ArtButtonTable, {
@@ -196,14 +262,14 @@
            }),
            h(ArtButtonTable, {
              type: 'delete',
              onClick: () => handleDeleteAuth()
              onClick: () => handleDeleteAuth(row)
            })
          ])
        }
        return h('div', buttonStyle, [
          h(ArtButtonTable, {
            type: 'add',
            onClick: () => handleAddAuth(),
            onClick: () => handleAddAuth(row),
            title: '新增权限'
          }),
          h(ArtButtonTable, {
@@ -212,25 +278,13 @@
          }),
          h(ArtButtonTable, {
            type: 'delete',
            onClick: () => handleDeleteMenu()
            onClick: () => handleDeleteMenu(row)
          })
        ])
      }
    }
    },
  ])
  const tableData = ref([])
  const handleReset = () => {
    Object.assign(formFilters, { ...initialSearchState })
    Object.assign(appliedFilters, { ...initialSearchState })
    getMenuList()
  }
  const handleSearch = () => {
    Object.assign(appliedFilters, { ...formFilters })
    getMenuList()
  }
  const handleRefresh = () => {
    getMenuList()
  }
  const deepClone = (obj) => {
    if (obj === null || typeof obj !== 'object') return obj
    if (obj instanceof Date) return new Date(obj)
@@ -243,6 +297,7 @@
    }
    return cloned
  }
  const convertAuthListToChildren = (items) => {
    return items.map((item) => {
      const clonedItem = deepClone(item)
@@ -251,13 +306,17 @@
      }
      if (item.meta?.authList?.length) {
        const authChildren = item.meta.authList.map((auth) => ({
          path: `${item.path}_auth_${auth.authMark}`,
          name: `${String(item.name)}_auth_${auth.authMark}`,
          ...deepClone(auth),
          route: auth.route || '',
          component: auth.component || '',
          meta: {
            title: auth.title,
            authMark: auth.authMark,
            isAuthButton: true,
            parentPath: item.path
            parentPath: item.path,
            icon: auth.icon,
            sort: auth.sort,
            isEnable: normalizeNumber(auth.status, 1) === 1
          }
        }))
        clonedItem.children = clonedItem.children?.length
@@ -267,15 +326,17 @@
      return clonedItem
    })
  }
  const searchMenu = (items) => {
      const results = []
    const results = []
    for (const item of items) {
      const searchName = appliedFilters.name?.toLowerCase().trim() || ''
      const searchRoute = appliedFilters.route?.toLowerCase().trim() || ''
      const menuTitle = getMenuDisplayTitle(item).toLowerCase()
      const menuPath = (item.path || '').toLowerCase()
      const menuRoute = String(item.route || item.path || item.authority || '').toLowerCase()
      const nameMatch = !searchName || menuTitle.includes(searchName)
      const routeMatch = !searchRoute || menuPath.includes(searchRoute)
      const routeMatch = !searchRoute || menuRoute.includes(searchRoute)
      if (item.children?.length) {
        const matchedChildren = searchMenu(item.children)
        if (matchedChildren.length > 0) {
@@ -285,77 +346,147 @@
          continue
        }
      }
      if (nameMatch && routeMatch) {
        results.push(deepClone(item))
      }
    }
    return results
  }
  const filteredTableData = computed(() => {
    const searchedData = searchMenu(tableData.value)
    return convertAuthListToChildren(searchedData)
  })
  const closeDialog = () => {
    dialogVisible.value = false
    editData.value = null
  }
  const handleAddMenu = () => {
    dialogType.value = 'menu'
    editData.value = null
    lockMenuType.value = true
    dialogVisible.value = true
  }
  const handleAddAuth = () => {
    dialogType.value = 'menu'
    editData.value = null
    lockMenuType.value = false
  const handleAddAuth = (row) => {
    dialogType.value = 'button'
    editData.value = {
      parentId: row.id,
      type: 1,
      status: 1,
      sort: 0
    }
    lockMenuType.value = true
    dialogVisible.value = true
  }
  const handleEditMenu = (row) => {
    dialogType.value = 'menu'
    editData.value = row
    lockMenuType.value = true
    dialogVisible.value = true
  }
  const handleEditAuth = (row) => {
    dialogType.value = 'button'
    editData.value = {
      title: row.meta?.title,
      authMark: row.meta?.authMark
    }
    lockMenuType.value = false
    editData.value = row
    lockMenuType.value = true
    dialogVisible.value = true
  }
  const handleSubmit = (formData) => {
    console.log('提交数据:', formData)
    getMenuList()
  const buildMenuSubmitPayload = (formData) => {
    return {
      ...(formData.id ? { id: normalizeNumber(formData.id, 0) } : {}),
      parentId: normalizeNumber(formData.parentId, 0),
      name: String(formData.name || '').trim(),
      route: String(formData.route || '').trim(),
      component: String(formData.component || '').trim(),
      authority: String(formData.authority || '').trim(),
      icon: String(formData.icon || '').trim(),
      sort: normalizeNumber(formData.sort, 0),
      status: normalizeNumber(formData.status, 1),
      memo: String(formData.memo || '').trim(),
      type: formData.menuType === 'button' ? 1 : 0
    }
  }
  const handleDeleteMenu = async () => {
  const handleSubmit = async (formData) => {
    const payload = buildMenuSubmitPayload(formData)
    if (payload.id && payload.id === payload.parentId) {
      ElMessage.error('上级菜单不能选择当前菜单')
      return
    }
    try {
      await ElMessageBox.confirm('确定要删除该菜单吗?删除后无法恢复', '提示', {
      if (payload.id) {
        await fetchUpdateMenu(payload)
        ElMessage.success('修改成功')
      } else {
        await fetchSaveMenu(payload)
        ElMessage.success('新增成功')
      }
      closeDialog()
      await loadMenuResources()
    } catch (error) {
      ElMessage.error(error?.message || '提交失败')
    }
  }
  const handleDeleteMenu = async (row) => {
    try {
      await ElMessageBox.confirm(
        `确定要删除菜单「${formatMenuTitle(row.meta?.title || row.name || '')}」吗?删除后无法恢复`,
        '删除确认',
        {
          confirmButtonText: '确定',
          cancelButtonText: '取消',
          type: 'warning'
        }
      )
      await fetchDeleteMenu(row.id)
      ElMessage.success('删除成功')
      await loadMenuResources()
    } catch (error) {
      if (error !== 'cancel') {
        ElMessage.error(error?.message || '删除失败')
      }
    }
  }
  const handleDeleteAuth = async (row) => {
    try {
      await ElMessageBox.confirm(`确定要删除权限「${row.name || row.authority || row.id}」吗?删除后无法恢复`, '删除确认', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      })
      await fetchDeleteMenu(row.id)
      ElMessage.success('删除成功')
      getMenuList()
      await loadMenuResources()
    } catch (error) {
      if (error !== 'cancel') {
        ElMessage.error('删除失败')
        ElMessage.error(error?.message || '删除失败')
      }
    }
  }
  const handleDeleteAuth = async () => {
    try {
      await ElMessageBox.confirm('确定要删除该权限吗?删除后无法恢复', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      })
      ElMessage.success('删除成功')
      getMenuList()
    } catch (error) {
      if (error !== 'cancel') {
        ElMessage.error('删除失败')
      }
    }
  const handleReset = () => {
    Object.assign(formFilters, { ...initialSearchState })
    Object.assign(appliedFilters, { ...initialSearchState })
    loadMenuResources()
  }
  const handleSearch = () => {
    Object.assign(appliedFilters, { ...formFilters })
  }
  const handleRefresh = () => {
    loadMenuResources()
  }
  const toggleExpand = () => {
    isExpanded.value = !isExpanded.value
    nextTick(() => {
rsf-design/src/views/system/menu/modules/menu-dialog.vue
@@ -3,7 +3,7 @@
    :title="dialogTitle"
    :model-value="visible"
    @update:model-value="handleCancel"
    width="860px"
    width="760px"
    align-center
    class="menu-dialog"
    @closed="handleClosed"
@@ -13,7 +13,7 @@
      v-model="form"
      :items="formItems"
      :rules="rules"
      :span="width > 640 ? 12 : 24"
      :span="24"
      :gutter="20"
      label-width="100px"
      :show-reset="false"
@@ -21,16 +21,16 @@
    >
      <template #menuType>
        <ElRadioGroup v-model="form.menuType" :disabled="disableMenuType">
          <ElRadioButton value="menu" label="menu">菜单</ElRadioButton>
          <ElRadioButton value="button" label="button">按钮</ElRadioButton>
          <ElRadioButton value="menu">菜单</ElRadioButton>
          <ElRadioButton value="button">按钮</ElRadioButton>
        </ElRadioGroup>
      </template>
    </ArtForm>
    <template #footer>
      <span class="dialog-footer">
        <ElButton @click="handleCancel">取 消</ElButton>
        <ElButton type="primary" @click="handleSubmit">确 定</ElButton>
        <ElButton @click="handleCancel">取消</ElButton>
        <ElButton type="primary" @click="handleSubmit">确定</ElButton>
      </span>
    </template>
  </ElDialog>
@@ -39,248 +39,252 @@
<script setup>
  import ArtForm from '@/components/core/forms/art-form/index.vue'
  import { ElIcon, ElTooltip } from 'element-plus'
  import { QuestionFilled } from '@element-plus/icons-vue'
  import { formatMenuTitle } from '@/utils/router'
  import { useWindowSize } from '@vueuse/core'
  const { width } = useWindowSize()
  const createLabelTooltip = (label, tooltip) => {
    return () =>
      h('span', { class: 'flex items-center' }, [
        h('span', label),
        h(
          ElTooltip,
          {
            content: tooltip,
            placement: 'top'
          },
          () => h(ElIcon, { class: 'ml-0.5 cursor-help' }, () => h(QuestionFilled))
        )
      ])
  }
  const createMenuFormState = () => ({
    menuType: 'menu',
    id: null,
    parentId: 0,
    name: '',
    route: '',
    component: '',
    authority: '',
    icon: '',
    sort: 0,
    status: 1,
    memo: ''
  })
  const props = defineProps({
    visible: { required: false, default: false },
    type: { required: false, default: 'menu' },
    lockType: { required: false, default: false }
    lockType: { required: false, default: false },
    editData: { required: false, default: null },
    menuTreeOptions: { required: false, default: () => [] }
  })
  const emit = defineEmits(['update:visible', 'submit'])
  const formRef = ref()
  const isEdit = ref(false)
  const form = reactive({
    menuType: 'menu',
    id: 0,
    name: '',
    path: '',
    label: '',
    component: '',
    icon: '',
    isEnable: true,
    sort: 1,
    isMenu: true,
    keepAlive: true,
    isHide: false,
    isHideTab: false,
    link: '',
    isIframe: false,
    showBadge: false,
    showTextBadge: '',
    fixedTab: false,
    activePath: '',
    roles: [],
    isFullPage: false,
    authName: '',
    authLabel: '',
    authIcon: '',
    authSort: 1
  })
  const rules = reactive({
    name: [
      { required: true, message: '请输入菜单名称', trigger: 'blur' },
      { min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
    ],
    path: [{ required: true, message: '请输入路由地址', trigger: 'blur' }],
    label: [{ required: true, message: '输入权限标识', trigger: 'blur' }],
    authName: [{ required: true, message: '请输入权限名称', trigger: 'blur' }],
    authLabel: [{ required: true, message: '请输入权限标识', trigger: 'blur' }]
  })
  const form = reactive(createMenuFormState())
  const isEdit = computed(() => Boolean(form.id))
  const dialogTitle = computed(() => `${isEdit.value ? '编辑' : '新建'}${form.menuType === 'button' ? '按钮' : '菜单'}`)
  const disableMenuType = computed(() => props.lockType || isEdit.value)
  const rules = computed(() => ({
    name: [{ required: true, message: form.menuType === 'button' ? '请输入权限名称' : '请输入菜单名称', trigger: 'blur' }],
    route:
      form.menuType === 'menu'
        ? [{ required: true, message: '请输入路由地址', trigger: 'blur' }]
        : [],
    authority:
      form.menuType === 'button'
        ? [{ required: true, message: '请输入权限标识', trigger: 'blur' }]
        : []
  }))
  const formItems = computed(() => {
    const baseItems = [{ label: '菜单类型', key: 'menuType', span: 24 }]
    const switchSpan = width.value < 640 ? 12 : 6
    const items = [
      { label: '菜单类型', key: 'menuType', span: 24 },
      {
        label: '上级菜单',
        key: 'parentId',
        type: 'treeselect',
        span: 24,
        props: {
          data: props.menuTreeOptions,
          props: {
            label: 'label',
            value: 'value',
            children: 'children'
          },
          placeholder: '请选择上级菜单',
          checkStrictly: true,
          clearable: false,
          defaultExpandAll: true
        }
      },
      {
        label: form.menuType === 'button' ? '权限名称' : '菜单名称',
        key: 'name',
        type: 'input',
        span: 24,
        props: {
          placeholder: form.menuType === 'button' ? '请输入权限名称' : '请输入菜单名称',
          clearable: true
        }
      }
    ]
    if (form.menuType === 'menu') {
      return [
        ...baseItems,
        { label: '菜单名称', key: 'name', type: 'input', props: { placeholder: '菜单名称' } },
      items.push(
        {
          label: createLabelTooltip(
            '路由地址',
            '一级菜单:以 / 开头的绝对路径(如 /dashboard)\n二级及以下:相对路径(如 console、user)'
          ),
          key: 'path',
          label: '路由地址',
          key: 'route',
          type: 'input',
          props: { placeholder: '如:/dashboard 或 console' }
          span: 24,
          props: {
            placeholder: '请输入路由地址',
            clearable: true
          }
        },
        { label: '权限标识', key: 'label', type: 'input', props: { placeholder: '如:User' } },
        {
          label: createLabelTooltip(
            '组件路径',
            '一级父级菜单:填写 /index/index\n具体页面:填写组件路径(如 /system/user)\n目录菜单:留空'
          ),
          label: '组件标识',
          key: 'component',
          type: 'input',
          props: { placeholder: '如:/system/user 或留空' }
        },
        { label: '图标', key: 'icon', type: 'input', props: { placeholder: '如:ri:user-line' } },
        {
          label: createLabelTooltip(
            '角色权限',
            '仅用于前端权限模式:配置角色标识(如 R_SUPER、R_ADMIN)\n后端权限模式:无需配置'
          ),
          key: 'roles',
          type: 'inputtag',
          props: { placeholder: '输入角色标识后按回车,如:R_SUPER' }
        },
        {
          label: '菜单排序',
          key: 'sort',
          type: 'number',
          props: { min: 1, controlsPosition: 'right', style: { width: '100%' } }
        },
        {
          label: '外部链接',
          key: 'link',
          type: 'input',
          props: { placeholder: '如:https://www.example.com' }
        },
        {
          label: '文本徽章',
          key: 'showTextBadge',
          type: 'input',
          props: { placeholder: '如:New、Hot' }
        },
        {
          label: createLabelTooltip(
            '激活路径',
            '用于详情页等隐藏菜单,指定高亮显示的父级菜单路径\n例如:用户详情页高亮显示"用户管理"菜单'
          ),
          key: 'activePath',
          type: 'input',
          props: { placeholder: '如:/system/user' }
        },
        { label: '是否启用', key: 'isEnable', type: 'switch', span: switchSpan },
        { label: '页面缓存', key: 'keepAlive', type: 'switch', span: switchSpan },
        { label: '隐藏菜单', key: 'isHide', type: 'switch', span: switchSpan },
        { label: '是否内嵌', key: 'isIframe', type: 'switch', span: switchSpan },
        { label: '显示徽章', key: 'showBadge', type: 'switch', span: switchSpan },
        { label: '固定标签', key: 'fixedTab', type: 'switch', span: switchSpan },
        { label: '标签隐藏', key: 'isHideTab', type: 'switch', span: switchSpan },
        { label: '全屏页面', key: 'isFullPage', type: 'switch', span: switchSpan }
      ]
    } else {
      return [
        ...baseItems,
        {
          label: '权限名称',
          key: 'authName',
          type: 'input',
          props: { placeholder: '如:新增、编辑、删除' }
        },
        {
          label: '权限标识',
          key: 'authLabel',
          type: 'input',
          props: { placeholder: '如:add、edit、delete' }
        },
        {
          label: '权限排序',
          key: 'authSort',
          type: 'number',
          props: { min: 1, controlsPosition: 'right', style: { width: '100%' } }
          span: 24,
          props: {
            placeholder: '请输入组件标识',
            clearable: true
          }
        }
      ]
      )
    }
    items.push(
      {
        label: '权限标识',
        key: 'authority',
        type: 'input',
        span: 24,
        props: {
          placeholder: '请输入权限标识',
          clearable: true
        }
      },
      {
        label: '图标',
        key: 'icon',
        type: 'input',
        span: 24,
        props: {
          placeholder: '请输入图标名称',
          clearable: true
        }
      },
      {
        label: '排序',
        key: 'sort',
        type: 'number',
        span: 24,
        props: {
          min: 0,
          controlsPosition: 'right',
          style: { width: '100%' }
        }
      },
      {
        label: '状态',
        key: 'status',
        type: 'select',
        span: 24,
        props: {
          placeholder: '请选择状态',
          options: [
            { label: '启用', value: 1 },
            { label: '禁用', value: 0 }
          ]
        }
      },
      {
        label: '备注',
        key: 'memo',
        type: 'input',
        span: 24,
        props: {
          type: 'textarea',
          rows: 3,
          placeholder: '请输入备注',
          clearable: true
        }
      }
    )
    return items
  })
  const dialogTitle = computed(() => {
    const type = form.menuType === 'menu' ? '菜单' : '按钮'
    return isEdit.value ? `编辑${type}` : `新建${type}`
  })
  const disableMenuType = computed(() => {
    if (isEdit.value) return true
    if (!isEdit.value && form.menuType === 'menu' && props.lockType) return true
    return false
  })
  const normalizeNumber = (value, fallback = 0) => {
    if (value === '' || value === null || value === undefined) {
      return fallback
    }
    const normalized = Number(value)
    return Number.isNaN(normalized) ? fallback : normalized
  }
  const resetForm = () => {
    formRef.value?.reset()
    form.menuType = 'menu'
    Object.assign(form, createMenuFormState())
    formRef.value?.clearValidate?.()
  }
  const loadFormData = () => {
    if (!props.editData) return
    isEdit.value = true
    if (form.menuType === 'menu') {
      const row = props.editData
      form.id = row.id || 0
      form.name = formatMenuTitle(row.meta?.title || '')
      form.path = row.path || ''
      form.label = row.name || ''
      form.component = row.component || ''
      form.icon = row.meta?.icon || ''
      form.sort = row.meta?.sort || 1
      form.isMenu = row.meta?.isMenu ?? true
      form.keepAlive = row.meta?.keepAlive ?? false
      form.isHide = row.meta?.isHide ?? false
      form.isHideTab = row.meta?.isHideTab ?? false
      form.isEnable = row.meta?.isEnable ?? true
      form.link = row.meta?.link || ''
      form.isIframe = row.meta?.isIframe ?? false
      form.showBadge = row.meta?.showBadge ?? false
      form.showTextBadge = row.meta?.showTextBadge || ''
      form.fixedTab = row.meta?.fixedTab ?? false
      form.activePath = row.meta?.activePath || ''
      form.roles = row.meta?.roles || []
      form.isFullPage = row.meta?.isFullPage ?? false
    } else {
      const row = props.editData
      form.authName = row.title || ''
      form.authLabel = row.authMark || ''
      form.authIcon = row.icon || ''
      form.authSort = row.sort || 1
    resetForm()
    form.menuType = props.type || 'menu'
    const row = props.editData
    if (!row || typeof row !== 'object') {
      return
    }
    form.menuType = Number(row.type) === 1 ? 'button' : props.type || 'menu'
    form.id = row.id ?? null
    form.parentId = normalizeNumber(row.parentId, 0)
    form.name = row.name || ''
    form.route = row.route || ''
    form.component = row.component || ''
    form.authority = row.authority || row.meta?.authMark || ''
    form.icon = row.icon || row.meta?.icon || ''
    form.sort = normalizeNumber(row.sort ?? row.meta?.sort, 0)
    form.status = normalizeNumber(row.status, row.meta?.isEnable === false ? 0 : 1)
    form.memo = row.memo || ''
  }
  const handleSubmit = async () => {
    if (!formRef.value) return
    try {
      await formRef.value.validate()
      emit('submit', { ...form })
      ElMessage.success(`${isEdit.value ? '编辑' : '新增'}成功`)
      handleCancel()
      emit('submit', {
        ...form,
        type: form.menuType === 'button' ? 1 : 0
      })
    } catch {
      ElMessage.error('表单校验失败,请检查输入')
      return
    }
  }
  const handleCancel = () => {
    emit('update:visible', false)
  }
  const handleClosed = () => {
    resetForm()
    isEdit.value = false
  }
  watch(
    () => props.visible,
    (newVal) => {
      if (newVal) {
        form.menuType = props.type
    (visible) => {
      if (visible) {
        loadFormData()
        nextTick(() => {
          if (props.editData) {
            loadFormData()
          }
          formRef.value?.clearValidate?.()
        })
      }
    }
    },
    { immediate: true }
  )
  watch(
    () => props.editData,
    () => {
      if (props.visible) {
        loadFormData()
      }
    },
    { deep: true }
  )
  watch(
    () => props.type,
    (newType) => {
    () => {
      if (props.visible) {
        form.menuType = newType
        loadFormData()
      }
    }
  )
rsf-design/src/views/system/role/modules/role-edit-dialog.vue
@@ -1,109 +1,147 @@
<template>
  <ElDialog
    v-model="visible"
    :title="dialogType === 'add' ? '新增角色' : '编辑角色'"
    width="30%"
    :title="dialogTitle"
    :model-value="visible"
    width="720px"
    align-center
    @close="handleClose"
    @update:model-value="handleCancel"
    @closed="handleClosed"
  >
    <ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
      <ElFormItem label="角色名称" prop="roleName">
        <ElInput v-model="form.roleName" placeholder="请输入角色名称" />
      </ElFormItem>
      <ElFormItem label="角色编码" prop="roleCode">
        <ElInput v-model="form.roleCode" placeholder="请输入角色编码" />
      </ElFormItem>
      <ElFormItem label="描述" prop="description">
        <ElInput
          v-model="form.description"
          type="textarea"
          :rows="3"
          placeholder="请输入角色描述"
        />
      </ElFormItem>
      <ElFormItem label="启用">
        <ElSwitch v-model="form.enabled" />
      </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>
      <ElButton @click="handleClose">取消</ElButton>
      <ElButton type="primary" @click="handleSubmit">提交</ElButton>
      <span class="dialog-footer">
        <ElButton @click="handleCancel">取消</ElButton>
        <ElButton type="primary" @click="handleSubmit">确定</ElButton>
      </span>
    </template>
  </ElDialog>
</template>
<script setup>
  import ArtForm from '@/components/core/forms/art-form/index.vue'
  import { buildRoleDialogModel, createRoleFormState } from '../rolePage.helpers'
  const props = defineProps({
    modelValue: { required: false, default: false },
    visible: { required: false, default: false },
    dialogType: { required: false, default: 'add' },
    roleData: { required: false, default: void 0 }
    roleData: { required: false, default: () => ({}) }
  })
  const emit = defineEmits(['update:modelValue', 'success'])
  const emit = defineEmits(['update:visible', 'submit'])
  const formRef = ref()
  const visible = computed({
    get: () => props.modelValue,
    set: (value) => emit('update:modelValue', value)
  })
  const rules = reactive({
    roleName: [
      { required: true, message: '请输入角色名称', trigger: 'blur' },
      { min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
    ],
    roleCode: [
      { required: true, message: '请输入角色编码', trigger: 'blur' },
      { min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
    ],
    description: [{ required: true, message: '请输入角色描述', trigger: 'blur' }]
  })
  const form = reactive({
    roleId: 0,
    roleName: '',
    roleCode: '',
    description: '',
    createTime: '',
    enabled: true
  })
  watch(
    () => props.modelValue,
    (newVal) => {
      if (newVal) initForm()
    }
  )
  watch(
    () => props.roleData,
    (newData) => {
      if (newData && props.modelValue) initForm()
  const form = reactive(createRoleFormState())
  const isEdit = computed(() => props.dialogType === 'edit')
  const dialogTitle = computed(() => (isEdit.value ? '编辑角色' : '新增角色'))
  const rules = computed(() => ({
    name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }]
  }))
  const formItems = computed(() => [
    {
      label: '角色名称',
      key: 'name',
      type: 'input',
      props: {
        placeholder: '请输入角色名称',
        clearable: true
      }
    },
    { deep: true }
  )
  const initForm = () => {
    if (props.dialogType === 'edit' && props.roleData) {
      Object.assign(form, props.roleData)
    } else {
      Object.assign(form, {
        roleId: 0,
        roleName: '',
        roleCode: '',
        description: '',
        createTime: '',
        enabled: true
      })
    {
      label: '角色编码',
      key: 'code',
      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',
      span: 24,
      props: {
        type: 'textarea',
        rows: 3,
        placeholder: '请输入备注',
        clearable: true
      }
    }
  ])
  const resetForm = () => {
    Object.assign(form, createRoleFormState())
    formRef.value?.clearValidate?.()
  }
  const handleClose = () => {
    visible.value = false
    formRef.value?.resetFields()
  const loadFormData = () => {
    Object.assign(form, buildRoleDialogModel(props.roleData))
  }
  const handleSubmit = async () => {
    if (!formRef.value) return
    try {
      await formRef.value.validate()
      const message = props.dialogType === 'add' ? '新增成功' : '修改成功'
      ElMessage.success(message)
      emit('success')
      handleClose()
    } catch (error) {
      console.log('表单验证失败:', error)
      emit('submit', { ...form })
    } catch {
      return
    }
  }
  const handleCancel = () => {
    emit('update:visible', false)
  }
  const handleClosed = () => {
    resetForm()
  }
  watch(
    () => props.visible,
    (visible) => {
      if (visible) {
        loadFormData()
        nextTick(() => {
          formRef.value?.clearValidate?.()
        })
      }
    },
    { immediate: true }
  )
  watch(
    () => props.roleData,
    () => {
      if (props.visible) {
        loadFormData()
      }
    },
    { deep: true }
  )
</script>
rsf-design/src/views/system/role/modules/role-permission-dialog.vue
@@ -1,153 +1,304 @@
<template>
  <ElDialog
    v-model="visible"
    title="菜单权限"
    width="520px"
    align-center
    class="el-dialog-border"
    @close="handleClose"
  <ElDrawer
    :model-value="visible"
    title="角色权限"
    size="860px"
    destroy-on-close
    @update:model-value="handleVisibleChange"
    @closed="handleClosed"
  >
    <ElScrollbar height="70vh">
      <ElTree
        ref="treeRef"
        :data="processedMenuList"
        show-checkbox
        node-key="name"
        :default-expand-all="isExpandAll"
        :default-checked-keys="[1, 2, 3]"
        :props="defaultProps"
        @check="handleTreeCheck"
      >
        <template #default="{ data }">
          <div style="display: flex; align-items: center">
            <span v-if="data.isAuth">
              {{ data.label }}
            </span>
            <span v-else>{{ defaultProps.label(data) }}</span>
          </div>
        </template>
      </ElTree>
    </ElScrollbar>
    <template #footer>
      <ElButton @click="outputSelectedData" style="margin-left: 8px">获取选中数据</ElButton>
    <div class="mb-4 text-sm text-[var(--art-text-secondary)]">
      当前角色:{{ roleLabel }}
    </div>
      <ElButton @click="toggleExpandAll">{{ isExpandAll ? '全部收起' : '全部展开' }}</ElButton>
      <ElButton @click="toggleSelectAll" style="margin-left: 8px">{{
        isSelectAll ? '取消全选' : '全部选择'
      }}</ElButton>
      <ElButton type="primary" @click="savePermission">保存</ElButton>
    </template>
  </ElDialog>
    <ElTabs v-model="activeScopeType" class="role-scope-tabs">
      <ElTabPane
        v-for="config in scopeConfigs"
        :key="config.scopeType"
        :label="config.title"
        :name="config.scopeType"
      >
        <div v-if="scopeState[config.scopeType].loading" class="py-6">
          <ElSkeleton :rows="10" animated />
        </div>
        <div v-else class="space-y-3">
          <div class="flex items-center justify-between gap-3">
            <ElSpace wrap>
              <ElButton @click="handleSelectAll(config.scopeType)">全选</ElButton>
              <ElButton @click="handleClear(config.scopeType)">清空</ElButton>
            </ElSpace>
            <ElButton type="primary" @click="handleSave(config.scopeType)">保存当前权限</ElButton>
          </div>
          <div class="flex items-center gap-3">
            <ElInput
              v-model.trim="scopeState[config.scopeType].condition"
              clearable
              placeholder="搜索权限树"
              @clear="handleSearch(config.scopeType)"
              @keyup.enter="handleSearch(config.scopeType)"
            />
            <ElButton @click="handleSearch(config.scopeType)">搜索</ElButton>
          </div>
          <ElScrollbar height="56vh">
            <ElTree
              :ref="(el) => setTreeRef(config.scopeType, el)"
              :data="scopeState[config.scopeType].treeData"
              node-key="id"
              show-checkbox
              :default-expand-all="true"
              :default-checked-keys="scopeState[config.scopeType].checkedKeys"
              :props="treeProps"
              @check="handleTreeCheck(config.scopeType)"
            >
              <template #default="{ data }">
                <div class="flex items-center gap-2">
                  <span>{{ resolveScopeNodeLabel(data) }}</span>
                  <ElTag v-if="data.isAuthButton" type="info" effect="plain" size="small">
                    按钮
                  </ElTag>
                </div>
              </template>
            </ElTree>
          </ElScrollbar>
        </div>
      </ElTabPane>
    </ElTabs>
  </ElDrawer>
</template>
<script setup>
  import { useMenuStore } from '@/store/modules/menu'
  import { formatMenuTitle } from '@/utils/router'
  import {
    buildRoleScopeSubmitPayload,
    getRoleScopeConfig,
    normalizeRoleScopeTreeData
  } from '../rolePage.helpers'
  import { fetchGetRoleScopeList, fetchGetRoleScopeTree, fetchUpdateRoleScope } from '@/api/system-manage'
  import { resolveBackendMenuTitle } from '@/utils/backend-menu-title'
  import { ElMessage } from 'element-plus'
  const props = defineProps({
    modelValue: { required: false, default: false },
    roleData: { required: false, default: void 0 }
    visible: { required: false, default: false },
    roleData: { required: false, default: () => ({}) },
    scopeType: { required: false, default: 'menu' }
  })
  const emit = defineEmits(['update:modelValue', 'success'])
  const { menuList } = storeToRefs(useMenuStore())
  const treeRef = ref()
  const isExpandAll = ref(true)
  const isSelectAll = ref(false)
  const visible = computed({
    get: () => props.modelValue,
    set: (value) => emit('update:modelValue', value)
  })
  const processedMenuList = computed(() => {
    const processNode = (node) => {
      const processed = { ...node }
      if (node.meta?.authList?.length) {
        const authNodes = node.meta.authList.map((auth) => ({
          id: `${node.id}_${auth.authMark}`,
          name: `${node.name}_${auth.authMark}`,
          label: auth.title,
          authMark: auth.authMark,
          isAuth: true,
          checked: auth.checked || false
        }))
        processed.children = processed.children ? [...processed.children, ...authNodes] : authNodes
      }
      if (processed.children) {
        processed.children = processed.children.map(processNode)
      }
      return processed
    }
    return menuList.value.map(processNode)
  })
  const defaultProps = {
    children: 'children',
    label: (data) => formatMenuTitle(data.meta?.title) || data.label || ''
  const emit = defineEmits(['update:visible', 'success'])
  const scopeConfigs = ['menu', 'pda', 'matnr', 'warehouse'].map((scopeType) => getRoleScopeConfig(scopeType))
  const activeScopeType = ref(props.scopeType || 'menu')
  const treeRefs = reactive({})
  const treeProps = {
    label: 'label',
    children: 'children'
  }
  watch(
    () => props.modelValue,
    (newVal) => {
      if (newVal && props.roleData) {
        console.log('设置权限:', props.roleData)
      }
    }
  const scopeState = reactive(
    Object.fromEntries(
      scopeConfigs.map((config) => [
        config.scopeType,
        {
          loading: false,
          loaded: false,
          treeData: [],
          checkedKeys: [],
          halfCheckedKeys: [],
          condition: ''
        }
      ])
    )
  )
  const handleClose = () => {
    visible.value = false
    treeRef.value?.setCheckedKeys([])
  }
  const savePermission = () => {
    ElMessage.success('权限保存成功')
    emit('success')
    handleClose()
  }
  const toggleExpandAll = () => {
    const tree = treeRef.value
    if (!tree) return
    const nodes = tree.store.nodesMap
    Object.values(nodes).forEach((node) => {
      node.expanded = !isExpandAll.value
    })
    isExpandAll.value = !isExpandAll.value
  }
  const toggleSelectAll = () => {
    const tree = treeRef.value
    if (!tree) return
    if (!isSelectAll.value) {
      const allKeys = getAllNodeKeys(processedMenuList.value)
      tree.setCheckedKeys(allKeys)
    } else {
      tree.setCheckedKeys([])
  const visible = computed({
    get: () => props.visible,
    set: (value) => emit('update:visible', value)
  })
  const roleLabel = computed(() => props.roleData?.name || props.roleData?.code || '未选择角色')
  const loadScopeData = async (scopeType, { reloadSelection = true } = {}) => {
    const config = getRoleScopeConfig(scopeType)
    const state = scopeState[scopeType]
    state.loading = true
    try {
      const requests = [fetchGetRoleScopeTree(config.scopeType, { condition: state.condition || '' })]
      if (reloadSelection) {
        requests.unshift(fetchGetRoleScopeList(config.scopeType, props.roleData.id))
      }
      const [checkedIds, treeData] = reloadSelection ? await Promise.all(requests) : [state.checkedKeys, await requests[0]]
      state.treeData = normalizeRoleScopeTreeData(config.scopeType, treeData)
      state.checkedKeys = normalizeScopeKeys(checkedIds)
      state.halfCheckedKeys = []
      state.loaded = true
    } catch (error) {
      ElMessage.error(error?.message || `加载${config.title}失败`)
    } finally {
      state.loading = false
      nextTick(() => {
        treeRefs[scopeType]?.setCheckedKeys(scopeState[scopeType].checkedKeys)
      })
    }
    isSelectAll.value = !isSelectAll.value
  }
  const ensureScopeLoaded = async (scopeType, options = {}) => {
    if (!props.roleData?.id || !scopeType) {
      return
    }
    const { force = false, reloadSelection = true } = options
    if (!force && scopeState[scopeType].loaded) {
      return
    }
    await loadScopeData(scopeType, { reloadSelection })
  }
  const normalizeScopeKeys = (keys = []) => {
    if (!Array.isArray(keys)) {
      return []
    }
    return Array.from(
      new Set(
        keys
          .map((key) => normalizeScopeKey(key))
          .filter((key) => key !== '')
      )
    )
  }
  const normalizeScopeKey = (value) => {
    if (value === '' || value === null || value === void 0) {
      return ''
    }
    const numeric = Number(value)
    if (Number.isNaN(numeric)) {
      return String(value)
    }
    return String(numeric)
  }
  const setTreeRef = (scopeType, el) => {
    if (el) {
      treeRefs[scopeType] = el
    }
  }
  const handleTreeCheck = (scopeType) => {
    const tree = treeRefs[scopeType]
    if (!tree) return
    scopeState[scopeType].checkedKeys = normalizeScopeKeys(tree.getCheckedKeys())
    scopeState[scopeType].halfCheckedKeys = normalizeScopeKeys(tree.getHalfCheckedKeys())
  }
  const handleSelectAll = (scopeType) => {
    const tree = treeRefs[scopeType]
    if (!tree) return
    const allKeys = getAllNodeKeys(scopeState[scopeType].treeData)
    tree.setCheckedKeys(allKeys)
    handleTreeCheck(scopeType)
  }
  const handleClear = (scopeType) => {
    const tree = treeRefs[scopeType]
    if (!tree) return
    tree.setCheckedKeys([])
    handleTreeCheck(scopeType)
  }
  const handleSave = async (scopeType) => {
    if (!props.roleData?.id) return
    try {
      await fetchUpdateRoleScope(
        scopeType,
        buildRoleScopeSubmitPayload(
          props.roleData.id,
          scopeState[scopeType].checkedKeys,
          scopeState[scopeType].halfCheckedKeys
        )
      )
      ElMessage.success('权限保存成功')
      emit('success')
      visible.value = false
    } catch (error) {
      ElMessage.error(error?.message || '权限保存失败')
    }
  }
  const handleVisibleChange = (value) => {
    visible.value = value
  }
  const handleSearch = async (scopeType) => {
    await loadScopeData(scopeType, { reloadSelection: false })
  }
  const resolveScopeNodeLabel = (data) => {
    const rawLabel = typeof data?.label === 'string' ? data.label.trim() : ''
    if (!rawLabel) {
      return ''
    }
    return resolveBackendMenuTitle(rawLabel)
  }
  const handleClosed = () => {
    activeScopeType.value = props.scopeType || 'menu'
    Object.keys(scopeState).forEach((key) => {
      scopeState[key].loading = false
      scopeState[key].loaded = false
      scopeState[key].treeData = []
      scopeState[key].checkedKeys = []
      scopeState[key].halfCheckedKeys = []
      scopeState[key].condition = ''
    })
  }
  const getAllNodeKeys = (nodes) => {
    const keys = []
    const traverse = (nodeList) => {
      nodeList.forEach((node) => {
        if (node.name) keys.push(node.name)
        if (node.children?.length) traverse(node.children)
        if (node.id !== void 0 && node.id !== null && node.id !== '') {
          keys.push(String(node.id))
        }
        if (node.children?.length) {
          traverse(node.children)
        }
      })
    }
    traverse(nodes)
    traverse(Array.isArray(nodes) ? nodes : [])
    return keys
  }
  const handleTreeCheck = () => {
    const tree = treeRef.value
    if (!tree) return
    const checkedKeys = tree.getCheckedKeys()
    const allKeys = getAllNodeKeys(processedMenuList.value)
    isSelectAll.value = checkedKeys.length === allKeys.length && allKeys.length > 0
  }
  const outputSelectedData = () => {
    const tree = treeRef.value
    if (!tree) return
    const selectedData = {
      checkedKeys: tree.getCheckedKeys(),
      halfCheckedKeys: tree.getHalfCheckedKeys(),
      checkedNodes: tree.getCheckedNodes(),
      halfCheckedNodes: tree.getHalfCheckedNodes(),
      totalChecked: tree.getCheckedKeys().length,
      totalHalfChecked: tree.getHalfCheckedKeys().length
  watch(
    () => props.visible,
    async (isVisible) => {
      if (isVisible) {
        activeScopeType.value = props.scopeType || 'menu'
        await ensureScopeLoaded(activeScopeType.value, { force: true })
      }
    },
    { immediate: true }
  )
  watch(
    () => props.scopeType,
    async (scopeType) => {
      if (scopeType) {
        activeScopeType.value = scopeType
        if (props.visible) {
          await ensureScopeLoaded(scopeType, { force: true })
        }
      }
    }
    console.log('=== 选中的权限数据 ===', selectedData)
    ElMessage.success(`已输出选中数据到控制台,共选中 ${selectedData.totalChecked} 个节点`)
  }
  )
  watch(
    activeScopeType,
    async (scopeType) => {
      if (props.visible && scopeType) {
        await ensureScopeLoaded(scopeType)
      }
    }
  )
</script>
rsf-design/src/views/system/user-login/index.vue
New file
@@ -0,0 +1,164 @@
<template>
  <div class="art-full-height">
    <ArtSearchBar
      v-model="searchForm"
      :items="searchItems"
      :showExpand="false"
      @search="handleSearch"
      @reset="handleReset"
    />
    <ElCard class="art-table-card">
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData" />
      <ArtTable
        :loading="loading"
        :data="data"
        :columns="columns"
        :pagination="pagination"
        @pagination:size-change="handleSizeChange"
        @pagination:current-change="handleCurrentChange"
      />
    </ElCard>
  </div>
</template>
<script setup>
  import { fetchGetUserLoginList } from '@/api/system-manage'
  import { useTable } from '@/hooks/core/useTable'
  import { ElTag } from 'element-plus'
  import { createUserLoginApiParams, userLoginPaginationKey } from './userLoginTable.config'
  defineOptions({ name: 'UserLogin' })
  const LOGIN_TYPE_MAP = {
    0: { label: '登录成功', type: 'success' },
    1: { label: '登录失败', type: 'danger' },
    2: { label: '退出登录', type: 'info' },
    3: { label: 'token 续签', type: 'warning' }
  }
  const initialSearchForm = {
    token: '',
    ip: '',
    type: void 0,
    system: ''
  }
  const searchForm = ref({ ...initialSearchForm })
  const searchItems = computed(() => [
    {
      label: 'Token',
      key: 'token',
      type: 'input',
      props: {
        placeholder: '请输入 token',
        clearable: true
      }
    },
    {
      label: 'IP',
      key: 'ip',
      type: 'input',
      props: {
        placeholder: '请输入 IP',
        clearable: true
      }
    },
    {
      label: '系统',
      key: 'system',
      type: 'input',
      props: {
        placeholder: '请输入系统标识',
        clearable: true
      }
    },
    {
      label: '类型',
      key: 'type',
      type: 'select',
      props: {
        placeholder: '请选择类型',
        clearable: true,
        options: Object.entries(LOGIN_TYPE_MAP).map(([value, item]) => ({
          label: item.label,
          value: Number(value)
        }))
      }
    }
  ])
  const getLoginTypeConfig = (type) => {
    return (
      LOGIN_TYPE_MAP[type] || {
        label: type ?? '-',
        type: 'info'
      }
    )
  }
  const getUserDisplayName = (row) => {
    return row.userId$ || row.userName || row.nickname || row.username || row.userId || '-'
  }
  const {
    columns,
    columnChecks,
    data,
    loading,
    pagination,
    getData,
    resetSearchParams,
    replaceSearchParams,
    handleSizeChange,
    handleCurrentChange,
    refreshData
  } = useTable({
    core: {
      apiFn: fetchGetUserLoginList,
      apiParams: createUserLoginApiParams(searchForm.value),
      paginationKey: userLoginPaginationKey,
      columnsFactory: () => [
        { type: 'index', width: 60, label: '序号' },
        { prop: 'id', label: 'ID', minWidth: 90 },
        {
          prop: 'userDisplayName',
          label: '用户',
          minWidth: 140,
          formatter: (row) => getUserDisplayName(row)
        },
        { prop: 'token', label: 'Token', minWidth: 260, showOverflowTooltip: true },
        { prop: 'ip', label: 'IP', minWidth: 140 },
        {
          prop: 'type',
          label: '类型',
          width: 120,
          formatter: (row) => {
            const config = getLoginTypeConfig(row.type)
            return h(ElTag, { type: config.type }, () => config.label)
          }
        },
        { prop: 'system', label: '系统', minWidth: 140 },
        {
          prop: 'createTime',
          label: '创建时间',
          minWidth: 180,
          sortable: true,
          formatter: (row) => row.createTime$ || row.createTime || '-'
        }
      ]
    }
  })
  const handleSearch = (params) => {
    replaceSearchParams(params)
    getData()
  }
  const handleReset = () => {
    Object.assign(searchForm.value, { ...initialSearchForm })
    resetSearchParams()
  }
</script>
rsf-design/src/views/system/user-login/userLoginTable.config.js
New file
@@ -0,0 +1,14 @@
function createUserLoginApiParams(filters = {}) {
  return {
    current: 1,
    pageSize: 20,
    ...filters
  }
}
const userLoginPaginationKey = {
  current: 'current',
  size: 'pageSize'
}
export { createUserLoginApiParams, userLoginPaginationKey }
rsf-design/src/views/system/user/modules/user-search.vue
@@ -3,61 +3,143 @@
    ref="searchBarRef"
    v-model="formData"
    :items="formItems"
    :rules="rules"
    @reset="handleReset"
    @search="handleSearch"
  >
  </ArtSearchBar>
  />
</template>
<script setup>
  import { createUserSearchState } from '../userPage.helpers'
  const props = defineProps({
    modelValue: { required: true }
    modelValue: { required: true },
    deptTreeOptions: { required: false, default: () => [] },
    roleOptions: { required: false, default: () => [] }
  })
  const emit = defineEmits(['update:modelValue', 'search', 'reset'])
  const searchBarRef = ref()
  const formData = computed({
    get: () => props.modelValue,
    set: (val) => emit('update:modelValue', val)
  })
  const rules = {
    // userName: [{ required: true, message: '请输入用户名', trigger: 'blur' }]
  }
  const statusOptions = ref([])
  function fetchStatusOptions() {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve([
          { label: '在线', value: '1' },
          { label: '离线', value: '2' },
          { label: '异常', value: '3' },
          { label: '注销', value: '4' }
        ])
      }, 1e3)
    })
  }
  onMounted(async () => {
    statusOptions.value = await fetchStatusOptions()
  })
  const formItems = computed(() => [
    {
      label: '用户名',
      key: 'userName',
      key: 'username',
      type: 'input',
      placeholder: '请输入用户名',
      clearable: true
      props: {
        placeholder: '请输入用户名',
        clearable: true
      }
    },
    {
      label: '昵称',
      key: 'nickname',
      type: 'input',
      props: {
        placeholder: '请输入昵称',
        clearable: true
      }
    },
    {
      label: '手机号',
      key: 'userPhone',
      key: 'phone',
      type: 'input',
      props: { placeholder: '请输入手机号', maxlength: '11' }
      props: {
        placeholder: '请输入手机号',
        clearable: true
      }
    },
    {
      label: '邮箱',
      key: 'userEmail',
      key: 'email',
      type: 'input',
      props: { placeholder: '请输入邮箱' }
      props: {
        placeholder: '请输入邮箱',
        clearable: true
      }
    },
    {
      label: '工号',
      key: 'code',
      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: 'sex',
      type: 'select',
      props: {
        placeholder: '请选择性别',
        clearable: true,
        options: [
          { label: '未知', value: 0 },
          { label: '男', value: 1 },
          { label: '女', value: 2 }
        ]
      }
    },
    {
      label: '真实姓名',
      key: 'realName',
      type: 'input',
      props: {
        placeholder: '请输入真实姓名',
        clearable: true
      }
    },
    {
      label: '身份证号',
      key: 'idCard',
      type: 'input',
      props: {
        placeholder: '请输入身份证号',
        clearable: true
      }
    },
    {
      label: '关键字',
      key: 'condition',
      type: 'input',
      props: {
        placeholder: '输入关键字搜索',
        clearable: true
      }
    },
    {
      label: '状态',
@@ -65,28 +147,22 @@
      type: 'select',
      props: {
        placeholder: '请选择状态',
        options: statusOptions.value
      }
    },
    {
      label: '性别',
      key: 'userGender',
      type: 'radiogroup',
      props: {
        clearable: true,
        options: [
          { label: '男', value: '1' },
          { label: '女', value: '2' }
          { label: '正常', value: 1 },
          { label: '禁用', value: 0 }
        ]
      }
    }
  ])
  function handleReset() {
    console.log('重置表单')
    emit('update:modelValue', createUserSearchState())
    emit('reset')
  }
  async function handleSearch(params) {
    await searchBarRef.value.validate()
    emit('search', params)
    console.log('表单数据', params)
  }
</script>
rsf-design/src/views/system/user/userPage.helpers.js
New file
@@ -0,0 +1,287 @@
export function createUserSearchState() {
  return {
    username: '',
    nickname: '',
    phone: '',
    email: '',
    status: void 0,
    deptId: void 0,
    roleIds: [],
    code: '',
    sex: void 0,
    realName: '',
    idCard: '',
    condition: ''
  }
}
export function createUserFormState() {
  return {
    id: void 0,
    username: '',
    nickname: '',
    deptId: 0,
    roleIds: [],
    userRoleIds: [],
    password: '',
    confirmPassword: '',
    sex: 0,
    code: '',
    phone: '',
    email: '',
    realName: '',
    idCard: '',
    memo: '',
    status: 1
  }
}
export function buildUserSearchParams(params = {}) {
  const searchParams = {
    username: params.username,
    nickname: params.nickname,
    phone: params.phone,
    email: params.email,
    status: params.status,
    deptId: params.deptId,
    roleIds: normalizeRoleIds(params),
    code: params.code,
    sex: params.sex,
    realName: params.realName,
    idCard: params.idCard,
    condition: params.condition
  }
  return Object.fromEntries(
    Object.entries(searchParams).filter(([, value]) => {
      if (Array.isArray(value)) {
        return value.length > 0
      }
      return value !== '' && value !== undefined && value !== null
    })
  )
}
export function buildUserPageQueryParams(params = {}) {
  const { current, size, pageSize, ...filters } = params
  return {
    current: current || 1,
    pageSize: pageSize || size || 20,
    ...buildUserSearchParams(filters)
  }
}
export function buildUserDialogModel(record = {}) {
  const roleIds = normalizeRoleIds(record)
  return {
    ...createUserFormState(),
    id: record.id !== undefined && record.id !== null && record.id !== '' ? record.id : void 0,
    username: record.username || '',
    nickname: record.nickname || '',
    deptId: normalizeDeptId(record.deptId),
    roleIds,
    userRoleIds: [...roleIds],
    sex: record.sex !== undefined && record.sex !== null ? record.sex : 0,
    code: record.code || '',
    phone: record.phone || '',
    email: record.email || '',
    realName: record.realName || '',
    idCard: record.idCard || '',
    memo: record.memo || '',
    status: record.status !== undefined && record.status !== null ? record.status : 1
  }
}
export function mergeUserDetailRecord(detail = {}, fallback = {}) {
  const merged = {
    ...(fallback && typeof fallback === 'object' ? fallback : {}),
    ...(detail && typeof detail === 'object' ? detail : {})
  }
  if (!hasRoleSelection(detail) && hasRoleSelection(fallback)) {
    merged.roles = fallback.roles
  }
  if (merged.deptLabel === undefined && fallback?.deptLabel) {
    merged.deptLabel = fallback.deptLabel
  }
  if (merged.roleNames === undefined && fallback?.roleNames) {
    merged.roleNames = fallback.roleNames
  }
  return merged
}
export function buildUserSavePayload(form = {}) {
  const roleIds = normalizeRoleIds(form)
  const password = form.password || form.newPassword || ''
  const payload = {
    id: form.id !== undefined && form.id !== null && form.id !== '' ? form.id : void 0,
    username: form.username || '',
    nickname: form.nickname || '',
    deptId: normalizeDeptId(form.deptId),
    roleIds,
    sex: form.sex !== undefined && form.sex !== null ? form.sex : 0,
    code: form.code || '',
    phone: form.phone || '',
    email: form.email || '',
    realName: form.realName || '',
    idCard: form.idCard || '',
    memo: form.memo || '',
    status: form.status !== undefined && form.status !== null ? form.status : 1
  }
  if (password) {
    payload.password = password
  }
  return payload
}
export function normalizeUserListRow(record = {}) {
  const roles = Array.isArray(record.roles) ? record.roles : []
  const statusMeta = getUserStatusMeta(record.statusBool ?? record.status)
  return {
    ...record,
    deptLabel:
      record.deptLabel ||
      record.deptId$ ||
      record.deptName ||
      record.dept?.name ||
      (record.deptId === 0 ? '根部门' : '-'),
    roleNames: formatUserRoleNames(roles) || record.roleNames || '',
    statusBool: record.statusBool !== undefined ? Boolean(record.statusBool) : statusMeta.bool,
    statusText: statusMeta.text,
    statusType: statusMeta.type,
    createTimeText: record.createTime$ || record.createTime || '',
    updateTimeText: record.updateTime$ || record.updateTime || ''
  }
}
export function normalizeDeptTreeOptions(tree = []) {
  if (!Array.isArray(tree)) {
    return []
  }
  return tree
    .map((node) => normalizeDeptTreeNode(node))
    .filter(Boolean)
}
export function normalizeRoleOptions(roles = []) {
  if (!Array.isArray(roles)) {
    return []
  }
  return roles
    .map((role) => {
      if (!role || typeof role !== 'object') {
        return null
      }
      const value = normalizeRoleId(role.id ?? role.roleId)
      if (value === void 0) {
        return null
      }
      return {
        value,
        label: role.name || role.roleName || role.code || ''
      }
    })
    .filter(Boolean)
}
export function getUserStatusMeta(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 formatUserRoleNames(roles = []) {
  if (!Array.isArray(roles) || roles.length === 0) {
    return ''
  }
  const names = roles
    .map((role) => {
      if (typeof role === 'string') {
        return role
      }
      if (role && typeof role === 'object') {
        return role.name || role.roleName || role.code || ''
      }
      return ''
    })
    .filter(Boolean)
  return names.join('、')
}
function normalizeDeptTreeNode(node) {
  if (!node || typeof node !== 'object') {
    return null
  }
  return {
    value: normalizeDeptId(node.id ?? node.value),
    label: node.name || node.label || '',
    children: normalizeDeptTreeOptions(node.children)
  }
}
function normalizeRoleIds(source) {
  if (!source || typeof source !== 'object') {
    return []
  }
  const directRoleIds = Array.isArray(source.roleIds)
    ? source.roleIds
    : Array.isArray(source.userRoleIds)
      ? source.userRoleIds
      : Array.isArray(source.roles)
        ? source.roles.map((item) => item?.id ?? item?.roleId)
        : []
  return Array.from(
    new Set(
      directRoleIds
        .map((item) => normalizeRoleId(item))
        .filter((item) => item !== void 0)
    )
  )
}
function normalizeDeptId(value) {
  if (value === '' || value === null || value === undefined) {
    return 0
  }
  return value
}
function normalizeRoleId(value) {
  if (value === '' || value === null || value === undefined) {
    return void 0
  }
  const numeric = Number(value)
  if (Number.isNaN(numeric)) {
    return value
  }
  return numeric
}
function hasRoleSelection(source) {
  if (!source || typeof source !== 'object') {
    return false
  }
  if (Array.isArray(source.roleIds) && source.roleIds.length > 0) {
    return true
  }
  if (Array.isArray(source.userRoleIds) && source.userRoleIds.length > 0) {
    return true
  }
  return Array.isArray(source.roles) && source.roles.length > 0
}
rsf-design/tests/backend-api-base.test.mjs
New file
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import test from 'node:test'
const projectRoot = path.resolve(import.meta.dirname, '..')
async function readProjectFile(relativePath) {
  return readFile(path.join(projectRoot, relativePath), 'utf8')
}
test('development env routes API requests through the rsf-server context path', async () => {
  const envContent = await readProjectFile('.env.development')
  assert.match(envContent, /VITE_API_URL\s*=\s*\/rsf-server\b/)
  assert.match(envContent, /VITE_API_PROXY_URL\s*=\s*http:\/\/127\.0\.0\.1:8085\b/)
})
test('production env keeps the rsf-server context path as the API base', async () => {
  const envContent = await readProjectFile('.env.production')
  assert.match(envContent, /VITE_API_URL\s*=\s*\/rsf-server\b/)
})
test('vite dev server proxies the rsf-server context path to the backend target', async () => {
  const viteConfig = await readProjectFile('vite.config.js')
  assert.match(viteConfig, /'\/rsf-server'\s*:\s*\{/)
  assert.match(viteConfig, /target:\s*VITE_API_PROXY_URL/)
})
rsf-design/tests/backend-menu-adapter.test.mjs
New file
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import test from 'node:test'
import { RoutesAlias } from '../src/router/routesAlias.js'
test('adapts a backend menu tree using route for path and name for title', async () => {
  const { adaptBackendMenuTree } = await import('../src/router/adapters/backendMenuAdapter.js')
  const result = adaptBackendMenuTree([
    {
      id: 10,
      name: 'menu.system',
      parentId: 0,
      path: '1',
      route: '/system',
      type: 0,
      children: [
        {
          id: 11,
          name: 'menu.userLogin',
          parentId: 10,
          path: '1,10',
          route: 'user-login',
          component: 'userLogin',
          type: 0
        }
      ]
    }
  ])
  assert.equal(result[0].path, 'system')
  assert.equal(result[0].meta.title, '系统设置')
  assert.equal(result[0].children[0].path, 'user-login')
  assert.equal(result[0].children[0].meta.title, '登录日志')
  assert.equal(result[0].children[0].component, '/system/user-login')
})
test('keeps backend leaves by deriving component paths from the route hierarchy when no alias exists', async () => {
  const { adaptBackendMenuTree } = await import('../src/router/adapters/backendMenuAdapter.js')
  const result = adaptBackendMenuTree([
    {
      id: 1,
      name: 'menu.system',
      parentId: 0,
      path: '1',
      route: '/system',
      type: 0,
      children: [
        {
          id: 2,
          name: 'menu.userLogin',
          parentId: 1,
          path: '1,2',
          route: 'user-login',
          component: 'userLogin',
          type: 0
        },
        {
          id: 3,
          name: 'menu.host',
          parentId: 1,
          path: '1,3',
          route: 'host',
          component: 'host',
          type: 0
        }
      ]
    }
  ])
  assert.equal(result[0].children.length, 2)
  assert.equal(result[0].children[0].path, 'user-login')
  assert.equal(result[0].children[0].meta.title, '登录日志')
  assert.equal(result[0].children[0].component, '/system/user-login')
  assert.equal(result[0].children[1].path, 'host')
  assert.equal(result[0].children[1].meta.title, '机构管理')
  assert.equal(result[0].children[1].component, '/system/host')
})
test('filters non-menu nodes while keeping plain string titles unchanged', async () => {
  const { adaptBackendMenuTree } = await import('../src/router/adapters/backendMenuAdapter.js')
  const result = adaptBackendMenuTree([
    {
      id: 20,
      name: '系统设置',
      parentId: 0,
      path: '20',
      route: '/system',
      type: 0,
      children: [
        {
          id: 21,
          name: 'menu.user',
          parentId: 20,
          path: '20,21',
          route: 'user',
          component: 'user',
          type: 0
        },
        {
          id: 22,
          name: 'system:user:add',
          parentId: 20,
          path: '20,22',
          route: 'user/add',
          component: 'user',
          type: 1
        }
      ]
    }
  ])
  assert.equal(result[0].meta.title, '系统设置')
  assert.equal(result[0].children.length, 1)
  assert.equal(result[0].children[0].id, '21')
  assert.equal(result[0].children[0].component, '/system/user')
})
test('preserves absolute backend child routes instead of nesting them under the parent path', async () => {
  const { adaptBackendMenuTree } = await import('../src/router/adapters/backendMenuAdapter.js')
  const result = adaptBackendMenuTree([
    {
      id: 5318,
      name: 'AI管理中心',
      parentId: 0,
      route: '/AI',
      type: 0,
      children: [
        {
          id: 422,
          name: 'menu.aiParam',
          parentId: 5318,
          route: '/system/aiParam',
          component: 'aiParam',
          type: 0
        }
      ]
    }
  ])
  assert.equal(result[0].path, 'AI')
  assert.equal(result[0].children[0].path, '/system/aiParam')
  assert.equal(result[0].children[0].meta.title, 'AI 参数')
  assert.equal(result[0].children[0].component, '/system/aiParam')
})
test('keeps directory menus as layout nodes when they only group child routes', async () => {
  const { adaptBackendMenuTree } = await import('../src/router/adapters/backendMenuAdapter.js')
  const result = adaptBackendMenuTree([
    {
      id: 5318,
      name: 'AI管理中心',
      parentId: 0,
      route: '/AI',
      type: 0,
      children: [
        {
          id: 422,
          name: 'menu.aiParam',
          parentId: 5318,
          route: '/system/aiParam',
          component: 'aiParam',
          type: 0
        }
      ]
    }
  ])
  assert.equal(result[0].component, RoutesAlias.Layout)
})
test('maps legacy backend icon names into renderable Iconify icons', async () => {
  const { adaptBackendMenuTree } = await import('../src/router/adapters/backendMenuAdapter.js')
  const result = adaptBackendMenuTree([
    {
      id: 1,
      name: 'AI管理中心',
      route: '/ai',
      icon: 'SmartToy',
      type: 0,
      children: [
        {
          id: 2,
          name: 'menu.userLogin',
          route: 'user-login',
          component: 'userLogin',
          icon: 'Token',
          type: 0
        }
      ]
    }
  ])
  assert.equal(result[0].meta.icon, 'ri:robot-2-line')
  assert.equal(result[0].children[0].meta.icon, 'ri:key-2-line')
})
test('phase 1 resource mappings resolve to existing views and translatable menu labels', async () => {
  const { PHASE_1_COMPONENTS } = await import('../src/router/adapters/backendMenuAdapter.js')
  Object.entries(PHASE_1_COMPONENTS).forEach(([resourceKey, viewPath]) => {
    const normalizedPath = viewPath.replace(/^\//, '')
    const componentWithIndex = new URL(`../src/views/${normalizedPath}/index.vue`, import.meta.url)
    const singleFileComponent = new URL(`../src/views/${normalizedPath}.vue`, import.meta.url)
    const componentExists = fs.existsSync(componentWithIndex) || fs.existsSync(singleFileComponent)
    assert.equal(componentExists, true, `${resourceKey} should resolve to an existing view`)
  })
})
rsf-design/tests/iconify-local-minimal.test.mjs
@@ -23,13 +23,28 @@
}
function collectUsedIconsByPrefix() {
  const iconPattern = /icon\s*[:=]\s*["']([a-z0-9-]+):([a-z0-9-]+)["']/g
  const iconPattern = /["']([a-z0-9-]+):([a-z0-9-]+)["']/g
  const knownPrefixes = new Set([
    'fluent',
    'icon-park-outline',
    'iconamoon',
    'ix',
    'line-md',
    'ri',
    'svg-spinners',
    'system-uicons',
    'vaadin'
  ])
  const usedIconsByPrefix = new Map()
  for (const filePath of collectSourceFiles(srcRoot)) {
    const content = fs.readFileSync(filePath, 'utf8')
    for (const [, prefix, name] of content.matchAll(iconPattern)) {
      if (!knownPrefixes.has(prefix)) {
        continue
      }
      const names = usedIconsByPrefix.get(prefix) || new Set()
      names.add(name)
      usedIconsByPrefix.set(prefix, names)
rsf-design/tests/iconify-local-prefixes.test.mjs
@@ -23,14 +23,29 @@
}
function collectIconPrefixes() {
  const iconPattern = /icon\s*[:=]\s*["']([a-z0-9-]+):/g
  const iconPattern = /["']([a-z0-9-]+):([a-z0-9-]+)["']/g
  const knownPrefixes = new Set([
    'fluent',
    'icon-park-outline',
    'iconamoon',
    'ix',
    'line-md',
    'ri',
    'svg-spinners',
    'system-uicons',
    'vaadin'
  ])
  const prefixes = new Set()
  for (const filePath of collectSourceFiles(srcRoot)) {
    const content = fs.readFileSync(filePath, 'utf8')
    for (const match of content.matchAll(iconPattern)) {
      prefixes.add(match[1])
    for (const [, prefix] of content.matchAll(iconPattern)) {
      if (!knownPrefixes.has(prefix)) {
        continue
      }
      prefixes.add(prefix)
    }
  }
rsf-design/tests/list-print-preview-style-contract.test.mjs
New file
@@ -0,0 +1,202 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import {
  buildPreviewColumns,
  buildReportStyleMeta
} from '../src/components/biz/list-export-print/list-export-print.helpers.js'
import { buildPrintDocumentHtml } from '../src/components/biz/list-export-print/list-print-document.js'
const previewDialogSource = readFileSync(
  new URL('../src/components/biz/list-export-print/list-print-preview-dialog.vue', import.meta.url),
  'utf8'
)
const printDocumentSource = readFileSync(
  new URL('../src/components/biz/list-export-print/list-print-document.js', import.meta.url),
  'utf8'
)
const scriptSetupIndex = previewDialogSource.indexOf('<script setup>')
const templateBlock =
  scriptSetupIndex >= 0
    ? previewDialogSource.slice(previewDialogSource.indexOf('<template>'), scriptSetupIndex)
    : previewDialogSource
test('report style meta defaults to the shared print preview contract', () => {
  assert.deepEqual(buildReportStyleMeta({ reportTitle: '角色报表' }).reportStyle, {
    titleAlign: 'center',
    titleLevel: 'strong',
    orientation: 'portrait',
    density: 'compact',
    showSequence: true,
    showBorder: true
  })
})
test('preview columns do not inject a sequence column when disabled', () => {
  assert.deepEqual(
    buildPreviewColumns({
      columns: [{ source: 'name', label: '角色名称' }],
      reportStyle: { showSequence: false }
    }),
    [{ source: 'name', label: '角色名称' }]
  )
})
test('preview dialog template uses passed columns for sequence rendering and colspan', () => {
  assert.equal(templateBlock.includes('>序号<'), false)
  assert.match(templateBlock, /<div\s+:class="titleClass">\s*\{\{\s*meta\.reportTitle\s*\?\?\s*'--'\s*\}\}\s*<\/div>/)
  assert.match(templateBlock, /<div\s+v-for="item in metaItems"[\s\S]*?:key="item\.key"[\s\S]*?>/)
  assert.match(templateBlock, /\{\{\s*item\.label\s*\}\}/)
  assert.match(templateBlock, /\{\{\s*item\.value\s*\?\?\s*'--'\s*\}\}/)
  assert.match(templateBlock, /<th[\s\S]*?v-for="column in columns"[\s\S]*?>[\s\S]*?\{\{\s*column\.label\s*\}\}[\s\S]*?<\/th>/)
  assert.match(
    templateBlock,
    /<td[\s\S]*?v-for="column in columns"[\s\S]*?>[\s\S]*?\{\{\s*column\.source === '__sequence__' \? index \+ 1 : row\?\.\[column\.source\] \?\? '--'\s*\}\}[\s\S]*?<\/td>/
  )
  assert.ok(templateBlock.includes("column.source === '__sequence__' ? index + 1"))
  assert.ok(templateBlock.includes('Math.max(columns.length, 1)'))
  assert.ok(previewDialogSource.includes('报表日期'))
  assert.ok(previewDialogSource.includes('打印人'))
  assert.ok(previewDialogSource.includes('打印时间'))
  assert.ok(previewDialogSource.includes('记录数'))
  assert.ok(templateBlock.includes('border-b'))
  assert.ok(templateBlock.includes('print:hidden'))
})
test('preview dialog derives titleClass from reportStyle title alignment and level', () => {
  assert.ok(previewDialogSource.includes('const titleClass = computed(() =>'))
  assert.ok(previewDialogSource.includes('meta.reportTitle'))
  assert.ok(previewDialogSource.includes('titleClass'))
  assert.ok(previewDialogSource.includes('reportStyle.titleAlign'))
  assert.ok(previewDialogSource.includes('reportStyle.titleLevel'))
  assert.ok(previewDialogSource.includes("left: 'text-left'"))
  assert.ok(previewDialogSource.includes("center: 'text-center'"))
  assert.ok(previewDialogSource.includes("right: 'text-right'"))
  assert.ok(previewDialogSource.includes("normal: 'text-[18px] font-medium'"))
  assert.ok(previewDialogSource.includes("strong: 'text-[22px] font-semibold'"))
  assert.ok(previewDialogSource.includes("prominent: 'text-[26px] font-bold'"))
  assert.ok(previewDialogSource.includes("?? alignMap.center"))
  assert.ok(previewDialogSource.includes("?? levelMap.strong"))
})
test('preview dialog keeps report content compact and prioritizes showing all columns', () => {
  assert.ok(previewDialogSource.includes("text-[18px] font-medium"))
  assert.ok(previewDialogSource.includes("text-[22px] font-semibold"))
  assert.ok(previewDialogSource.includes("text-[26px] font-bold"))
  assert.ok(previewDialogSource.includes('grid grid-cols-4 gap-2 text-[11px]'))
  assert.ok(previewDialogSource.includes('table-auto'))
  assert.ok(previewDialogSource.includes('text-[11px] leading-tight'))
  assert.ok(previewDialogSource.includes('px-1.5 py-1.5'))
  assert.ok(previewDialogSource.includes('break-all'))
})
test('preview dialog prints through a standalone report document instead of window.print on the app shell', () => {
  assert.equal(previewDialogSource.includes('window.print()'), false)
  assert.ok(previewDialogSource.includes("import { printReportDocument } from './list-print-document.js'"))
  assert.ok(previewDialogSource.includes('printReportDocument({'))
})
test('standalone print document preserves report title, meta row, sequence column, and print page styles', () => {
  const html = buildPrintDocumentHtml({
    title: '角色管理报表',
    meta: {
      reportTitle: '角色管理报表',
      reportDate: '2026/3/29',
      operator: 'root',
      printedAt: '2026/3/29 17:31:00',
      count: 2,
      reportStyle: {
        titleAlign: 'center',
        titleLevel: 'strong',
        orientation: 'portrait'
      }
    },
    columns: [
      { source: '__sequence__', label: '序号', align: 'center' },
      { source: 'name', label: '角色名称' },
      { source: 'code', label: '角色编码' }
    ],
    rows: [
      { id: 1, name: 'WMS系统管理员', code: 'admin' },
      { id: 2, name: 'ERP财务管理员', code: 'erpcw' }
    ]
  })
  assert.ok(html.includes('<title>角色管理报表</title>'))
  assert.ok(html.includes('报表日期'))
  assert.ok(html.includes('打印人'))
  assert.ok(html.includes('打印时间'))
  assert.ok(html.includes('记录数'))
  assert.ok(html.includes('序号'))
  assert.ok(html.includes('WMS系统管理员'))
  assert.ok(html.includes('ERP财务管理员'))
  assert.ok(html.includes('@page'))
  assert.ok(html.includes('size: A4 portrait;'))
  const pageRule = html.match(/@page\s*\{[\s\S]*?\}/)?.[0] ?? ''
  assert.equal(pageRule.includes('margin:'), false)
  assert.ok(html.includes('print-color-adjust: exact;'))
  assert.ok(html.includes('window.print()'))
  assert.ok(html.includes('window.close()'))
  assert.ok(html.includes('font-size: 11px;'))
  assert.ok(html.includes('font-size: 22px;'))
  assert.ok(html.includes('table-layout: auto;'))
})
test('standalone print document opens a writable popup window for document injection', () => {
  assert.equal(printDocumentSource.includes('noopener,noreferrer'), false)
  assert.ok(printDocumentSource.includes("window.open('', '_blank')"))
  assert.ok(printDocumentSource.includes('printWindow.document.write(buildPrintDocumentHtml(payload))'))
})
test('preview dialog caps rendered rows and keeps the table body scrollable for large datasets', () => {
  assert.ok(previewDialogSource.includes("maxPreviewRows: { type: Number, default: 50 }"))
  assert.ok(previewDialogSource.includes('const previewRows = computed(() =>'))
  assert.ok(previewDialogSource.includes('props.rows.slice(0, props.maxPreviewRows)'))
  assert.ok(previewDialogSource.includes('const hiddenRowCount = computed(() =>'))
  assert.ok(previewDialogSource.includes('预览仅展示前'))
  assert.ok(previewDialogSource.includes('min-h-0 flex-1 overflow-auto'))
  assert.ok(previewDialogSource.includes('v-for="(row, index) in previewRows"'))
  assert.ok(previewDialogSource.includes('v-if="hiddenRowCount > 0"'))
})
test('preview dialog keeps the whole modal inside the viewport and lets only the table area scroll', () => {
  assert.ok(previewDialogSource.includes('width="min(96vw, 1100px)"'))
  assert.ok(previewDialogSource.includes('top="4vh"'))
  assert.ok(previewDialogSource.includes('class="max-h-[88vh] overflow-hidden"'))
  assert.ok(previewDialogSource.includes('flex max-h-[calc(88vh-160px)] min-h-0 flex-col'))
  assert.ok(previewDialogSource.includes('mx-auto flex min-h-0 flex-1 flex-col overflow-hidden'))
  assert.ok(previewDialogSource.includes(':class="paperClass"'))
  assert.ok(previewDialogSource.includes(':style="paperStyle"'))
  assert.ok(previewDialogSource.includes('mt-4 min-h-0 flex-1 overflow-auto'))
})
test('preview dialog lets the user switch between portrait and landscape before printing', () => {
  assert.ok(previewDialogSource.includes("const currentOrientation = ref('portrait')"))
  assert.ok(previewDialogSource.includes('watch('))
  assert.ok(previewDialogSource.includes('v-model="currentOrientation"'))
  assert.ok(previewDialogSource.includes('label="portrait"'))
  assert.ok(previewDialogSource.includes('label="landscape"'))
  assert.ok(previewDialogSource.includes('竖版'))
  assert.ok(previewDialogSource.includes('横版'))
  assert.ok(previewDialogSource.includes('const effectiveMeta = computed(() =>'))
  assert.ok(previewDialogSource.includes('orientation: currentOrientation.value'))
  assert.ok(previewDialogSource.includes('meta: effectiveMeta.value'))
  assert.ok(previewDialogSource.includes('const paperClass = computed(() =>'))
  assert.ok(previewDialogSource.includes("currentOrientation.value === 'landscape'"))
  assert.ok(previewDialogSource.includes("aspectRatio: currentOrientation.value === 'landscape' ? '297 / 210' : '210 / 297'"))
})
test('preview dialog lets the user toggle table borders and carries the setting into print meta', () => {
  assert.ok(previewDialogSource.includes("const currentShowBorder = ref(true)"))
  assert.ok(previewDialogSource.includes('v-model="currentShowBorder"'))
  assert.ok(previewDialogSource.includes('边框开'))
  assert.ok(previewDialogSource.includes('边框关'))
  assert.ok(previewDialogSource.includes('showBorder: currentShowBorder.value'))
  assert.ok(previewDialogSource.includes('const tableWrapClass = computed(() =>'))
  assert.ok(previewDialogSource.includes('const headerCellClass = computed(() =>'))
  assert.ok(previewDialogSource.includes('const bodyCellClass = computed(() =>'))
  assert.ok(printDocumentSource.includes('const showBorder = reportStyle.showBorder !== false'))
  assert.ok(printDocumentSource.includes("const tableWrapClass = showBorder ? 'report-table-wrap' : 'report-table-wrap report-table-wrap-borderless'"))
})
rsf-design/tests/navigation-home-path.test.mjs
New file
@@ -0,0 +1,65 @@
import test from 'node:test'
import assert from 'node:assert/strict'
test('getFirstMenuPath skips unreleased menu routes and selects the first implemented page', async () => {
  const { getFirstMenuPath } = await import('../src/utils/navigation/route.js')
  const menuList = [
    {
      path: '/system',
      children: [
        {
          path: '/system/aiParam',
          component: '/system/aiParam',
          meta: { title: 'AI 参数' }
        }
      ]
    },
    {
      path: '/logs',
      children: [
        {
          path: '/logs/system/userLogin',
          component: '/system/user-login',
          meta: { title: '登录日志' }
        }
      ]
    }
  ]
  assert.equal(getFirstMenuPath(menuList), '/logs/system/userLogin')
})
test('getFirstMenuPath keeps traversing siblings when the first branch has no implemented leaf', async () => {
  const { getFirstMenuPath } = await import('../src/utils/navigation/route.js')
  const menuList = [
    {
      path: '/ai',
      children: [
        {
          path: '/ai/param',
          component: '/system/aiParam',
          meta: { title: 'AI 参数' }
        }
      ]
    },
    {
      path: '/permissions',
      children: [
        {
          path: '/permissions/system',
          children: [
            {
              path: '/permissions/system/role',
              component: '/system/role',
              meta: { title: '角色管理' }
            }
          ]
        }
      ]
    }
  ]
  assert.equal(getFirstMenuPath(menuList), '/permissions/system/role')
})
rsf-design/tests/system-manage-contract.test.mjs
@@ -1,20 +1,147 @@
import assert from 'node:assert/strict'
import { register } from 'node:module'
import test from 'node:test'
import { buildUserListParams, buildRoleListParams } from '../src/api/system-manage.js'
const menuTreeFixture = [
  {
    id: 1,
    parentId: 0,
    name: 'menu.user',
    route: 'user',
    type: 0,
    component: 'user',
    authority: 'system:user',
    icon: 'People',
    sort: 1,
    status: 1,
    memo: 'User menu',
    children: [
      {
        id: 2,
        parentId: 1,
        name: 'Query User',
        type: 1,
        authority: 'system:user:list',
        icon: 'Search',
        sort: 1,
        status: 0,
        memo: 'Query action'
      }
    ]
  }
]
register(
  `data:text/javascript,export function resolve(specifier, context, nextResolve){ if(specifier===\'@/utils/http\'){ return { shortCircuit:true, url:\'data:text/javascript,export default { get(){ return Promise.resolve({}) }, post(config){ if(config?.url===\\'/menu/list\\'){ globalThis.__lastMenuListConfig = config; return Promise.resolve(${JSON.stringify(menuTreeFixture.flatMap((node) => [node, ...node.children]))}) } if(config?.url===\\'/menu/tree\\'){ globalThis.__lastMenuTreeConfig = config; return Promise.resolve(${JSON.stringify(menuTreeFixture)}) } return Promise.resolve(config) } }\' } } return nextResolve(specifier, context) }`,
  import.meta.url
)
const {
  buildUserListParams,
  buildRoleListParams,
  fetchDeleteMenu,
  fetchGetMenuList,
  fetchGetMenuTree,
  fetchResetUserPassword,
  fetchGetRoleScopeList,
  fetchGetRoleScopeTree,
  fetchSaveMenu,
  fetchUpdateMenu
} = await import('../src/api/system-manage.js')
test('buildUserListParams matches the rsf-admin paging contract', () => {
  assert.equal(typeof buildUserListParams, 'function')
  assert.equal(typeof buildRoleListParams, 'function')
  assert.deepEqual(
    buildUserListParams({
      current: 2,
      pageSize: 20,
      username: 'root',
      email: 'root@example.com',
      deptId: 3
    }),
    {
      current: 2,
      pageSize: 20,
      username: 'root',
      email: 'root@example.com',
      deptId: 3
    }
  )
})
test('fetchResetUserPassword requires an admin update payload', () => {
  assert.throws(() => fetchResetUserPassword(1), /object payload/i)
  assert.throws(() => fetchResetUserPassword({ id: 1 }), /password/i)
  return fetchResetUserPassword({ id: 1, newPassword: 'secret' }).then((config) => {
    assert.equal(config.params.password, 'secret')
  })
})
test('fetchGetMenuList folds button nodes into authList', async () => {
  const menuList = await fetchGetMenuList()
  assert.equal(menuList.length, 1)
  assert.equal(menuList[0].id, '1')
  assert.equal(menuList[0].parentId, '0')
  assert.equal(menuList[0].route, 'user')
  assert.equal(menuList[0].authority, 'system:user')
  assert.equal(menuList[0].status, 1)
  assert.equal(menuList[0].memo, 'User menu')
  assert.equal(menuList[0].meta.title, 'menus.user')
  assert.equal(menuList[0].component, 'user')
  assert.equal(menuList[0].meta.sort, 1)
  assert.equal(menuList[0].meta.isEnable, true)
  assert.deepEqual(menuList[0].meta.authList, [
    {
      id: '2',
      parentId: '1',
      parentName: '',
      name: 'Query User',
      title: 'Query User',
      route: '',
      component: '',
      authMark: 'system:user:list',
      authority: 'system:user:list',
      icon: 'Search',
      sort: 1,
      status: 0,
      memo: 'Query action',
      type: 1
    }
  ])
  assert.equal(menuList[0].children.length, 0)
  assert.deepEqual(globalThis.__lastMenuListConfig?.params, {})
})
test('menu tree helpers always send an explicit empty body for Spring Map request bodies', async () => {
  await fetchGetMenuTree()
  assert.deepEqual(globalThis.__lastMenuTreeConfig?.params, {})
  await fetchGetRoleScopeTree('menu')
  assert.equal(globalThis.__lastMenuTreeConfig?.url, '/menu/tree')
  assert.deepEqual(globalThis.__lastMenuTreeConfig?.params, {})
})
test('menu CRUD helpers target the real rsf-server endpoints', async () => {
  assert.equal(typeof fetchSaveMenu, 'function')
  assert.equal(typeof fetchUpdateMenu, 'function')
  assert.equal(typeof fetchDeleteMenu, 'function')
  const saveConfig = await fetchSaveMenu({ name: '菜单' })
  const updateConfig = await fetchUpdateMenu({ id: 1, name: '菜单' })
  const deleteConfig = await fetchDeleteMenu('1,2')
  assert.equal(saveConfig.url, '/menu/save')
  assert.deepEqual(saveConfig.params, { name: '菜单' })
  assert.equal(updateConfig.url, '/menu/update')
  assert.deepEqual(updateConfig.params, { id: 1, name: '菜单' })
  assert.equal(deleteConfig.url, '/menu/remove/1,2')
})
test('scope resolvers fail fast on invalid scope types', () => {
  assert.throws(() => fetchGetRoleScopeList('invalid', 1), /Unsupported scope type/i)
  assert.throws(() => fetchGetRoleScopeTree('invalid', {}), /Unsupported scope type/i)
})
rsf-design/tests/system-menu-page-contract.test.mjs
@@ -1,37 +1,56 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const projectRoot = path.resolve(import.meta.dirname, '..')
const zhLocalePath = path.join(projectRoot, 'src', 'locales', 'langs', 'zh.json')
const enLocalePath = path.join(projectRoot, 'src', 'locales', 'langs', 'en.json')
const routerUtilsPath = path.join(projectRoot, 'src', 'utils', 'router.js')
const menuPagePath = path.join(projectRoot, 'src', 'views', 'system', 'menu', 'index.vue')
const menuPageSource = readFileSync(
  new URL('../src/views/system/menu/index.vue', import.meta.url),
  'utf8'
)
test('current-system menu keys are available in rsf-design locales', () => {
  const zhMessages = JSON.parse(fs.readFileSync(zhLocalePath, 'utf8'))
  const enMessages = JSON.parse(fs.readFileSync(enLocalePath, 'utf8'))
const menuDialogSource = readFileSync(
  new URL('../src/views/system/menu/modules/menu-dialog.vue', import.meta.url),
  'utf8'
)
  assert.equal(zhMessages.menu?.system, '系统设置')
  assert.equal(zhMessages.menu?.basicInfo, '基础信息')
  assert.equal(zhMessages.menu?.aiParam, 'AI 参数')
const routerSource = readFileSync(new URL('../src/utils/router.js', import.meta.url), 'utf8')
const zhLocale = JSON.parse(readFileSync(new URL('../src/locales/langs/zh.json', import.meta.url), 'utf8'))
const enLocale = JSON.parse(readFileSync(new URL('../src/locales/langs/en.json', import.meta.url), 'utf8'))
  assert.equal(enMessages.menu?.system, 'System')
  assert.equal(enMessages.menu?.basicInfo, 'BasicInfo')
  assert.equal(enMessages.menu?.aiParam, 'AI Params')
test('menu page submit and delete handlers call the real backend menu APIs', () => {
  assert.match(menuPageSource, /fetchSaveMenu/)
  assert.match(menuPageSource, /fetchUpdateMenu/)
  assert.match(menuPageSource, /fetchDeleteMenu/)
  assert.doesNotMatch(menuPageSource, /console\.log\('提交数据:'/)
  assert.match(menuPageSource, /const handleAddAuth = \(row\) =>/)
  assert.match(menuPageSource, /const handleDeleteMenu = async \(row\) =>/)
  assert.match(menuPageSource, /const handleDeleteAuth = async \(row\) =>/)
  assert.match(menuPageSource, /await fetchDeleteMenu\(row\.id\)/)
})
test('formatMenuTitle recognizes current-system menu translation keys', () => {
  const routerSource = fs.readFileSync(routerUtilsPath, 'utf8')
  assert.match(routerSource, /startsWith\('menu\.'\)|startsWith\("menu\."\)/)
test('menu dialog accepts edit data and parent menu options from the page', () => {
  assert.match(menuDialogSource, /editData:/)
  assert.match(menuDialogSource, /menuTreeOptions:/)
  assert.match(menuDialogSource, /key: 'parentId'/)
  assert.match(menuDialogSource, /type: 'treeselect'/)
})
test('menu management table shows icon preview and translated names', () => {
  const menuPageSource = fs.readFileSync(menuPagePath, 'utf8')
test('menu page renders Iconify preview and current-system translated menu names', () => {
  assert.ok(
    menuPageSource.indexOf("label: '菜单名称'") < menuPageSource.indexOf("label: '图标预览'"),
    '菜单名称列应保持在图标预览列之前,避免树形展开箭头跑到图标列'
  )
  assert.match(menuPageSource, /label:\s*'图标预览'/)
  assert.match(menuPageSource, /ArtSvgIcon/)
  assert.match(menuPageSource, /row\.meta\?\.title\s*\|\|\s*row\.name|item\.meta\?\.title\s*\|\|\s*item\.name/)
  assert.match(menuPageSource, /rounded-md/)
  assert.match(menuPageSource, /row\.meta\?\.title\s*\|\|\s*row\.name/)
  assert.match(routerSource, /startsWith\('menu\.'\)|startsWith\("menu\."\)/)
  assert.match(routerSource, /const fallbackTitle =/)
  assert.match(routerSource, /title\.startsWith\('menus\.'\)/)
  assert.match(routerSource, /i18n\.global\.te\(fallbackTitle\)/)
  assert.equal(zhLocale.menu?.system, '系统设置')
  assert.equal(zhLocale.menu?.basicInfo, '基础信息')
  assert.equal(zhLocale.menu?.aiParam, 'AI 参数')
  assert.equal(enLocale.menu?.system, 'System')
  assert.equal(enLocale.menu?.basicInfo, 'BasicInfo')
  assert.equal(enLocale.menu?.aiParam, 'AI Params')
})
rsf-design/tests/system-role-scope-contract.test.mjs
@@ -1,21 +1,211 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import test from 'node:test'
import {
  buildScopeTreeNodes,
  buildScopeSavePayload,
  SCOPE_TYPES
} from '../src/views/system/role/roleScope.config.js'
test('menu scope nodes preserve backend ids for save', () => {
  const tree = buildScopeTreeNodes(SCOPE_TYPES.menu, [{ id: 1, label: '系统管理' }])
  assert.equal(tree[0].id, 1)
import {
  buildRoleDialogModel,
  buildRolePageQueryParams,
  buildRoleSavePayload,
  buildRoleScopeSubmitPayload,
  buildRoleSearchParams,
  getRoleScopeConfig,
  normalizeRoleScopeTreeData,
  normalizeRoleListRow,
  createRoleSearchState
} from '../src/views/system/role/rolePage.helpers.js'
import { resolveBackendMenuTitle } from '../src/utils/backend-menu-title.js'
const rolePermissionDialogSource = fs.readFileSync(
  new URL('../src/views/system/role/modules/role-permission-dialog.vue', import.meta.url),
  'utf8'
)
const roleEditDialogSource = fs.readFileSync(
  new URL('../src/views/system/role/modules/role-edit-dialog.vue', import.meta.url),
  'utf8'
)
const roleIndexSource = fs.readFileSync(
  new URL('../src/views/system/role/index.vue', import.meta.url),
  'utf8'
)
test('buildRoleSearchParams keeps real role search fields', () => {
  assert.deepEqual(
    buildRoleSearchParams({
      name: '管理员',
      code: 'R_ADMIN',
      memo: '核心角色',
      status: 1,
      condition: 'admin'
    }),
    {
      name: '管理员',
      code: 'R_ADMIN',
      memo: '核心角色',
      status: 1,
      condition: 'admin'
    }
  )
})
test('scope save payload is delegated per scope type', () => {
  const payload = buildScopeSavePayload(SCOPE_TYPES.menu, {
    roleId: 9,
    selectedIds: [1, 2],
    authType: 0
test('buildRolePageQueryParams merges paging and search fields', () => {
  assert.deepEqual(
    buildRolePageQueryParams({
      current: 3,
      size: 20,
      name: '管理员'
    }),
    {
      current: 3,
      pageSize: 20,
      name: '管理员'
    }
  )
})
test('buildRoleDialogModel normalizes backend role data into the form model', () => {
  assert.deepEqual(
    buildRoleDialogModel({
      id: 7,
      name: '管理员',
      code: 'R_ADMIN',
      memo: '核心角色',
      status: 0
    }),
    {
      id: 7,
      name: '管理员',
      code: 'R_ADMIN',
      memo: '核心角色',
      status: 0
    }
  )
})
test('buildRoleSavePayload submits backend role fields', () => {
  assert.deepEqual(
    buildRoleSavePayload({
      id: 7,
      name: '管理员',
      code: 'R_ADMIN',
      memo: '核心角色',
      status: 1
    }),
    {
      id: 7,
      name: '管理员',
      code: 'R_ADMIN',
      memo: '核心角色',
      status: 1
    }
  )
})
test('normalizeRoleListRow exposes table friendly fields', () => {
  const normalized = normalizeRoleListRow({
    id: 7,
    name: '管理员',
    code: 'R_ADMIN',
    memo: '核心角色',
    status: 1,
    createTime: '2025-03-28 10:00:00',
    updateTime: '2025-03-28 11:00:00'
  })
  assert.equal(payload.id, 9)
  assert.equal(normalized.name, '管理员')
  assert.equal(normalized.statusBool, true)
  assert.equal(normalized.statusText, '正常')
  assert.equal(normalized.statusType, 'success')
  assert.equal(normalized.createTimeText, '2025-03-28 10:00:00')
  assert.equal(normalized.updateTimeText, '2025-03-28 11:00:00')
})
test('buildRoleScopeSubmitPayload normalizes checked keys for backend scope update', () => {
  assert.deepEqual(
    buildRoleScopeSubmitPayload(7, ['1', 2], ['3']),
    {
      id: 7,
      menuIds: {
        checked: [1, 2],
        halfChecked: [3]
      }
    }
  )
})
test('getRoleScopeConfig resolves the four backend scope contracts', () => {
  assert.deepEqual(getRoleScopeConfig('menu'), {
    scopeType: 'menu',
    title: '网页权限',
    listUrl: '/role/scope/list',
    treeUrl: '/menu/tree'
  })
  assert.deepEqual(getRoleScopeConfig('warehouse'), {
    scopeType: 'warehouse',
    title: '仓库权限',
    listUrl: '/roleWarehouse/scope/list',
    treeUrl: '/menuWarehouse/tree'
  })
  assert.throws(() => getRoleScopeConfig('invalid'), /Unsupported scope type/i)
})
test('normalizeRoleScopeTreeData folds menu auth buttons into child nodes', () => {
  const tree = normalizeRoleScopeTreeData('menu', [
    {
      id: 1,
      name: 'menu.role',
      type: 0,
      children: [
        {
          id: 2,
          name: 'Query Role',
          type: 1,
          authority: 'system:role:list'
        }
      ]
    }
  ])
  assert.equal(tree[0].label, 'menus.role')
  assert.equal(tree[0].children[0].isAuthButton, true)
  assert.equal(tree[0].children[0].authMark, 'system:role:list')
})
test('resolveBackendMenuTitle translates legacy backend menu keys into readable labels', () => {
  assert.equal(resolveBackendMenuTitle('menu.role'), '角色管理')
  assert.equal(resolveBackendMenuTitle('menus.aiParam'), 'AI 参数')
  assert.equal(resolveBackendMenuTitle('AI管理中心'), 'AI管理中心')
})
test('role permission dialog only loads the active scope instead of all scopes together', () => {
  assert.match(rolePermissionDialogSource, /ensureScopeLoaded\(activeScopeType\.value, \{ force: true \}\)/)
  assert.doesNotMatch(rolePermissionDialogSource, /loadAllScopeData/)
})
test('role permission dialog keeps scope tree search and readable labels', () => {
  assert.match(rolePermissionDialogSource, /placeholder="搜索权限树"/)
  assert.match(rolePermissionDialogSource, /reloadSelection: false/)
  assert.match(rolePermissionDialogSource, /resolveBackendMenuTitle/)
})
test('role edit dialog keeps code optional to match the backend contract', () => {
  assert.match(roleEditDialogSource, /name: \[\{ required: true, message: '请输入角色名称'/)
  assert.doesNotMatch(roleEditDialogSource, /code: \[\{ required: true, message: '请输入角色编码'/)
})
test('role page uses semantic auth aliases so backend permissions render actions', () => {
  assert.match(roleIndexSource, /v-auth=\"'add'\"/)
  assert.match(roleIndexSource, /v-auth=\"'delete'\"/)
  assert.match(roleIndexSource, /v-auth=\"'query'\"/)
  assert.match(roleIndexSource, /auth: 'edit'/)
  assert.match(roleIndexSource, /auth: 'delete'/)
})
test('createRoleSearchState exposes the role search form model', () => {
  assert.deepEqual(createRoleSearchState(), {
    name: '',
    code: '',
    memo: '',
    status: void 0,
    condition: ''
  })
})
rsf-design/tests/system-user-page-contract.test.mjs
@@ -1,10 +1,209 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildUserDialogModel } from '../src/views/system/user/userPage.helpers.js'
test('buildUserDialogModel maps rsf-admin edit data into the dialog model', () => {
  assert.equal(
    buildUserDialogModel({ username: 'root', nickname: '管理员', deptId: 1, roles: [{ id: 3 }] }).username,
    'root'
import {
  buildUserDialogModel,
  buildUserPageQueryParams,
  buildUserSavePayload,
  buildUserSearchParams,
  getUserStatusMeta,
  mergeUserDetailRecord,
  normalizeDeptTreeOptions,
  normalizeRoleOptions,
  normalizeUserListRow
} from '../src/views/system/user/userPage.helpers.js'
test('buildUserSearchParams keeps real user page search fields', () => {
  assert.deepEqual(
    buildUserSearchParams({
      username: 'root',
      nickname: '管理员',
      phone: '13800000000',
      email: 'root@example.com',
      status: 1,
      deptId: 0,
      roleIds: [3, 8],
      code: 'A001',
      sex: 1,
      realName: 'Vincent',
      idCard: '330421199511233211',
      condition: 'root'
    }),
    {
      username: 'root',
      nickname: '管理员',
      phone: '13800000000',
      email: 'root@example.com',
      status: 1,
      deptId: 0,
      roleIds: [3, 8],
      code: 'A001',
      sex: 1,
      realName: 'Vincent',
      idCard: '330421199511233211',
      condition: 'root'
    }
  )
})
test('buildUserPageQueryParams merges paging and search fields', () => {
  assert.deepEqual(
    buildUserPageQueryParams({
      current: 2,
      size: 20,
      username: 'root',
      condition: 'manager'
    }),
    {
      current: 2,
      pageSize: 20,
      username: 'root',
      condition: 'manager'
    }
  )
})
test('buildUserDialogModel normalizes backend edit data into the form model', () => {
  assert.deepEqual(
    buildUserDialogModel({
      id: 7,
      username: 'root',
      nickname: '管理员',
      deptId: 1,
      roles: [{ id: 3 }, { roleId: 8 }],
      sex: 1,
      code: 'A001',
      phone: '13800000000',
      email: 'root@example.com',
      realName: 'Vincent',
      idCard: '330421199511233211',
      memo: 'memo',
      status: 0
    }),
    {
      id: 7,
      username: 'root',
      nickname: '管理员',
      deptId: 1,
      roleIds: [3, 8],
      userRoleIds: [3, 8],
      password: '',
      confirmPassword: '',
      sex: 1,
      code: 'A001',
      phone: '13800000000',
      email: 'root@example.com',
      realName: 'Vincent',
      idCard: '330421199511233211',
      memo: 'memo',
      status: 0
    }
  )
})
test('buildUserSavePayload submits roleIds and password for the backend', () => {
  assert.deepEqual(
    buildUserSavePayload({
      id: 7,
      username: 'root',
      nickname: '管理员',
      deptId: 1,
      userRoleIds: [3, 8],
      password: 'secret',
      confirmPassword: 'secret',
      sex: 1,
      code: 'A001',
      phone: '13800000000',
      email: 'root@example.com',
      realName: 'Vincent',
      idCard: '330421199511233211',
      memo: 'memo',
      status: 1
    }),
    {
      id: 7,
      username: 'root',
      nickname: '管理员',
      deptId: 1,
      roleIds: [3, 8],
      password: 'secret',
      sex: 1,
      code: 'A001',
      phone: '13800000000',
      email: 'root@example.com',
      realName: 'Vincent',
      idCard: '330421199511233211',
      memo: 'memo',
      status: 1
    }
  )
})
test('normalizeUserListRow exposes table friendly fields', () => {
  const normalized = normalizeUserListRow({
    username: 'root',
    nickname: '管理员',
    deptLabel: '研发部',
    deptId$: '研发部',
    roleNames: '超级管理员、OPS',
    roles: [{ name: '超级管理员' }, { code: 'OPS' }],
    status: 1,
    createTime$: '2025-03-28 10:00:00',
    updateTime: '2025-03-28 11:00:00'
  })
  assert.equal(normalized.username, 'root')
  assert.equal(normalized.deptLabel, '研发部')
  assert.equal(normalized.roleNames, '超级管理员、OPS')
  assert.equal(normalized.statusBool, true)
  assert.equal(normalized.statusText, '正常')
  assert.equal(normalized.statusType, 'success')
  assert.equal(normalized.createTimeText, '2025-03-28 10:00:00')
  assert.equal(normalized.updateTimeText, '2025-03-28 11:00:00')
})
test('mergeUserDetailRecord keeps list row roles when detail omits them', () => {
  assert.deepEqual(
    mergeUserDetailRecord(
      {
        id: 7,
        username: 'root',
        nickname: '管理员'
      },
      {
        id: 7,
        deptLabel: '研发部',
        roleNames: '超级管理员',
        roles: [{ id: 3, name: '超级管理员' }]
      }
    ),
    {
      id: 7,
      username: 'root',
      nickname: '管理员',
      deptLabel: '研发部',
      roleNames: '超级管理员',
      roles: [{ id: 3, name: '超级管理员' }]
    }
  )
})
test('normalizeDeptTreeOptions and normalizeRoleOptions adapt lookup data', () => {
  assert.deepEqual(
    normalizeDeptTreeOptions([{ id: 1, name: '总部', children: [{ id: 2, name: '研发部' }] }]),
    [{ value: 1, label: '总部', children: [{ value: 2, label: '研发部', children: [] }] }]
  )
  assert.deepEqual(
    normalizeRoleOptions([{ id: 3, name: '管理员' }, { roleId: 8, code: 'OPS' }]),
    [
      { value: 3, label: '管理员' },
      { value: 8, label: 'OPS' }
    ]
  )
})
test('getUserStatusMeta maps enabled and disabled states', () => {
  assert.deepEqual(getUserStatusMeta(1), { type: 'success', text: '正常', bool: true })
  assert.deepEqual(getUserStatusMeta(0), { type: 'danger', text: '禁用', bool: false })
})
rsf-design/tests/user-login-page-contract.test.mjs
New file
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict'
import test from 'node:test'
const { createUserLoginApiParams, userLoginPaginationKey } = await import(
  '../src/views/system/user-login/userLoginTable.config.js'
)
test('user login page uses the backend pageSize pagination contract', () => {
  assert.deepEqual(userLoginPaginationKey, {
    current: 'current',
    size: 'pageSize'
  })
})
test('user login page builds initial params with pageSize instead of size', () => {
  assert.deepEqual(
    createUserLoginApiParams({
      token: 'abc',
      ip: '127.0.0.1',
      system: 'wms'
    }),
    {
      current: 1,
      pageSize: 20,
      token: 'abc',
      ip: '127.0.0.1',
      system: 'wms'
    }
  )
})
rsf-design/tests/work-tab-icon-contract.test.mjs
New file
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const projectRoot = path.resolve(__dirname, '..')
const workTabSource = fs.readFileSync(
  path.join(projectRoot, 'src/components/core/layouts/art-work-tab/index.vue'),
  'utf8'
)
test('work tabs only render the leading icon when a tab actually has an icon', () => {
  assert.match(workTabSource, /<ArtSvgIcon\s+v-if="item\.icon"/)
  assert.doesNotMatch(workTabSource, /<ArtSvgIcon\s+v-show="item\.icon"/)
})
rsf-design/tests/worktab-icon-normalization-contract.test.mjs
New file
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const projectRoot = path.resolve(__dirname, '..')
const worktabSource = fs.readFileSync(
  path.join(projectRoot, 'src/store/modules/worktab.js'),
  'utf8'
)
test('worktab store normalizes persisted legacy icon names before rendering tabs', () => {
  assert.match(worktabSource, /import\s+\{\s*normalizeIcon\s*\}\s+from\s+'@\/router\/adapters\/backendMenuAdapter\.js'/)
  assert.match(worktabSource, /icon:\s*normalizeIcon\(/)
  assert.match(worktabSource, /const validTabs = opened\.value\.filter\(\(tab\) => isTabRouteValid\(tab\)\)\.map\(normalizeTabState\)/)
  assert.match(worktabSource, /opened\.value\s*=\s*validTabs/)
})
rsf-design/vite.config.js
@@ -28,7 +28,7 @@
    server: {
      port: Number(VITE_PORT),
      proxy: {
        '/api': {
        '/rsf-server': {
          target: VITE_API_PROXY_URL,
          changeOrigin: true
        }