Jelajahi Sumber

feat(setting): 添加分类关联配置功能

- 新增 CategoryLinkConfig 组件,支持分类关联的增删查管理
- 在设置页添加分类关联入口,按企业ID和环境控制显示
- 新增 getCategoryLinks、saveCategoryLink、deleteCategoryLink 三个 IPC 通道
- 在 electron API 层添加分类关联的 GET/POST 接口
- 在 SettingController 中添加分类关联的控制器方法
- 拍照执行 run_mcu 时携带 company_id 参数
- 程序配置对话框支持左右脚切换与已占用过滤
panqiuyao 8 jam lalu
induk
melakukan
bee4e1685a

+ 25 - 0
electron/api/setting.js

@@ -152,6 +152,31 @@ module.exports = {
     })
   },
 
+  getCategoryLinks(data){
+    return get({
+      url: '/get_category_links',
+      data: data || {},
+    })
+  },
+
+  saveCategoryLink(data){
+    return post({
+      url: '/save_category_link',
+      data: data,
+      headers:{
+        'Content-Type':"application/json",
+      }
+    })
+  },
 
+  deleteCategoryLink(data){
+    return post({
+      url: '/delete_category_link',
+      data: data,
+      headers:{
+        'Content-Type':"application/json",
+      }
+    })
+  },
 
 }

+ 37 - 1
electron/controller/setting.js

@@ -19,7 +19,10 @@ const {
   updateTabName,
   syncSysConfigs,
   syncPhotoRecord,
-  syncActions
+  syncActions,
+  getCategoryLinks: fetchCategoryLinks,
+  saveCategoryLink: postCategoryLink,
+  deleteCategoryLink: removeCategoryLink
 } = require('../api/setting');
 
 const errData = {
@@ -236,6 +239,39 @@ class SettingController extends Controller {
       return errData;
     }
   }
+
+  async getCategoryLinks(args) {
+    try {
+      const result = await fetchCategoryLinks(args);
+      if(result.data) return result.data
+      return errData;
+    } catch (error) {
+      Log.error('获取分类关联失败:', error);
+      return errData;
+    }
+  }
+
+  async saveCategoryLink(args) {
+    try {
+      const result = await postCategoryLink(args);
+      if(result.data) return result.data
+      return errData;
+    } catch (error) {
+      Log.error('保存分类关联失败:', error);
+      return errData;
+    }
+  }
+
+  async deleteCategoryLink(args) {
+    try {
+      const result = await removeCategoryLink(args);
+      if(result.data) return result.data
+      return errData;
+    } catch (error) {
+      Log.error('删除分类关联失败:', error);
+      return errData;
+    }
+  }
 }
 
 SettingController.toString = () => '[class SettingController]';

+ 4 - 1
frontend/src/utils/ipc.ts

@@ -53,7 +53,10 @@ const icpList = {
         updateTabName: 'controller.setting.updateTabName',
         syncSysConfigs: 'controller.setting.syncSysConfigs',
         syncActions: 'controller.setting.syncActions',
-        syncPhotoRecord: 'controller.setting.syncPhotoRecord'
+        syncPhotoRecord: 'controller.setting.syncPhotoRecord',
+        getCategoryLinks: 'controller.setting.getCategoryLinks',
+        saveCategoryLink: 'controller.setting.saveCategoryLink',
+        deleteCategoryLink: 'controller.setting.deleteCategoryLink'
     },
     takePhoto:{
         getPhotoRecords: 'controller.takephoto.getPhotoRecords',

+ 3 - 0
frontend/src/utils/takePhotoApi.ts

@@ -4,6 +4,7 @@
  */
 import client from '@/stores/modules/client'
 import socket from '@/stores/modules/socket'
+import useUserInfo from '@/stores/modules/user'
 import icpList from '@/utils/ipc'
 
 export type FootSide = 'left' | 'right'
@@ -136,11 +137,13 @@ async function sendRunMcu(params: TakePhotoParams): Promise<TriggerResult> {
       setTimeout(() => reject(new Error('设备服务未连接')), 8000)
     }),
   ])
+  const companyId = Number((useUserInfo().userInfo as any)?.brand_company_id)
   socketStore.sendMessage({
     type: 'run_mcu',
     data: {
       action: params.action,
       goods_art_no: params.goods_art_no,
+      company_id: Number.isFinite(companyId) ? companyId : 0,
     },
   })
   return { started: true, code: 0 }

+ 4 - 1
frontend/src/views/Photography/mixin/usePhotography.ts

@@ -221,9 +221,12 @@ export default function usePhotography() {
       await socketStore.connectSocket();
 
       const weight = peekLockedWeight()
+      const companyId = Number((useUserInfoStore.userInfo as any)?.brand_company_id)
+      const payload = weight == null ? { ...data } : { ...data, weight }
+      payload.company_id = Number.isFinite(companyId) ? companyId : 0
       socketStore.sendMessage({
         type: 'run_mcu',
-        data: weight == null ? data : { ...data, weight },
+        data: payload,
       })
       runLoading.value = true;
       runAction.value.action = data.action

+ 393 - 0
frontend/src/views/Setting/components/CategoryLinkConfig.vue

@@ -0,0 +1,393 @@
+<template>
+  <div class="category-link" v-loading="loading">
+    <div class="btnBox">
+      <div class="primary-btn" @click="openAdd" v-log="{ describe: { action: '点击新增分类关联' } }">新增</div>
+    </div>
+    <el-table :data="links" border max-height="420" style="width: 100%" empty-text="暂无分类关联">
+      <el-table-column prop="category_name" label="分类名称" />
+      <el-table-column label="左右脚" width="120">
+        <template #default="{ row }">
+          {{ footText(row) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="程序配置">
+        <template #default="{ row }">
+          {{ configText(row) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="140">
+        <template #default="{ row, $index }">
+          <a class="mar-right-10 cursor-pointer" @click="openEdit(row, $index)" v-log="{ describe: { action: '点击编辑分类关联', category_name: row.category_name } }">编辑</a>
+          <a class="cursor-pointer" @click="removeLink(row, $index)" v-log="{ describe: { action: '点击删除分类关联', category_name: row.category_name } }">删除</a>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-dialog
+      v-model="dialogVisible"
+      :title="editingIndex >= 0 ? '编辑分类关联' : '新增分类关联'"
+      width="480px"
+      :close-on-click-modal="false"
+      append-to-body
+    >
+      <el-form label-width="90px">
+        <el-form-item label="分类名称">
+          <el-input
+            v-model="form.category_name"
+            :disabled="editingIndex >= 0"
+            maxlength="128"
+            placeholder="请输入分类名称"
+          />
+        </el-form-item>
+        <el-form-item label="左右脚">
+          <el-radio-group v-model="form.foot" @change="onFootChange">
+            <el-radio :label="0">左脚</el-radio>
+            <el-radio :label="1">右脚</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="程序配置">
+          <el-select v-model="form.tab_id" placeholder="请选择程序" style="width: 100%">
+            <el-option
+              v-for="tab in tabOptions"
+              :key="tab.id"
+              :label="tab.mode_name"
+              :value="tab.id"
+            />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <div class="btn-row">
+          <div class="normal-btn" @click="dialogVisible = false">取消</div>
+          <div class="primary-btn" :class="{ disabled: saving }" @click="saveLink">
+            {{ saving ? '保存中' : '保存' }}
+          </div>
+        </div>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import client from '@/stores/modules/client'
+import tokenInfo from '@/stores/modules/token'
+import useUserInfo from '@/stores/modules/user'
+import icpList from '@/utils/ipc'
+
+const clientStore = client()
+const tokenInfoStore = tokenInfo()
+const userInfoStore = useUserInfo()
+
+type TabItem = {
+  id: number
+  mode_name: string
+}
+
+type LinkItem = {
+  category_name: string
+  tab_name?: string
+  tab_id?: number
+  mode_type?: number
+  left_tab_id?: number
+  left_tab_name?: string
+  right_tab_id?: number
+  right_tab_name?: string
+}
+
+const loading = ref(false)
+const saving = ref(false)
+const dialogVisible = ref(false)
+const editingIndex = ref(-1)
+const links = ref<LinkItem[]>([])
+const leftTabs = ref<TabItem[]>([])
+const rightTabs = ref<TabItem[]>([])
+
+const form = reactive({
+  category_name: '',
+  foot: 0 as 0 | 1,
+  tab_id: undefined as number | undefined,
+})
+
+function tabNameById(tabId: number, storedName?: string) {
+  const tab = [...leftTabs.value, ...rightTabs.value].find(item => item.id === tabId)
+  return tab?.mode_name || storedName || ''
+}
+
+function footOf(tabId: number) {
+  if (leftTabs.value.some(tab => tab.id === tabId)) return 0
+  if (rightTabs.value.some(tab => tab.id === tabId)) return 1
+  return -1
+}
+
+function rowSides(row: LinkItem) {
+  const sides: { foot: 0 | 1, id: number, name: string }[] = []
+  const tabId = Number(row.tab_id || 0)
+  if (tabId) {
+    const inferred = footOf(tabId)
+    const foot: 0 | 1 = row.mode_type === 0 || row.mode_type === 1
+      ? row.mode_type
+      : inferred === 1 ? 1 : 0
+    sides.push({ foot, id: tabId, name: tabNameById(tabId, row.tab_name) })
+    return sides
+  }
+  const leftId = Number(row.left_tab_id || 0)
+  const rightId = Number(row.right_tab_id || 0)
+  if (leftId) sides.push({ foot: 0, id: leftId, name: tabNameById(leftId, row.left_tab_name) })
+  if (rightId) sides.push({ foot: 1, id: rightId, name: tabNameById(rightId, row.right_tab_name) })
+  return sides
+}
+
+function footText(row: LinkItem) {
+  const sides = rowSides(row)
+  if (!sides.length) return '未设置'
+  return sides.map(side => (side.foot === 0 ? '左脚' : '右脚')).join('、')
+}
+
+function configText(row: LinkItem) {
+  const sides = rowSides(row)
+  if (!sides.length) return '未设置'
+  return sides.map(side => side.name || '未设置').join('、')
+}
+
+function usedTabIds() {
+  const used = new Set<number>()
+  links.value.forEach((item, index) => {
+    if (index === editingIndex.value) return
+    rowSides(item).forEach(side => used.add(side.id))
+  })
+  return used
+}
+
+const tabOptions = computed(() => {
+  const used = usedTabIds()
+  const tabs = form.foot === 0 ? leftTabs.value : rightTabs.value
+  return tabs.filter(tab => !used.has(tab.id) || tab.id === form.tab_id)
+})
+
+function onFootChange(foot: 0 | 1) {
+  const tabs = foot === 0 ? leftTabs.value : rightTabs.value
+  if (!tabs.some(tab => tab.id === form.tab_id)) {
+    form.tab_id = undefined
+  }
+}
+
+function applyList(list: LinkItem[] | undefined) {
+  links.value = Array.isArray(list) ? list : []
+}
+
+function resultMessage(result: any, fallback: string) {
+  return result?.message || result?.msg || result?.err_msg || fallback
+}
+
+function currentCompanyId() {
+  const companyId = Number((userInfoStore.userInfo as any)?.brand_company_id)
+  return Number.isFinite(companyId) ? companyId : 0
+}
+
+async function loadTabs() {
+  const [left, right] = await Promise.all([
+    clientStore.ipc.invoke(icpList.setting.getDeviceTabs, { type: 0 }),
+    clientStore.ipc.invoke(icpList.setting.getDeviceTabs, { type: 1 }),
+  ])
+  if (left?.code === 0) leftTabs.value = left.data?.tabs || []
+  if (right?.code === 0) rightTabs.value = right.data?.tabs || []
+}
+
+async function loadLinks() {
+  const companyId = currentCompanyId()
+  if (!companyId) {
+    throw { msg: '企业ID不能为空' }
+  }
+  const result = await clientStore.ipc.invoke(icpList.setting.getCategoryLinks, {
+    company_id: companyId,
+  })
+  if (result?.code !== 0) {
+    throw result
+  }
+  applyList(result.data?.list)
+}
+
+async function refresh() {
+  loading.value = true
+  try {
+    await Promise.all([loadTabs(), loadLinks()])
+  } catch (error) {
+    ElMessage.error(resultMessage(error, '获取分类关联失败'))
+  } finally {
+    loading.value = false
+  }
+}
+
+function openAdd() {
+  editingIndex.value = -1
+  form.category_name = ''
+  form.foot = 0
+  form.tab_id = undefined
+  dialogVisible.value = true
+}
+
+function openEdit(row: LinkItem, index: number) {
+  const side = rowSides(row)[0]
+  editingIndex.value = index
+  form.category_name = row.category_name
+  form.foot = side?.foot ?? 0
+  form.tab_id = side?.id
+  dialogVisible.value = true
+}
+
+async function saveLink() {
+  if (saving.value) return
+  const categoryName = (form.category_name || '').trim()
+  if (!categoryName) {
+    ElMessage.warning('分类名称不能为空')
+    return
+  }
+  if (!form.tab_id) {
+    ElMessage.warning('请选择左右脚程序')
+    return
+  }
+  saving.value = true
+  try {
+    const companyId = currentCompanyId()
+    if (!companyId) {
+      ElMessage.warning('企业ID不能为空')
+      return
+    }
+    const result = await clientStore.ipc.invoke(icpList.setting.saveCategoryLink, {
+      company_id: companyId,
+      category_name: categoryName,
+      tab_id: form.tab_id,
+      token: tokenInfoStore.getToken || '',
+    })
+    if (result?.code !== 0) {
+      ElMessage.error(resultMessage(result, '保存失败'))
+      return
+    }
+    applyList(result.data?.list)
+    dialogVisible.value = false
+    ElMessage.success(result.msg || '操作成功')
+  } catch (error) {
+    ElMessage.error(resultMessage(error, '保存失败'))
+  } finally {
+    saving.value = false
+  }
+}
+
+async function removeLink(row: LinkItem, index: number) {
+  try {
+    await ElMessageBox.confirm(`确定删除分类「${row.category_name}」的关联吗?`, '删除分类关联', {
+      confirmButtonText: '删除',
+      cancelButtonText: '取消',
+      type: 'warning',
+    })
+  } catch {
+    return
+  }
+  loading.value = true
+  try {
+    const companyId = currentCompanyId()
+    if (!companyId) {
+      ElMessage.warning('企业ID不能为空')
+      return
+    }
+    const result = await clientStore.ipc.invoke(icpList.setting.deleteCategoryLink, {
+      company_id: companyId,
+      index,
+      token: tokenInfoStore.getToken || '',
+    })
+    if (result?.code !== 0) {
+      ElMessage.error(resultMessage(result, '删除失败'))
+      return
+    }
+    applyList(result.data?.list)
+    ElMessage.success(result.msg || '删除成功')
+  } catch (error) {
+    ElMessage.error(resultMessage(error, '删除失败'))
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(() => {
+  refresh()
+})
+</script>
+
+<style scoped lang="scss">
+.category-link {
+  .btnBox {
+    display: flex;
+    align-items: center;
+    margin-bottom: 12px;
+  }
+  .primary-btn {
+    width: 80px;
+    height: 30px;
+    background: linear-gradient(135deg, #2FB0FF 0%, #B863FB 100%);
+    border-radius: 4px;
+    color: #fff;
+    font-size: 14px;
+    text-align: center;
+    cursor: pointer;
+    line-height: 30px;
+    margin-right: 10px;
+    &.disabled {
+      opacity: 0.6;
+      pointer-events: none;
+    }
+  }
+  .cursor-pointer {
+    cursor: pointer;
+  }
+  .mar-right-10 {
+    margin-right: 10px;
+  }
+  :deep(.el-table .el-table__header) {
+    .el-table__cell {
+      background: #F1F4FF;
+    }
+  }
+  :deep(.el-table .el-table__cell) {
+    text-align: center;
+  }
+  :deep(.el-table__row:nth-child(even)) {
+    background: #F1F4FF;
+  }
+}
+.btn-row {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 10px;
+  .primary-btn,
+  .normal-btn {
+    width: 100px;
+    height: 30px;
+    border-radius: 4px;
+    font-size: 14px;
+    text-align: center;
+    line-height: 30px;
+    cursor: pointer;
+  }
+  .primary-btn {
+    background: linear-gradient(135deg, #2FB0FF 0%, #B863FB 100%);
+    color: #fff;
+    &.disabled {
+      opacity: 0.6;
+      pointer-events: none;
+    }
+  }
+  .normal-btn {
+    background: #fff;
+    border: 1px solid #CCCCCC;
+  }
+}
+.empty-tip {
+  margin-top: 6px;
+  color: #999;
+  font-size: 12px;
+  line-height: 1.4;
+}
+</style>

+ 87 - 1
frontend/src/views/Setting/components/action_config.vue

@@ -1,5 +1,17 @@
 <template>
 
+  <div
+    class="category-link-entry"
+    v-if="showCategoryLink"
+    @click="categoryDialogVisible = true"
+    v-log="{ describe: { action: '点击分类关联' } }"
+  >
+    <i class="entry-bar"></i>
+    <span class="entry-label">分类关联</span>
+    <span class="entry-desc">按分类指定左右脚程序</span>
+    <span class="entry-action">配置</span>
+  </div>
+
   <el-tabs v-model="topsTab" type="card" class="top_tabs" :disabled="isSortMode">
     <el-tab-pane label="执行左脚程序" name="left">
     </el-tab-pane>
@@ -92,17 +104,33 @@
       :addRowData="addRowData"
       @confirm="getList"
   />
+
+  <el-dialog
+      v-model="categoryDialogVisible"
+      title="分类关联"
+      width="860px"
+      append-to-body
+      custom-class="category-link-dialog"
+      :close-on-click-modal="false"
+  >
+    <CategoryLinkConfig v-if="categoryDialogVisible" />
+  </el-dialog>
 </template>
 
 <script setup lang="ts">
-import { ref, defineProps, defineEmits , watch,onMounted, reactive,onBeforeUnmount, nextTick } from 'vue'
+import { ref, computed, defineProps, defineEmits , watch,onMounted, reactive,onBeforeUnmount, nextTick } from 'vue'
 import EditDialog from "./EditDialog.vue";
+import CategoryLinkConfig from "./CategoryLinkConfig.vue";
 import { ElMessage, ElMessageBox } from 'element-plus';
 import { Rank, Warning } from '@element-plus/icons-vue';
 import client from "@/stores/modules/client";
 import icpList from '@/utils/ipc';
 import tokenInfo from '@/stores/modules/token';
+import useUserInfo from '@/stores/modules/user';
+import configInfo from '@/stores/modules/config';
 const clientStore = client();
+const userInfoStore = useUserInfo();
+const configInfoStore = configInfo();
 import socket from "@/stores/modules/socket";
 const socketStore = socket(); // WebSocket状态管理实例
 const tokenInfoStore = tokenInfo();
@@ -122,8 +150,23 @@ const selectID = ref(0) //当前默认的ID
 const isSortMode = ref(false); // 是否处于排序模式
 const originalTableData = ref([]); // 保存原始数据用于排序
 const tableRef = ref(null); // 表格引用
+const categoryDialogVisible = ref(false);
 let dragEventHandlers = null; // 拖拽事件处理器
 
+const CATEGORY_LINK_COMPANY = {
+  dev: 34398,
+  prod: 45064,
+}
+
+const showCategoryLink = computed(() => {
+  const env = (configInfoStore.appConfig as any)?.env
+  const companyId = Number((userInfoStore.userInfo as any)?.brand_company_id)
+  if (!companyId) return false
+  if (env === 'prod') return companyId === CATEGORY_LINK_COMPANY.prod
+  if (env === 'dev' || env === 'local') return companyId === CATEGORY_LINK_COMPANY.dev
+  return false
+})
+
 
 onBeforeUnmount(()=>{
     window.removeEventListener('beforeunload', handleBeforeUnload);
@@ -676,6 +719,41 @@ const exitSortMode = () => {
 </script>
 
 <style lang="scss" scoped>
+.category-link-entry {
+  display: flex;
+  align-items: center;
+  height: 48px;
+  margin-bottom: 8px;
+  background: #F7F8FF;
+  border: 1px solid #d9def2;
+  border-radius: 4px;
+  cursor: pointer;
+  .entry-bar {
+    width: 3px;
+    height: 18px;
+    margin: 0 10px 0 12px;
+    background: #2957FF;
+    border-radius: 2px;
+  }
+  .entry-label {
+    font-size: 14px;
+    color: #333;
+  }
+  .entry-desc {
+    margin-left: 8px;
+    font-size: 12px;
+    color: #999;
+    flex: 1;
+  }
+  .entry-action {
+    margin-right: 12px;
+    font-size: 13px;
+    color: #2957FF;
+  }
+  &:hover {
+    background: #F1F4FF;
+  }
+}
 .top_tabs {
   height: 40px;
   overflow: hidden;
@@ -874,3 +952,11 @@ const exitSortMode = () => {
 }
 
 </style>
+
+<style lang="scss">
+.category-link-dialog {
+  .el-dialog__body {
+    padding-top: 6px;
+  }
+}
+</style>