gaoluyang
2026-04-29 3b577bb9493c389c5a3f0dca4538ddbf41fb1387
Merge remote-tracking branch 'origin/dev_NEW_pro' into dev_NEW_pro
已添加6个文件
已修改14个文件
5268 ■■■■■ 文件已修改
src/api/basicData/storageAttachment.js 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/productionOrder.js 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/AIChatSidebar/index.vue 3001 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/Dialog/FileList.vue 253 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/PurchaseAIChatSidebar/index.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layout/index.vue 264 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/equipmentManagement/repair/Modal/RepairModal.vue 16 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/equipmentManagement/repair/index.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/equipmentManagement/upkeep/Form/PlanModal.vue 22 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/equipmentManagement/upkeep/index.vue 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productionOrder/components/MaterialDetailDialog.vue 72 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productionOrder/components/MaterialSupplementDialog.vue 159 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productionOrder/components/PrintMaterialRequisition.vue 225 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productionOrder/index.vue 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productionProcess/Edit.vue 168 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productionProcess/New.vue 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productionReporting/index.vue 61 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/workOrderEdit/index.vue 447 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/workOrderManagement/index.vue 206 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.js 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/basicData/storageAttachment.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
// é™„件页面接口
import request from '@/utils/request'
// é™„件查询
export function attachmentList(query) {
    return request({
        url: '/basic/storage_attachment/list',
        method: 'get',
        params: query
    })
}
// é™„件新增
export function createAttachment(data) {
    return request({
        url: '/basic/storage_attachment/add',
        method: 'post',
        data
    })
}
// é™„件删除
export function deleteAttachment(data) {
    return request({
        url: '/basic/storage_attachment/delete',
        method: 'delete',
        data
    })
}
src/api/productionManagement/productionOrder.js
@@ -116,7 +116,7 @@
// ç”Ÿäº§è®¢å•-补料记录列表
export function listMaterialSupplementRecord(query) {
  return request({
    url: "/productOrderMaterial/supplementRecord",
    url: "/productionOrderPickRecord/feeding",
    method: "get",
    params: query,
  });
src/components/AIChatSidebar/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,3001 @@
<template>
  <div class="ai-chat-sidebar-wrapper">
    <!-- æ‚¬æµ®å›¾æ ‡ -->
    <div class="ai-chat-trigger" @click="toggleSidebar" v-show="!visible">
      <el-tooltip :content="currentAssistant.tooltip" placement="left">
        <div class="trigger-icon">
          <el-icon :size="30" color="#fff"><component :is="currentAssistant.icon" /></el-icon>
        </div>
      </el-tooltip>
    </div>
    <!-- ä¾§è¾¹æ å¯¹è¯æ¡† -->
    <el-drawer
        v-model="visible"
        :size="drawerSize"
        direction="rtl"
        :with-header="true"
        class="ai-chat-drawer"
        :modal="false"
        modal-class="ai-chat-overlay"
        :show-close="false"
        :append-to-body="false"
        @close="handleClose"
    >
      <template #header>
        <div class="drawer-header">
          <div class="header-left">
            <el-icon :size="20" class="header-icon"><component :is="currentAssistant.icon" /></el-icon>
            <span class="title">{{ currentAssistant.title }}</span>
          </div>
          <div v-if="showAssistantSwitch" class="assistant-switcher">
            <el-radio-group v-model="selectedAssistantKey" size="small">
              <el-radio-button
                  v-for="assistant in assistants"
                  :key="assistant.key"
                  :label="assistant.key"
              >
                {{ assistant.label }}
              </el-radio-button>
            </el-radio-group>
          </div>
          <div class="header-actions">
            <el-tooltip content="会话历史" placement="bottom">
              <el-button link class="header-action-btn" @click="handleToggleHistory">
                <el-icon :size="18"><Timer /></el-icon>
              </el-button>
            </el-tooltip>
            <el-tooltip content="开启新会话" placement="bottom">
              <el-button link class="header-action-btn" @click="handleNewChat">
                <el-icon :size="18"><Plus /></el-icon>
              </el-button>
            </el-tooltip>
            <div class="action-divider"></div>
            <el-tooltip content="关闭" placement="bottom">
              <el-button link class="header-action-btn close-btn" @click="handleManualClose">
                <el-icon :size="18"><Close /></el-icon>
              </el-button>
            </el-tooltip>
          </div>
        </div>
      </template>
      <div class="chat-container">
        <!-- åŽ†å²ä¼šè¯åˆ—è¡¨ -->
        <div v-if="showHistory" class="history-panel">
          <div class="history-header">
            <span>最近会话</span>
            <el-button link type="primary" @click="showHistory = false">返回对话</el-button>
          </div>
          <el-skeleton :loading="loadingSessions" animated>
            <template #template>
              <div v-for="i in 5" :key="i" style="padding: 10px">
                <el-skeleton-item variant="p" style="width: 80%" />
              </div>
            </template>
            <div class="session-list">
              <div
                  v-for="session in sessions"
                  :key="session.memoryId"
                  :class="['session-item', { active: uuid === session.memoryId }]"
                  @click="selectSession(session)"
              >
                <el-icon><ChatDotSquare /></el-icon>
                <span class="session-name" :title="session.lastMessage || '新会话'">
                  {{ session.lastMessage || '新会话' }}
                </span>
                <el-button
                    link
                    type="danger"
                    class="delete-btn"
                    @click.stop="handleDeleteSession(session.memoryId)"
                >
                  <el-icon><Delete /></el-icon>
                </el-button>
              </div>
              <el-empty v-if="sessions.length === 0" :description="currentAssistant.emptySessionText" />
            </div>
          </el-skeleton>
        </div>
        <div v-else class="chat-main">
          <div :class="['chat-hero', { compact: hasMessages }]">
            <div :class="['assistant-stand', { thinking: isSending, compact: hasMessages }]">
              <div class="assistant-halo"></div>
              <div class="assistant-scan-ring"></div>
              <div class="assistant-orbit assistant-orbit-a"></div>
              <div class="assistant-orbit assistant-orbit-b"></div>
              <div class="assistant-bot">
                <div class="assistant-bot-antenna assistant-bot-antenna-left"></div>
                <div class="assistant-bot-antenna assistant-bot-antenna-right"></div>
                <div class="assistant-bot-head">
                  <div class="assistant-bot-head-glow"></div>
                  <div class="assistant-bot-eye assistant-bot-eye-left"></div>
                  <div class="assistant-bot-eye assistant-bot-eye-right"></div>
                  <div class="assistant-bot-mouth"></div>
                </div>
                <div class="assistant-bot-neck"></div>
                <div class="assistant-bot-body">
                  <div class="assistant-bot-core">
                    <div class="assistant-bot-core-ring"></div>
                    <el-icon :size="22"><component :is="currentAssistant.icon" /></el-icon>
                  </div>
                  <div class="assistant-bot-arm assistant-bot-arm-left"></div>
                  <div class="assistant-bot-arm assistant-bot-arm-right"></div>
                </div>
              </div>
              <div class="assistant-status">
                <span class="assistant-status-dot"></span>
                {{ isSending ? '思考中...' : currentAssistant.label }}
              </div>
              <div class="assistant-base assistant-base-lg"></div>
              <div class="assistant-base assistant-base-md"></div>
              <div class="assistant-base assistant-base-sm"></div>
            </div>
            <div :class="['welcome-card', { compact: hasMessages }]">
              <div class="welcome-eyebrow">智能助手</div>
              <h3 class="welcome-title">
                æ‚¨å¥½
                <br />
                æˆ‘是{{ currentAssistant.label }}分析解读助手
              </h3>
              <p class="welcome-desc">
                {{ currentAssistant.description || '我可以围绕业务问题提供解读、查询建议和分析支持,帮助你更快完成判断与处理。' }}
              </p>
              <div class="quick-prompt-list">
                <button
                    v-for="prompt in displayedQuickPrompts"
                    :key="prompt"
                    type="button"
                    class="quick-prompt-btn"
                    :disabled="isSending"
                    @click="sendQuickPrompt(prompt)"
                >
                  {{ prompt }}
                </button>
              </div>
              <button
                  v-if="quickPrompts.length > quickPromptLimit"
                  type="button"
                  class="more-prompts-btn"
                  @click="refreshQuickPrompts"
              >
                <el-icon><RefreshRight /></el-icon>
                <span>换一换</span>
              </button>
            </div>
          </div>
          <div v-show="!hasMessages" class="hero-dot-grid" aria-hidden="true">
            <span v-for="dot in 28" :key="dot"></span>
          </div>
          <div class="message-list" ref="messageListRef">
            <div
                v-for="(message, index) in messages"
                :key="index"
                :class="['message-item', message.isUser ? 'user-message' : 'bot-message']"
            >
              <div class="avatar">
                <el-icon v-if="message.isUser"><User /></el-icon>
                <el-icon v-else><Cpu /></el-icon>
              </div>
              <div class="message-content">
                <!-- æ–‡æœ¬å†…容 -->
                <div class="text-box" v-html="message.htmlContent"></div>
                <!-- å›¾è¡¨å†…容 -->
                <div v-if="message.chartOptions && message.chartRenderReady" class="charts-wrapper">
                  <div
                      v-for="(option, key) in message.chartOptions"
                      :key="key"
                      class="chart-item"
                      :id="`ai-chart-${index}-${key}`"
                  ></div>
                </div>
                <!-- è¡¨æ ¼å†…容 -->
                <div v-if="message.type === 'todo_list' && message.tableData" class="table-wrapper">
                  <el-table :data="message.tableData.items" border stripe size="small" style="width: 100%">
                    <el-table-column
                        v-for="col in message.tableData.columns"
                        :key="col"
                        :prop="col"
                        :label="columnLabelMap[col] || col"
                        min-width="100"
                        show-overflow-tooltip
                    />
                  </el-table>
                </div>
                <!-- æ‰“字中动画 -->
                <div v-if="message.isTyping" class="typing-indicator">
                  <span class="dot"></span>
                  <span class="dot"></span>
                  <span class="dot"></span>
                </div>
              </div>
            </div>
          </div>
          <div class="input-area">
            <div class="input-actions">
              <el-button link class="utility-action-btn" type="primary" size="small" @click="handleNewChat">
                <el-icon><Plus /></el-icon>新会话
              </el-button>
              <el-button v-if="isSending" link class="utility-action-btn stop-action-btn" type="danger" size="small" @click="stopGeneration">
                <el-icon><VideoPause /></el-icon>停止生成
              </el-button>
              <el-upload
                  v-if="currentAssistant.allowFileUpload"
                  class="file-upload-trigger"
                  action="#"
                  :auto-upload="false"
                  :show-file-list="false"
                  :on-change="handleFileChange"
                  :disabled="isSending"
              >
                <el-button link class="utility-action-btn upload-action-btn" type="primary" size="small" :disabled="isSending">
                  <el-icon><Upload /></el-icon>分析文件
                </el-button>
              </el-upload>
            </div>
            <div class="input-box">
              <div v-if="selectedFile" class="selected-file-tag">
                <el-icon><Document /></el-icon>
                <span class="file-name">{{ selectedFile.name }}</span>
                <el-icon class="remove-file" @click="removeSelectedFile"><Close /></el-icon>
              </div>
              <el-input
                  v-model="inputMessage"
                  type="textarea"
                  :rows="selectedFile ? 2 : 3"
                  :placeholder="currentAssistant.placeholder"
                  resize="none"
                  @keydown.enter.exact.prevent="sendMessage"
              />
              <el-button
                  type="primary"
                  class="send-btn"
                  :disabled="isSending || (!inputMessage.trim() && !selectedFile)"
                  @click="sendMessage"
                  aria-label="发送"
              >
                <el-icon><Promotion /></el-icon>
              </el-button>
            </div>
          </div>
        </div>
      </div>
    </el-drawer>
  </div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick, watch, computed } from 'vue'
import request from '@/utils/request'
import * as echarts from 'echarts'
import { Cpu, User, Plus, Timer, Delete, ChatDotSquare, VideoPause, Upload, Document, Close, ShoppingCart, Promotion, RefreshRight } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
  assistants: {
    type: Array,
    default: () => []
  },
  defaultAssistant: {
    type: String,
    default: ''
  }
})
const builtInAssistants = [
  {
    key: 'general',
    label: '待办助理',
    title: '待办智能助理',
    tooltip: '待办助手',
    icon: Cpu,
    apiBase: '/xiaozhi',
    storageKey: 'ai_chat_uuid',
    placeholder: '请输入您的问题... (Enter å‘送, Shift+Enter æ¢è¡Œ)',
    welcomeMessage: '你好',
    description: '我可以回答你的问题,为你提供业务数据解读信息、处理建议和辅助决策支持。',
    allowFileUpload: true,
    emptySessionText: '暂无历史会话'
  },
  {
    key: 'purchase',
    label: '采购助理',
    title: '采购智能助理',
    tooltip: '采购智能助理',
    icon: ShoppingCart,
    apiBase: '/purchase-ai',
    storageKey: 'purchase_ai_chat_uuid',
    placeholder: '请输入采购问题... (Enter å‘送, Shift+Enter æ¢è¡Œ)',
    welcomeMessage: '你好',
    description: '我可以协助你分析采购订单、到货进度、供应商表现和付款情况,帮助你快速定位采购异常。',
    allowFileUpload: false,
    emptySessionText: '暂无采购会话'
  }
]
const assistants = computed(() => props.assistants?.length ? props.assistants : builtInAssistants)
const selectedAssistantKey = ref(props.defaultAssistant || assistants.value[0]?.key || 'general')
const currentAssistant = computed(() => assistants.value.find(item => item.key === selectedAssistantKey.value) || assistants.value[0] || builtInAssistants[0])
const showAssistantSwitch = computed(() => assistants.value.length > 1)
const assistantQuickPromptMap = {
  general: [
    '我当前有哪些审批待办需要处理?',
    '帮我列出今天新增的审批待办。',
    '当前待我审批的单据,按时间倒序列出来。',
    '我发起的审批里,哪些还在处理中?',
    '查询流程编号 XXX çš„审批详情。',
    '流程编号 XXX çŽ°åœ¨å¡åœ¨å“ªä¸ªå®¡æ‰¹èŠ‚ç‚¹ï¼Ÿå½“å‰å®¡æ‰¹äººæ˜¯è°ï¼Ÿ',
    '帮我查看流程编号 XXX çš„审批流转记录。',
    '近7天我的审批待办统计情况怎么样?',
    '本月我的审批中,通过、驳回、处理中各有多少?',
    '近30天各类型审批数量分布是什么?',
    '帮我审批通过流程编号 XXX,备注“同意”。',
    '帮我驳回流程编号 XXX,备注“请补充说明”。',
    '撤销我刚刚对流程编号 XXX çš„审批操作。',
    '帮我修改流程编号 XXX çš„备注为“已补充附件”。',
    '删除我发起的流程编号 XXX。'
  ],
  purchase: [
    '本月采购金额排名前十的物料有哪些?',
    '哪些采购订单还未入库?',
    '最近7天供应商到货异常有哪些?',
    '帮我统计待付款采购单',
    '列出本月采购退货情况'
  ]
}
const quickPromptLimit = 3
const quickPromptStart = ref(0)
const quickPrompts = computed(() => {
  const assistant = currentAssistant.value || {}
  if (Array.isArray(assistant.quickPrompts) && assistant.quickPrompts.length) {
    return assistant.quickPrompts
  }
  return assistantQuickPromptMap[assistant.key] || assistantQuickPromptMap.general
})
const displayedQuickPrompts = computed(() => {
  const prompts = quickPrompts.value || []
  if (prompts.length <= quickPromptLimit) return prompts
  const result = []
  for (let i = 0; i < quickPromptLimit; i++) {
    result.push(prompts[(quickPromptStart.value + i) % prompts.length])
  }
  return result
})
const hasMessages = computed(() => messages.value.length > 0)
const visible = ref(false)
const windowWidth = ref(window.innerWidth)
const drawerSize = computed(() => {
  if (windowWidth.value < 768) return '100%'
  if (windowWidth.value < 1200) return '500px'
  return '600px'
})
const messageListRef = ref(null)
const isSending = ref(false)
const currentAbortController = ref(null)
const inputMessage = ref('')
const selectedFile = ref(null)
const messages = ref([])
const uuid = ref('')
const chartInstances = ref({})
const resizeHandlers = ref([])
const outputState = ref({})
// åŽ†å²ä¼šè¯ç›¸å…³
const showHistory = ref(false)
const sessions = ref([])
const loadingSessions = ref(false)
const abortCurrentRequest = () => {
  if (!currentAbortController.value) return
  currentAbortController.value.abort()
  currentAbortController.value = null
  isSending.value = false
  const lastMsg = messages.value[messages.value.length - 1]
  if (lastMsg && !lastMsg.isUser) {
    lastMsg.isTyping = false
  }
}
const toggleHistory = () => {
  showHistory.value = !showHistory.value
  if (showHistory.value) {
    loadSessions()
  }
}
const handleToggleHistory = () => {
  if (isSending.value) {
    abortCurrentRequest()
  }
  toggleHistory()
}
const loadSessions = async () => {
  loadingSessions.value = true
  try {
    const res = await request.get(`${currentAssistant.value.apiBase}/history/sessions`)
    if (res.code === 200) {
      sessions.value = res.data || []
    }
  } catch (err) {
    console.error('Failed to load sessions', err)
  } finally {
    loadingSessions.value = false
  }
}
const selectSession = async (session) => {
  showHistory.value = false
  uuid.value = session.memoryId
  localStorage.setItem(currentAssistant.value.storageKey, uuid.value)
  // åŠ è½½ä¼šè¯æ¶ˆæ¯
  try {
    const res = await request.get(`${currentAssistant.value.apiBase}/history/messages/${uuid.value}`)
    if (res.code === 200) {
      disposeCharts()
      messages.value = []
      const historyMsgs = res.data || []
      // é‡æ–°æž„造消息列表并解析
      historyMsgs.forEach((msg, idx) => {
        const isUser = msg.role === 'user'
        const botMsgIndex = messages.value.length
        const messageObj = {
          isUser,
          content: msg.content,
          htmlContent: '',
          isTyping: false,
          chartOptions: null,
          chartRenderReady: false,
          type: '',
          tableData: null
        }
        messages.value.push(messageObj)
        if (!isUser) {
          outputState.value[botMsgIndex] = {
            isPaused: false,
            jsonBlockStartPos: -1,
            jsBlockStartPos: -1,
            blockEndPos: -1,
            hasRenderedChart: false
          }
          // è§£æžåŽ†å²æ¶ˆæ¯ä¸­çš„ JSON
          const extracted = extractEmbeddedSuccessJson(msg.content)
          if (extracted) {
            applyStructuredMessageData(messageObj, extracted.data, botMsgIndex)
          }
          updateOutputState(msg.content, botMsgIndex)
          messageObj.htmlContent = convertStreamOutput(msg.content, botMsgIndex)
        } else {
          messageObj.htmlContent = convertTextToHtml(msg.content)
        }
      })
      scrollToBottom()
    }
  } catch (err) {
    console.error('Failed to load messages', err)
  }
}
const handleDeleteSession = async (memoryId) => {
  try {
    const res = await request.delete(`${currentAssistant.value.apiBase}/history/${memoryId}`)
    if (res.code === 200) {
      loadSessions()
      if (uuid.value === memoryId) {
        newChat()
      }
    }
  } catch (err) {
    console.error('Failed to delete session', err)
  }
}
const columnLabelMap = {
  approveId: '审批编号',
  approveType: '审批类型',
  approveUserName: '审批人',
  approveUserCurrentName: '当前处理人',
  approveReason: '审批原因',
  approveStatus: '审批状态',
  createTime: '创建时间'
}
onMounted(() => {
  initUUID()
  // åˆå§‹æ¬¢è¿Ž
  if (messages.value.length === 0) {
    hello()
  }
  window.addEventListener('resize', handleWindowResize)
})
onUnmounted(() => {
  disposeCharts()
  window.removeEventListener('resize', handleWindowResize)
})
watch(selectedAssistantKey, (nextKey, prevKey) => {
  if (!prevKey || nextKey === prevKey) return
  abortCurrentRequest()
  disposeCharts()
  messages.value = []
  outputState.value = {}
  sessions.value = []
  showHistory.value = false
  selectedFile.value = null
  inputMessage.value = ''
  quickPromptStart.value = 0
  initUUID()
  hello()
})
const handleWindowResize = () => {
  windowWidth.value = window.innerWidth
}
const toggleSidebar = () => {
  visible.value = !visible.value
  if (visible.value) {
    scrollToBottom()
  }
}
const handleClose = () => {
  visible.value = false
}
const handleManualClose = () => {
  if (isSending.value) {
    abortCurrentRequest()
  }
  handleClose()
}
const initUUID = () => {
  let storedUUID = localStorage.getItem(currentAssistant.value.storageKey)
  if (!storedUUID) {
    storedUUID = Math.random().toString(36).substring(2, 10) + Date.now().toString(36).substring(4)
    localStorage.setItem(currentAssistant.value.storageKey, storedUUID)
  }
  uuid.value = storedUUID
}
const hello = () => {
  sendRequest(currentAssistant.value.welcomeMessage || '你好')
}
const newChat = () => {
  disposeCharts()
  messages.value = []
  outputState.value = {}
  sessions.value = []
  showHistory.value = false
  selectedFile.value = null
  quickPromptStart.value = 0
  localStorage.removeItem(currentAssistant.value.storageKey)
  initUUID()
  hello()
}
const handleNewChat = () => {
  if (isSending.value) {
    abortCurrentRequest()
  }
  newChat()
}
const sendQuickPrompt = (prompt) => {
  if (!prompt || isSending.value) return
  inputMessage.value = prompt
  sendMessage()
}
const refreshQuickPrompts = () => {
  const prompts = quickPrompts.value || []
  if (prompts.length <= quickPromptLimit) return
  quickPromptStart.value = (quickPromptStart.value + quickPromptLimit) % prompts.length
}
const disposeCharts = () => {
  Object.values(chartInstances.value).forEach(chart => chart.dispose())
  resizeHandlers.value.forEach(handler => window.removeEventListener('resize', handler))
  chartInstances.value = {}
  resizeHandlers.value = []
}
const extractEmbeddedSuccessJson = (text) => {
  if (!text || typeof text !== 'string') return null
  const startIdx = text.indexOf('{"success"')
  if (startIdx === -1) return null
  for (let i = startIdx; i < text.length; i++) {
    if (text[i] !== '{') continue
    let depth = 0
    let inString = false
    let escaped = false
    for (let j = i; j < text.length; j++) {
      const char = text[j]
      if (inString) {
        if (escaped) {
          escaped = false
        } else if (char === '\\') {
          escaped = true
        } else if (char === '"') {
          inString = false
        }
        continue
      }
      if (char === '"') {
        inString = true
        continue
      }
      if (char === '{') {
        depth++
      } else if (char === '}') {
        depth--
        if (depth === 0) {
          const candidate = text.slice(i, j + 1)
          try {
            const parsed = JSON.parse(candidate)
            if (parsed?.success === true) {
              return {
                data: parsed,
                startIdx: i,
                endIdx: j + 1
              }
            }
          } catch (err) {
            continue
          }
        }
      }
    }
  }
  return null
}
const applyStructuredMessageData = (messageObj, parsedData, msgIndex, shouldRenderCharts = true) => {
  if (!messageObj || !parsedData?.success) return
  messageObj.type = parsedData.type || ''
  if (messageObj.type === 'todo_list' && parsedData.data) {
    messageObj.tableData = parsedData.data
  }
  if (parsedData.charts && Object.keys(parsedData.charts).length > 0) {
    messageObj.chartOptions = parsedData.charts
    messageObj.chartRenderReady = true
    if (shouldRenderCharts) {
      renderCharts(msgIndex, messageObj.chartOptions)
      if (outputState.value[msgIndex]) {
        outputState.value[msgIndex].hasRenderedChart = true
      }
    }
  }
}
const scrollToBottom = () => {
  nextTick(() => {
    if (messageListRef.value) {
      messageListRef.value.scrollTop = messageListRef.value.scrollHeight
    }
  })
}
const handleFileChange = (file) => {
  if (!file) return
  const rawFile = file.raw
  if (rawFile) {
    // é™åˆ¶æ–‡ä»¶å¤§å°ï¼Œä¾‹å¦‚ 10MB
    const isLt10M = rawFile.size / 1024 / 1024 < 10
    if (!isLt10M) {
      ElMessage.error('文件大小不能超过 10MB!')
      return
    }
    selectedFile.value = rawFile
  }
}
const removeSelectedFile = () => {
  selectedFile.value = null
}
const analyzeFile = async (file, message = '') => {
  if (isSending.value) return
  isSending.value = true
  currentAbortController.value = new AbortController()
  const userMsg = message ? `${message}\n[上传文件分析] ${file.name}` : `[上传文件分析] ${file.name}`
  messages.value.push({
    isUser: true,
    content: userMsg,
    htmlContent: convertTextToHtml(userMsg),
    isTyping: false
  })
  const botMsgIndex = messages.value.length
  messages.value.push({
    isUser: false,
    content: '',
    htmlContent: '',
    isTyping: true,
    chartOptions: null,
    chartRenderReady: false,
    type: '',
    tableData: null
  })
  outputState.value[botMsgIndex] = {
    isPaused: false,
    jsonBlockStartPos: -1,
    jsBlockStartPos: -1,
    blockEndPos: -1,
    hasRenderedChart: false
  }
  scrollToBottom()
  const formData = new FormData()
  formData.append('file', file)
  formData.append('memoryId', uuid.value)
  if (message.trim()) {
    formData.append('message', message.trim())
  }
  request.post(`${currentAssistant.value.apiBase}/analyze-file`, formData, {
    headers: {
      'Content-Type': 'multipart/form-data'
    },
    signal: currentAbortController.value.signal,
    onDownloadProgress: (e) => {
      const fullText = e.target ? e.target.responseText : (e.event ? e.event.target.responseText : '')
      if (!fullText) return
      const currentMsg = messages.value[botMsgIndex]
      if (!currentMsg) return
      currentMsg.content = fullText
      // è§£æž JSON æ•°æ®ï¼ˆé’ˆå¯¹åµŒå…¥å¼ JSON)
      const extracted = extractEmbeddedSuccessJson(fullText)
      if (extracted) {
        applyStructuredMessageData(currentMsg, extracted.data, botMsgIndex, !outputState.value[botMsgIndex].hasRenderedChart)
      }
      updateOutputState(fullText, botMsgIndex)
      currentMsg.htmlContent = convertStreamOutput(fullText, botMsgIndex)
      scrollToBottom()
    }
  }).then(() => {
    const currentMsg = messages.value[botMsgIndex]
    currentMsg.isTyping = false
    isSending.value = false
    currentAbortController.value = null
    // æœ€ç»ˆè§£æžç¡®ä¿å›¾è¡¨æ¸²æŸ“
    if (currentMsg.chartOptions && !outputState.value[botMsgIndex].hasRenderedChart) {
      renderCharts(botMsgIndex, currentMsg.chartOptions)
      outputState.value[botMsgIndex].hasRenderedChart = true
    }
  }).catch(err => {
    if (err.name === 'CanceledError' || err.name === 'AbortError') {
      console.log('Analysis aborted by user')
      return
    }
    console.error('File analysis error:', err)
    const errorMsg = '抱歉,文件分析过程中遇到了一点问题,请稍后再试。'
    if (messages.value[botMsgIndex]) {
      messages.value[botMsgIndex].content = errorMsg
      messages.value[botMsgIndex].htmlContent = convertTextToHtml(errorMsg)
      messages.value[botMsgIndex].isTyping = false
    }
    isSending.value = false
    currentAbortController.value = null
  })
}
const sendMessage = () => {
  const msg = inputMessage.value?.trim() || ''
  if ((msg || selectedFile.value) && !isSending.value) {
    if (selectedFile.value) {
      analyzeFile(selectedFile.value, msg)
      selectedFile.value = null
    } else {
      sendRequest(msg)
    }
    inputMessage.value = ''
  }
}
const stopGeneration = () => {
  abortCurrentRequest()
}
const sendRequest = (message) => {
  isSending.value = true
  currentAbortController.value = new AbortController()
  // ç”¨æˆ·æ¶ˆæ¯
  messages.value.push({
    isUser: true,
    content: message,
    htmlContent: convertTextToHtml(message),
    isTyping: false
  })
  // æœºå™¨äººå ä½
  const botMsgIndex = messages.value.length
  const botMsg = {
    isUser: false,
    content: '',
    htmlContent: '',
    isTyping: true,
    chartOptions: null,
    chartRenderReady: false,
    type: '',
    tableData: null
  }
  messages.value.push(botMsg)
  outputState.value[botMsgIndex] = {
    isPaused: false,
    jsonBlockStartPos: -1,
    jsBlockStartPos: -1,
    blockEndPos: -1,
    hasRenderedChart: false
  }
  scrollToBottom()
  request.post(`${currentAssistant.value.apiBase}/chat`,
      { memoryId: uuid.value, message },
      {
        signal: currentAbortController.value.signal,
        onDownloadProgress: (e) => {
          // å…¼å®¹ä¸åŒç‰ˆæœ¬çš„ axios èŽ·å–å“åº”æ–‡æœ¬çš„æ–¹å¼
          const fullText = e.target ? e.target.responseText : (e.event ? e.event.target.responseText : '')
          if (!fullText) return
          const currentMsg = messages.value[botMsgIndex]
          if (!currentMsg) return
          currentMsg.content = fullText
          // å°è¯•提取并解析 JSON
          const extracted = extractEmbeddedSuccessJson(fullText)
          if (extracted) {
            applyStructuredMessageData(currentMsg, extracted.data, botMsgIndex)
          } else {
            const extractJson = (text) => {
              const startIdx = text.indexOf('{"success": true')
              if (startIdx === -1) return null
              // ä»ŽåŽå¾€å‰æ‰¾æœ€åŽä¸€ä¸ª '}'
              const lastBraceIdx = text.lastIndexOf('}')
              if (lastBraceIdx === -1 || lastBraceIdx < startIdx) return null
              const potentialJson = text.substring(startIdx, lastBraceIdx + 1)
              try {
                return JSON.parse(potentialJson)
              } catch (err) {
                return null
              }
            }
            const parsedData = extractJson(fullText)
            if (parsedData) {
              currentMsg.type = parsedData.type || ''
              if (currentMsg.type === 'todo_list' && parsedData.data) {
                currentMsg.tableData = parsedData.data
              }
              if (parsedData.charts && Object.keys(parsedData.charts).length > 0) {
                currentMsg.chartOptions = parsedData.charts
                currentMsg.chartRenderReady = true
                // æ¯æ¬¡è§£æžæˆåŠŸéƒ½å°è¯•æ¸²æŸ“/更新图表,以支持流式更新
                renderCharts(botMsgIndex, currentMsg.chartOptions)
              }
            }
          }
          updateOutputState(fullText, botMsgIndex)
          currentMsg.htmlContent = convertStreamOutput(fullText, botMsgIndex)
          scrollToBottom()
        }
      }
  ).then(() => {
    const currentMsg = messages.value[botMsgIndex]
    currentMsg.isTyping = false
    isSending.value = false
    currentAbortController.value = null
    // æœ€ç»ˆè§£æž
    const extracted = extractEmbeddedSuccessJson(currentMsg.content)
    if (extracted) {
      applyStructuredMessageData(currentMsg, extracted.data, botMsgIndex)
    } else {
      const extractJson = (text) => {
        const startIdx = text.indexOf('{"success": true')
        if (startIdx === -1) return null
        const lastBraceIdx = text.lastIndexOf('}')
        if (lastBraceIdx === -1 || lastBraceIdx < startIdx) return null
        const potentialJson = text.substring(startIdx, lastBraceIdx + 1)
        try {
          return JSON.parse(potentialJson)
        } catch (err) {
          return null
        }
      }
      const finalParsed = extractJson(currentMsg.content)
      if (finalParsed) {
        currentMsg.type = finalParsed.type || ''
        if (currentMsg.type === 'todo_list' && finalParsed.data) {
          currentMsg.tableData = finalParsed.data
        }
        if (finalParsed.charts && Object.keys(finalParsed.charts).length > 0) {
          currentMsg.chartOptions = finalParsed.charts
          currentMsg.chartRenderReady = true
          renderCharts(botMsgIndex, currentMsg.chartOptions)
        }
      }
    }
  }).catch(err => {
    if (err.name === 'CanceledError' || err.name === 'AbortError') {
      console.log('Request aborted by user')
      return
    }
    console.error('AI Chat Error:', err)
    const errorMsg = '抱歉,我现在遇到了一点问题,请稍后再试。'
    if (messages.value[botMsgIndex]) {
      messages.value[botMsgIndex].content = errorMsg
      messages.value[botMsgIndex].htmlContent = convertTextToHtml(errorMsg)
      messages.value[botMsgIndex].isTyping = false
    }
    isSending.value = false
    currentAbortController.value = null
  })
}
const updateOutputState = (text, msgIndex) => {
  const state = outputState.value[msgIndex]
  if (state.jsonBlockStartPos === -1) {
    const pos = text.indexOf('```json')
    if (pos !== -1) { state.jsonBlockStartPos = pos; state.isPaused = true }
  }
  if (state.jsBlockStartPos === -1) {
    const pos = text.indexOf('```javascript') !== -1 ? text.indexOf('```javascript') : text.indexOf('```js')
    if (pos !== -1) { state.jsBlockStartPos = pos; state.isPaused = true }
  }
  if ((state.jsonBlockStartPos !== -1 || state.jsBlockStartPos !== -1) && state.blockEndPos === -1) {
    const startCheck = state.jsonBlockStartPos !== -1 ? state.jsonBlockStartPos + 7 : state.jsBlockStartPos + (text.includes('javascript') ? 13 : 5)
    const endPos = text.indexOf('```', startCheck)
    if (endPos !== -1) { state.blockEndPos = endPos + 3; state.isPaused = false }
  }
}
const convertTextToHtml = (text) => {
  if (!text) return ''
  return text
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/\n/g, '<br>')
}
const convertStreamOutput = (output, msgIndex) => {
  if (!output) return ''
  const state = outputState.value[msgIndex]
  let display = output
  // å°è¯•提取 JSON éƒ¨åˆ†
  const extracted = extractEmbeddedSuccessJson(output)
  const startIdx = extracted ? extracted.startIdx : output.indexOf('{"success"')
  // å¦‚果还在代码块中且未结束,显示提示文字
  if (state && ((state.jsonBlockStartPos !== -1) || (state.jsBlockStartPos !== -1)) && state.blockEndPos === -1) {
    const startPos = state.jsonBlockStartPos !== -1 ? state.jsonBlockStartPos : state.jsBlockStartPos
    const textBeforeBlock = display.substring(0, startPos).trim()
    display = textBeforeBlock || '正在分析数据并生成图表...'
    return convertTextToHtml(display)
  }
  if (extracted) {
    const parsed = extracted.data
    display = (output.substring(0, extracted.startIdx) + output.substring(extracted.endIdx)).trim()
    if (/^[\s}\],,。.::;;]+$/.test(display)) {
      display = ''
    }
    if (parsed.description) {
      display = parsed.description
    }
    if (!display) {
      if (parsed.type === 'todo_list') {
        display = '已为您整理好相关数据。'
      } else if (parsed.charts && Object.keys(parsed.charts).length > 0) {
        display = '已为您生成分析图表。'
      } else {
        display = '正在为您展示分析结果...'
      }
    }
  } else if (startIdx !== -1) {
    const lastBraceIdx = output.lastIndexOf('}')
    if (lastBraceIdx !== -1 && lastBraceIdx > startIdx) {
      const potentialJson = output.substring(startIdx, lastBraceIdx + 1)
      try {
        const parsed = JSON.parse(potentialJson)
        // æˆåŠŸè§£æžï¼Œç§»é™¤ JSON éƒ¨åˆ†æ˜¾ç¤ºæ–‡å­—
        display = (output.substring(0, startIdx) + output.substring(lastBraceIdx + 1)).trim()
        if (/^[\s}\],,。.::;;]+$/.test(display)) {
          display = ''
        }
        if (parsed.description) {
          display = parsed.description
        }
        if (!display) {
          if (parsed.type === 'todo_list') {
            display = '已为您整理好相关数据:'
          } else if (parsed.charts && Object.keys(parsed.charts).length > 0) {
            display = '已为您生成分析图表:'
          } else {
            display = '正在为您展示分析结果...'
          }
        }
      } catch (e) {
        // è§£æžå¤±è´¥ï¼Œè¯´æ˜Ž JSON è¿˜åœ¨ä¼ è¾“中或格式不正确
        display = output.substring(0, startIdx).trim() || '正在分析数据并生成图表...'
      }
    } else {
      // æ‰¾åˆ°äº†å¼€å§‹ä½†è¿˜æ²¡æ‰¾åˆ°ç»“束
      display = output.substring(0, startIdx).trim() || '正在分析数据并生成图表...'
    }
  }
  let html = convertTextToHtml(display)
  // è¿˜åŽŸä»£ç å—
  html = html.replace(/```(javascript|js)([\s\S]*?)```/g, '<pre class="code-block js-code">$2</pre>')
  html = html.replace(/```([\s\S]*?)```/g, '<pre class="code-block">$1</pre>')
  return html || '...'
}
const renderCharts = (msgIndex, chartOptions) => {
  nextTick(() => {
    Object.keys(chartOptions).forEach(key => {
      const id = `ai-chart-${msgIndex}-${key}`
      const tryInit = (count = 0) => {
        const dom = document.getElementById(id)
        if (dom) {
          if (chartInstances.value[id]) {
            // å¦‚果已经初始化过,直接更新数据
            const chart = chartInstances.value[id]
            const option = normalizeAiChartOption(chartOptions[key])
            if (option) chart.setOption(option)
            return
          }
          const chart = echarts.init(dom)
          chartInstances.value[id] = chart
          const option = normalizeAiChartOption(chartOptions[key])
          if (option) {
            chart.setOption(option)
          } else {
            console.warn('Invalid chart option for:', id, chartOptions[key])
          }
          const handler = () => chart.resize()
          resizeHandlers.value.push(handler)
          window.addEventListener('resize', handler)
        } else if (count < 15) { // ç¨å¾®å¢žåŠ é‡è¯•æ¬¡æ•°
          setTimeout(() => tryInit(count + 1), 200)
        }
      }
      tryInit()
    })
  })
}
// æ ¼å¼åŒ– AI è¿”回的图表配置,将其转换为标准的 ECharts é…ç½®
const formatChartOption = (rawOption) => {
  if (!rawOption) return null
  // å¦‚果已经是标准 ECharts é…ç½®ï¼ˆåŒ…含 series),则直接返回
  const hasSeries = rawOption.series && Array.isArray(rawOption.series)
  // å°è¯•转换简易格式
  try {
    const isPie = rawOption.type === 'pie' || (rawOption.title && rawOption.title.includes('占比'))
    const option = {
      title: {
        text: rawOption.title || '',
        left: 'center',
        textStyle: { fontSize: 14 }
      },
      tooltip: {
        trigger: isPie ? 'item' : 'axis'
      },
      legend: {
        bottom: '0'
      },
      grid: {
        left: '3%',
        right: '4%',
        bottom: '15%',
        containLabel: true
      },
      xAxis: isPie ? undefined : {
        type: 'category',
        data: rawOption.xAxisData || (Array.isArray(rawOption.xAxis) ? rawOption.xAxis : []),
        name: typeof rawOption.xAxis === 'string' ? rawOption.xAxis : ''
      },
      yAxis: isPie ? undefined : {
        type: 'value',
        name: typeof rawOption.yAxis === 'string' ? rawOption.yAxis : ''
      },
      series: rawOption.series || [{
        name: rawOption.title || '数值',
        type: rawOption.type || 'line',
        data: rawOption.seriesData || (Array.isArray(rawOption.data) ? rawOption.data : []),
        smooth: true,
        radius: isPie ? '50%' : undefined
      }]
    }
    // é’ˆå¯¹é¥¼å›¾çš„特殊处理
    if (isPie && !option.series[0].data && Array.isArray(rawOption.data)) {
      option.series[0].data = rawOption.data
    }
    return option
  } catch (err) {
    console.error('Chart option conversion failed:', err)
    return null
  }
}
const normalizeAiChartOption = (rawOption) => {
  if (!rawOption) return null
  try {
    const hasSeries = Array.isArray(rawOption.series) && rawOption.series.length > 0
    const firstSeriesType = hasSeries ? rawOption.series[0]?.type : rawOption.type
    const titleConfig = rawOption.title && typeof rawOption.title === 'object'
      ? rawOption.title
      : null
    const tooltipConfig = rawOption.tooltip && typeof rawOption.tooltip === 'object'
      ? rawOption.tooltip
      : null
    const legendConfig = rawOption.legend && typeof rawOption.legend === 'object'
      ? rawOption.legend
      : null
    const rawXAxisConfig = rawOption.xAxis && typeof rawOption.xAxis === 'object' && !Array.isArray(rawOption.xAxis)
      ? rawOption.xAxis
      : null
    const rawYAxisConfig = rawOption.yAxis && typeof rawOption.yAxis === 'object' && !Array.isArray(rawOption.yAxis)
      ? rawOption.yAxis
      : null
    const titleText = typeof rawOption.title === 'string' ? rawOption.title : rawOption.title?.text || ''
    const isPie = firstSeriesType === 'pie' || titleText.includes('占比')
    const baseXAxisData = Array.isArray(rawOption.xAxisData)
        ? rawOption.xAxisData
        : (Array.isArray(rawOption.xAxis) ? rawOption.xAxis : (Array.isArray(rawXAxisConfig?.data) ? rawXAxisConfig.data : []))
    const fallbackSeries = [{
      name: titleText || '数据',
      type: rawOption.type || 'line',
      data: Array.isArray(rawOption.seriesData) ? rawOption.seriesData : (Array.isArray(rawOption.data) ? rawOption.data : [])
    }]
    const normalizedSeries = (hasSeries ? rawOption.series : fallbackSeries).map((seriesItem, index) => {
      const seriesType = seriesItem?.type || rawOption.type || 'line'
      const nextSeries = {
        ...seriesItem,
        name: seriesItem?.name || titleText || `系列${index + 1}`,
        type: seriesType
      }
      if (isPie) {
        nextSeries.radius = nextSeries.radius || '55%'
        nextSeries.data = Array.isArray(nextSeries.data) ? nextSeries.data : (Array.isArray(rawOption.data) ? rawOption.data : [])
      } else {
        nextSeries.smooth = typeof nextSeries.smooth === 'boolean' ? nextSeries.smooth : seriesType === 'line'
        nextSeries.data = Array.isArray(nextSeries.data) ? nextSeries.data : []
      }
      return nextSeries
    })
    const categorySource = !isPie
      ? normalizedSeries.find(seriesItem => Array.isArray(seriesItem.data) && seriesItem.data.every(item => item && typeof item === 'object' && 'name' in item && 'value' in item))
      : null
    const xAxisData = categorySource
      ? categorySource.data.map(item => item.name)
      : baseXAxisData
    const finalSeries = !isPie && categorySource
      ? normalizedSeries.map(seriesItem => ({
          ...seriesItem,
          data: Array.isArray(seriesItem.data)
            ? seriesItem.data.map(item => (item && typeof item === 'object' && 'value' in item ? item.value : item))
            : []
        }))
      : normalizedSeries
    return {
      title: titleConfig || {
        text: titleText,
        left: 'center',
        textStyle: { fontSize: 14 }
      },
      tooltip: tooltipConfig || {
        trigger: isPie ? 'item' : 'axis'
      },
      legend: legendConfig || {
        bottom: '0'
      },
      grid: isPie ? undefined : {
        left: '3%',
        right: '4%',
        bottom: '15%',
        containLabel: true
      },
      xAxis: isPie ? undefined : {
        ...(rawXAxisConfig || {}),
        type: 'category',
        data: xAxisData,
        name: typeof rawOption.xAxis === 'string' ? rawOption.xAxis : (rawXAxisConfig?.name || '')
      },
      yAxis: isPie ? undefined : (rawYAxisConfig || {
        type: 'value',
        name: typeof rawOption.yAxis === 'string' ? rawOption.yAxis : ''
      }),
      series: finalSeries
    }
  } catch (err) {
    console.error('AI chart normalization failed:', err, rawOption)
    return formatChartOption(rawOption)
  }
}
watch(messages, () => scrollToBottom(), { deep: true })
</script>
<style lang="scss">
.ai-chat-overlay {
  pointer-events: none !important;
  background: transparent !important;
}
.ai-chat-overlay .el-drawer {
  pointer-events: auto;
}
</style>
<style scoped lang="scss">
$primary-blue: #0055d4;
$secondary-blue: #2e8ce0;
$light-blue: #7ab8ff;
$pale-blue: #c5dcff;
$ice-white: #e8f2ff;
$deep-blue: #003b8e;
$deepest-blue: #002b66;
$gradient-blue: linear-gradient(145deg, #004fc7 0%, #0066e0 40%, #2580e8 70%, #5a9fe0 100%);
$gradient-dark: linear-gradient(145deg, #003b8e 0%, #0055d4 50%, #0077e8 100%);
$gradient-ice: linear-gradient(180deg, #e0ecff 0%, #d4e5ff 50%, #e8f0ff 100%);
$shadow-blue: 0 8px 40px rgba(0, 85, 212, 0.35);
$shadow-deep: 0 12px 48px rgba(0, 40, 120, 0.4);
$shadow-card: 0 6px 24px rgba(0, 51, 136, 0.12);
.ai-chat-sidebar-wrapper {
  position: static;
  z-index: 2000;
  pointer-events: auto;
  :deep(.el-drawer) {
    pointer-events: auto;
  }
}
.ai-chat-trigger {
  pointer-events: auto;
  position: fixed;
  right: 24px;
  bottom: 100px;
  width: 56px;
  height: 56px;
  background: $gradient-dark;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  box-shadow: $shadow-deep, 0 0 0 2px rgba(0, 85, 212, 0.3) inset, 0 0 30px rgba(0, 119, 232, 0.2);
  transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
  z-index: 2001;
  animation: triggerPulse 3s ease-in-out infinite;
  &::before {
    content: '';
    position: absolute;
    inset: -6px;
    background: linear-gradient(135deg, rgba(0, 85, 212, 0.4), rgba(0, 136, 232, 0.3), rgba(90, 159, 224, 0.2));
    border-radius: 50%;
    z-index: -1;
    filter: blur(16px);
    animation: glowPulse 2s ease-in-out infinite alternate;
  }
  &::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: 50%;
    background: linear-gradient(135deg, rgba(255, 255, 255, 0.3) 0%, transparent 50%);
    pointer-events: none;
  }
  &:hover {
    transform: scale(1.12) translateY(-4px);
    box-shadow: $shadow-deep, 0 0 0 3px rgba(0, 136, 232, 0.4) inset, 0 0 50px rgba(0, 136, 232, 0.3);
    &::before {
      animation: glowPulse 1s ease-in-out infinite alternate;
    }
    .trigger-icon {
      transform: rotate(-8deg) scale(1.05);
      filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.5));
    }
  }
  .trigger-icon {
    display: flex;
    align-items: center;
    justify-content: center;
    transition: all 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275);
    color: #fff;
    filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
  }
}
@keyframes triggerPulse {
  0%, 100% {
    box-shadow: $shadow-blue, 0 0 0 2px rgba(0, 85, 212, 0.25) inset, 0 0 20px rgba(0, 119, 232, 0.15);
  }
  50% {
    box-shadow: $shadow-deep, 0 0 0 3px rgba(0, 136, 232, 0.35) inset, 0 0 40px rgba(0, 136, 232, 0.25);
  }
}
@keyframes glowPulse {
  0% {
    opacity: 0.6;
    transform: scale(1);
  }
  100% {
    opacity: 1;
    transform: scale(1.1);
  }
}
.ai-chat-drawer {
  :deep(.el-drawer__body) {
    padding: 0;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    height: 100%;
  }
  :deep(.el-drawer__header) {
    margin-bottom: 0;
    padding: 0;
    background: $gradient-dark;
    color: #fff;
  }
}
.drawer-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  padding: 18px 20px;
  background: $gradient-dark;
  position: relative;
  overflow: hidden;
  &::before {
    content: '';
    position: absolute;
    top: -60%;
    right: -25%;
    width: 250px;
    height: 250px;
    background: radial-gradient(circle, rgba(0, 136, 232, 0.4) 0%, transparent 70%);
    pointer-events: none;
    animation: headerGlow 4s ease-in-out infinite alternate;
  }
  &::after {
    content: '';
    position: absolute;
    bottom: -40%;
    left: -15%;
    width: 200px;
    height: 200px;
    background: radial-gradient(circle, rgba(0, 85, 212, 0.3) 0%, transparent 70%);
    pointer-events: none;
    animation: headerGlow 5s ease-in-out infinite alternate-reverse;
  }
  .header-left {
    display: flex;
    align-items: center;
    gap: 12px;
    position: relative;
    z-index: 1;
    .header-icon {
      color: rgba(255, 255, 255, 0.95);
      filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.2));
      animation: iconFloat 3s ease-in-out infinite;
    }
    .title {
      font-size: 17px;
      font-weight: 600;
      color: #fff;
      text-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
      letter-spacing: 0.5px;
    }
  }
  .header-actions {
    display: flex;
    align-items: center;
    gap: 10px;
    position: relative;
    z-index: 1;
    .action-divider {
      width: 1px;
      height: 16px;
      background: rgba(255, 255, 255, 0.2);
      margin: 0 2px;
    }
    :deep(.el-button) {
      color: rgba(255, 255, 255, 0.85);
      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
      background: rgba(255, 255, 255, 0.12);
      border: 1px solid rgba(255, 255, 255, 0.1);
      border-radius: 8px;
      padding: 8px;
      height: 32px;
      width: 32px;
      display: flex;
      align-items: center;
      justify-content: center;
      &:hover {
        color: #fff;
        background: rgba(255, 255, 255, 0.25);
        border-color: rgba(255, 255, 255, 0.3);
        transform: translateY(-1px);
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
      }
      &:active {
        transform: translateY(0);
      }
      &.close-btn {
        background: rgba(255, 255, 255, 0.1);
        &:hover {
          background: rgba(245, 108, 108, 0.8);
          border-color: rgba(245, 108, 108, 0.5);
        }
      }
    }
    :deep(.header-action-btn) {
      position: relative;
      overflow: hidden;
      background: linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.08));
      border: 1px solid rgba(255, 255, 255, 0.16);
      box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 10px 18px rgba(0, 0, 0, 0.12);
      &::before {
        content: '';
        position: absolute;
        inset: 0;
        background: linear-gradient(135deg, rgba(255, 255, 255, 0.22), transparent 55%);
        pointer-events: none;
      }
      &::after {
        content: '';
        position: absolute;
        top: -120%;
        left: -40%;
        width: 60%;
        height: 260%;
        background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.28), transparent);
        transform: rotate(24deg);
        opacity: 0;
        transition: all 0.35s ease;
      }
      &:hover::after {
        left: 100%;
        opacity: 1;
      }
    }
  }
  .assistant-switcher {
    display: flex;
    align-items: center;
    justify-content: center;
    flex: 1;
    padding: 0 12px;
    position: relative;
    z-index: 1;
    :deep(.el-radio-group) {
      display: flex;
      gap: 6px;
      flex-wrap: wrap;
      justify-content: center;
      padding: 4px;
      border-radius: 999px;
      background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.08));
      border: 1px solid rgba(255, 255, 255, 0.12);
      box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 10px 18px rgba(0, 0, 0, 0.1);
    }
    :deep(.el-radio-button__inner) {
      border-radius: 999px;
      border: 1px solid rgba(255, 255, 255, 0.18);
      background: rgba(255, 255, 255, 0.12);
      color: rgba(255, 255, 255, 0.86);
      box-shadow: none;
      padding: 7px 14px;
      font-weight: 500;
    }
    :deep(.el-radio-button:first-child .el-radio-button__inner),
    :deep(.el-radio-button:last-child .el-radio-button__inner) {
      border-radius: 999px;
    }
    :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
      background: #fff;
      color: $primary-blue;
      border-color: #fff;
      box-shadow: 0 6px 14px rgba(0, 40, 120, 0.16);
    }
  }
}
@keyframes headerGlow {
  0% {
    opacity: 0.6;
    transform: scale(1);
  }
  100% {
    opacity: 1;
    transform: scale(1.15);
  }
}
@keyframes iconFloat {
  0%, 100% {
    transform: translateY(0) rotate(0);
  }
  50% {
    transform: translateY(-2px) rotate(3deg);
  }
}
.chat-container {
  display: flex;
  flex-direction: column;
  height: 100%;
  width: 100%;
  background: $ice-white;
  position: relative;
  overflow: hidden;
  &::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 240px;
    background: linear-gradient(180deg, rgba(0, 85, 212, 0.06) 0%, transparent 100%);
    pointer-events: none;
  }
}
.history-panel {
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, #fff 0%, $ice-white 100%);
  z-index: 10;
  display: flex;
  flex-direction: column;
  box-shadow: -8px 0 32px rgba(0, 85, 212, 0.15);
  .history-header {
    padding: 18px 20px;
    border-bottom: 1px solid rgba(0, 85, 212, 0.12);
    display: flex;
    justify-content: space-between;
    align-items: center;
    font-weight: 600;
    font-size: 14px;
    color: $deep-blue;
    background: linear-gradient(135deg, rgba(0, 85, 212, 0.08) 0%, rgba(0, 136, 232, 0.05) 100%);
    position: relative;
    &::after {
      content: '';
      position: absolute;
      bottom: 0;
      left: 0;
      right: 0;
      height: 1px;
      background: linear-gradient(90deg, transparent, rgba(0, 85, 212, 0.2), transparent);
    }
  }
  .session-list {
    flex: 1;
    overflow-y: auto;
    padding: 12px 16px;
    &::-webkit-scrollbar {
      width: 8px;
    }
    &::-webkit-scrollbar-thumb {
      background: linear-gradient(180deg, $secondary-blue, $primary-blue);
      border-radius: 4px;
      box-shadow: 0 0 6px rgba(0, 85, 212, 0.25);
    }
    .session-item {
      display: flex;
      align-items: center;
      padding: 14px 16px;
      margin-bottom: 6px;
      border-radius: 12px;
      cursor: pointer;
      transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
      gap: 12px;
      position: relative;
      border: 1px solid transparent;
      background: #fff;
      animation: sessionSlideIn 0.35s ease;
      @keyframes sessionSlideIn {
        from {
          opacity: 0;
          transform: translateX(-15px);
        }
        to {
          opacity: 1;
          transform: translateX(0);
        }
      }
      &:hover {
        background: linear-gradient(135deg, rgba(0, 85, 212, 0.06) 0%, rgba(0, 136, 232, 0.08) 100%);
        border-color: rgba(0, 85, 212, 0.12);
        box-shadow: 0 4px 16px rgba(0, 85, 212, 0.1);
        transform: translateX(4px);
        .delete-btn {
          opacity: 1;
          transform: scale(1);
        }
      }
      &.active {
        background: linear-gradient(135deg, rgba(0, 85, 212, 0.12) 0%, rgba(0, 136, 232, 0.15) 100%);
        border-color: rgba(0, 85, 212, 0.25);
        color: $primary-blue;
        box-shadow: 0 4px 16px rgba(0, 85, 212, 0.15);
        .el-icon {
          color: $primary-blue;
        }
      }
      .el-icon {
        font-size: 18px;
        flex-shrink: 0;
        color: $secondary-blue;
        transition: color 0.2s;
      }
      .session-name {
        flex: 1;
        font-size: 13px;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        color: #1a1a2e;
        font-weight: 500;
      }
      .delete-btn {
        opacity: 0;
        transform: scale(0.8);
        transition: all 0.25s ease;
        padding: 6px;
        border-radius: 6px;
        color: #c0c4cc;
        &:hover {
          color: #fff;
          background: rgba(245, 108, 108, 0.85);
          transform: scale(1.1) rotate(8deg);
        }
      }
    }
  }
}
.chat-main {
  display: flex;
  flex-direction: column;
  height: 100%;
  flex: 1;
  overflow: hidden;
}
.message-list {
  flex: 1;
  overflow-y: auto;
  padding: 24px 20px;
  display: flex;
  flex-direction: column;
  gap: 20px;
  background: linear-gradient(180deg, transparent 0%, rgba(0, 85, 212, 0.02) 100%);
  &::-webkit-scrollbar {
    width: 8px;
  }
  &::-webkit-scrollbar-thumb {
    background: linear-gradient(180deg, $secondary-blue, $primary-blue);
    border-radius: 4px;
    box-shadow: 0 0 8px rgba(0, 85, 212, 0.3);
  }
}
.message-item {
  display: flex;
  gap: 14px;
  width: 100%;
  animation: messageSlideIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
  @keyframes messageSlideIn {
    from {
      opacity: 0;
      transform: translateY(20px) scale(0.95);
    }
    to {
      opacity: 1;
      transform: translateY(0) scale(1);
    }
  }
  .avatar {
    width: 42px;
    height: 42px;
    border-radius: 14px;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    font-size: 24px;
    position: relative;
    overflow: hidden;
    &::before {
      content: '';
      position: absolute;
      inset: 0;
      background: inherit;
      filter: blur(10px);
      opacity: 0.5;
      z-index: -1;
    }
    &::after {
      content: '';
      position: absolute;
      top: -50%;
      left: -50%;
      width: 200%;
      height: 200%;
      background: linear-gradient(45deg, transparent 40%, rgba(255, 255, 255, 0.2) 50%, transparent 60%);
      animation: shimmer 3s infinite;
    }
  }
  .message-content {
    flex: 1;
    overflow-x: hidden;
    display: flex;
    flex-direction: column;
    max-width: calc(100% - 56px);
    .text-box {
      padding: 14px 20px;
      border-radius: 18px;
      font-size: 14px;
      line-height: 1.7;
      word-break: break-word;
      max-width: 100%;
      width: fit-content;
      overflow-x: auto;
      transition: all 0.3s ease;
      position: relative;
      &::-webkit-scrollbar {
        height: 4px;
      }
      &::-webkit-scrollbar-thumb {
        background: rgba(0, 85, 212, 0.25);
        border-radius: 2px;
      }
    }
  }
  &.bot-message {
    .message-content {
      align-items: flex-start;
    }
    .avatar {
      background: $gradient-dark;
      color: #fff;
      box-shadow: 0 6px 20px rgba(0, 85, 212, 0.35);
    }
    .text-box {
      background: #fff;
      color: #1a1a2e;
      box-shadow: $shadow-card;
      border: 1px solid rgba(0, 85, 212, 0.08);
      border-top-left-radius: 6px;
      &::before {
        content: '';
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        height: 1px;
        background: linear-gradient(90deg, rgba(0, 85, 212, 0.15), transparent);
      }
    }
  }
  &.user-message {
    flex-direction: row-reverse;
    .message-content {
      align-items: flex-end;
    }
    .avatar {
      background: linear-gradient(145deg, #5a9fe0, #3d8bd4);
      color: #fff;
      box-shadow: 0 6px 20px rgba(0, 85, 212, 0.4);
    }
    .text-box {
      background: $gradient-dark;
      color: #fff;
      border-top-right-radius: 6px;
      box-shadow: 0 6px 24px rgba(0, 85, 212, 0.3);
      &::before {
        content: '';
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        height: 1px;
        background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3));
      }
    }
  }
}
@keyframes shimmer {
  0% {
    transform: translateX(-100%) rotate(45deg);
  }
  100% {
    transform: translateX(100%) rotate(45deg);
  }
}
.charts-wrapper {
  margin-top: 12px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  overflow-x: auto;
  width: 100%;
  padding-bottom: 8px;
  &::-webkit-scrollbar {
    height: 6px;
  }
  &::-webkit-scrollbar-thumb {
    background: linear-gradient(90deg, $light-blue, $secondary-blue);
    border-radius: 3px;
  }
}
.chart-item {
  width: 100%;
  min-width: 300px;
  height: 300px;
  border-radius: 12px;
  padding: 12px;
  margin-bottom: 12px;
}
.table-wrapper {
  margin-top: 12px;
  background: #fff;
  border-radius: 12px;
  overflow: hidden;
  overflow-x: auto;
  width: 100%;
  box-shadow: $shadow-card;
  border: 1px solid rgba(0, 122, 255, 0.06);
  &::-webkit-scrollbar {
    height: 6px;
  }
  &::-webkit-scrollbar-thumb {
    background: linear-gradient(90deg, $light-blue, $secondary-blue);
    border-radius: 3px;
  }
  .el-table {
    min-width: 300px;
    --el-table-border-color: rgba(0, 122, 255, 0.08);
    --el-table-header-bg-color: $ice-white;
  }
}
.input-area {
  padding: 18px 20px;
  background: linear-gradient(180deg, rgba(232, 242, 255, 0.95) 0%, #fff 100%);
  border-top: 1px solid rgba(0, 85, 212, 0.1);
  position: relative;
  &::before {
    content: '';
    position: absolute;
    top: 0;
    left: 20px;
    right: 20px;
    height: 1px;
    background: linear-gradient(90deg, transparent, rgba(0, 85, 212, 0.15), transparent);
  }
  .input-actions {
    display: flex;
    gap: 14px;
    margin-bottom: 12px;
    align-items: center;
    .file-upload-trigger {
      display: inline-flex;
      align-items: center;
    }
    :deep(.utility-action-btn) {
      position: relative;
      height: 34px;
      padding: 0 14px;
      border-radius: 999px;
      border: 1px solid rgba(92, 119, 255, 0.18);
      background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(236, 243, 255, 0.98));
      color: $primary-blue;
      font-weight: 600;
      box-shadow: 0 10px 20px rgba(0, 85, 212, 0.08);
      transition: all 0.25s ease;
      .el-icon {
        margin-right: 5px;
      }
      &:hover:not(.is-disabled) {
        color: #fff;
        border-color: transparent;
        background: linear-gradient(135deg, #1f6dff 0%, #6b38ef 100%);
        box-shadow: 0 14px 24px rgba(64, 90, 255, 0.2);
        transform: translateY(-1px);
      }
    }
    :deep(.stop-action-btn) {
      border-color: rgba(255, 99, 123, 0.18);
      color: #d33e5e;
      &:hover:not(.is-disabled) {
        background: linear-gradient(135deg, #f5536e 0%, #a33cff 100%);
      }
    }
  }
  .input-box {
    padding: 16px;
    position: relative;
    background: #fff;
    border: 2px solid rgba(0, 85, 212, 0.12);
    border-radius: 16px;
    margin: 0 4px;
    transition: all 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275);
    &:focus-within {
      border-color: $primary-blue;
      box-shadow: 0 0 0 4px rgba(0, 85, 212, 0.12), $shadow-deep;
      transform: translateY(-2px);
      background: #fff;
    }
    .selected-file-tag {
      display: flex;
      align-items: center;
      background: linear-gradient(135deg, rgba(0, 85, 212, 0.1) 0%, rgba(0, 136, 232, 0.15) 100%);
      border: 1px solid rgba(0, 85, 212, 0.2);
      border-radius: 10px;
      padding: 8px 12px;
      margin-bottom: 12px;
      gap: 10px;
      width: fit-content;
      max-width: 100%;
      animation: tagSlideIn 0.3s ease;
      @keyframes tagSlideIn {
        from {
          opacity: 0;
          transform: translateX(-10px);
        }
        to {
          opacity: 1;
          transform: translateX(0);
        }
      }
      .el-icon {
        color: $primary-blue;
        font-size: 18px;
      }
      .file-name {
        font-size: 13px;
        color: $deep-blue;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        font-weight: 600;
      }
      .remove-file {
        cursor: pointer;
        color: $secondary-blue;
        transition: all 0.2s;
        padding: 4px;
        border-radius: 50%;
        &:hover {
          color: #fff;
          background: rgba(245, 108, 108, 0.8);
          transform: scale(1.1) rotate(90deg);
        }
      }
    }
    :deep(.el-textarea__inner) {
      padding: 0;
      padding-bottom: 35px;
      border: none;
      box-shadow: none;
      background: transparent;
      font-family: inherit;
      font-size: 14px;
      line-height: 1.6;
      color: #1a1a2e;
      &::placeholder {
        color: #7ab8ff;
      }
      &:focus {
        box-shadow: none;
      }
    }
    .send-btn {
      position: absolute;
      right: 16px;
      bottom: 16px;
      padding: 10px 22px;
      background: $gradient-dark;
      border: none;
      border-radius: 10px;
      font-weight: 600;
      font-size: 14px;
      color: #fff;
      box-shadow: 0 6px 20px rgba(0, 85, 212, 0.4);
      transition: all 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275);
      overflow: hidden;
      display: inline-flex;
      align-items: center;
      gap: 6px;
      letter-spacing: 0.3px;
      &::before {
        content: '';
        position: absolute;
        top: 0;
        left: -100%;
        width: 100%;
        height: 100%;
        background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
        transition: left 0.5s;
      }
      &:hover:not(:disabled) {
        transform: translateY(-3px) scale(1.02);
        box-shadow: 0 10px 30px rgba(0, 85, 212, 0.5);
        &::before {
          left: 100%;
        }
      }
      &:active:not(:disabled) {
        transform: translateY(-1px) scale(0.98);
      }
      &:disabled {
        background: linear-gradient(145deg, #b0b0b0, #c5c5c5);
        box-shadow: none;
        cursor: not-allowed;
      }
      .el-icon {
        font-size: 15px;
        transform: translateY(-1px);
      }
    }
  }
}
.typing-indicator {
  display: flex;
  gap: 5px;
  padding: 10px 14px;
  background: #fff;
  border-radius: 14px;
  width: fit-content;
  box-shadow: $shadow-card;
  margin-top: 6px;
  border: 1px solid rgba(0, 122, 255, 0.06);
  border-top-left-radius: 4px;
  .dot {
    width: 7px;
    height: 7px;
    background: $secondary-blue;
    border-radius: 50%;
    animation: typing 1.4s infinite ease-in-out;
    &:nth-child(2) {
      animation-delay: 0.2s;
      background: $primary-blue;
    }
    &:nth-child(3) {
      animation-delay: 0.4s;
      background: $deep-blue;
    }
  }
}
@keyframes typing {
  0%, 80%, 100% {
    transform: scale(0.6);
    opacity: 0.4;
  }
  40% {
    transform: scale(1);
    opacity: 1;
  }
}
.code-block {
  background: linear-gradient(145deg, #1a1a2e, #16213e);
  color: #a8d8ff;
  padding: 14px;
  border-radius: 10px;
  font-family: 'Fira Code', 'Consolas', monospace;
  margin: 10px 0;
  overflow-x: auto;
  border: 1px solid rgba(90, 200, 250, 0.15);
  box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.2);
  &.js-code {
    color: #5ac8fa;
  }
}
.chat-main {
  background:
    radial-gradient(circle at top left, rgba(46, 140, 224, 0.12) 0%, transparent 34%),
    linear-gradient(180deg, #fff 0%, #f7fbff 46%, #fff 100%);
}
.chat-hero {
  display: grid;
  grid-template-columns: 164px minmax(0, 1fr);
  gap: 18px;
  align-items: start;
  padding: 14px 18px 6px;
  &.compact {
    grid-template-columns: 122px minmax(0, 1fr);
    gap: 12px;
    padding: 8px 18px 2px;
  }
}
.assistant-stand {
  position: relative;
  min-height: 252px;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  padding-top: 18px;
  overflow: hidden;
  &.compact {
    min-height: 176px;
    padding-top: 8px;
  }
  &.thinking {
    .assistant-halo {
      opacity: 1;
      transform: scale(1.08);
      filter: blur(8px);
    }
    .assistant-scan-ring {
      opacity: 1;
      animation-duration: 1.6s;
    }
    .assistant-orbit {
      opacity: 1;
    }
    .assistant-bot {
      transform: translateY(-4px) scale(1.02);
    }
    .assistant-bot-head {
      box-shadow: 0 0 30px rgba(80, 157, 255, 0.36);
    }
    .assistant-bot-eye {
      animation: robotBlinkFast 1.1s infinite;
      box-shadow: 0 0 16px rgba(72, 186, 255, 0.95);
    }
    .assistant-bot-mouth {
      width: 28px;
      opacity: 1;
      animation: robotTalk 1.2s ease-in-out infinite;
    }
    .assistant-bot-core {
      animation: corePulse 1.4s ease-in-out infinite;
      box-shadow: 0 0 24px rgba(78, 120, 255, 0.26);
    }
    .assistant-bot-core-ring {
      animation: coreRotate 3s linear infinite;
    }
    .assistant-status {
      color: #6a3bee;
      box-shadow: 0 10px 22px rgba(106, 59, 238, 0.14);
    }
    .assistant-status-dot {
      background: #6a3bee;
      box-shadow: 0 0 12px rgba(106, 59, 238, 0.9);
      animation: thinkingDot 1s ease-in-out infinite;
    }
    .assistant-base-sm {
      box-shadow: 0 0 24px rgba(255, 93, 122, 0.48);
    }
  }
}
.assistant-halo {
  position: absolute;
  top: 22px;
  width: 130px;
  height: 130px;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(46, 140, 224, 0.3) 0%, rgba(0, 85, 212, 0.18) 42%, rgba(113, 54, 244, 0.12) 60%, transparent 78%);
  filter: blur(6px);
  opacity: 0.82;
  transition: all 0.35s ease;
}
.assistant-scan-ring {
  position: absolute;
  top: 40px;
  width: 132px;
  height: 132px;
  border-radius: 50%;
  border: 1px solid rgba(90, 159, 224, 0.22);
  box-shadow: inset 0 0 16px rgba(255, 255, 255, 0.25);
  opacity: 0.55;
  animation: scanRing 4s linear infinite;
}
.assistant-orbit {
  position: absolute;
  top: 52px;
  width: 150px;
  height: 150px;
  border-radius: 50%;
  border: 1px dashed rgba(92, 135, 255, 0.22);
  opacity: 0.45;
}
.assistant-orbit-a {
  animation: orbitRotate 8s linear infinite;
}
.assistant-orbit-b {
  width: 118px;
  height: 118px;
  top: 68px;
  border-color: rgba(255, 108, 150, 0.22);
  animation: orbitRotateReverse 5.6s linear infinite;
}
.assistant-bot {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  margin-top: 12px;
  transition: transform 0.35s ease;
}
.assistant-bot-antenna {
  position: absolute;
  top: -4px;
  width: 4px;
  height: 20px;
  border-radius: 999px;
  background: linear-gradient(180deg, #fefefe, #aac9ff);
  &::before {
    content: '';
    position: absolute;
    top: -6px;
    left: 50%;
    width: 10px;
    height: 10px;
    border-radius: 50%;
    transform: translateX(-50%);
    background: linear-gradient(135deg, #54bfff, #7a41ff);
    box-shadow: 0 0 14px rgba(84, 191, 255, 0.65);
  }
}
.assistant-bot-antenna-left {
  left: 36px;
  transform: rotate(-14deg);
}
.assistant-bot-antenna-right {
  right: 36px;
  transform: rotate(14deg);
}
.assistant-bot-head {
  position: relative;
  width: 92px;
  height: 78px;
  border-radius: 28px;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, #e8f1ff 100%);
  border: 1px solid rgba(0, 85, 212, 0.14);
  box-shadow: 0 16px 32px rgba(0, 85, 212, 0.14);
}
.assistant-bot-head-glow {
  position: absolute;
  inset: 10px 16px auto;
  height: 20px;
  border-radius: 999px;
  background: linear-gradient(180deg, rgba(0, 85, 212, 0.16), transparent);
}
.assistant-bot-eye {
  position: absolute;
  top: 30px;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: radial-gradient(circle, #8ef0ff 0%, #56c0ff 42%, #2869ff 100%);
  box-shadow: 0 0 12px rgba(72, 186, 255, 0.72);
  animation: robotBlink 3.2s infinite;
}
.assistant-bot-eye-left {
  left: 22px;
}
.assistant-bot-eye-right {
  right: 22px;
}
.assistant-bot-mouth {
  position: absolute;
  left: 50%;
  bottom: 16px;
  width: 22px;
  height: 4px;
  transform: translateX(-50%);
  border-radius: 999px;
  background: linear-gradient(90deg, rgba(72, 186, 255, 0.2), rgba(72, 186, 255, 0.9), rgba(72, 186, 255, 0.2));
}
.assistant-bot-neck {
  width: 16px;
  height: 10px;
  border-radius: 0 0 10px 10px;
  background: linear-gradient(180deg, #dceaff, #bdd5ff);
  margin-top: -2px;
}
.assistant-bot-body {
  position: relative;
  width: 104px;
  height: 92px;
  margin-top: 2px;
  border-radius: 28px 28px 34px 34px;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, #e3eeff 100%);
  border: 1px solid rgba(0, 85, 212, 0.14);
  box-shadow: 0 18px 36px rgba(0, 85, 212, 0.16);
  display: flex;
  align-items: center;
  justify-content: center;
}
.assistant-bot-arm {
  position: absolute;
  top: 18px;
  width: 16px;
  height: 44px;
  border-radius: 999px;
  background: linear-gradient(180deg, #eff5ff, #c7dbff);
  border: 1px solid rgba(0, 85, 212, 0.12);
}
.assistant-bot-arm-left {
  left: -10px;
  transform: rotate(16deg);
}
.assistant-bot-arm-right {
  right: -10px;
  transform: rotate(-16deg);
}
.assistant-bot-core {
  position: relative;
  width: 46px;
  height: 46px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: $primary-blue;
  background: radial-gradient(circle, rgba(255, 255, 255, 1) 0%, #dae8ff 55%, #adc7ff 100%);
}
.assistant-bot-core-ring {
  position: absolute;
  inset: -6px;
  border-radius: 50%;
  border: 1px solid rgba(88, 135, 255, 0.3);
  border-top-color: rgba(255, 96, 139, 0.85);
  border-right-color: rgba(79, 145, 255, 0.9);
}
.assistant-status {
  position: relative;
  z-index: 1;
  margin-top: 14px;
  padding: 6px 12px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
  color: $deep-blue;
  background: rgba(255, 255, 255, 0.95);
  border: 1px solid rgba(0, 85, 212, 0.12);
  box-shadow: 0 8px 20px rgba(0, 85, 212, 0.08);
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.assistant-status-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #2e8ce0;
  box-shadow: 0 0 10px rgba(46, 140, 224, 0.72);
}
.assistant-base {
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  border-radius: 50%;
  border: 2px solid rgba(255, 93, 122, 0.22);
  background: radial-gradient(circle, rgba(255, 255, 255, 0.9) 0%, rgba(255, 111, 145, 0.1) 70%, transparent 100%);
}
.assistant-base-lg {
  width: 118px;
  height: 30px;
}
.assistant-base-md {
  bottom: 6px;
  width: 88px;
  height: 20px;
  border-color: rgba(255, 93, 122, 0.34);
}
.assistant-base-sm {
  bottom: 11px;
  width: 54px;
  height: 10px;
  background: linear-gradient(90deg, rgba(255, 93, 122, 0.95), rgba(255, 173, 188, 0.9));
  border: none;
  box-shadow: 0 0 18px rgba(255, 93, 122, 0.38);
}
@keyframes robotBlink {
  0%, 44%, 48%, 100% {
    transform: scaleY(1);
  }
  46% {
    transform: scaleY(0.14);
  }
}
@keyframes robotBlinkFast {
  0%, 100% {
    transform: scaleY(1);
  }
  50% {
    transform: scaleY(0.3);
  }
}
@keyframes robotTalk {
  0%, 100% {
    transform: translateX(-50%) scaleX(1);
  }
  50% {
    transform: translateX(-50%) scaleX(1.35);
  }
}
@keyframes corePulse {
  0%, 100% {
    transform: scale(1);
    filter: brightness(1);
  }
  50% {
    transform: scale(1.08);
    filter: brightness(1.08);
  }
}
@keyframes coreRotate {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
@keyframes orbitRotate {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
@keyframes orbitRotateReverse {
  from {
    transform: rotate(360deg);
  }
  to {
    transform: rotate(0deg);
  }
}
@keyframes scanRing {
  0%, 100% {
    transform: scale(0.96);
    opacity: 0.42;
  }
  50% {
    transform: scale(1.04);
    opacity: 0.86;
  }
}
@keyframes thinkingDot {
  0%, 100% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.35);
  }
}
.welcome-card {
  position: relative;
  padding: 14px 14px 12px;
  border-radius: 16px;
  background:
    linear-gradient(#fff, #fff) padding-box,
    linear-gradient(135deg, rgba(255, 64, 96, 0.85), rgba(117, 65, 255, 0.9)) border-box;
  border: 1px solid transparent;
  box-shadow: 0 16px 36px rgba(0, 85, 212, 0.12);
  &.compact {
    padding: 10px 12px;
    border-radius: 12px;
    box-shadow: 0 8px 16px rgba(0, 85, 212, 0.07);
    .welcome-eyebrow {
      margin-bottom: 4px;
    }
    .welcome-title {
      font-size: 17px;
      line-height: 1.3;
      br {
        display: none;
      }
    }
    .welcome-desc {
      margin-top: 6px;
      font-size: 12px;
      line-height: 1.55;
    }
    .quick-prompt-list {
      margin-top: 10px;
      gap: 6px;
    }
    .quick-prompt-btn {
      padding: 8px 10px;
      font-size: 12px;
      border-radius: 7px;
    }
    .more-prompts-btn {
      margin-top: 8px;
      font-size: 12px;
    }
  }
}
.welcome-eyebrow {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 2px;
  color: rgba(0, 85, 212, 0.58);
  margin-bottom: 8px;
}
.welcome-title {
  margin: 0;
  font-size: 26px;
  line-height: 1.2;
  font-weight: 800;
  color: #172033;
}
.welcome-desc {
  margin: 10px 0 0;
  font-size: 13px;
  line-height: 1.7;
  color: #5f6980;
}
.quick-prompt-list {
  display: grid;
  gap: 8px;
  margin-top: 14px;
}
.quick-prompt-btn {
  width: 100%;
  border: none;
  border-radius: 10px;
  padding: 11px 14px;
  text-align: left;
  font-size: 13px;
  font-weight: 600;
  color: #fff;
  cursor: pointer;
  background: linear-gradient(90deg, #ff4c55 0%, #7c38ef 100%);
  box-shadow: 0 12px 22px rgba(124, 56, 239, 0.18);
  transition: transform 0.25s ease, box-shadow 0.25s ease, opacity 0.2s ease;
  position: relative;
  overflow: hidden;
  &::before {
    content: '';
    position: absolute;
    inset: 0;
    background: linear-gradient(135deg, rgba(255, 255, 255, 0.22), transparent 56%);
    pointer-events: none;
  }
  &::after {
    content: '';
    position: absolute;
    top: -120%;
    left: -30%;
    width: 45%;
    height: 260%;
    background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.3), transparent);
    transform: rotate(22deg);
    opacity: 0;
    transition: all 0.35s ease;
  }
  &:hover:not(:disabled) {
    transform: translateY(-2px) scale(1.01);
    box-shadow: 0 16px 28px rgba(124, 56, 239, 0.24);
    &::after {
      left: 100%;
      opacity: 1;
    }
  }
  &:disabled {
    cursor: not-allowed;
    opacity: 0.65;
  }
}
.more-prompts-btn {
  margin-top: 10px;
  padding: 0 12px;
  height: 32px;
  border: 1px solid rgba(208, 65, 81, 0.12);
  border-radius: 999px;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 241, 245, 0.96));
  color: #d04151;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  box-shadow: 0 10px 18px rgba(208, 65, 81, 0.08);
  transition: all 0.25s ease;
  &:hover {
    transform: translateY(-1px);
    background: linear-gradient(135deg, #ff5570 0%, #8a3df6 100%);
    border-color: transparent;
    color: #fff;
    box-shadow: 0 14px 24px rgba(138, 61, 246, 0.18);
  }
}
.hero-dot-grid {
  display: grid;
  grid-template-columns: repeat(14, 1fr);
  gap: 7px;
  padding: 0 18px 14px;
  span {
    display: block;
    width: 100%;
    aspect-ratio: 1;
    border-radius: 2px;
    background: linear-gradient(135deg, rgba(255, 110, 138, 0.95), rgba(255, 190, 201, 0.55));
  }
}
.message-list {
  padding: 8px 18px 18px;
  gap: 16px;
  background: transparent;
}
.input-area {
  padding: 12px 18px 16px;
  background: #fff;
  border-top: none;
  &::before {
    display: none;
  }
  .input-box {
    padding: 14px 16px 16px;
    border: 1px solid rgba(123, 56, 239, 0.9);
    border-radius: 22px;
    margin: 0;
    transition: all 0.25s ease;
    box-shadow: 0 14px 34px rgba(0, 85, 212, 0.08);
    &:focus-within {
      border-color: #7c38ef;
      box-shadow: 0 0 0 3px rgba(124, 56, 239, 0.1), 0 18px 40px rgba(0, 85, 212, 0.12);
      transform: none;
    }
    :deep(.el-textarea__inner) {
      padding-right: 58px;
      padding-bottom: 0;
      min-height: 104px;
      &::placeholder {
        color: #a0a9bc;
      }
    }
    .send-btn {
      right: 25px;
      top: 50%;
      transform: translateY(-50%);
      width: 36px;
      min-width: 36px;
      height: 36px;
      padding: 0;
      background: linear-gradient(135deg, #ff5570 0%, #7a36f2 58%, #2d79ff 100%);
      border-radius: 50%;
      box-shadow: 0 12px 24px rgba(109, 50, 236, 0.24);
      transition: all 0.25s ease;
      gap: 0;
      &:hover:not(:disabled) {
        transform: translateY(calc(-50% - 1px)) scale(1.04);
        box-shadow: 0 16px 28px rgba(109, 50, 236, 0.3);
      }
      &:active:not(:disabled) {
        transform: translateY(-50%) scale(0.96);
      }
      .el-icon {
        margin: 0;
        font-size: 16px;
        transform: translate(0, -1px);
      }
    }
  }
}
@media (max-width: 767px) {
  .chat-hero {
    grid-template-columns: 1fr;
    gap: 10px;
    padding: 14px 14px 6px;
    &.compact {
      padding: 8px 14px 4px;
    }
  }
  .assistant-stand {
    min-height: 184px;
  }
  .welcome-card {
    padding: 12px 12px 10px;
  }
  .welcome-title {
    font-size: 21px;
  }
  .hero-dot-grid {
    grid-template-columns: repeat(12, 1fr);
    gap: 6px;
    padding: 0 14px 12px;
  }
  .message-list {
    padding: 8px 14px 14px;
  }
  .input-area {
    padding: 10px 14px 14px;
  }
  .input-area .input-actions {
    gap: 10px;
    flex-wrap: wrap;
  }
}
</style>
src/components/Dialog/FileList.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,253 @@
<template>
  <el-dialog
      v-model="isShow"
      :title="title"
      :width="width"
      @close="handleClose"
      class="attachment-dialog"
  >
    <!-- å·¥å…·æ  -->
    <div class="toolbar">
      <el-button
          type="primary"
          size="small"
          @click="handleUpload"
      >
        ä¸Šä¼ é™„ä»¶
      </el-button>
    </div>
    <!-- ä¸Šä¼ ç»„件弹窗 -->
    <el-dialog
        v-model="uploadDialogVisible"
        title="上传附件"
        width="50%"
        @close="handleUploadClose"
    >
      <AttachmentUpload
          v-model:file-list="newFileList"
      />
      <template #footer>
        <el-button @click="handleUploadClose">关闭</el-button>
      </template>
    </el-dialog>
    <!-- æ–‡ä»¶åˆ—表表格 -->
    <div class="table-container">
      <el-table
          :data="tableData"
          border
          class="attachment-table"
          :height="tableData.length > 0 ? 'auto' : '120px'"
      >
        <el-table-column
            label="附件名称"
            prop="originalFilename"
            show-overflow-tooltip
        />
        <el-table-column
            v-if="showActions"
            fixed="right"
            label="操作"
            :width="120"
            align="center"
        >
          <template #default="scope">
            <el-button
                link
                type="primary"
                size="small"
                :href="scope.row.downloadURL"
                class="download-link"
            >
              ä¸‹è½½
            </el-button>
            <el-button
                link
                type="danger"
                size="small"
                @click="handleDelete(scope.row)"
            >
              åˆ é™¤
            </el-button>
          </template>
        </el-table-column>
      </el-table>
    </div>
  </el-dialog>
</template>
<script setup>
import { ref, computed, getCurrentInstance, onMounted, watch } from 'vue'
import AttachmentUpload from '@/components/AttachmentUpload/file/index.vue'
import {attachmentList, deleteAttachment, createAttachment} from "@/api/basicData/storageAttachment.js";
const props = defineProps({
  visible: {
    type: Boolean,
    required: true,
  },
  recordType: {
    type: String,
    default: '',
    required: true
  },
  recordId: {
    type: Number,
    default: 0,
    required: true
  },
  title: {
    type: String,
    default: '附件'
  },
  width: {
    type: String,
    default: '50%'
  },
  showActions: {
    type: Boolean,
    default: true
  }
})
const emit = defineEmits([
  'close',
  'download',
  'upload',
  'delete'
])
const { proxy } = getCurrentInstance()
const tableData = ref([])
const uploadDialogVisible = ref(false)
const newFileList = ref([])
const isShow = computed({
  get() {
    return props.visible;
  },
  set(val) {
    emit("update:visible", val);
  },
});
const handleClose = () => {
  isShow.value = false
}
const handleUpload = () => {
  uploadDialogVisible.value = true
}
const handleUploadClose = async () => {
  // æ£€æŸ¥æ˜¯å¦æœ‰æ–°ä¸Šä¼ çš„æ–‡ä»¶
  if (newFileList.value.length > 0) {
    try {
      await createAttachment({
        application: 'file',
        recordType: props.recordType,
        recordId: props.recordId,
        storageBlobDTOs: [...newFileList.value, ...tableData.value]
      })
      newFileList.value = []
      // åˆ·æ–°åˆ—表
      setList()
    } catch (error) {
      proxy?.$modal?.msgError('上传失败')
    }
  }
  uploadDialogVisible.value = false
}
const handleDelete = async (row, index) => {
  try {
    await deleteAttachment([row.storageAttachmentId])
    proxy?.$modal?.msgSuccess('删除成功')
    setList()
  } catch (error) {
    proxy?.$modal?.msgError('删除失败')
  }
}
const setList = () => {
  attachmentList({
    recordType: props.recordType,
    recordId: props.recordId,
  }).then(res => {
    if (res && res.data) {
      tableData.value = res.data || []
    }
  })
}
onMounted(() => {
  setList()
})
</script>
<style scoped>
.attachment-dialog {
  border-radius: 12px;
}
.toolbar {
  margin-bottom: 16px;
  text-align: right;
}
.table-container {
  max-height: 40vh;
  overflow-y: auto;
  min-height: 120px;
  padding-bottom: 16px;
  box-sizing: border-box;
  will-change: scroll-position;
  transform: translateZ(0);
  -webkit-overflow-scrolling: touch;
}
:deep(.el-table) {
  margin-bottom: 0;
}
:deep(.el-table__body-wrapper) {
  overflow-y: auto;
  will-change: transform;
  transform: translateZ(0);
}
:deep(.el-table__body tr) {
  transition: none;
}
:deep(.el-dialog__footer) {
  padding-top: 12px;
  border-top: 1px solid #e9ecef;
}
.attachment-table {
  border-radius: 8px;
}
:deep(.el-dialog__header) {
  background-color: #f8f9fa;
  border-bottom: 1px solid #e9ecef;
  padding: 16px 20px;
}
:deep(.el-dialog__title) {
  font-size: 16px;
  font-weight: 600;
}
:deep(.el-dialog__body) {
  padding: 16px 20px;
}
:deep(.el-table__empty-text) {
  color: #999;
}
</style>
src/components/PurchaseAIChatSidebar/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
<template>
  <AIChatSidebar :assistants="assistants" default-assistant="purchase" />
</template>
<script setup>
import { ShoppingCart } from '@element-plus/icons-vue'
import AIChatSidebar from '@/components/AIChatSidebar/index.vue'
const assistants = [
  {
    key: 'purchase',
    label: '采购助理',
    title: '采购智能助理',
    tooltip: '采购智能助理',
    icon: ShoppingCart,
    apiBase: '/purchase-ai',
    storageKey: 'purchase_ai_chat_uuid',
    placeholder: '请输入采购问题... (Enter å‘送, Shift+Enter æ¢è¡Œ)',
    welcomeMessage: '你好',
    allowFileUpload: false,
    emptySessionText: '暂无采购会话'
  }
]
</script>
src/layout/index.vue
@@ -1,131 +1,133 @@
<template>
  <div :class="classObj"
       class="app-wrapper"
       :style="{ '--current-color': theme }">
    <div v-if="device === 'mobile' && sidebar.opened"
         class="drawer-bg"
         @click="handleClickOutside" />
    <sidebar v-if="!sidebar.hide"
             class="sidebar-container" />
    <div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }"
         class="main-container">
      <div :class="{ 'fixed-header': fixedHeader }">
        <navbar @setLayout="setLayout" />
        <tags-view v-if="needTagsView" />
      </div>
      <app-main />
      <settings ref="settingRef" />
    </div>
  </div>
</template>
<script setup>
  import { useWindowSize } from "@vueuse/core";
  import Sidebar from "./components/Sidebar/index.vue";
  import { AppMain, Navbar, Settings, TagsView } from "./components";
  import defaultSettings from "@/settings";
  import useAppStore from "@/store/modules/app";
  import useSettingsStore from "@/store/modules/settings";
  const settingsStore = useSettingsStore();
  const theme = computed(() => settingsStore.theme);
  const sideTheme = computed(() => settingsStore.sideTheme);
  const sidebar = computed(() => useAppStore().sidebar);
  const device = computed(() => useAppStore().device);
  const needTagsView = computed(() => settingsStore.tagsView);
  const fixedHeader = computed(() => settingsStore.fixedHeader);
  const classObj = computed(() => ({
    hideSidebar: !sidebar.value.opened,
    openSidebar: sidebar.value.opened,
    withoutAnimation: sidebar.value.withoutAnimation,
    mobile: device.value === "mobile",
  }));
  const { width, height } = useWindowSize();
  const WIDTH = 992; // refer to Bootstrap's responsive design
  watch(
    () => device.value,
    () => {
      if (device.value === "mobile" && sidebar.value.opened) {
        useAppStore().closeSideBar({ withoutAnimation: false });
      }
    }
  );
  watchEffect(() => {
    if (width.value - 1 < WIDTH) {
      useAppStore().toggleDevice("mobile");
      useAppStore().closeSideBar({ withoutAnimation: true });
    } else {
      useAppStore().toggleDevice("desktop");
    }
  });
  function handleClickOutside() {
    useAppStore().closeSideBar({ withoutAnimation: false });
  }
  const settingRef = ref(null);
  function setLayout() {
    settingRef.value.openSetting();
  }
</script>
<style lang="scss" scoped>
  @import "@/assets/styles/mixin.scss";
  @import "@/assets/styles/variables.module.scss";
  .app-wrapper {
    @include clearfix;
    position: relative;
    height: 100%;
    width: 100%;
    background: radial-gradient(
        circle at top,
        rgba(223, 232, 226, 0.95),
        transparent 32%
      ),
      linear-gradient(180deg, #f7faf8 0%, var(--app-bg) 100%);
    &.mobile.openSidebar {
      position: fixed;
      top: 0;
    }
  }
  .drawer-bg {
    background: #000;
    opacity: 0.3;
    width: 100%;
    top: 0;
    height: 100%;
    position: absolute;
    z-index: 999;
  }
  .fixed-header {
    position: fixed;
    top: 0px;
    padding-top: 12px;
    right: 16px;
    z-index: 9;
    width: calc(100% - #{$base-sidebar-width} - 32px);
    transition: width 0.28s, right 0.28s;
    padding-bottom: 8px;
    background-color: #f3f6f4;
  }
  .hideSidebar .fixed-header {
    width: calc(100% - 100px);
  }
  .sidebarHide .fixed-header {
    width: calc(100% - 32px);
  }
  .mobile .fixed-header {
    width: 100%;
  }
</style>
<template>
  <div :class="classObj"
       class="app-wrapper"
       :style="{ '--current-color': theme }">
    <div v-if="device === 'mobile' && sidebar.opened"
         class="drawer-bg"
         @click="handleClickOutside" />
    <sidebar v-if="!sidebar.hide"
             class="sidebar-container" />
    <div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }"
         class="main-container">
      <div :class="{ 'fixed-header': fixedHeader }">
        <navbar @setLayout="setLayout" />
        <tags-view v-if="needTagsView" />
      </div>
      <app-main />
      <settings ref="settingRef" />
    </div>
    <AIChatSidebar />
  </div>
</template>
<script setup>
  import { useWindowSize } from "@vueuse/core";
  import Sidebar from "./components/Sidebar/index.vue";
  import { AppMain, Navbar, Settings, TagsView } from "./components";
  import AIChatSidebar from "@/components/AIChatSidebar/index.vue";
  import defaultSettings from "@/settings";
  import useAppStore from "@/store/modules/app";
  import useSettingsStore from "@/store/modules/settings";
  const settingsStore = useSettingsStore();
  const theme = computed(() => settingsStore.theme);
  const sideTheme = computed(() => settingsStore.sideTheme);
  const sidebar = computed(() => useAppStore().sidebar);
  const device = computed(() => useAppStore().device);
  const needTagsView = computed(() => settingsStore.tagsView);
  const fixedHeader = computed(() => settingsStore.fixedHeader);
  const classObj = computed(() => ({
    hideSidebar: !sidebar.value.opened,
    openSidebar: sidebar.value.opened,
    withoutAnimation: sidebar.value.withoutAnimation,
    mobile: device.value === "mobile",
  }));
  const { width, height } = useWindowSize();
  const WIDTH = 992; // refer to Bootstrap's responsive design
  watch(
    () => device.value,
    () => {
      if (device.value === "mobile" && sidebar.value.opened) {
        useAppStore().closeSideBar({ withoutAnimation: false });
      }
    }
  );
  watchEffect(() => {
    if (width.value - 1 < WIDTH) {
      useAppStore().toggleDevice("mobile");
      useAppStore().closeSideBar({ withoutAnimation: true });
    } else {
      useAppStore().toggleDevice("desktop");
    }
  });
  function handleClickOutside() {
    useAppStore().closeSideBar({ withoutAnimation: false });
  }
  const settingRef = ref(null);
  function setLayout() {
    settingRef.value.openSetting();
  }
</script>
<style lang="scss" scoped>
  @import "@/assets/styles/mixin.scss";
  @import "@/assets/styles/variables.module.scss";
  .app-wrapper {
    @include clearfix;
    position: relative;
    height: 100%;
    width: 100%;
    background: radial-gradient(
        circle at top,
        rgba(223, 232, 226, 0.95),
        transparent 32%
      ),
      linear-gradient(180deg, #f7faf8 0%, var(--app-bg) 100%);
    &.mobile.openSidebar {
      position: fixed;
      top: 0;
    }
  }
  .drawer-bg {
    background: #000;
    opacity: 0.3;
    width: 100%;
    top: 0;
    height: 100%;
    position: absolute;
    z-index: 999;
  }
  .fixed-header {
    position: fixed;
    top: 0px;
    padding-top: 12px;
    right: 16px;
    z-index: 9;
    width: calc(100% - #{$base-sidebar-width} - 32px);
    transition: width 0.28s, right 0.28s;
    padding-bottom: 8px;
    background-color: #f3f6f4;
  }
  .hideSidebar .fixed-header {
    width: calc(100% - 100px);
  }
  .sidebarHide .fixed-header {
    width: calc(100% - 32px);
  }
  .mobile .fixed-header {
    width: 100%;
  }
</style>
src/views/equipmentManagement/repair/Modal/RepairModal.vue
@@ -49,8 +49,8 @@
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="类目">
            <el-input v-model="form.machineryCategory" placeholder="请输入类目" />
          <el-form-item label="项目">
            <el-input v-model="form.machineryCategory" placeholder="请输入项目" />
          </el-form-item>
        </el-col>
      </el-row>
@@ -77,12 +77,20 @@
          </el-form-item>
        </el-col>
      </el-row>
      <el-row :gutter="30">
        <el-col :span="24">
          <el-form-item label="附件" prop="attachmentIds">
            <FileUpload v-model:file-list="form.storageBlobDTOs" />
          </el-form-item>
        </el-col>
      </el-row>
    </el-form>
  </FormDialog>
</template>
<script setup>
import FormDialog from "@/components/Dialog/FormDialog.vue";
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import {
  addRepair,
  editRepair,
@@ -106,6 +114,7 @@
const userStore = useUserStore();
const deviceOptions = ref([]);
const fileList = ref([]);
const loadDeviceName = async () => {
  const { data } = await getDeviceLedger();
@@ -121,6 +130,7 @@
  remark: undefined, // æ•…障现象
  status: 0, // æŠ¥ä¿®çŠ¶æ€
  machineryCategory: undefined,
  storageBlobDTOs: [],
});
const setDeviceModel = (deviceId) => {
@@ -137,6 +147,7 @@
  form.remark = data.remark;
  form.status = data.status;
  form.machineryCategory = data.machineryCategory;
  form.storageBlobDTOs = data.storageBlobVOs || [];
};
const sendForm = async () => {
@@ -168,6 +179,7 @@
const openAdd = async () => {
  id.value = undefined;
  visible.value = true;
  fileList.value = [];
  await nextTick();
  await loadDeviceName();
};
src/views/equipmentManagement/repair/index.vue
@@ -127,22 +127,31 @@
          >
            åˆ é™¤
          </el-button>
          <el-button
              type="primary"
              link
              @click="openFileDialog(row)"
          >
            é™„ä»¶
          </el-button>
        </template>
      </PIMTable>
    </div>
    <RepairModal ref="repairModalRef" @ok="getTableData"/>
    <MaintainModal ref="maintainModalRef" @ok="getTableData"/>
    <FileList v-if="fileDialogVisible"  v-model:visible="fileDialogVisible" :record-type="'device_repair'" :record-id="recordId"  />
  </div>
</template>
<script setup>
import { onMounted, getCurrentInstance, computed } from "vue";
import {onMounted, getCurrentInstance, computed, ref, defineAsyncComponent} from "vue";
import {usePaginationApi} from "@/hooks/usePaginationApi";
import {getRepairPage, delRepair} from "@/api/equipmentManagement/repair";
import RepairModal from "./Modal/RepairModal.vue";
import {ElMessageBox, ElMessage} from "element-plus";
import dayjs from "dayjs";
import MaintainModal from "./Modal/MaintainModal.vue";
const FileList = defineAsyncComponent(() => import("@/components/Dialog/FileList.vue"));
defineOptions({
  name: "设备报修",
@@ -188,7 +197,7 @@
        prop: "deviceModel",
      },
      {
        label: "类目",
        label: "项目",
        align: "center",
        prop: "machineryCategory",
      },
@@ -258,6 +267,15 @@
  getTableData();
};
// æ‰“开附件弹窗
const recordId =ref(0)
const fileDialogVisible = ref(false)
const openFileDialog = async (row) => {
  recordId.value = row.id
  fileDialogVisible.value = true
}
// å¤šé€‰åŽåšä»€ä¹ˆ
const handleSelectionChange = (selectionList) => {
  multipleList.value = selectionList;
src/views/equipmentManagement/upkeep/Form/PlanModal.vue
@@ -32,10 +32,10 @@
          disabled
        />
      </el-form-item>
      <el-form-item label="类目">
      <el-form-item label="项目">
        <el-input
            v-model="form.machineryCategory"
            placeholder="请输入类目"
            placeholder="请输入项目"
        />
      </el-form-item>
      <el-form-item label="录入人">
@@ -73,6 +73,13 @@
          clearable
        />
      </el-form-item>
      <el-row :gutter="30">
        <el-col :span="24">
          <el-form-item label="附件" prop="attachmentIds">
            <FileUpload v-model:file-list="form.storageBlobDTOs" />
          </el-form-item>
        </el-col>
      </el-row>
    </el-form>
  </FormDialog>
</template>
@@ -90,6 +97,7 @@
import { onMounted } from "vue";
import dayjs from "dayjs";
import { userListNoPage } from "@/api/system/user.js";
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
defineOptions({
  name: "设备保养新增计划",
@@ -115,6 +123,7 @@
  createUser: undefined, // å½•入人
  status: 0, //保修状态
  machineryCategory: undefined,
  storageBlobDTOs: [],
});
const setDeviceModel = (deviceId) => {
@@ -133,9 +142,12 @@
  form.createUser = Number(data.createUser);
  form.status = data.status;
  form.machineryCategory = data.machineryCategory;
  form.maintenancePlanTime = dayjs(data.maintenancePlanTime).format(
    "YYYY-MM-DD HH:mm:ss"
  );
  if (data.maintenancePlanTime) {
    form.maintenancePlanTime = dayjs(data.maintenancePlanTime).format(
      "YYYY-MM-DD HH:mm:ss"
    );
  }
  form.storageBlobDTOs = data.storageBlobVOs || [];
};
// ç”¨æˆ·åˆ—表
src/views/equipmentManagement/upkeep/index.vue
@@ -218,40 +218,27 @@
    <PlanModal ref="planModalRef" @ok="getTableData" />
        <MaintenanceModal ref="maintainModalRef" @ok="getTableData" />
        <FormDia ref="formDiaRef" @closeDia="getScheduledTableData" />
    <FileListDialog
      ref="fileListDialogRef"
      v-model="fileDialogVisible"
      :show-upload-button="true"
      :show-delete-button="true"
      :delete-method="handleAttachmentDelete"
      :name-column-label="'附件名称'"
      :rulesRegulationsManagementId="currentMaintenanceTaskId"
      @upload="handleAttachmentUpload" />
    <FileList v-if="fileDialogVisible"  v-model:visible="fileDialogVisible" :record-type="'device_maintenance'" :record-id="currentMaintenanceTaskId"  />
  </div>
</template>
<script setup>
import { ref, onMounted, reactive, getCurrentInstance, nextTick, computed } from 'vue'
import {ref, onMounted, reactive, getCurrentInstance, nextTick, computed, defineAsyncComponent} from 'vue'
import { Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import PlanModal from './Form/PlanModal.vue'
import MaintenanceModal from './Form/MaintenanceModal.vue'
import FormDia from './Form/formDia.vue'
import FileListDialog from '@/components/Dialog/FileListDialog.vue'
import {
  getUpkeepPage,
  delUpkeep,
  deviceMaintenanceTaskList,
  deviceMaintenanceTaskDel,
} from '@/api/equipmentManagement/upkeep'
import {
  listMaintenanceTaskFiles,
  addMaintenanceTaskFile,
  delMaintenanceTaskFile,
} from '@/api/equipmentManagement/maintenanceTaskFile'
import dayjs from 'dayjs'
const { proxy } = getCurrentInstance()
const FileList = defineAsyncComponent(() => import("@/components/Dialog/FileList.vue"));
// Tab相关
const activeTab = ref('scheduled')
@@ -373,7 +360,7 @@
        prop: "createUserName",
    },
  {
    label: "类目",
    label: "项目",
    align: "center",
    prop: "machineryCategory",
  },
@@ -602,77 +589,10 @@
  getTableData()
}
// é™„件相关方法
// æŸ¥è¯¢é™„件列表
const fetchMaintenanceTaskFiles = async (deviceMaintenanceId) => {
  try {
    const params = {
      current: 1,
      size: 100,
      deviceMaintenanceId,
      rulesRegulationsManagementId:deviceMaintenanceId
    }
    const res = await listMaintenanceTaskFiles(params)
    const records = res?.data?.records || []
    const mapped = records.map(item => ({
      id: item.id,
      name: item.fileName || item.name,
      url: item.fileUrl || item.url,
      raw: item,
    }))
    fileListDialogRef.value?.setList(mapped)
  } catch (error) {
    ElMessage.error('获取附件列表失败')
  }
}
// æ‰“开附件弹窗
const openFileDialog = async (row) => {
  currentMaintenanceTaskId.value = row.id
  fileDialogVisible.value = true
  await fetchMaintenanceTaskFiles(row.id)
}
// åˆ·æ–°é™„件列表
const refreshFileList = async () => {
  if (!currentMaintenanceTaskId.value) return
  await fetchMaintenanceTaskFiles(currentMaintenanceTaskId.value)
}
// ä¸Šä¼ é™„ä»¶
const handleAttachmentUpload = async (filePayload) => {
  if (!currentMaintenanceTaskId.value) return
  try {
    const payload = {
      name: filePayload?.fileName || filePayload?.name,
      url: filePayload?.fileUrl || filePayload?.url,
      deviceMaintenanceId: currentMaintenanceTaskId.value,
    }
    await addMaintenanceTaskFile(payload)
    ElMessage.success('文件上传成功')
    await refreshFileList()
  } catch (error) {
    ElMessage.error('文件上传失败')
  }
}
// åˆ é™¤é™„ä»¶
const handleAttachmentDelete = async (row) => {
  if (!row?.id) return false
  try {
    await ElMessageBox.confirm('确认删除该附件?', '提示', { type: 'warning' })
  } catch {
    return false
  }
  try {
    await delMaintenanceTaskFile(row.id)
    ElMessage.success('删除成功')
    await refreshFileList()
    return true
  } catch (error) {
    ElMessage.error('删除失败')
    return false
  }
}
onMounted(() => {
src/views/productionManagement/productionOrder/components/MaterialDetailDialog.vue
@@ -35,16 +35,30 @@
            <el-button type="primary"
                       link
                       @click="handleViewSupplementRecord(row)">
              {{ row.supplementQty ?? 0 }}
              {{ row.feedingQty ?? 0 }}
            </el-button>
          </template>
        </el-table-column>
        <el-table-column label="退料数量"
                         prop="returnQty"
                         min-width="110" />
                         min-width="110">
          <template #default="{ row }">
            {{ row.returnQty ?? 0 }}
          </template>
        </el-table-column>
        <el-table-column label="实际数量"
                         prop="actualQty"
                         min-width="110" />
                         min-width="140">
          <template #default="{ row }">
            <el-input-number v-model="row.actualQty"
                             :min="0"
                             :precision="3"
                             :step="1"
                             controls-position="right"
                             placeholder="输入实际数量"
                             style="width: 100%;"
                             :disabled="row.returned"
                             @change="val => handleActualQtyChange(row, val)" />
          </template>
        </el-table-column>
      </el-table>
      <template #footer>
        <span class="dialog-footer">
@@ -66,7 +80,7 @@
                border
                row-key="id">
        <el-table-column label="补料数量"
                         prop="supplementQty"
                         prop="pickQuantity"
                         min-width="120" />
        <el-table-column label="补料人"
                         prop="supplementUserName"
@@ -75,7 +89,7 @@
                         prop="supplementTime"
                         min-width="160" />
        <el-table-column label="补料原因"
                         prop="supplementReason"
                         prop="feedingReason"
                         min-width="200" />
      </el-table>
      <template #footer>
@@ -121,7 +135,7 @@
  import {
    listMaterialPickingDetail,
    listMaterialSupplementRecord,
    confirmMaterialReturn,
    updateMaterialPickingLedger,
  } from "@/api/productionManagement/productionOrder.js";
  const props = defineProps({
@@ -145,10 +159,12 @@
  const returnSummaryList = ref([]);
  const calcReturnQty = item =>
    Number(item.pickQuantity || 0) +
    Number(item.supplementQty || 0) -
    Number(item.feedingQty || 0) -
    Number(item.actualQty || 0);
  const canOpenReturnSummary = computed(() =>
    materialDetailTableData.value.some(item => calcReturnQty(item) > 0)
    materialDetailTableData.value.some(
      item => item.returned !== true && calcReturnQty(item) > 0
    )
  );
  const loadDetailList = async () => {
@@ -157,7 +173,13 @@
    materialDetailTableData.value = [];
    try {
      const res = await listMaterialPickingDetail(props.orderRow.id);
      materialDetailTableData.value = res.data || [];
      materialDetailTableData.value = (res.data || []).map(item => ({
        ...item,
        actualQty:
          item.actualQty ??
          Number(item.pickQuantity || 0) + Number(item.feedingQty || 0),
        returnQty: item.returnQty ?? 0,
      }));
    } finally {
      materialDetailLoading.value = false;
    }
@@ -176,6 +198,10 @@
    materialDetailTableData.value = [];
  };
  const handleActualQtyChange = (row, val) => {
    row.returnQty = calcReturnQty(row);
  };
  const handleViewSupplementRecord = async row => {
    if (!row?.id) return;
    supplementRecordDialogVisible.value = true;
@@ -183,7 +209,8 @@
    supplementRecordTableData.value = [];
    try {
      const res = await listMaterialSupplementRecord({
        materialDetailId: row.id,
        pickId: row.id,
        productionOrderId: props.orderRow.id,
      });
      supplementRecordTableData.value = res.data || [];
    } finally {
@@ -225,9 +252,24 @@
    if (!props.orderRow?.id) return;
    materialReturnConfirming.value = true;
    try {
      await confirmMaterialReturn({
        orderId: props.orderRow.id,
        returnSummaryList: returnSummaryList.value,
      await updateMaterialPickingLedger({
        productionOrderId: props.orderRow.id,
        productionOrderPickDto: materialDetailTableData.value.map(item => ({
          id: item.id,
          technologyOperationId: item.technologyOperationId,
          operationName: item.operationName,
          bom: item.bom === true,
          productModelId: item.productModelId,
          demandedQuantity: item.demandedQuantity,
          unit: item.unit,
          pickQuantity: item.pickQuantity,
          batchNo: item.batchNo,
          feedingQty: item.feedingQty,
          returnQty: item.returnQty,
          actualQty: item.actualQty,
          feedingReason: item.feedingReason,
          returned: true,
        })),
      });
      returnSummaryDialogVisible.value = false;
      dialogVisible.value = false;
src/views/productionManagement/productionOrder/components/MaterialSupplementDialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,159 @@
<template>
  <el-dialog v-model="dialogVisible"
             title="补料"
             width="1200px"
             @close="handleClose">
    <el-table v-loading="loading"
              :data="tableData"
              border
              row-key="id">
      <el-table-column label="工序名称"
                       prop="operationName"
                       min-width="140" />
      <el-table-column label="原料名称"
                       prop="productName"
                       min-width="140" />
      <el-table-column label="原料型号"
                       prop="model"
                       min-width="140" />
      <el-table-column label="计量单位"
                       prop="unit"
                       width="100" />
      <el-table-column label="领用数量"
                       prop="pickQuantity"
                       width="100" />
      <el-table-column label="补料数量"
                       min-width="150">
        <template #default="{ row }">
          <el-input-number v-model="row.newSupplementQty"
                           :min="0"
                           :precision="3"
                           :step="1"
                           controls-position="right"
                           placeholder="输入补料数量"
                           style="width: 100%;" />
        </template>
      </el-table-column>
      <el-table-column label="补料原因"
                       min-width="200">
        <template #default="{ row }">
          <el-input v-model="row.newSupplementReason"
                    placeholder="输入补料原因"
                    maxlength="200"
                    show-word-limit />
        </template>
      </el-table-column>
    </el-table>
    <template #footer>
      <span class="dialog-footer">
        <el-button type="primary"
                   :loading="submitting"
                   @click="handleSubmit">ç¡® å®š</el-button>
        <el-button @click="dialogVisible = false">取 æ¶ˆ</el-button>
      </span>
    </template>
  </el-dialog>
</template>
<script setup>
  import { computed, ref, watch } from "vue";
  import { ElMessage } from "element-plus";
  import {
    listMaterialPickingDetail,
    updateMaterialPickingLedger,
  } from "@/api/productionManagement/productionOrder.js";
  const props = defineProps({
    modelValue: { type: Boolean, default: false },
    orderRow: { type: Object, default: null },
  });
  const emit = defineEmits(["update:modelValue", "saved"]);
  const dialogVisible = computed({
    get: () => props.modelValue,
    set: val => emit("update:modelValue", val),
  });
  const loading = ref(false);
  const submitting = ref(false);
  const tableData = ref([]);
  const loadData = async () => {
    if (!props.orderRow?.id) return;
    loading.value = true;
    try {
      const res = await listMaterialPickingDetail(props.orderRow.id);
      tableData.value = (res.data || []).map(item => ({
        ...item,
        newSupplementQty: 0,
        newSupplementReason: "",
      }));
    } catch (e) {
      console.error("获取物料明细失败:", e);
      ElMessage.error("获取物料明细失败");
    } finally {
      loading.value = false;
    }
  };
  watch(
    () => dialogVisible.value,
    visible => {
      if (visible) {
        loadData();
      }
    }
  );
  const handleClose = () => {
    tableData.value = [];
  };
  const handleSubmit = async () => {
    const supplementList = tableData.value.filter(
      item => item.newSupplementQty > 0
    );
    if (supplementList.length === 0) {
      ElMessage.warning("请至少输入一条补料数量");
      return;
    }
    const invalidRow = supplementList.find(item => !item.newSupplementReason);
    if (invalidRow) {
      ElMessage.warning("请输入补料原因");
      return;
    }
    submitting.value = true;
    try {
      await updateMaterialPickingLedger({
        productionOrderId: props.orderRow.id,
        productionOrderPickDto: tableData.value.map(item => ({
          id: item.id,
          technologyOperationId: item.technologyOperationId,
          operationName: item.operationName,
          bom: item.bom === true,
          productModelId: item.productModelId,
          demandedQuantity: item.demandedQuantity,
          unit: item.unit,
          pickQuantity: item.pickQuantity,
          batchNo: item.batchNo,
          feedingQuantity: item.newSupplementQty || 0,
          feedingReason: item.newSupplementReason || "",
          pickType: 2,
        })),
      });
      ElMessage.success("补料成功");
      dialogVisible.value = false;
      emit("saved");
    } catch (e) {
      console.error("补料失败:", e);
      ElMessage.error("补料失败");
    } finally {
      submitting.value = false;
    }
  };
</script>
<style scoped lang="scss">
</style>
src/views/productionManagement/productionOrder/components/PrintMaterialRequisition.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,225 @@
<template>
  <div class="print-container"
       id="print-requisition">
    <div class="print-content">
      <div class="bill-title">生产领料单</div>
      <div class="info-grid">
        <div class="info-row">
          <div class="info-item">
            <span class="label">创建日期:</span>
            <span class="value">{{ formatDate(orderRow?.createTime) }}</span>
          </div>
          <div class="info-item">
            <span class="label">领料单号:</span>
            <span class="value">{{ orderRow?.npsNo }}</span>
          </div>
          <div class="info-item">
            <span class="label">申请人:</span>
            <span class="value">{{ userName }}</span>
          </div>
        </div>
        <div class="info-row">
          <div class="info-item"
               style="width: 50%;">
            <span class="label">产品名称/型号:</span>
            <span class="value">{{ orderRow?.productName }} / {{ orderRow?.model }}</span>
          </div>
          <div class="info-item"
               style="width: 25%;">
            <span class="label">生产数量:</span>
            <span class="value">{{ orderRow?.quantity }}</span>
          </div>
          <div class="info-item"
               style="width: 25%;">
            <span class="label">需求日期:</span>
            <span class="value">{{ formatDate(orderRow?.planCompleteTime) }}</span>
          </div>
        </div>
      </div>
      <table class="material-table">
        <thead>
          <tr>
            <th width="50">序号</th>
            <th>工序名称</th>
            <th>规格/名称</th>
            <th>批号</th>
            <th width="80">需求数量</th>
            <th width="80">领料数量</th>
            <th width="60">单位</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="(item, index) in materialList"
              :key="index">
            <td align="center">{{ index + 1 }}</td>
            <td>{{ item.operationName || '-' }}</td>
            <td>{{ item.materialName || item.productName }} {{ item.materialModel || item.model }}</td>
            <td>{{ item.batchNo || '-' }}</td>
            <td align="right">{{ item.demandedQuantity }}</td>
            <td align="right">{{ item.pickQuantity || item.pickQty || 0 }}</td>
            <td align="center">{{ item.unit }}</td>
          </tr>
        </tbody>
      </table>
      <div class="print-footer">
        <div class="footer-item">领料:________________</div>
        <div class="footer-item">发料:________________</div>
        <div class="footer-item">审核:________________</div>
      </div>
    </div>
  </div>
</template>
<script setup>
  import dayjs from "dayjs";
  import useUserStore from "@/store/modules/user";
  import { computed } from "vue";
  const props = defineProps({
    orderRow: {
      type: Object,
      default: () => ({}),
    },
    materialList: {
      type: Array,
      default: () => [],
    },
  });
  const userStore = useUserStore();
  const userName = computed(() => userStore.nickName || userStore.name || "-");
  const formatDate = date => {
    return date ? dayjs(date).format("YYYYå¹´MM月DD日") : "-";
  };
</script>
<style lang="scss">
  /* å±å¹•显示样式 */
  .print-requisition-wrapper {
    display: none;
  }
  /* æ‰“印专用样式 */
  @media print {
    @page {
      size: landscape;
      margin: 10mm;
    }
    /* åŸºç¡€æ‰“印设置 */
    html,
    body {
      visibility: hidden;
      height: auto !important;
      overflow: visible !important;
      margin: 0 !important;
      padding: 0 !important;
      width: 100%;
    }
    /* æ˜¾å¼æ˜¾ç¤ºæ‰“印容器及其所有子元素 */
    .print-requisition-wrapper,
    .print-requisition-wrapper * {
      visibility: visible !important;
    }
    /* ç¡®ä¿æ‰“印容器占据整个页面并移除绝对定位干扰 */
    .print-requisition-wrapper {
      display: block !important;
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: auto;
      background: white;
      margin: 0 !important;
      padding: 0 !important;
      z-index: 99999;
    }
    .print-container {
      width: 100% !important;
      padding: 0 10mm; /* ä½¿ç”¨å¯¹ç§°çš„左右内边距确保居中 */
      box-sizing: border-box;
      height: auto;
      overflow: visible;
      color: #000;
      font-family: "SimSun", "STSong", serif;
      page-break-inside: avoid;
      display: block;
      .print-content {
        width: 100%;
        text-align: center;
      }
      .bill-title {
        font-size: 20px;
        font-weight: bold;
        text-align: center;
        margin-bottom: 20px;
        letter-spacing: 5px;
        text-decoration: underline;
      }
      .info-grid {
        margin-bottom: 10px;
        font-size: 14px;
        .info-row {
          display: flex;
          flex-wrap: wrap;
          margin-bottom: 8px;
          .info-item {
            width: 33.33%;
            display: flex;
            align-items: flex-end;
            .label {
              font-weight: bold;
              white-space: nowrap;
            }
            .value {
              border-bottom: 1px solid #000;
              padding: 0 5px;
              flex: 1;
              min-height: 20px;
            }
          }
        }
      }
      .material-table {
        width: 100%;
        border-collapse: collapse;
        border: 2px solid #000;
        font-size: 13px;
        th,
        td {
          border: 1px solid #000 !important;
          padding: 6px 4px;
          height: 25px;
          -webkit-print-color-adjust: exact;
          print-color-adjust: exact;
        }
        th {
          background-color: #f2f2f2 !important;
          font-weight: bold;
        }
      }
      .print-footer {
        display: flex;
        justify-content: space-between;
        margin-top: 30px;
        padding: 0 10px;
        .footer-item {
          font-size: 14px;
        }
      }
    }
  }
</style>
src/views/productionManagement/productionOrder/index.vue
@@ -175,9 +175,18 @@
    <MaterialDetailDialog v-model="materialDetailDialogVisible"
                          :order-row="currentMaterialDetailOrder"
                          @confirmed="getList" />
    <MaterialSupplementDialog v-model="materialSupplementDialogVisible"
                              :order-row="currentMaterialSupplementOrder"
                              @saved="getList" />
    <new-product-order v-if="isShowNewModal"
                       v-model:visible="isShowNewModal"
                       @completed="handleQuery" />
    <!-- æ‰“印领料单组件 -->
    <div class="print-requisition-wrapper">
      <PrintMaterialRequisition ref="printRef"
                                :order-row="printOrderRow"
                                :material-list="printMaterialList" />
    </div>
  </div>
</template>
@@ -205,8 +214,14 @@
  import { listMain as getOrderProcessRouteMain } from "@/api/productionManagement/productProcessRoute.js";
  import MaterialLedgerDialog from "@/views/productionManagement/productionOrder/components/MaterialLedgerDialog.vue";
  import MaterialDetailDialog from "@/views/productionManagement/productionOrder/components/MaterialDetailDialog.vue";
  import MaterialSupplementDialog from "@/views/productionManagement/productionOrder/components/MaterialSupplementDialog.vue";
  import PrintMaterialRequisition from "@/views/productionManagement/productionOrder/components/PrintMaterialRequisition.vue";
  import PIMTable from "@/components/PIMTable/PIMTable.vue";
  import { listPage } from "@/api/productionManagement/processRoute.js";
  import {
    listMaterialPickingDetail,
    listMaterialPickingBom,
  } from "@/api/productionManagement/productionOrder.js";
  const NewProductOrder = defineAsyncComponent(() =>
    import("@/views/productionManagement/productionOrder/New.vue")
  );
@@ -304,7 +319,7 @@
      label: "操作",
      align: "center",
      fixed: "right",
      width: 360,
      width: 260,
      operation: [
        {
          name: "工艺路线",
@@ -340,15 +355,33 @@
        {
          name: "领料",
          type: "text",
          color: "#5EC7AB",
          clickFun: row => {
            openMaterialDialog(row);
          },
        },
        {
          name: "补料",
          type: "text",
          color: "#5EC7AB",
          clickFun: row => {
            openMaterialSupplementDialog(row);
          },
        },
        {
          name: "领料详情",
          type: "text",
          color: "#5EC7AB",
          clickFun: row => {
            openMaterialDetailDialog(row);
          },
        },
        {
          name: "打印领料单",
          type: "text",
          color: "#409eff",
          clickFun: row => {
            handlePrint(row);
          },
        },
      ],
@@ -423,6 +456,44 @@
  const currentMaterialOrder = ref(null);
  const materialDetailDialogVisible = ref(false);
  const currentMaterialDetailOrder = ref(null);
  const materialSupplementDialogVisible = ref(false);
  const currentMaterialSupplementOrder = ref(null);
  // æ‰“印相关
  const printOrderRow = ref(null);
  const printMaterialList = ref([]);
  const handlePrint = async row => {
    printOrderRow.value = row;
    proxy.$modal.loading("正在获取领料数据...");
    try {
      printMaterialList.value = [];
      const detailRes = await listMaterialPickingDetail(row.id);
      const detailList = Array.isArray(detailRes?.data)
        ? detailRes.data
        : detailRes?.data?.records || [];
      if (detailList.length > 0) {
        printMaterialList.value = detailList;
      }
      if (printMaterialList.value.length === 0) {
        proxy.$modal.msgWarning("暂无领料数据");
        return;
      }
      // ç­‰å¾… DOM æ›´æ–°åŽæ‰§è¡Œæ‰“印
      proxy.$nextTick(() => {
        setTimeout(() => {
          window.print();
        }, 800);
      });
    } catch (e) {
      console.error("获取领料数据失败:", e);
      proxy.$modal.msgError("获取领料数据失败");
    } finally {
      proxy.$modal.closeLoading();
    }
  };
  const openBindRouteDialog = async (row, type) => {
    bindForm.orderId = row.id;
@@ -478,6 +549,11 @@
    materialDetailDialogVisible.value = true;
  };
  const openMaterialSupplementDialog = row => {
    currentMaterialSupplementOrder.value = row;
    materialSupplementDialogVisible.value = true;
  };
  const handleReset = () => {
    searchForm.value = {
      ...searchForm.value,
src/views/productionManagement/productionProcess/Edit.vue
@@ -0,0 +1,168 @@
<template>
  <div>
    <el-dialog
        v-model="isShow"
        title="编辑工序"
        width="400"
        @close="closeModal"
    >
      <el-form label-width="140px" :model="formState" label-position="top" ref="formRef">
        <el-form-item
            label="工序名称:"
            prop="name"
            :rules="[
                {
                required: true,
                message: '请输入工序名称',
              },
              {
                max: 100,
                message: '最多100个字符',
              }
            ]">
          <el-input v-model="formState.name" />
        </el-form-item>
        <el-form-item label="工序编号" prop="no">
          <el-input v-model="formState.no"  />
        </el-form-item>
        <el-form-item
            label="工序类型"
            prop="type"
            :rules="[
                {
                required: true,
                message: '请选择工序类型',
              }
            ]"
        >
          <el-select v-model="formState.type" placeholder="请选择工序类型">
            <el-option label="计时" :value="0" />
            <el-option label="计件" :value="1" />
          </el-select>
        </el-form-item>
        <el-form-item label="工资定额" prop="salaryQuota">
          <el-input v-model="formState.salaryQuota" type="number" :step="0.001" />
        </el-form-item>
        <el-form-item label="是否质检" prop="isQuality">
          <el-switch v-model="formState.isQuality" :active-value="true" inactive-value="false"/>
        </el-form-item>
        <el-form-item label="是否入库" prop="inbound">
          <el-switch v-model="formState.inbound" :active-value="true" inactive-value="false"/>
        </el-form-item>
        <el-form-item label="是否报工" prop="reportWork">
          <el-switch v-model="formState.reportWork" :active-value="true" inactive-value="false"/>
        </el-form-item>
        <el-form-item label="备注" prop="remark">
          <el-input v-model="formState.remark" type="textarea" />
        </el-form-item>
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="handleSubmit">确认</el-button>
          <el-button @click="closeModal">取消</el-button>
        </div>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { ref, computed, getCurrentInstance, watch } from "vue";
import {update} from "@/api/productionManagement/productionProcess.js";
const props = defineProps({
  visible: {
    type: Boolean,
    required: true,
  },
  record: {
    type: Object,
    required: true,
  }
});
const emit = defineEmits(['update:visible', 'completed']);
// å“åº”式数据(替代选项式的 data)
const formState = ref({
  id: props.record.id,
  name: props.record.name,
  type: props.record.type,
  no: props.record.no,
  remark: props.record.remark,
  salaryQuota: props.record.salaryQuota,
  isQuality: props.record.isQuality,
  inbound: props.record.inbound,
  reportWork: props.record.reportWork,
});
const isShow = computed({
  get() {
    return props.visible;
  },
  set(val) {
    emit('update:visible', val);
  },
});
// ç›‘听 record å˜åŒ–,更新表单数据
watch(() => props.record, (newRecord) => {
  if (newRecord && isShow.value) {
    formState.value = {
      id: newRecord.id,
      name: newRecord.name || '',
      no: newRecord.no || '',
      type: newRecord.type,
      remark: newRecord.remark || '',
      salaryQuota: newRecord.salaryQuota || '',
      isQuality: props.record.isQuality,
      inbound: newRecord.inbound,
      reportWork: newRecord.reportWork,
    };
  }
}, { immediate: true, deep: true });
// ç›‘听弹窗打开,重新初始化表单数据
watch(() => props.visible, (visible) => {
  if (visible && props.record) {
    formState.value = {
      id: props.record.id,
      name: props.record.name || '',
      no: props.record.no || '',
      type: props.record.type,
      remark: props.record.remark || '',
      salaryQuota: props.record.salaryQuota || '',
      isQuality: props.record.isQuality,
      inbound: props.record.inbound,
      reportWork: props.record.reportWork,
    };
  }
});
let { proxy } = getCurrentInstance()
const closeModal = () => {
  isShow.value = false;
};
const handleSubmit = () => {
  proxy.$refs["formRef"].validate(valid => {
    if (valid) {
      update(formState.value).then(res => {
        // å…³é—­æ¨¡æ€æ¡†
        isShow.value = false;
        // å‘ŠçŸ¥çˆ¶ç»„件已完成
        emit('completed');
        proxy.$modal.msgSuccess("提交成功");
      })
    }
  })
};
defineExpose({
  closeModal,
  handleSubmit,
  isShow,
});
</script>
src/views/productionManagement/productionProcess/New.vue
@@ -0,0 +1,129 @@
<template>
  <div>
    <el-dialog
        v-model="isShow"
        title="新增工序"
        width="400"
        @close="closeModal"
    >
      <el-form label-width="140px" :model="formState" label-position="top" ref="formRef">
        <el-form-item
            label="工序名称:"
            prop="name"
            :rules="[
                {
                required: true,
                message: '请输入工序名称',
              },
              {
                max: 100,
                message: '最多100个字符',
              }
            ]">
          <el-input v-model="formState.name" />
        </el-form-item>
        <el-form-item label="工序编号" prop="no">
          <el-input v-model="formState.no"  />
        </el-form-item>
        <el-form-item
            label="工序类型"
            prop="type"
            :rules="[
                {
                required: true,
                message: '请选择工序类型',
              }
            ]"
        >
          <el-select v-model="formState.type" placeholder="请选择工序类型">
            <el-option label="计时" :value="0" />
            <el-option label="计件" :value="1" />
          </el-select>
        </el-form-item>
        <el-form-item label="工资定额" prop="salaryQuota">
          <el-input v-model="formState.salaryQuota" type="number" :step="0.001">
            <template #append>元</template>
          </el-input>
        </el-form-item>
        <el-form-item label="是否质检" prop="isQuality">
          <el-switch v-model="formState.isQuality" :active-value="true" inactive-value="false"/>
        </el-form-item>
        <el-form-item label="是否入库" prop="inbound">
          <el-switch v-model="formState.inbound" :active-value="true" inactive-value="false"/>
        </el-form-item>
        <el-form-item label="是否报工" prop="reportWork">
          <el-switch v-model="formState.reportWork" :active-value="true" inactive-value="false"/>
        </el-form-item>
        <el-form-item label="备注" prop="remark">
          <el-input v-model="formState.remark" type="textarea" />
        </el-form-item>
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="handleSubmit">确认</el-button>
          <el-button @click="closeModal">取消</el-button>
        </div>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { ref, computed, getCurrentInstance } from "vue";
import {add} from "@/api/productionManagement/productionProcess.js";
const props = defineProps({
  visible: {
    type: Boolean,
    required: true,
  },
});
const emit = defineEmits(['update:visible', 'completed']);
// å“åº”式数据(替代选项式的 data)
const formState = ref({
  name: '',
  type: undefined,
  remark: '',
  salaryQuota:  '',
  isQuality: false,
  inbound: false,
  reportWork: false,
});
const isShow = computed({
  get() {
    return props.visible;
  },
  set(val) {
    emit('update:visible', val);
  },
});
let { proxy } = getCurrentInstance()
const closeModal = () => {
  isShow.value = false;
};
const handleSubmit = () => {
  proxy.$refs["formRef"].validate(valid => {
    if (valid) {
      add(formState.value).then(res => {
        // å…³é—­æ¨¡æ€æ¡†
        isShow.value = false;
        // å‘ŠçŸ¥çˆ¶ç»„件已完成
        emit('completed');
        proxy.$modal.msgSuccess("提交成功");
      })
    }
  })
};
defineExpose({
  closeModal,
  handleSubmit,
  isShow,
});
</script>
src/views/productionManagement/productionReporting/index.vue
@@ -99,8 +99,7 @@
                                style="width: 100%" />
              </template>
            </el-table-column>
            <el-table-column label="操作"
                             >
            <el-table-column label="操作">
              <template #default="scope">
                <el-button link
                           type="primary"
@@ -124,11 +123,36 @@
    <input-modal v-if="isShowInput"
                 v-model:visible="isShowInput"
                 :production-product-main-id="isShowingId" />
    <!-- å‚数详情弹窗 -->
    <el-dialog v-model="paramDetailVisible"
               title="参数详情"
               width="600px">
      <div v-if="currentParams && currentParams.length > 0"
           class="param-detail-list">
        <el-descriptions :column="1"
                         border>
          <el-descriptions-item v-for="param in currentParams"
                                :key="param.id"
                                :label="param.paramName">
            {{ param.inputValue }}
            <span v-if="param.unit && param.unit !== '/'"
                  class="unit-text">({{ param.unit }})</span>
          </el-descriptions-item>
        </el-descriptions>
      </div>
      <el-empty v-else
                description="暂无参数数据" />
      <template #footer>
        <span class="dialog-footer">
          <el-button @click="paramDetailVisible = false">关闭</el-button>
        </span>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
  import { onMounted, ref } from "vue";
  import { onMounted, ref, reactive, toRefs, getCurrentInstance } from "vue";
  import FormDia from "@/views/productionManagement/productionReporting/components/formDia.vue";
  import { ElMessageBox } from "element-plus";
  import {
@@ -202,7 +226,7 @@
      prop: "unit",
      width: 120,
    },
    {
      label: "创建时间",
      prop: "createTime",
@@ -213,12 +237,20 @@
      label: "操作",
      align: "center",
      fixed: "right",
      width: 250,
      operation: [
        {
          name: "查看投入",
          type: "text",
          clickFun: row => {
            showInput(row);
          },
        },
        {
          name: "参数详情",
          type: "text",
          clickFun: row => {
            showParamDetail(row);
          },
        },
        {
@@ -232,6 +264,13 @@
    },
  ]);
  const tableData = ref([]);
  const paramDetailVisible = ref(false);
  const currentParams = ref([]);
  const showParamDetail = row => {
    currentParams.value = row.productionOperationParamList || [];
    paramDetailVisible.value = true;
  };
  const selectedRows = ref([]);
  const tableLoading = ref(false);
  const childrenLoading = ref(false);
@@ -418,7 +457,15 @@
</script>
<style scoped>
.table_list {
    margin-top: unset;
}
  .unit-text {
    margin-left: 5px;
    color: #909399;
    font-size: 12px;
  }
  .param-detail-list {
    padding: 10px;
  }
  .table_list {
    margin-top: unset;
  }
</style>
src/views/productionManagement/workOrderEdit/index.vue
@@ -4,74 +4,66 @@
      <div class="search-row">
        <div class="search-item">
          <span class="search_title">工单编号:</span>
          <el-input
            v-model="searchForm.workOrderNo"
            style="width: 240px"
            placeholder="请输入"
            @change="handleQuery"
            clearable
            prefix-icon="Search"
          />
          <el-input v-model="searchForm.workOrderNo"
                    style="width: 240px"
                    placeholder="请输入"
                    @change="handleQuery"
                    clearable
                    prefix-icon="Search" />
        </div>
        <div class="search-item">
          <span class="search_title">生产订单号:</span>
          <el-input
            v-model="searchForm.productOrderNpsNo"
            style="width: 240px"
            placeholder="请输入"
            @change="handleQuery"
            clearable
            prefix-icon="Search"
          />
          <el-input v-model="searchForm.productOrderNpsNo"
                    style="width: 240px"
                    placeholder="请输入"
                    @change="handleQuery"
                    clearable
                    prefix-icon="Search" />
        </div>
        <div class="search-item">
          <el-button type="primary" @click="handleQuery">搜索</el-button>
          <el-button type="primary"
                     @click="handleQuery">搜索</el-button>
        </div>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :tableLoading="tableLoading"
        @pagination="pagination"
      >
      <PIMTable rowKey="id"
                :column="tableColumn"
                :tableData="tableData"
                :page="page"
                :tableLoading="tableLoading"
                @pagination="pagination">
        <template #completionStatus="{ row }">
          <el-progress
            :percentage="toProgressPercentage(row?.completionStatus)"
            :color="progressColor(toProgressPercentage(row?.completionStatus))"
            :status="toProgressPercentage(row?.completionStatus) >= 100 ? 'success' : ''"
          />
          <el-progress :percentage="toProgressPercentage(row?.completionStatus)"
                       :color="progressColor(toProgressPercentage(row?.completionStatus))"
                       :status="toProgressPercentage(row?.completionStatus) >= 100 ? 'success' : ''" />
        </template>
      </PIMTable>
    </div>
    <el-dialog v-model="editDialogVisible" title="编辑计划时间" width="500px">
      <el-form :model="editrow" label-width="120px">
    <el-dialog v-model="editDialogVisible"
               title="编辑计划时间"
               width="500px">
      <el-form :model="editrow"
               label-width="120px">
        <el-form-item label="计划开始时间">
          <el-date-picker
            v-model="editrow.planStartTime"
            type="date"
            placeholder="请选择"
            value-format="YYYY-MM-DD"
            style="width: 300px"
          />
          <el-date-picker v-model="editrow.planStartTime"
                          type="date"
                          placeholder="请选择"
                          value-format="YYYY-MM-DD"
                          style="width: 300px" />
        </el-form-item>
        <el-form-item label="计划结束时间">
          <el-date-picker
            v-model="editrow.planEndTime"
            type="date"
            placeholder="请选择"
            value-format="YYYY-MM-DD"
            style="width: 300px"
          />
          <el-date-picker v-model="editrow.planEndTime"
                          type="date"
                          placeholder="请选择"
                          value-format="YYYY-MM-DD"
                          style="width: 300px" />
        </el-form-item>
      </el-form>
      <template #footer>
        <span class="dialog-footer">
          <el-button type="primary" @click="handleUpdate">确定</el-button>
          <el-button type="primary"
                     @click="handleUpdate">确定</el-button>
          <el-button @click="editDialogVisible = false">取消</el-button>
        </span>
      </template>
@@ -80,200 +72,201 @@
</template>
<script setup>
import { getCurrentInstance, onMounted, reactive, ref, toRefs } from "vue";
import { ElMessageBox } from "element-plus";
import {
  productWorkOrderPage,
  updateProductWorkOrder,
} from "@/api/productionManagement/workOrder.js";
  import { getCurrentInstance, onMounted, reactive, ref, toRefs } from "vue";
  import { ElMessageBox } from "element-plus";
  import {
    productWorkOrderPage,
    updateProductWorkOrder,
  } from "@/api/productionManagement/workOrder.js";
const { proxy } = getCurrentInstance();
  const { proxy } = getCurrentInstance();
const tableColumn = ref([
  {
    label: "工单类型",
    prop: "workOrderType",
    width: "80",
  },
  {
    label: "工单编号",
    prop: "workOrderNo",
    width: "140",
  },
  {
    label: "生产订单号",
    prop: "productOrderNpsNo",
    width: "140",
  },
  {
    label: "产品名称",
    prop: "productName",
    width: "140",
  },
  {
    label: "规格",
    prop: "model",
  },
  {
    label: "单位",
    prop: "unit",
  },
  {
    label: "工序名称",
    prop: "processName",
  },
  {
    label: "需求数量",
    prop: "planQuantity",
    width: "140",
  },
  {
    label: "完成数量",
    prop: "completeQuantity",
    width: "140",
  },
  {
    label: "完成进度",
    prop: "completionStatus",
    dataType: "slot",
    slot: "completionStatus",
    width: "140",
  },
  {
    label: "计划开始时间",
    prop: "planStartTime",
    width: "140",
  },
  {
    label: "计划结束时间",
    prop: "planEndTime",
    width: "140",
  },
  {
    label: "实际开始时间",
    prop: "actualStartTime",
    width: "140",
  },
  {
    label: "实际结束时间",
    prop: "actualEndTime",
    width: "140",
  },
  {
    label: "操作",
    width: "100",
    align: "center",
    dataType: "action",
    fixed: "right",
    operation: [
      {
        name: "计划时间",
        clickFun: row => {
          handleEdit(row);
  const tableColumn = ref([
    {
      label: "工单类型",
      prop: "workOrderType",
      width: "80",
    },
    {
      label: "工单编号",
      prop: "workOrderNo",
      width: "140",
    },
    {
      label: "生产订单号",
      prop: "npsNo",
      width: "140",
    },
    {
      label: "产品名称",
      prop: "productName",
      width: "140",
    },
    {
      label: "规格",
      prop: "model",
    },
    {
      label: "单位",
      prop: "unit",
    },
    {
      label: "工序名称",
      prop: "operationName",
      width: "100",
    },
    {
      label: "需求数量",
      prop: "planQuantity",
      width: "140",
    },
    {
      label: "完成数量",
      prop: "completeQuantity",
      width: "140",
    },
    {
      label: "完成进度",
      prop: "completionStatus",
      dataType: "slot",
      slot: "completionStatus",
      width: "140",
    },
    {
      label: "计划开始时间",
      prop: "planStartTime",
      width: "140",
    },
    {
      label: "计划结束时间",
      prop: "planEndTime",
      width: "140",
    },
    {
      label: "实际开始时间",
      prop: "actualStartTime",
      width: "140",
    },
    {
      label: "实际结束时间",
      prop: "actualEndTime",
      width: "140",
    },
    {
      label: "操作",
      width: "100",
      align: "center",
      dataType: "action",
      fixed: "right",
      operation: [
        {
          name: "计划时间",
          clickFun: row => {
            handleEdit(row);
          },
        },
      },
    ],
  },
]);
      ],
    },
  ]);
const tableData = ref([]);
const tableLoading = ref(false);
const editDialogVisible = ref(false);
const editrow = ref(null);
const page = reactive({
  current: 1,
  size: 100,
  total: 0,
});
  const tableData = ref([]);
  const tableLoading = ref(false);
  const editDialogVisible = ref(false);
  const editrow = ref(null);
  const page = reactive({
    current: 1,
    size: 100,
    total: 0,
  });
const data = reactive({
  searchForm: {
    workOrderNo: "",
    productOrderNpsNo: "",
  },
});
const { searchForm } = toRefs(data);
  const data = reactive({
    searchForm: {
      workOrderNo: "",
      productOrderNpsNo: "",
    },
  });
  const { searchForm } = toRefs(data);
const toProgressPercentage = val => {
  const n = Number(val);
  if (!Number.isFinite(n)) return 0;
  if (n <= 0) return 0;
  if (n >= 100) return 100;
  return Math.round(n);
};
  const toProgressPercentage = val => {
    const n = Number(val);
    if (!Number.isFinite(n)) return 0;
    if (n <= 0) return 0;
    if (n >= 100) return 100;
    return Math.round(n);
  };
const progressColor = percentage => {
  const p = toProgressPercentage(percentage);
  if (p < 30) return "#f56c6c";
  if (p < 50) return "#e6a23c";
  if (p < 80) return "#409eff";
  return "#67c23a";
};
  const progressColor = percentage => {
    const p = toProgressPercentage(percentage);
    if (p < 30) return "#f56c6c";
    if (p < 50) return "#e6a23c";
    if (p < 80) return "#409eff";
    return "#67c23a";
  };
const handleQuery = () => {
  page.current = 1;
  getList();
};
  const handleQuery = () => {
    page.current = 1;
    getList();
  };
const pagination = obj => {
  page.current = obj.page;
  page.size = obj.limit;
  getList();
};
  const pagination = obj => {
    page.current = obj.page;
    page.size = obj.limit;
    getList();
  };
const getList = () => {
  tableLoading.value = true;
  const params = { ...searchForm.value, ...page };
  productWorkOrderPage(params)
    .then(res => {
      tableLoading.value = false;
      tableData.value = res.data.records;
      page.total = res.data.total;
    })
    .catch(() => {
      tableLoading.value = false;
    });
};
const handleEdit = row => {
  editrow.value = JSON.parse(JSON.stringify(row));
  editDialogVisible.value = true;
};
const handleUpdate = () => {
  updateProductWorkOrder(editrow.value)
    .then(() => {
      proxy.$modal.msgSuccess("提交成功");
      editDialogVisible.value = false;
      getList();
    })
    .catch(() => {
      ElMessageBox.alert("修改失败", "提示", {
        confirmButtonText: "确定",
  const getList = () => {
    tableLoading.value = true;
    const params = { ...searchForm.value, ...page };
    productWorkOrderPage(params)
      .then(res => {
        tableLoading.value = false;
        tableData.value = res.data.records;
        page.total = res.data.total;
      })
      .catch(() => {
        tableLoading.value = false;
      });
    });
};
  };
onMounted(() => {
  getList();
});
  const handleEdit = row => {
    editrow.value = JSON.parse(JSON.stringify(row));
    editDialogVisible.value = true;
  };
  const handleUpdate = () => {
    updateProductWorkOrder(editrow.value)
      .then(() => {
        proxy.$modal.msgSuccess("提交成功");
        editDialogVisible.value = false;
        getList();
      })
      .catch(() => {
        ElMessageBox.alert("修改失败", "提示", {
          confirmButtonText: "确定",
        });
      });
  };
  onMounted(() => {
    getList();
  });
</script>
<style scoped lang="scss">
.search-row {
  display: flex;
  align-items: center;
  gap: 12px;
}
  .search-row {
    display: flex;
    align-items: center;
    gap: 12px;
  }
.search-item {
  display: flex;
  align-items: center;
}
  .search-item {
    display: flex;
    align-items: center;
  }
.search_title {
  margin-right: 8px;
  font-size: 14px;
  color: #606266;
}
  .search_title {
    margin-right: 8px;
    font-size: 14px;
    color: #606266;
  }
</style>
src/views/productionManagement/workOrderManagement/index.vue
@@ -40,7 +40,6 @@
        </template>
      </PIMTable>
    </div>
    <!-- æµè½¬å¡å¼¹çª— -->
    <el-dialog v-model="transferCardVisible"
               title="流转卡"
@@ -116,7 +115,6 @@
                   @click="printTransferCard">打印流转卡</el-button>
      </div>
    </el-dialog>
    <!-- æŠ¥å·¥å¼¹çª— -->
    <el-dialog v-model="reportDialogVisible"
               title="报工"
@@ -163,6 +161,75 @@
                       :value="user.userId" />
          </el-select>
        </el-form-item>
        <div v-if="params.length > 0"
             class="param-grid"
             v-loading="paramLoading">
          <el-form-item v-for="param in params"
                        :key="param.id"
                        :label="param.paramName"
                        :label-width="120"
                        class="param-item">
            <template v-if="param.paramType == '1'">
              <div class="param-input-group">
                <el-input-number v-model="reportForm.paramGroups[param.id]"
                                 controls-position="right"
                                 :key="param.id"
                                 style="width: 250px"
                                 class="param-input" />
                <span v-if="param.unit && param.unit != '/'"
                      class="param-unit">{{ param.unit }}</span>
              </div>
            </template>
            <template v-else-if="param.paramType == '2'">
              <div class="param-input-group">
                <el-input v-model="reportForm.paramGroups[param.id]"
                          :key="param.id"
                          style="width: 250px"
                          class="param-input" />
                <span v-if="param.unit && param.unit != '/'"
                      class="param-unit">{{ param.unit }}</span>
              </div>
            </template>
            <template v-else-if="param.paramType == '3'">
              <div class="param-input-group">
                <el-select v-model="reportForm.paramGroups[param.id]"
                           placeholder="请选择"
                           :key="param.id"
                           class="param-select"
                           style="width: 250px">
                  <el-option v-for="option in dictOptions[param.paramFormat] || []"
                             :key="option.dictLabel"
                             :label="option.dictLabel"
                             :value="option.dictLabel" />
                </el-select>
                <span v-if="param.unit && param.unit != '/'"
                      class="param-unit">{{ param.unit }}</span>
              </div>
            </template>
            <template v-else-if="param.paramType == '4'">
              <div class="param-input-group">
                <el-date-picker :value-format="param.paramFormat.replace('yyyy', 'YYYY').replace('dd', 'DD')"
                                :format="param.paramFormat.replace('yyyy', 'YYYY').replace('dd', 'DD')"
                                :key="param.id"
                                :type="param.paramFormat=='yyyy-MM-dd'?'date':'datetime'"
                                v-model="reportForm.paramGroups[param.id]"
                                class="param-input"
                                style="width: 250px" />
                <span v-if="param.unit && param.unit != '/'"
                      class="param-unit">{{ param.unit }}</span>
              </div>
            </template>
            <template v-else>
              <div class="param-input-group">
                <el-input v-model="reportForm.paramGroups[param.id]"
                          :key="param.id"
                          class="param-input" />
                <span v-if="param.unit && param.unit != '/'"
                      class="param-unit">{{ param.unit }}</span>
              </div>
            </template>
          </el-form-item>
        </div>
      </el-form>
      <template #footer>
        <span class="dialog-footer">
@@ -172,13 +239,9 @@
        </span>
      </template>
    </el-dialog>
    <MaterialDialog
      v-model="materialDialogVisible"
      :row-data="currentMaterialOrderRow"
      @refresh="getList"
    />
    <MaterialDialog v-model="materialDialogVisible"
                    :row-data="currentMaterialOrderRow"
                    @refresh="getList" />
    <FilesDia ref="workOrderFilesRef" />
  </div>
</template>
@@ -192,7 +255,9 @@
    addProductMain,
    downProductWorkOrder,
  } from "@/api/productionManagement/workOrder.js";
  import { findProcessParamListOrder } from "@/api/productionManagement/productProcessRoute.js";
  import { getUserProfile, userListNoPageByTenantId } from "@/api/system/user.js";
  import { getDicts } from "@/api/system/dict/data";
  import QRCode from "qrcode";
  import { getCurrentInstance, reactive, toRefs } from "vue";
  import FilesDia from "./components/filesDia.vue";
@@ -212,7 +277,7 @@
    },
    {
      label: "生产订单号",
      prop: "productOrderNpsNo",
      prop: "npsNo",
      width: "140",
    },
    {
@@ -230,7 +295,8 @@
    },
    {
      label: "工序名称",
      prop: "processName",
      prop: "operationName",
      width: "100",
    },
    {
      label: "需求数量",
@@ -288,12 +354,12 @@
            openWorkOrderFiles(row);
          },
        },
        {
          name: "物料",
          clickFun: row => {
            openMaterialDialog(row);
          },
        },
        // {
        //   name: "物料",
        //   clickFun: row => {
        //     openMaterialDialog(row);
        //   },
        // },
        {
          name: "报工",
          clickFun: row => {
@@ -304,7 +370,7 @@
      ],
    },
  ]);
  const tableData = ref([]);
  const tableLoading = ref(false);
  const transferCardVisible = ref(false);
@@ -325,7 +391,14 @@
    productProcessRouteItemId: "",
    userId: "",
    productMainId: null,
    productionOrderRoutingOperationId: "",
    productionOrderId: "",
    paramGroups: {},
  });
  const params = ref({});
  const dictOptions = ref({});
  const paramLoading = ref(false);
  // æœ¬æ¬¡ç”Ÿäº§æ•°é‡éªŒè¯è§„则
  const validateQuantity = (rule, value, callback) => {
@@ -416,7 +489,7 @@
    // æœ‰æ•ˆçš„非负整数(包括0)
    reportForm.scrapQty = num;
  };
  const currentReportRowData = ref(null);
  const materialDialogVisible = ref(false);
  const currentMaterialOrderRow = ref(null);
@@ -454,13 +527,13 @@
    page.current = 1;
    getList();
  };
  const pagination = obj => {
    page.current = obj.page;
    page.size = obj.limit;
    getList();
  };
  const getList = () => {
    tableLoading.value = true;
    const params = { ...searchForm.value, ...page };
@@ -554,10 +627,15 @@
    reportForm.productMainId = row.productMainId;
    reportForm.scrapQty =
      row.scrapQty !== undefined && row.scrapQty !== null ? row.scrapQty : null;
    reportForm.productionOrderRoutingOperationId =
      row.productionOrderRoutingOperationId;
    reportForm.productionOrderId = row.productionOrderId;
    nextTick(() => {
      reportFormRef.value?.clearValidate();
      if (row.productionOrderRoutingOperationId && row.productionOrderId) {
        loadParams(row.productionOrderRoutingOperationId, row.productionOrderId);
      }
    });
    // èŽ·å–å½“å‰ç™»å½•ç”¨æˆ·ä¿¡æ¯ï¼Œè®¾ç½®ä¸ºé»˜è®¤é€‰ä¸­
    getUserProfile()
      .then(res => {
        if (res.code === 200) {
@@ -634,18 +712,27 @@
        return;
      }
      const params = {
      const productionOperationParamList = params.value.map(param => ({
        ...param,
        inputValue: reportForm.paramGroups[param.id] ?? "",
      }));
      const submitParams = {
        quantity: quantity,
        scrapQty: isNaN(scrapQty) ? 0 : scrapQty,
        userId: reportForm.userId,
        userName: reportForm.userName,
        workOrderId: reportForm.workOrderId,
        productionOperationTaskId: reportForm.workOrderId,
        productProcessRouteItemId: reportForm.productProcessRouteItemId,
        reportWork: reportForm.reportWork,
        productMainId: reportForm.productMainId,
        productionOrderRoutingOperationId:
          reportForm.productionOrderRoutingOperationId,
        productionOrderId: reportForm.productionOrderId,
        productionOperationParamList: productionOperationParamList,
      };
      addProductMain(params)
      addProductMain(submitParams)
        .then(res => {
          proxy.$modal.msgSuccess("报工成功");
          reportDialogVisible.value = false;
@@ -662,6 +749,51 @@
  const handleUserChange = val => {
    const user = userOptions.value.find(item => item.userId === val);
    reportForm.userName = user ? user.nickName : "";
  };
  const getDictOptions = async dictType => {
    if (!dictType) return [];
    if (dictOptions.value[dictType]) return dictOptions.value[dictType];
    try {
      const res = await getDicts(dictType);
      if (res.code === 200) {
        dictOptions.value[dictType] = res.data;
        return res.data;
      }
      return [];
    } catch (error) {
      console.error("获取字典数据失败:", error);
      return [];
    }
  };
  const loadParams = (productionOrderRoutingOperationId, productionOrderId) => {
    paramLoading.value = true;
    findProcessParamListOrder({
      productionOrderRoutingOperationId,
      productionOrderId,
    })
      .then(res => {
        if (res.code === 200) {
          const paramList = res.data || [];
          params.value = paramList;
          reportForm.paramGroups = {};
          paramList.forEach(param => {
            if (!reportForm.paramGroups[param.id]) {
              reportForm.paramGroups[param.id] = "";
            }
            if (param.paramType == "3" && param.paramFormat) {
              getDictOptions(param.paramFormat);
            }
          });
        }
      })
      .catch(err => {
        console.error("获取工序参数失败:", err);
      })
      .finally(() => {
        paramLoading.value = false;
      });
  };
  onMounted(() => {
@@ -734,6 +866,30 @@
    text-align: center;
    margin-top: 20px;
  }
  .param-grid {
    margin-top: 10px;
    border-top: 1px solid #ebe9f3;
    padding-top: 10px;
  }
  .param-item {
    margin-bottom: 12px;
  }
  .param-input-group {
    display: flex;
    align-items: center;
    gap: 8px;
  }
  .param-input {
    flex: 1;
  }
  .param-select {
    flex: 1;
  }
  .param-unit {
    color: #909399;
    font-size: 12px;
    min-width: 30px;
  }
</style>
<style  lang="scss">
vite.config.js
@@ -8,7 +8,7 @@
  const { VITE_APP_ENV } = env;
  const baseUrl =
      env.VITE_APP_ENV === "development"
          ? "http://1.15.17.182:9003"
          ? "http://localhost:7005"
          : env.VITE_BASE_API;
  const javaUrl =
      env.VITE_APP_ENV === "development"