zhou zhou
1 天以前 df8467bc8891af847802cc6aee501c18d50f451a
#前端
1个文件已添加
9个文件已修改
1725 ■■■■ 已修改文件
rsf-design/src/api/ai-chat.js 153 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/core/layouts/art-chat-window/index.vue 1218 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/locales/langs/en.json 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/locales/langs/zh.json 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/plugins/iconify.collections.js 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/abnormal/index.vue 42 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/manager/loc-preview/modules/loc-preview-detail-drawer.vue 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/ai-mcp-mount/index.vue 64 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/role/index.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/role/rolePage.helpers.js 38 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/ai-chat.js
New file
@@ -0,0 +1,153 @@
import request from '@/utils/http'
import { useUserStore } from '@/store/modules/user'
const DEFAULT_PROMPT_CODE = 'home.default'
function buildRuntimeParams(promptCode = DEFAULT_PROMPT_CODE, sessionId = null, aiParamId = null) {
  return {
    promptCode,
    ...(sessionId ? { sessionId } : {}),
    ...(aiParamId !== null && aiParamId !== undefined ? { aiParamId } : {})
  }
}
function fetchGetAiRuntime(promptCode = DEFAULT_PROMPT_CODE, sessionId = null, aiParamId = null) {
  return request.get({
    url: '/ai/chat/runtime',
    params: buildRuntimeParams(promptCode, sessionId, aiParamId),
    showErrorMessage: false
  })
}
function fetchGetAiSessions(promptCode = DEFAULT_PROMPT_CODE, keyword = '') {
  return request.get({
    url: '/ai/chat/sessions',
    params: {
      promptCode,
      ...(keyword ? { keyword } : {})
    },
    showErrorMessage: false
  })
}
function fetchRemoveAiSession(sessionId) {
  return request.post({
    url: `/ai/chat/session/remove/${sessionId}`,
    showErrorMessage: false
  })
}
function fetchRenameAiSession(sessionId, title) {
  return request.post({
    url: `/ai/chat/session/rename/${sessionId}`,
    params: { title },
    showErrorMessage: false
  })
}
function fetchPinAiSession(sessionId, pinned) {
  return request.post({
    url: `/ai/chat/session/pin/${sessionId}`,
    params: { pinned },
    showErrorMessage: false
  })
}
function fetchClearAiSessionMemory(sessionId) {
  return request.post({
    url: `/ai/chat/session/memory/clear/${sessionId}`,
    showErrorMessage: false
  })
}
function fetchRetainAiSessionLatestRound(sessionId) {
  return request.post({
    url: `/ai/chat/session/memory/retain-latest/${sessionId}`,
    showErrorMessage: false
  })
}
async function fetchStreamAiChat(payload, { signal, onEvent } = {}) {
  const { VITE_API_URL } = import.meta.env
  const { accessToken } = useUserStore()
  const response = await fetch(`${VITE_API_URL}/ai/chat/stream`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'text/event-stream',
      ...(accessToken ? { Authorization: accessToken } : {})
    },
    body: JSON.stringify(payload),
    signal
  })
  if (!response.ok) {
    throw new Error(`AI 请求失败 (${response.status})`)
  }
  if (!response.body) {
    throw new Error('AI 响应流不可用')
  }
  const reader = response.body.getReader()
  const decoder = new TextDecoder('utf-8')
  let buffer = ''
  while (true) {
    const { done, value } = await reader.read()
    if (done) {
      break
    }
    buffer += decoder.decode(value, { stream: true })
    const events = buffer.split(/\r?\n\r?\n/)
    buffer = events.pop() || ''
    events.forEach((item) => dispatchSseEvent(item, onEvent))
  }
  if (buffer.trim()) {
    dispatchSseEvent(buffer, onEvent)
  }
}
function dispatchSseEvent(rawEvent, onEvent) {
  if (!onEvent) {
    return
  }
  const lines = rawEvent.split(/\r?\n/)
  let eventName = 'message'
  const dataLines = []
  lines.forEach((line) => {
    if (line.startsWith('event:')) {
      eventName = line.slice(6).trim()
    }
    if (line.startsWith('data:')) {
      dataLines.push(line.slice(5).trim())
    }
  })
  if (!dataLines.length) {
    return
  }
  const rawData = dataLines.join('\n')
  let payload = rawData
  try {
    payload = JSON.parse(rawData)
  } catch {
  }
  onEvent(eventName, payload)
}
export {
  DEFAULT_PROMPT_CODE,
  fetchClearAiSessionMemory,
  fetchGetAiRuntime,
  fetchGetAiSessions,
  fetchPinAiSession,
  fetchRemoveAiSession,
  fetchRenameAiSession,
  fetchRetainAiSessionLatestRound,
  fetchStreamAiChat
}
rsf-design/src/components/core/layouts/art-chat-window/index.vue
@@ -1,228 +1,1056 @@
<!-- 系统聊天窗口 -->
<template>
  <div>
    <ElDrawer v-model="isDrawerVisible" :size="isMobile ? '100%' : '480px'" :with-header="false">
      <div class="mb-5 flex-cb">
        <div>
          <span class="text-base font-medium">Art Bot</span>
          <div class="mt-1.5 flex-c gap-1">
            <div
              class="h-2 w-2 rounded-full"
              :class="isOnline ? 'bg-success/100' : 'bg-danger/100'"
            ></div>
            <span class="text-xs text-g-600">{{ isOnline ? '在线' : '离线' }}</span>
  <ElDrawer
    v-model="isDrawerVisible"
    :size="isMobile ? '100%' : '76vw'"
    :with-header="false"
    destroy-on-close
    class="ai-chat-drawer"
  >
    <div class="flex h-full min-h-0 flex-col overflow-hidden bg-[var(--art-main-bg-color)]">
      <div class="flex items-center gap-4 border-b border-[var(--el-border-color-lighter)] bg-[var(--art-main-bg-color)] px-6 py-4">
        <div class="flex size-11 items-center justify-center rounded-3xl bg-[var(--el-color-primary-light-9)] text-[var(--el-color-primary)]">
          <ArtSvgIcon icon="ri:robot-2-line" class="text-[22px]" />
        </div>
        <div class="min-w-0 flex-1">
          <div class="flex flex-wrap items-center gap-2">
            <h3 class="text-base font-semibold text-[var(--art-gray-900)]">{{ $t('ai.drawer.title') }}</h3>
            <ElTag v-if="streaming" type="success" effect="light" round>Streaming</ElTag>
          </div>
          <p class="mt-1 truncate text-xs text-[var(--art-gray-500)]">
            {{ runtime?.promptName || runtime?.promptCode || DEFAULT_PROMPT_CODE }}
          </p>
        </div>
        <div>
          <ElIcon class="c-p" :size="20" @click="closeChat">
            <Close />
          </ElIcon>
        </div>
        <ElButton plain :disabled="streaming" @click="startNewSession">
          <ArtSvgIcon icon="ri:add-line" class="mr-1 text-sm" />
          {{ $t('ai.drawer.newSession') }}
        </ElButton>
        <ArtIconButton icon="ri:close-line" @click="closeChat" />
      </div>
      <div class="flex h-[calc(100%-70px)] flex-col">
        <!-- 聊天消息区域 -->
        <div
          class="flex-1 overflow-y-auto border-t-d px-4 py-7.5 [&::-webkit-scrollbar]:!w-1"
          ref="messageContainer"
        >
          <template v-for="(message, index) in messages" :key="index">
            <div
              :class="[
                'mb-7.5 flex w-full items-start gap-2',
                message.isMe ? 'flex-row-reverse' : 'flex-row'
              ]"
      <div class="min-h-0 flex-1 bg-g-100/35 ai-chat-body">
        <aside class="box-border flex min-h-0 flex-col gap-4 p-4 ai-chat-sidebar">
          <div class="rounded-3xl bg-[var(--art-main-bg-color)] p-4 shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]">
            <div class="mb-3 flex items-center justify-between gap-3">
              <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ $t('ai.drawer.sessionList') }}</div>
              <ElTag effect="plain" round>{{ sessions.length }}</ElTag>
            </div>
            <ElInput
              v-model="sessionKeyword"
              clearable
              :placeholder="$t('ai.drawer.searchPlaceholder')"
            >
              <ElAvatar :size="32" :src="message.avatar" class="shrink-0" />
              <template #prefix>
                <ArtSvgIcon icon="ri:search-line" />
              </template>
            </ElInput>
          </div>
          <div class="min-h-0 flex-1 overflow-hidden rounded-3xl bg-[var(--art-main-bg-color)] shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]">
          <ElScrollbar class="h-full px-3 py-3">
            <div class="space-y-3">
              <ElEmpty v-if="!sessions.length" :description="$t('ai.drawer.noSessions')" :image-size="88" />
              <button
                v-for="item in sessions"
                :key="item.sessionId"
                type="button"
                class="w-full rounded-3xl border p-3 text-left transition-all"
                :class="
                  item.sessionId === sessionId
                    ? 'border-[var(--el-color-primary-light-7)] bg-[var(--el-color-primary-light-9)] shadow-[0_12px_30px_rgba(64,158,255,0.08)]'
                    : 'border-transparent bg-[var(--art-main-bg-color)] hover:border-[var(--el-color-primary-light-8)] hover:bg-g-100/50'
                "
                :disabled="streaming"
                @click="handleSwitchSession(item.sessionId)"
              >
                <div class="flex items-start gap-3">
                  <div
                    class="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-2xl"
                    :class="
                      item.sessionId === sessionId
                        ? 'bg-[var(--el-color-primary)]/12 text-[var(--el-color-primary)]'
                        : 'bg-g-100 text-[var(--art-gray-500)]'
                    "
                  >
                    <ArtSvgIcon :icon="item.pinned ? 'ri:pushpin-2-fill' : 'ri:chat-3-line'" class="text-base" />
                  </div>
                  <div class="min-w-0 flex-1">
                    <div class="flex items-start justify-between gap-2">
                      <div class="min-w-0">
                        <div class="truncate text-sm font-medium text-[var(--art-gray-900)]">
                          {{ item.title || $t('ai.drawer.sessionTitle', { id: item.sessionId }) }}
                        </div>
                        <div class="mt-1 truncate text-xs text-[var(--art-gray-500)]">
                          {{ item.lastMessageTime || $t('ai.drawer.sessionMetric', { id: item.sessionId }) }}
                        </div>
                      </div>
                      <div class="shrink-0" @click.stop>
                        <ArtButtonMore
                          :list="getSessionActions(item)"
                          @click="handleSessionAction(item, $event)"
                        />
                      </div>
                    </div>
                    <div class="mt-3 line-clamp-2 text-xs leading-5 text-[var(--art-gray-500)]">
                      {{ item.lastMessagePreview || $t('ai.drawer.emptyHint') }}
                    </div>
                  </div>
                </div>
              </button>
            </div>
          </ElScrollbar>
          </div>
        </aside>
        <section class="box-border flex min-h-0 flex-1 flex-col gap-4 p-4 pl-0">
          <div class="rounded-3xl bg-[var(--art-main-bg-color)] px-5 py-4 shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]">
            <div class="flex flex-wrap items-center justify-between gap-3">
              <div>
                <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ $t('ai.drawer.runtimeOverview') }}</div>
                <div class="mt-1 text-xs text-[var(--art-gray-500)]">
                  {{ $t('ai.drawer.modelSelectorHint') }}
                </div>
              </div>
              <ElButton text @click="runtimePanelExpanded = !runtimePanelExpanded">
                {{ $t(runtimePanelExpanded ? 'ai.drawer.runtimeCollapse' : 'ai.drawer.runtimeExpand') }}
              </ElButton>
            </div>
            <ElCollapseTransition>
              <div v-show="runtimePanelExpanded" class="mt-4 space-y-4">
                <div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
                  <div
                    v-for="item in runtimeMetricCards"
                    :key="item.label"
                    class="rounded-2xl bg-g-100/55 px-4 py-3 ring-1 ring-[var(--el-border-color-extra-light)]"
                  >
                    <div class="mb-2 flex items-center gap-2 text-xs text-[var(--art-gray-500)]">
                      <ArtSvgIcon :icon="item.icon" class="text-sm" />
                      <span>{{ item.label }}</span>
                    </div>
                    <div class="truncate text-sm font-semibold text-[var(--art-gray-900)]">
                      {{ item.value }}
                    </div>
                  </div>
                </div>
                <div class="flex flex-wrap items-center gap-3">
                  <ElSelect
                    v-if="selectableModelOptions.length"
                    v-model="selectedAiParamId"
                    :placeholder="$t('ai.drawer.modelSelectorLabel')"
                    :disabled="streaming || loadingRuntime || selectableModelOptions.length <= 1"
                    class="min-w-[280px]"
                    @change="handleModelChange"
                  >
                    <ElOption
                      v-for="item in selectableModelOptions"
                      :key="String(item.aiParamId)"
                      :label="formatModelOption(item)"
                      :value="item.aiParamId"
                    />
                  </ElSelect>
                  <div class="flex flex-wrap gap-2">
                    <ElButton
                      v-for="item in quickLinks"
                      :key="item.path"
                      plain
                      size="small"
                      @click="navigateTo(item.path)"
                    >
                      {{ item.label }}
                    </ElButton>
                    <ElButton plain size="small" :disabled="!sessionId || streaming" @click="handleRetainLatestRound">
                      {{ $t('ai.drawer.retainLatestRound') }}
                    </ElButton>
                    <ElButton plain size="small" :disabled="!sessionId || streaming" @click="handleClearMemory">
                      {{ $t('ai.drawer.clearMemory') }}
                    </ElButton>
                  </div>
                </div>
                <div class="flex flex-wrap gap-2">
                  <ElTag effect="plain" round>{{ $t('ai.drawer.requestMetric', { value: runtimeSummary.requestId }) }}</ElTag>
                  <ElTag effect="plain" round>{{ $t('ai.drawer.sessionMetric', { id: sessionId || '--' }) }}</ElTag>
                  <ElTag effect="plain" round>{{ $t('ai.drawer.recentMetric', { value: runtimeSummary.recentMessageCount }) }}</ElTag>
                  <ElTag :type="runtimeSummary.hasSummary ? 'success' : 'info'" effect="light" round>
                    {{ $t(runtimeSummary.hasSummary ? 'ai.drawer.hasSummary' : 'ai.drawer.noSummary') }}
                  </ElTag>
                  <ElTag :type="runtimeSummary.hasFacts ? 'success' : 'info'" effect="light" round>
                    {{ $t(runtimeSummary.hasFacts ? 'ai.drawer.hasFacts' : 'ai.drawer.noFacts') }}
                  </ElTag>
                </div>
                <ElAlert
                  v-if="runtime?.memorySummary"
                  type="info"
                  :closable="false"
                  :title="runtime.memorySummary"
                />
                <ElAlert
                  v-if="runtime?.memoryFacts"
                  type="success"
                  :closable="false"
                  :title="runtime.memoryFacts"
                />
              </div>
            </ElCollapseTransition>
          </div>
          <ElAlert
            v-if="drawerError"
            type="warning"
            :closable="false"
            :title="drawerError"
            class="rounded-2xl"
          />
          <div v-if="traceEvents.length" class="rounded-3xl bg-[var(--art-main-bg-color)] px-5 py-4 shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]">
            <div class="mb-3 flex items-center justify-between gap-3">
              <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ $t('ai.drawer.activityTrace') }}</div>
              <ElTag effect="plain" round>{{ traceEvents.length }}</ElTag>
            </div>
            <div class="space-y-3">
              <div
                :class="['flex max-w-[70%] flex-col', message.isMe ? 'items-end' : 'items-start']"
                v-for="item in traceEvents"
                :key="item.traceId"
                class="rounded-2xl bg-g-100/55 px-4 py-3 ring-1 ring-[var(--el-border-color-extra-light)]"
              >
                <div class="flex flex-wrap items-center justify-between gap-3">
                  <div class="min-w-0">
                    <div class="flex items-center gap-2">
                      <ElTag size="small" :type="item.traceType === 'thinking' ? 'primary' : 'success'" effect="light">
                        {{ item.traceType === 'thinking' ? $t('ai.drawer.traceTypeThinking') : $t('ai.drawer.traceTypeTool') }}
                      </ElTag>
                      <span class="text-sm font-medium text-[var(--art-gray-900)]">
                        {{ item.toolName || item.title || $t('ai.drawer.unknownTool') }}
                      </span>
                    </div>
                    <div class="mt-1 text-xs text-[var(--art-gray-500)]">
                      {{ item.traceType === 'thinking' ? getThinkingStatusLabel(item.status) : getToolStatusLabel(item.status) }}
                    </div>
                  </div>
                  <ElButton
                    v-if="item.traceType !== 'thinking'"
                    text
                    size="small"
                    @click="toggleTraceEventExpanded(item.traceId)"
                  >
                    {{ $t(expandedTraceIds.includes(item.traceId) ? 'ai.drawer.collapseDetail' : 'ai.drawer.viewDetail') }}
                  </ElButton>
                </div>
                <div
                  v-if="item.traceType === 'thinking'"
                  class="mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--art-gray-700)]"
                >
                  {{ item.content || $t('ai.drawer.thinkingEmpty') }}
                </div>
                <div
                  v-else-if="expandedTraceIds.includes(item.traceId)"
                  class="mt-3 space-y-2 text-sm leading-6 text-[var(--art-gray-700)]"
                >
                  <div v-if="item.title">{{ item.title }}</div>
                  <div v-if="item.inputSummary">{{ $t('ai.drawer.toolInput', { value: item.inputSummary }) }}</div>
                  <div v-if="item.outputSummary">{{ $t('ai.drawer.toolOutput', { value: item.outputSummary }) }}</div>
                  <div v-if="item.errorMessage" class="text-[var(--el-color-danger)]">
                    {{ $t('ai.drawer.toolError', { value: item.errorMessage }) }}
                  </div>
                </div>
              </div>
            </div>
          </div>
          <div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-3xl bg-[var(--art-main-bg-color)] shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]">
            <div class="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--el-border-color-extra-light)] px-5 py-4">
              <div>
                <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ currentSessionTitle }}</div>
                <div class="mt-1 text-xs text-[var(--art-gray-500)]">
                  {{ usageSummaryText }}
                </div>
              </div>
              <div class="flex flex-wrap gap-2">
                <ElTag v-if="usage?.totalTokens != null" effect="plain" round>
                  {{ $t('ai.drawer.tokenMetric', {
                    prompt: usage?.promptTokens ?? 0,
                    completion: usage?.completionTokens ?? 0,
                    total: usage?.totalTokens ?? 0
                  }) }}
                </ElTag>
              </div>
            </div>
          <ElScrollbar class="min-h-0 flex-1 bg-g-100/35 px-5 py-5">
            <div class="space-y-5">
              <div v-if="!messages.length" class="rounded-3xl border border-dashed border-[var(--el-border-color)] bg-slate-50/80 px-4 py-8 text-center text-sm text-[var(--art-gray-500)]">
                {{ $t('ai.drawer.emptyHint') }}
              </div>
              <div
                v-for="(message, index) in messages"
                :key="`${message.role}-${index}`"
                class="flex items-end gap-3"
                :class="message.role === 'user' ? 'justify-end' : 'justify-start'"
              >
                <div
                  :class="[
                    'mb-1 flex gap-2 text-xs',
                    message.isMe ? 'flex-row-reverse' : 'flex-row'
                  ]"
                  v-if="message.role !== 'user'"
                  class="flex size-9 shrink-0 items-center justify-center rounded-2xl bg-[var(--el-color-primary-light-9)] text-[var(--el-color-primary)]"
                >
                  <span class="font-medium">{{ message.sender }}</span>
                  <span class="text-g-600">{{ message.time }}</span>
                  <ArtSvgIcon icon="ri:robot-2-line" class="text-base" />
                </div>
                <div
                  :class="[
                    'rounded-md px-3.5 py-2.5 text-sm leading-[1.4] text-g-900',
                    message.isMe ? 'message-right bg-theme/15' : 'message-left bg-g-300/50'
                  ]"
                  >{{ message.content }}</div
                >
              </div>
            </div>
          </template>
        </div>
        <!-- 聊天输入区域 -->
        <div class="px-4 pt-4">
          <ElInput
            v-model="messageText"
            type="textarea"
            :rows="3"
            placeholder="输入消息"
            resize="none"
            @keyup.enter.prevent="sendMessage"
          >
            <template #append>
              <div class="flex gap-2 py-2">
                <ElButton :icon="Paperclip" circle plain />
                <ElButton :icon="Picture" circle plain />
                <ElButton type="primary" @click="sendMessage" v-ripple>发送</ElButton>
                <div class="max-w-[82%]">
                  <div
                    class="rounded-3xl px-4 py-3 text-sm leading-6 shadow-[0_10px_30px_rgba(15,23,42,0.04)]"
                    :class="
                      message.role === 'user'
                        ? 'rounded-br-xl bg-[var(--el-color-primary)] text-white'
                        : 'rounded-bl-xl bg-[var(--art-main-bg-color)] text-[var(--art-gray-900)] ring-1 ring-[var(--el-border-color-extra-light)]'
                    "
                  >
                    <div class="whitespace-pre-wrap break-words">
                      {{
                        message.role === 'assistant'
                          ? message.content || (streaming && index === messages.length - 1 ? $t('ai.drawer.thinking') : '')
                          : message.content || ''
                      }}
                    </div>
                  </div>
                </div>
                <div
                  v-if="message.role === 'user'"
                  class="flex size-9 shrink-0 items-center justify-center rounded-2xl bg-[var(--el-color-primary)] text-white"
                >
                  <ArtSvgIcon icon="ri:user-3-line" class="text-base" />
                </div>
              </div>
            </template>
          </ElInput>
          <div class="mt-3 flex-cb">
            <div class="flex-c">
              <ArtSvgIcon icon="ri:image-line" class="mr-5 c-p text-g-600 text-lg" />
              <ArtSvgIcon icon="ri:emotion-happy-line" class="mr-5 c-p text-g-600 text-lg" />
              <div ref="messagesBottomRef"></div>
            </div>
            <ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20">发送</ElButton>
          </ElScrollbar>
          <div class="border-t border-[var(--el-border-color-extra-light)] bg-[var(--art-main-bg-color)] px-5 py-4">
            <div class="rounded-3xl bg-g-100/45 p-4 ring-1 ring-[var(--el-border-color-lighter)]">
              <ElInput
                v-model="input"
                type="textarea"
                :autosize="{ minRows: 3, maxRows: 6 }"
                resize="none"
                :placeholder="$t('ai.drawer.inputPlaceholder')"
                @keydown="handleInputKeydown"
              />
              <div class="mt-3 flex flex-wrap items-center justify-between gap-3">
                <div class="text-xs text-[var(--art-gray-500)]">
                  Enter 发送,Shift + Enter 换行
                </div>
                <div class="flex flex-wrap items-center gap-2">
                  <ElButton text @click="input = ''">{{ $t('ai.drawer.clearInput') }}</ElButton>
                  <ElButton
                    v-if="streaming"
                    type="warning"
                    plain
                    @click="stopStream(true)"
                  >
                    {{ $t('ai.drawer.stop') }}
                  </ElButton>
                  <ElButton
                    v-else
                    type="primary"
                    @click="handleSend"
                  >
                    <ArtSvgIcon icon="ri:send-plane-2-line" class="mr-1 text-sm" />
                    {{ $t('ai.drawer.send') }}
                  </ElButton>
                </div>
              </div>
            </div>
          </div>
        </div>
          </div>
        </section>
      </div>
    </ElDrawer>
  </div>
    </div>
    <ElDialog
      v-model="renameDialog.open"
      :title="$t('ai.drawer.renameDialogTitle')"
      width="420px"
      append-to-body
    >
      <ElInput
        v-model="renameDialog.title"
        :placeholder="$t('ai.drawer.sessionTitleField')"
      />
      <template #footer>
        <ElButton @click="closeRenameDialog">{{ $t('common.cancel') }}</ElButton>
        <ElButton
          type="primary"
          :disabled="streaming || !renameDialog.title.trim()"
          @click="handleRenameSubmit"
        >
          {{ $t('common.confirm') }}
        </ElButton>
      </template>
    </ElDialog>
  </ElDrawer>
</template>
<script setup>
  import { Picture, Paperclip, Close } from '@element-plus/icons-vue'
  import { ElMessage } from 'element-plus'
  import { useRoute, useRouter } from 'vue-router'
  import { useWindowSize } from '@vueuse/core'
  import { useI18n } from 'vue-i18n'
  import { mittBus } from '@/utils/sys'
  import meAvatar from '@/assets/images/avatar/avatar5.webp'
  import aiAvatar from '@/assets/images/avatar/avatar10.webp'
  import {
    DEFAULT_PROMPT_CODE,
    fetchClearAiSessionMemory,
    fetchGetAiRuntime,
    fetchGetAiSessions,
    fetchPinAiSession,
    fetchRemoveAiSession,
    fetchRenameAiSession,
    fetchRetainAiSessionLatestRound,
    fetchStreamAiChat
  } from '@/api/ai-chat'
  defineOptions({ name: 'ArtChatWindow' })
  const MOBILE_BREAKPOINT = 640
  const SCROLL_DELAY = 100
  const BOT_NAME = 'Art Bot'
  const USER_NAME = 'Ricky'
  const MOBILE_BREAKPOINT = 768
  const SESSION_SEARCH_DELAY = 250
  const router = useRouter()
  const route = useRoute()
  const { width } = useWindowSize()
  const { t } = useI18n()
  const isMobile = computed(() => width.value < MOBILE_BREAKPOINT)
  const isDrawerVisible = ref(false)
  const isOnline = ref(true)
  const messageText = ref('')
  const messageId = ref(10)
  const messageContainer = ref(null)
  const initializeMessages = () => [
    {
      id: 1,
      sender: BOT_NAME,
      content: '你好!我是你的AI助手,有什么我可以帮你的吗?',
      time: '10:00',
      isMe: false,
      avatar: aiAvatar
    },
    {
      id: 2,
      sender: USER_NAME,
      content: '我想了解一下系统的使用方法。',
      time: '10:01',
      isMe: true,
      avatar: meAvatar
    },
    {
      id: 3,
      sender: BOT_NAME,
      content: '好的,我来为您介绍系统的主要功能。首先,您可以通过左侧菜单访问不同的功能模块...',
      time: '10:02',
      isMe: false,
      avatar: aiAvatar
    },
    {
      id: 4,
      sender: USER_NAME,
      content: '听起来很不错,能具体讲讲数据分析部分吗?',
      time: '10:05',
      isMe: true,
      avatar: meAvatar
    },
    {
      id: 5,
      sender: BOT_NAME,
      content: '当然可以。数据分析模块可以帮助您实时监控关键指标,并生成详细的报表...',
      time: '10:06',
      isMe: false,
      avatar: aiAvatar
    },
    {
      id: 6,
      sender: USER_NAME,
      content: '太好了,那我如何开始使用呢?',
      time: '10:08',
      isMe: true,
      avatar: meAvatar
    },
    {
      id: 7,
      sender: BOT_NAME,
      content: '您可以先创建一个项目,然后在项目中添加相关的数据源,系统会自动进行分析。',
      time: '10:09',
      isMe: false,
      avatar: aiAvatar
    },
    {
      id: 8,
      sender: USER_NAME,
      content: '明白了,谢谢你的帮助!',
      time: '10:10',
      isMe: true,
      avatar: meAvatar
    },
    {
      id: 9,
      sender: BOT_NAME,
      content: '不客气,有任何问题随时联系我。',
      time: '10:11',
      isMe: false,
      avatar: aiAvatar
  const runtime = ref(null)
  const selectedAiParamId = ref(null)
  const sessionId = ref(null)
  const sessions = ref([])
  const persistedMessages = ref([])
  const messages = ref([])
  const traceEvents = ref([])
  const expandedTraceIds = ref([])
  const input = ref('')
  const usage = ref(null)
  const drawerError = ref('')
  const sessionKeyword = ref('')
  const loadingRuntime = ref(false)
  const streaming = ref(false)
  const runtimePanelExpanded = ref(false)
  const messagesBottomRef = ref(null)
  const renameDialog = reactive({
    open: false,
    sessionId: null,
    title: ''
  })
  const abortController = ref(null)
  let sessionSearchTimer = null
  const quickLinks = computed(() => [
    { label: t('menu.aiParam'), path: '/system/ai-param' },
    { label: t('menu.aiPrompt'), path: '/system/ai-prompt' },
    { label: t('menu.aiMcpMount'), path: '/system/ai-mcp-mount' }
  ])
  const currentSessionRecord = computed(() =>
    sessions.value.find((item) => item.sessionId === sessionId.value) || null
  )
  const currentSessionTitle = computed(() => {
    return (
      currentSessionRecord.value?.title ||
      (sessionId.value ? t('ai.drawer.sessionTitle', { id: sessionId.value }) : t('ai.drawer.newSession'))
    )
  })
  const runtimeSummary = computed(() => ({
    requestId: runtime.value?.requestId || '--',
    promptName: runtime.value?.promptName || '--',
    model: runtime.value?.model || '--',
    mountedMcpCount: runtime.value?.mountedMcpCount ?? 0,
    recentMessageCount: runtime.value?.recentMessageCount ?? 0,
    hasSummary: !!runtime.value?.memorySummary,
    hasFacts: !!runtime.value?.memoryFacts
  }))
  const usageSummaryText = computed(() => {
    if (loadingRuntime.value) {
      return t('ai.drawer.loadingRuntime')
    }
  ]
  const messages = ref(initializeMessages())
  const formatCurrentTime = () => {
    return /* @__PURE__ */ new Date().toLocaleTimeString([], {
      hour: '2-digit',
      minute: '2-digit'
    })
  }
  const scrollToBottom = () => {
    nextTick(() => {
      setTimeout(() => {
        if (messageContainer.value) {
          messageContainer.value.scrollTop = messageContainer.value.scrollHeight
    if (usage.value?.elapsedMs != null) {
      const firstTokenText =
        usage.value?.firstTokenLatencyMs != null
          ? ` / ${t('ai.drawer.firstTokenMetric', { value: usage.value.firstTokenLatencyMs })}`
          : ''
      return `${t('ai.drawer.elapsedMetric', { value: usage.value.elapsedMs })}${firstTokenText}`
    }
    return t('ai.drawer.modelSelectorHint')
  })
  const runtimeMetricCards = computed(() => [
    {
      label: t('ai.drawer.modelLabel'),
      value: runtimeSummary.value.model,
      icon: 'ri:cpu-line'
    },
    {
      label: t('ai.drawer.promptLabel'),
      value: runtimeSummary.value.promptName,
      icon: 'ri:magic-line'
    },
    {
      label: t('ai.drawer.mcpLabel'),
      value: String(runtimeSummary.value.mountedMcpCount),
      icon: 'ri:plug-2-line'
    },
    {
      label: t('ai.drawer.historyLabel'),
      value: String(persistedMessages.value.length),
      icon: 'ri:chat-history-line'
    }
  ])
  const selectableModelOptions = computed(() => {
    if (Array.isArray(runtime.value?.modelOptions) && runtime.value.modelOptions.length) {
      return runtime.value.modelOptions
    }
    if (runtime.value?.model) {
      return [
        {
          aiParamId: runtime.value.aiParamId ?? 'CURRENT_MODEL',
          name: runtime.value.model,
          model: runtime.value.model,
          active: true
        }
      }, SCROLL_DELAY)
    })
  }
  const sendMessage = () => {
    const text = messageText.value.trim()
    if (!text) return
    const newMessage = {
      id: messageId.value++,
      sender: USER_NAME,
      content: text,
      time: formatCurrentTime(),
      isMe: true,
      avatar: meAvatar
      ]
    }
    messages.value.push(newMessage)
    messageText.value = ''
    scrollToBottom()
  }
  const openChat = () => {
    isDrawerVisible.value = true
    scrollToBottom()
  }
  const closeChat = () => {
    isDrawerVisible.value = false
  }
    return []
  })
  watch(isDrawerVisible, async (visible) => {
    if (visible) {
      runtimePanelExpanded.value = false
      await initializeDrawer()
      scrollMessagesToBottom()
      return
    }
    stopStream(false)
  })
  watch(
    () => sessionKeyword.value,
    () => {
      if (!isDrawerVisible.value) {
        return
      }
      if (sessionSearchTimer) {
        window.clearTimeout(sessionSearchTimer)
      }
      sessionSearchTimer = window.setTimeout(() => {
        void loadSessions()
      }, SESSION_SEARCH_DELAY)
    }
  )
  watch(
    () => [messages.value.length, streaming.value, isDrawerVisible.value],
    async ([, , visible]) => {
      if (!visible) {
        return
      }
      await nextTick()
      scrollMessagesToBottom()
    }
  )
  onMounted(() => {
    scrollToBottom()
    mittBus.on('openChat', openChat)
  })
  onUnmounted(() => {
    mittBus.off('openChat', openChat)
    stopStream(false)
    if (sessionSearchTimer) {
      window.clearTimeout(sessionSearchTimer)
      sessionSearchTimer = null
    }
  })
  function getSessionActions(item) {
    return [
      {
        key: item?.pinned ? 'unpin' : 'pin',
        label: t(item?.pinned ? 'ai.drawer.unpinAction' : 'ai.drawer.pinAction'),
        icon: item?.pinned ? 'ri:pushpin-line' : 'ri:pushpin-2-line',
        disabled: streaming.value
      },
      {
        key: 'rename',
        label: t('ai.drawer.renameAction'),
        icon: 'ri:edit-line',
        disabled: streaming.value
      },
      {
        key: 'delete',
        label: t('ai.drawer.deleteAction'),
        icon: 'ri:delete-bin-line',
        color: 'var(--el-color-danger)',
        disabled: streaming.value
      }
    ]
  }
  function openChat() {
    isDrawerVisible.value = true
  }
  function closeChat() {
    isDrawerVisible.value = false
  }
  async function initializeDrawer(targetSessionId = null) {
    traceEvents.value = []
    expandedTraceIds.value = []
    usage.value = null
    await Promise.all([loadRuntime(targetSessionId), loadSessions()])
  }
  async function loadRuntime(targetSessionId = null, targetAiParamId = selectedAiParamId.value) {
    loadingRuntime.value = true
    drawerError.value = ''
    try {
      const data = await fetchGetAiRuntime(DEFAULT_PROMPT_CODE, targetSessionId, targetAiParamId)
      const historyMessages = normalizeMessageList(data?.persistedMessages)
      runtime.value = data
      selectedAiParamId.value = data?.aiParamId ?? targetAiParamId ?? null
      sessionId.value = data?.sessionId || null
      persistedMessages.value = historyMessages
      messages.value = historyMessages
      return data
    } catch (error) {
      drawerError.value = error?.message || t('ai.drawer.runtimeFailed')
      return null
    } finally {
      loadingRuntime.value = false
    }
  }
  async function loadSessions() {
    try {
      sessions.value = await fetchGetAiSessions(DEFAULT_PROMPT_CODE, sessionKeyword.value.trim())
    } catch (error) {
      drawerError.value = error?.message || t('ai.drawer.sessionListFailed')
      sessions.value = []
    }
  }
  async function startNewSession() {
    if (streaming.value) {
      return
    }
    sessionId.value = null
    persistedMessages.value = []
    messages.value = []
    traceEvents.value = []
    expandedTraceIds.value = []
    usage.value = null
    drawerError.value = ''
    await loadRuntime(null, selectedAiParamId.value)
    await loadSessions()
  }
  async function handleSwitchSession(targetSessionId) {
    if (streaming.value || targetSessionId === sessionId.value) {
      return
    }
    usage.value = null
    traceEvents.value = []
    expandedTraceIds.value = []
    await loadRuntime(targetSessionId, selectedAiParamId.value)
  }
  async function handleModelChange(nextAiParamId) {
    if (streaming.value) {
      return
    }
    const previousAiParamId = selectedAiParamId.value
    selectedAiParamId.value = nextAiParamId
    const data = await loadRuntime(sessionId.value, nextAiParamId)
    if (!data) {
      selectedAiParamId.value = previousAiParamId
      ElMessage.error(t('ai.drawer.modelSwitchFailed'))
    }
  }
  async function handleDeleteSession(targetSessionId) {
    if (streaming.value || !targetSessionId) {
      return
    }
    try {
      await fetchRemoveAiSession(targetSessionId)
      ElMessage.success(t('ai.drawer.sessionDeleted'))
      if (targetSessionId === sessionId.value) {
        await startNewSession()
        return
      }
      await loadSessions()
    } catch (error) {
      drawerError.value = error?.message || t('ai.drawer.deleteSessionFailed')
      ElMessage.error(drawerError.value)
    }
  }
  async function handlePinSession(targetSessionId, pinned) {
    if (streaming.value || !targetSessionId) {
      return
    }
    try {
      await fetchPinAiSession(targetSessionId, pinned)
      ElMessage.success(t(pinned ? 'ai.drawer.pinned' : 'ai.drawer.unpinned'))
      await loadSessions()
    } catch (error) {
      drawerError.value = error?.message || t('ai.drawer.pinFailed')
      ElMessage.error(drawerError.value)
    }
  }
  function handleSessionAction(item, action) {
    if (!item?.sessionId || !action?.key) {
      return
    }
    if (action.key === 'pin') {
      void handlePinSession(item.sessionId, true)
      return
    }
    if (action.key === 'unpin') {
      void handlePinSession(item.sessionId, false)
      return
    }
    if (action.key === 'rename') {
      openRenameDialog(item)
      return
    }
    if (action.key === 'delete') {
      void handleDeleteSession(item.sessionId)
    }
  }
  function openRenameDialog(item) {
    renameDialog.open = true
    renameDialog.sessionId = item?.sessionId || null
    renameDialog.title = item?.title || ''
  }
  function closeRenameDialog() {
    renameDialog.open = false
    renameDialog.sessionId = null
    renameDialog.title = ''
  }
  async function handleRenameSubmit() {
    if (streaming.value || !renameDialog.sessionId) {
      return
    }
    try {
      await fetchRenameAiSession(renameDialog.sessionId, renameDialog.title.trim())
      ElMessage.success(t('ai.drawer.renamed'))
      closeRenameDialog()
      await loadSessions()
    } catch (error) {
      drawerError.value = error?.message || t('ai.drawer.renameFailed')
      ElMessage.error(drawerError.value)
    }
  }
  async function handleClearMemory() {
    if (streaming.value || !sessionId.value) {
      return
    }
    try {
      await fetchClearAiSessionMemory(sessionId.value)
      ElMessage.success(t('ai.drawer.memoryCleared'))
      await Promise.all([loadRuntime(sessionId.value, selectedAiParamId.value), loadSessions()])
    } catch (error) {
      drawerError.value = error?.message || t('ai.drawer.clearMemoryFailed')
      ElMessage.error(drawerError.value)
    }
  }
  async function handleRetainLatestRound() {
    if (streaming.value || !sessionId.value) {
      return
    }
    try {
      await fetchRetainAiSessionLatestRound(sessionId.value)
      ElMessage.success(t('ai.drawer.retainLatestRoundSuccess'))
      await Promise.all([loadRuntime(sessionId.value, selectedAiParamId.value), loadSessions()])
    } catch (error) {
      drawerError.value = error?.message || t('ai.drawer.retainLatestRoundFailed')
      ElMessage.error(drawerError.value)
    }
  }
  function stopStream(showTip = true) {
    if (!abortController.value) {
      return
    }
    abortController.value.abort()
    abortController.value = null
    streaming.value = false
    if (showTip) {
      ElMessage.success(t('ai.drawer.stopSuccess'))
    }
  }
  function scrollMessagesToBottom() {
    if (!messagesBottomRef.value) {
      return
    }
    messagesBottomRef.value.scrollIntoView({ behavior: 'smooth', block: 'end' })
  }
  function appendAssistantDelta(delta) {
    messages.value = (() => {
      const next = [...messages.value]
      const last = next[next.length - 1]
      if (last && last.role === 'assistant') {
        next[next.length - 1] = {
          ...last,
          content: `${last.content || ''}${delta}`
        }
        return next
      }
      next.push({ role: 'assistant', content: delta })
      return next
    })()
  }
  function ensureAssistantPlaceholder(seedMessages) {
    const next = [...seedMessages]
    const last = next[next.length - 1]
    if (!last || last.role !== 'assistant') {
      next.push({ role: 'assistant', content: '' })
    }
    return next
  }
  function appendTraceEvent(payload) {
    if (!payload?.traceId) {
      return
    }
    const index = traceEvents.value.findIndex((item) => item.traceId === payload.traceId)
    if (index < 0) {
      traceEvents.value = [...traceEvents.value, payload].sort(
        (left, right) => (left?.sequence ?? 0) - (right?.sequence ?? 0)
      )
      return
    }
    const next = [...traceEvents.value]
    next[index] = { ...next[index], ...payload }
    traceEvents.value = next
  }
  function toggleTraceEventExpanded(traceId) {
    if (!traceId) {
      return
    }
    expandedTraceIds.value = expandedTraceIds.value.includes(traceId)
      ? expandedTraceIds.value.filter((item) => item !== traceId)
      : [...expandedTraceIds.value, traceId]
  }
  function getThinkingStatusLabel(status) {
    if (status === 'COMPLETED') return t('ai.drawer.thinkingStatusCompleted')
    if (status === 'FAILED') return t('ai.drawer.thinkingStatusFailed')
    if (status === 'ABORTED') return t('ai.drawer.thinkingStatusAborted')
    if (status === 'UPDATED') return t('ai.drawer.thinkingStatusUpdated')
    return t('ai.drawer.thinkingStatusStarted')
  }
  function getToolStatusLabel(status) {
    if (status === 'FAILED') return t('ai.drawer.toolStatusFailed')
    if (status === 'COMPLETED') return t('ai.drawer.toolStatusCompleted')
    return t('ai.drawer.toolStatusRunning')
  }
  async function handleSend() {
    const content = input.value.trim()
    if (!content || streaming.value) {
      return
    }
    const nextMessages = [...messages.value, { role: 'user', content }]
    input.value = ''
    usage.value = null
    drawerError.value = ''
    traceEvents.value = []
    expandedTraceIds.value = []
    messages.value = ensureAssistantPlaceholder(nextMessages)
    streaming.value = true
    const controller = new AbortController()
    abortController.value = controller
    let completed = false
    let completedSessionId = sessionId.value
    let completedAiParamId = selectedAiParamId.value
    try {
      await fetchStreamAiChat(
        {
          sessionId: sessionId.value,
          aiParamId: selectedAiParamId.value,
          promptCode: runtime.value?.promptCode || DEFAULT_PROMPT_CODE,
          messages: [{ role: 'user', content }],
          metadata: {
            path: route.fullPath
          }
        },
        {
          signal: controller.signal,
          onEvent: (eventName, payload) => {
            if (eventName === 'start') {
              runtime.value = payload
              selectedAiParamId.value = payload?.aiParamId ?? selectedAiParamId.value
              if (payload?.sessionId) {
                sessionId.value = payload.sessionId
                completedSessionId = payload.sessionId
              }
              completedAiParamId = payload?.aiParamId ?? completedAiParamId
            }
            if (eventName === 'delta') {
              appendAssistantDelta(payload?.content || '')
            }
            if (eventName === 'trace') {
              appendTraceEvent(payload)
            }
            if (eventName === 'done') {
              usage.value = payload
              completed = true
              if (payload?.sessionId) {
                completedSessionId = payload.sessionId
              }
            }
            if (eventName === 'error') {
              const message = payload?.message || t('ai.drawer.chatFailed')
              drawerError.value = payload?.requestId ? `${message} [${payload.requestId}]` : message
              ElMessage.error(drawerError.value)
            }
          }
        }
      )
    } catch (error) {
      if (error?.name !== 'AbortError') {
        drawerError.value = error?.message || t('ai.drawer.chatFailed')
        ElMessage.error(drawerError.value)
      }
    } finally {
      abortController.value = null
      streaming.value = false
      if (completed) {
        await Promise.all([
          loadRuntime(completedSessionId, completedAiParamId),
          loadSessions()
        ])
      }
    }
  }
  function handleInputKeydown(event) {
    if (event.key === 'Enter' && !event.shiftKey) {
      event.preventDefault()
      void handleSend()
    }
  }
  function navigateTo(path) {
    closeChat()
    void router.push(path)
  }
  function normalizeMessageList(list) {
    if (!Array.isArray(list)) {
      return []
    }
    return list
      .filter((item) => item && ['user', 'assistant'].includes(item.role))
      .map((item) => ({
        role: item.role,
        content: item.content || ''
      }))
  }
  function formatModelOption(item) {
    const label = item.name || item.model || '--'
    const suffix =
      item.model && item.name && item.name !== item.model ? ` / ${item.model}` : ''
    const defaultMark = item.active ? ` ${t('ai.drawer.defaultModelSuffix')}` : ''
    return `${label}${suffix}${defaultMark}`
  }
</script>
<style scoped>
  .ai-chat-body {
    display: flex;
  }
  .ai-chat-sidebar {
    width: 320px;
  }
  :deep(.ai-chat-drawer .el-drawer__body) {
    padding: 0;
  }
  :deep(.ai-chat-drawer .el-drawer) {
    overflow: hidden;
  }
  :deep(.ai-chat-drawer .el-input__wrapper),
  :deep(.ai-chat-drawer .el-textarea__inner),
  :deep(.ai-chat-drawer .el-select__wrapper) {
    box-shadow: none !important;
    border: 1px solid var(--el-border-color-lighter);
    border-radius: 16px;
  }
  :deep(.ai-chat-drawer .el-input__wrapper:hover),
  :deep(.ai-chat-drawer .el-textarea__inner:hover),
  :deep(.ai-chat-drawer .el-select__wrapper:hover) {
    border-color: var(--el-color-primary-light-8);
  }
  :deep(.ai-chat-drawer .el-input__wrapper.is-focus),
  :deep(.ai-chat-drawer .el-select__wrapper.is-focused) {
    border-color: var(--el-color-primary-light-7);
    box-shadow: 0 0 0 3px var(--el-color-primary-light-9) !important;
  }
  :deep(.ai-chat-drawer .el-textarea__inner),
  :deep(.ai-chat-drawer .el-select__wrapper),
  :deep(.ai-chat-drawer .el-input__wrapper) {
    background: var(--art-main-bg-color);
  }
  @media screen and (max-width: 768px) {
    .ai-chat-body {
      flex-direction: column;
    }
    .ai-chat-sidebar {
      width: 100%;
      max-height: 280px;
      border-right: 0;
      border-bottom: 1px solid var(--art-border-color);
    }
  }
</style>
rsf-design/src/locales/langs/en.json
@@ -389,6 +389,91 @@
    "taskPathTemplateMerge": "TaskPathTemplateMerge",
    "missionFlowStepInstance": "Mission Flow Steps"
  },
  "ai": {
    "drawer": {
      "title": "WMS Assistant",
      "runtimeFailed": "Failed to load AI runtime",
      "sessionListFailed": "Failed to load AI sessions",
      "sessionDeleted": "Session deleted",
      "deleteSessionFailed": "Failed to delete AI session",
      "pinned": "Session pinned",
      "unpinned": "Session unpinned",
      "pinFailed": "Failed to update session pin state",
      "renamed": "Session renamed",
      "renameFailed": "Failed to rename session",
      "memoryCleared": "Session memory cleared",
      "clearMemoryFailed": "Failed to clear session memory",
      "retainLatestRoundSuccess": "Only the latest round was kept",
      "retainLatestRoundFailed": "Failed to keep only the latest round",
      "stopSuccess": "Current output stopped",
      "chatFailed": "AI chat failed",
      "newSession": "New Session",
      "sessionList": "Sessions",
      "searchPlaceholder": "Search session titles",
      "noSessions": "No history sessions",
      "sessionTitle": "Session %{id}",
      "pinAction": "Pin session",
      "unpinAction": "Unpin session",
      "renameAction": "Rename session",
      "deleteAction": "Delete session",
      "activityTrace": "Thinking & Tool Trace",
      "thinkingEmpty": "Organizing the current stage information...",
      "thinkingStatusStarted": "Started",
      "thinkingStatusUpdated": "In Progress",
      "thinkingStatusCompleted": "Completed",
      "thinkingStatusFailed": "Failed",
      "thinkingStatusAborted": "Aborted",
      "unknownTool": "Unknown tool",
      "traceTypeThinking": "Thinking",
      "traceTypeTool": "Tool",
      "toolStatusFailed": "Failed",
      "toolStatusCompleted": "Completed",
      "toolStatusRunning": "Running",
      "collapseDetail": "Hide Details",
      "viewDetail": "View Details",
      "toolInput": "Input: %{value}",
      "toolOutput": "Output summary: %{value}",
      "toolError": "Error: %{value}",
      "hasSummary": "Summary",
      "noSummary": "No Summary",
      "hasFacts": "Facts",
      "noFacts": "No Facts",
      "retainLatestRound": "Keep Latest Round",
      "clearMemory": "Clear Memory",
      "runtimeOverview": "Runtime Overview",
      "runtimeExpand": "Show Overview",
      "runtimeCollapse": "Hide Overview",
      "loadingRuntime": "Loading AI runtime info...",
      "emptyHint": "AI responses stream back through SSE here. You can also maintain parameters, prompts, and MCP mounts from the quick links above.",
      "userRole": "You",
      "assistantRole": "AI",
      "thinking": "Thinking...",
      "inputPlaceholder": "Type your question. Press Enter to send, Shift + Enter for a new line",
      "clearInput": "Clear Input",
      "stop": "Stop",
      "send": "Send",
      "renameDialogTitle": "Rename Session",
      "sessionTitleField": "Session Title",
        "requestMetric": "Req: %{value}",
        "sessionMetric": "Session: %{id}",
        "promptMetric": "Prompt: %{value}",
        "modelMetric": "Model: %{value}",
        "promptLabel": "Prompt",
        "modelLabel": "Model",
        "modelSelectorLabel": "Chat Model",
        "modelSelectorHint": "Switching only affects subsequent replies in this session and does not change the global default model.",
        "modelSwitchFailed": "Failed to switch the chat model",
        "defaultModelSuffix": "(Default)",
        "mcpMetric": "MCP: %{value}",
        "historyMetric": "History: %{value}",
        "mcpLabel": "MCP",
        "historyLabel": "History",
        "recentMetric": "Recent: %{value}",
      "elapsedMetric": "Elapsed: %{value} ms",
      "firstTokenMetric": "First token: %{value} ms",
      "tokenMetric": "Tokens: prompt %{prompt} / completion %{completion} / total %{total}"
    }
  },
  "table": {
    "form": {
      "reset": "Reset",
rsf-design/src/locales/langs/zh.json
@@ -391,6 +391,91 @@
    "taskPathTemplateMerge": "任务路径模板合并",
    "missionFlowStepInstance": "任务流程步骤"
  },
  "ai": {
    "drawer": {
      "title": "WMS 助手",
      "runtimeFailed": "获取 AI 运行时失败",
      "sessionListFailed": "获取 AI 会话列表失败",
      "sessionDeleted": "会话已删除",
      "deleteSessionFailed": "删除 AI 会话失败",
      "pinned": "会话已置顶",
      "unpinned": "会话已取消置顶",
      "pinFailed": "更新会话置顶状态失败",
      "renamed": "会话已重命名",
      "renameFailed": "重命名会话失败",
      "memoryCleared": "会话记忆已清空",
      "clearMemoryFailed": "清空会话记忆失败",
      "retainLatestRoundSuccess": "已仅保留当前轮记忆",
      "retainLatestRoundFailed": "保留当前轮记忆失败",
      "stopSuccess": "已停止当前对话输出",
      "chatFailed": "AI 对话失败",
      "newSession": "新建会话",
      "sessionList": "会话列表",
      "searchPlaceholder": "搜索会话标题",
      "noSessions": "暂无历史会话",
      "sessionTitle": "会话 %{id}",
      "pinAction": "置顶会话",
      "unpinAction": "取消置顶",
      "renameAction": "重命名会话",
      "deleteAction": "删除会话",
      "activityTrace": "思维链与工具轨迹",
      "thinkingEmpty": "正在整理当前阶段信息...",
      "thinkingStatusStarted": "已开始",
      "thinkingStatusUpdated": "进行中",
      "thinkingStatusCompleted": "已完成",
      "thinkingStatusFailed": "失败",
      "thinkingStatusAborted": "已中止",
      "unknownTool": "未知工具",
      "traceTypeThinking": "思维链",
      "traceTypeTool": "工具",
      "toolStatusFailed": "失败",
      "toolStatusCompleted": "完成",
      "toolStatusRunning": "执行中",
      "collapseDetail": "收起详情",
      "viewDetail": "查看详情",
      "toolInput": "入参: %{value}",
      "toolOutput": "结果摘要: %{value}",
      "toolError": "错误: %{value}",
      "hasSummary": "有摘要",
      "noSummary": "无摘要",
      "hasFacts": "有事实",
      "noFacts": "无事实",
      "retainLatestRound": "仅保留当前轮",
      "clearMemory": "清空记忆",
      "runtimeOverview": "运行概览",
      "runtimeExpand": "展开概览",
      "runtimeCollapse": "收起概览",
      "loadingRuntime": "正在加载 AI 运行时信息...",
      "emptyHint": "这里会通过 SSE 流式返回 AI 回复。你也可以先去上面的快捷入口维护参数、Prompt 和 MCP 挂载。",
      "userRole": "你",
      "assistantRole": "AI",
      "thinking": "思考中...",
      "inputPlaceholder": "输入你的问题,按 Enter 发送,Shift + Enter 换行",
      "clearInput": "清空输入",
      "stop": "停止",
      "send": "发送",
      "renameDialogTitle": "重命名会话",
      "sessionTitleField": "会话标题",
        "requestMetric": "Req: %{value}",
        "sessionMetric": "Session: %{id}",
        "promptMetric": "Prompt: %{value}",
        "modelMetric": "Model: %{value}",
        "promptLabel": "Prompt",
        "modelLabel": "Model",
        "modelSelectorLabel": "对话模型",
        "modelSelectorHint": "切换后仅影响当前会话后续回复,不会改动全局默认模型。",
        "modelSwitchFailed": "切换对话模型失败",
        "defaultModelSuffix": "(默认)",
        "mcpMetric": "MCP: %{value}",
        "historyMetric": "History: %{value}",
        "mcpLabel": "MCP",
        "historyLabel": "History",
        "recentMetric": "Recent: %{value}",
      "elapsedMetric": "耗时: %{value} ms",
      "firstTokenMetric": "首包: %{value} ms",
      "tokenMetric": "Tokens: prompt %{prompt} / completion %{completion} / total %{total}"
    }
  },
  "table": {
    "form": {
      "reset": "重置",
rsf-design/src/plugins/iconify.collections.js
@@ -112,6 +112,9 @@
      '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"/>'
      },
      'chat-1-line': {
        body: '<path fill="currentColor" d="M10 3h4a8 8 0 1 1 0 16v3.5c-5-2-12-5-12-11.5a8 8 0 0 1 8-8m2 14h2a6 6 0 0 0 0-12h-4a6 6 0 0 0-6 6c0 3.61 2.462 5.966 8 8.48z"/>'
      },
      '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"/>'
      },
@@ -163,9 +166,6 @@
      '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"/>'
      },
      'emotion-happy-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-5-7h2a3 3 0 1 0 6 0h2a5 5 0 0 1-10 0m1-2a1.5 1.5 0 1 1 0-3a1.5 1.5 0 0 1 0 3m8 0a1.5 1.5 0 1 1 0-3a1.5 1.5 0 0 1 0 3"/>'
      },
      '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"/>'
      },
@@ -210,9 +210,6 @@
      },
      '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"/>'
      },
      '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"/>'
      },
      '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"/>'
@@ -301,6 +298,9 @@
      'pulse-line': {
        body: '<path fill="currentColor" d="m9 7.539l6 14L18.66 13H23v-2h-5.66L15 16.461l-6-14L5.34 11H1v2h5.66z"/>'
      },
      'pushpin-2-fill': {
        body: '<path fill="currentColor" d="M18 3v2h-1v6l2 3v2h-6v7h-2v-7H5v-2l2-3V5H6V3z"/>'
      },
      'pushpin-2-line': {
        body: '<path fill="currentColor" d="M18 3v2h-1v6l2 3v2h-6v7h-2v-7H5v-2l2-3V5H6V3zM9 5v6.606L7.404 14h9.192L15 11.606V5z"/>'
      },
rsf-design/src/views/abnormal/index.vue
@@ -9,13 +9,20 @@
      >
        <div class="min-w-0 pr-6">
          <p class="text-sm font-medium text-g-700">{{ item.title }}</p>
          <ArtCountTo class="mt-3 block text-[2.3rem] font-semibold leading-none text-g-900" :target="item.total" :duration="1200" />
          <ArtCountTo
            class="mt-3 block text-[2.3rem] font-semibold leading-none text-g-900"
            :target="item.total"
            :duration="1200"
          />
          <div class="mt-4 flex items-center gap-2 text-sm">
            <span class="text-g-500">{{ item.badgeText }}</span>
            <span class="text-g-600">{{ item.subtitle }}</span>
          </div>
        </div>
        <div class="flex size-13 shrink-0 items-center justify-center rounded-2xl" :class="item.iconBoxClass">
        <div
          class="flex size-13 shrink-0 items-center justify-center rounded-2xl"
          :class="item.iconBoxClass"
        >
          <ArtSvgIcon :icon="item.icon" class="text-2xl" :class="item.iconClass" />
        </div>
      </div>
@@ -39,11 +46,15 @@
            @click="navigateTo(item.route)"
          >
            <div class="flex min-w-0 items-center gap-3">
              <div class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-[var(--el-color-primary-light-9)]">
              <div
                class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-[var(--el-color-primary-light-9)]"
              >
                <ArtSvgIcon :icon="item.icon" class="text-xl text-[var(--el-color-primary)]" />
              </div>
              <div class="min-w-0">
                <p class="truncate text-sm font-medium text-[var(--art-gray-900)]">{{ item.title }}</p>
                <p class="truncate text-sm font-medium text-[var(--art-gray-900)]">{{
                  item.title
                }}</p>
                <p class="mt-1 truncate text-xs text-g-500">{{ item.description }}</p>
              </div>
            </div>
@@ -76,7 +87,9 @@
              </div>
              <div class="min-w-0 flex-1">
                <div class="flex items-center gap-2">
                  <p class="truncate text-base font-medium text-[var(--art-gray-900)]">{{ item.title }}</p>
                  <p class="truncate text-base font-medium text-[var(--art-gray-900)]">{{
                    item.title
                  }}</p>
                  <ElTag size="small" effect="light" type="danger">{{ item.source }}</ElTag>
                </div>
                <p class="mt-2 text-sm text-g-600">{{ item.summary }}</p>
@@ -129,12 +142,19 @@
  async function loadOverview() {
    sectionLoading.summary = true
    const [checkDiffResponse, qlyInspectResponse, freezeResponse, locReviseResponse] = await Promise.all([
      withRequestGuard(fetchCheckDiffPage({ current: 1, pageSize: 5 }), { records: [], total: 0 }),
      withRequestGuard(fetchQlyInspectPage({ current: 1, pageSize: 5 }), { records: [], total: 0 }),
      withRequestGuard(fetchFreezePage({ current: 1, pageSize: 5 }), { records: [], total: 0 }),
      withRequestGuard(fetchLocRevisePage({ current: 1, pageSize: 5 }), { records: [], total: 0 })
    ])
    const [checkDiffResponse, qlyInspectResponse, freezeResponse, locReviseResponse] =
      await Promise.all([
        withRequestGuard(fetchCheckDiffPage({ current: 1, pageSize: 5 }), {
          records: [],
          total: 0
        }),
        withRequestGuard(fetchQlyInspectPage({ current: 1, pageSize: 5 }), {
          records: [],
          total: 0
        }),
        withRequestGuard(fetchFreezePage({ current: 1, pageSize: 5 }), { records: [], total: 0 }),
        withRequestGuard(fetchLocRevisePage({ current: 1, pageSize: 5 }), { records: [], total: 0 })
      ])
    overviewState.value = {
      checkDiff: normalizeSummaryResponse(checkDiffResponse),
rsf-design/src/views/manager/loc-preview/modules/loc-preview-detail-drawer.vue
@@ -10,7 +10,9 @@
        <ElDescriptionsItem label="库位编码">{{ detail.locCode || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="仓库">{{ detail.warehouseLabel || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="库区">{{ detail.areaLabel || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="使用状态">{{ detail.useStatusLabel || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="使用状态">{{
          detail.useStatusLabel || '--'
        }}</ElDescriptionsItem>
        <ElDescriptionsItem label="库位类型">{{ detail.typeLabel || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="条码">{{ detail.barcode || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="排">{{ detail.row ?? '--' }}</ElDescriptionsItem>
rsf-design/src/views/system/ai-mcp-mount/index.vue
@@ -12,7 +12,9 @@
      <div class="mb-5 flex flex-wrap items-center justify-between gap-4">
        <div>
          <h3 class="text-lg font-semibold text-[var(--art-gray-900)]">MCP 挂载</h3>
          <p class="mt-1 text-sm text-[var(--art-gray-500)]">按传输类型管理 MCP 挂载、连通性和工具预览。</p>
          <p class="mt-1 text-sm text-[var(--art-gray-500)]"
            >按传输类型管理 MCP 挂载、连通性和工具预览。</p
          >
        </div>
        <ElSpace wrap>
@@ -39,12 +41,18 @@
              <div class="flex items-start justify-between gap-4">
                <div class="min-w-0">
                  <div class="flex items-center gap-3">
                    <div class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600">
                    <div
                      class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600"
                    >
                      <ArtSvgIcon icon="ri:plug-2-line" class="text-xl" />
                    </div>
                    <div class="min-w-0">
                      <h4 class="truncate text-base font-semibold text-[var(--art-gray-900)]">{{ item.name || '--' }}</h4>
                      <p class="mt-1 truncate text-sm text-[var(--art-gray-500)]">{{ item.transportText }}</p>
                      <h4 class="truncate text-base font-semibold text-[var(--art-gray-900)]">{{
                        item.name || '--'
                      }}</h4>
                      <p class="mt-1 truncate text-sm text-[var(--art-gray-500)]">{{
                        item.transportText
                      }}</p>
                    </div>
                  </div>
                </div>
@@ -56,20 +64,30 @@
              </div>
              <div class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
                <div class="rounded-2xl bg-[var(--art-main-bg-color)]/70 p-3 ring-1 ring-inset ring-[var(--art-border-color)]">
                <div
                  class="rounded-2xl bg-[var(--art-main-bg-color)]/70 p-3 ring-1 ring-inset ring-[var(--art-border-color)]"
                >
                  <p class="text-xs text-[var(--art-gray-500)]">目标地址</p>
                  <p class="mt-2 break-all text-[var(--art-gray-900)]">{{ item.targetLabel || '--' }}</p>
                  <p class="mt-2 break-all text-[var(--art-gray-900)]">{{
                    item.targetLabel || '--'
                  }}</p>
                </div>
                <div class="rounded-2xl bg-[var(--art-main-bg-color)]/70 p-3 ring-1 ring-inset ring-[var(--art-border-color)]">
                <div
                  class="rounded-2xl bg-[var(--art-main-bg-color)]/70 p-3 ring-1 ring-inset ring-[var(--art-border-color)]"
                >
                  <p class="text-xs text-[var(--art-gray-500)]">最近测试</p>
                  <p class="mt-2 text-[var(--art-gray-900)]">{{ item['lastTestTime$'] || '未测试' }}</p>
                  <p class="mt-2 text-[var(--art-gray-900)]">{{
                    item['lastTestTime$'] || '未测试'
                  }}</p>
                </div>
              </div>
              <div class="mt-4 grid gap-3 text-sm sm:grid-cols-3">
                <div class="rounded-2xl bg-slate-50 px-3 py-2">
                  <p class="text-xs text-[var(--art-gray-500)]">超时</p>
                  <p class="mt-1 font-medium text-[var(--art-gray-900)]">{{ item.requestTimeoutMs ?? '--' }} ms</p>
                  <p class="mt-1 font-medium text-[var(--art-gray-900)]"
                    >{{ item.requestTimeoutMs ?? '--' }} ms</p
                  >
                </div>
                <div class="rounded-2xl bg-slate-50 px-3 py-2">
                  <p class="text-xs text-[var(--art-gray-500)]">排序</p>
@@ -77,17 +95,25 @@
                </div>
                <div class="rounded-2xl bg-slate-50 px-3 py-2">
                  <p class="text-xs text-[var(--art-gray-500)]">初始化耗时</p>
                  <p class="mt-1 font-medium text-[var(--art-gray-900)]">{{ item.lastInitElapsedMs ?? '--' }}</p>
                  <p class="mt-1 font-medium text-[var(--art-gray-900)]">{{
                    item.lastInitElapsedMs ?? '--'
                  }}</p>
                </div>
              </div>
              <div class="mt-4 rounded-2xl bg-amber-50/80 px-4 py-3">
                <p class="text-xs text-[var(--art-gray-500)]">备注</p>
                <p class="mt-2 line-clamp-3 text-sm leading-6 text-[var(--art-gray-900)]">{{ item.memo || '--' }}</p>
                <p class="mt-2 line-clamp-3 text-sm leading-6 text-[var(--art-gray-900)]">{{
                  item.memo || '--'
                }}</p>
              </div>
              <div class="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-[var(--art-border-color)] pt-4">
                <div class="text-xs text-[var(--art-gray-500)]">{{ item['updateTime$'] || '--' }}</div>
              <div
                class="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-[var(--art-border-color)] pt-4"
              >
                <div class="text-xs text-[var(--art-gray-500)]">{{
                  item['updateTime$'] || '--'
                }}</div>
                <ElSpace wrap>
                  <ElButton text @click="openDetailDialog(item)">详情</ElButton>
@@ -101,7 +127,9 @@
                    连通性测试
                  </ElButton>
                  <ElButton v-auth="'list'" text @click="openToolsDrawer(item)">工具预览</ElButton>
                  <ElButton v-auth="'remove'" text type="danger" @click="handleDelete(item)">删除</ElButton>
                  <ElButton v-auth="'remove'" text type="danger" @click="handleDelete(item)"
                    >删除</ElButton
                  >
                </ElSpace>
              </div>
            </article>
@@ -253,7 +281,9 @@
  async function openEditDialog(record) {
    try {
      currentMcpMountData.value = buildAiMcpMountDialogModel(await fetchGetAiMcpMountDetail(record.id))
      currentMcpMountData.value = buildAiMcpMountDialogModel(
        await fetchGetAiMcpMountDetail(record.id)
      )
      dialogMode.value = 'edit'
      dialogVisible.value = true
    } catch {
@@ -263,7 +293,9 @@
  async function openDetailDialog(record) {
    try {
      currentMcpMountData.value = buildAiMcpMountDialogModel(await fetchGetAiMcpMountDetail(record.id))
      currentMcpMountData.value = buildAiMcpMountDialogModel(
        await fetchGetAiMcpMountDetail(record.id)
      )
      dialogMode.value = 'show'
      dialogVisible.value = true
    } catch {
rsf-design/src/views/system/role/index.vue
@@ -24,9 +24,9 @@
              :disabled="selectedRows.length === 0"
              @click="handleBatchDelete"
              v-ripple
              >
                批量删除
              </ElButton>
            >
              批量删除
            </ElButton>
            <span v-auth="'query'" class="inline-flex">
              <ListExportPrint
                :preview-visible="previewVisible"
@@ -218,13 +218,17 @@
  }
  const resolvePrintRecords = async (payload) => {
    const response = Array.isArray(payload?.ids) && payload.ids.length > 0
      ? await fetchGetRoleMany(payload.ids)
      : await fetchRolePrintPage({
          ...reportQueryParams.value,
          current: 1,
          pageSize: Number(pagination.total) > 0 ? Number(pagination.total) : Number(payload?.pageSize) || 20
        })
    const response =
      Array.isArray(payload?.ids) && payload.ids.length > 0
        ? await fetchGetRoleMany(payload.ids)
        : await fetchRolePrintPage({
            ...reportQueryParams.value,
            current: 1,
            pageSize:
              Number(pagination.total) > 0
                ? Number(pagination.total)
                : Number(payload?.pageSize) || 20
          })
    return defaultResponseAdapter(response).records
  }
rsf-design/src/views/system/role/rolePage.helpers.js
@@ -28,7 +28,9 @@
  }
  return Object.fromEntries(
    Object.entries(searchParams).filter(([, value]) => value !== '' && value !== void 0 && value !== null)
    Object.entries(searchParams).filter(
      ([, value]) => value !== '' && value !== void 0 && value !== null
    )
  )
}
@@ -86,7 +88,8 @@
        return null
      }
      const source = ROLE_REPORT_SOURCE_ALIAS[column.source ?? column.prop] ?? column.source ?? column.prop
      const source =
        ROLE_REPORT_SOURCE_ALIAS[column.source ?? column.prop] ?? column.source ?? column.prop
      if (!source || !allowedColumns.has(source) || seenSources.has(source)) {
        return null
      }
@@ -224,9 +227,7 @@
    return []
  }
  return treeData
    .map((node) => normalizeRoleScopeNode(scopeType, node))
    .filter(Boolean)
  return treeData.map((node) => normalizeRoleScopeNode(scopeType, node)).filter(Boolean)
}
function normalizeRoleScopeNode(scopeType, node) {
@@ -242,16 +243,17 @@
    ? normalizeRoleScopeTreeData(scopeType, node.children)
    : []
  const metaSource = node.meta && typeof node.meta === 'object' ? node.meta : node
  const authNodes = scopeType === 'menu' && Array.isArray(metaSource.authList) && metaSource.authList.length
    ? metaSource.authList.map((auth, index) => ({
        id: normalizeScopeKey(auth.id ?? auth.authMark ?? `${node.id || 'auth'}-${index}`),
        label: normalizeScopeTitle(auth.title || auth.name || auth.authMark || ''),
        type: 1,
        isAuthButton: true,
        authMark: auth.authMark || auth.authority || auth.code || '',
        children: []
      }))
    : []
  const authNodes =
    scopeType === 'menu' && Array.isArray(metaSource.authList) && metaSource.authList.length
      ? metaSource.authList.map((auth, index) => ({
          id: normalizeScopeKey(auth.id ?? auth.authMark ?? `${node.id || 'auth'}-${index}`),
          label: normalizeScopeTitle(auth.title || auth.name || auth.authMark || ''),
          type: 1,
          isAuthButton: true,
          authMark: auth.authMark || auth.authority || auth.code || '',
          children: []
        }))
      : []
  const mergedChildren =
    authNodes.length > 0 && !children.some((child) => child.isAuthButton)
@@ -291,11 +293,7 @@
  }
  return Array.from(
    new Set(
      keys
        .map((key) => normalizeRoleId(key))
        .filter((key) => key !== void 0)
    )
    new Set(keys.map((key) => normalizeRoleId(key)).filter((key) => key !== void 0))
  )
}