Explorar el Código

提交代码修改

DESKTOP-HN5QP3V\Administrator hace 4 semanas
padre
commit
0a7dbf41c3

+ 40 - 0
.cursor/rules/project-context.mdc

@@ -0,0 +1,40 @@
+---
+description: vue-element-plus-admin-mini 项目上下文,避免每次会话重复分析
+alwaysApply: true
+---
+
+# 项目上下文
+
+## 项目概况
+
+基于 [vue-element-plus-admin](https://github.com/kailong321200875/vue-element-plus-admin) 的中后台集成方案,非精简模板。
+
+## 技术栈
+
+- Vue 3.4 + TypeScript + Vite 5 + Element Plus 2.5
+- Pinia(含 pinia-plugin-persistedstate)
+- Vue Router 4、Axios
+- UnoCSS、Less
+- pnpm 包管理,Node >= 18
+
+## 目录结构
+
+| 目录 | 说明 |
+|------|------|
+| `src/api/` | 接口层:login、version、table 等 |
+| `src/store/modules/` | Pinia:user、permission、app、locale、tagsView、lock |
+| `src/router/` | 路由与动态权限 |
+| `src/views/` | 页面:Login、Version、Error、Redirect |
+| `src/components/` | 二次封装组件:Table、Form、Dialog、Search、Descriptions、Menu 等 |
+| `src/hooks/web/` | 通用 hooks |
+| `src/utils/` | 工具函数 |
+| `mock/` | vite-plugin-mock 的 Mock 接口 |
+
+## 关键约定
+
+- 常量:`src/constants/index.ts`,`SUCCESS_CODE = 200`
+- 登录 API:`/mock/user/login`,退出 `/mock/user/loginOut`
+- Mock 用户:`admin/admin` 全权限,`test/test` 前端控制权限
+- 默认首页:`/version/version`
+- 主路由:`/version` 下有 `version_server`、`test` 等子路由
+- 构建:`pnpm dev`、`pnpm build:pro`,多环境 base/pro/dev/test/gitee

+ 7 - 45
.eslintrc.js

@@ -1,5 +1,6 @@
 // @ts-check
 const { defineConfig } = require('eslint-define-config')
+// 个人开发用,保持宽松,只做基础检查
 module.exports = defineConfig({
   root: true,
   env: {
@@ -12,59 +13,20 @@ module.exports = defineConfig({
     parser: '@typescript-eslint/parser',
     ecmaVersion: 2020,
     sourceType: 'module',
-    jsxPragma: 'React',
-    ecmaFeatures: {
-      jsx: true
-    }
+    ecmaFeatures: { jsx: true }
   },
   extends: [
-    'plugin:vue/vue3-recommended',
+    'plugin:vue/vue3-essential',
     'plugin:@typescript-eslint/recommended',
-    'prettier',
-    'plugin:prettier/recommended'
+    'prettier'
   ],
   rules: {
-    'vue/no-setup-props-destructure': 'off',
-    'vue/script-setup-uses-vars': 'error',
-    'vue/no-reserved-component-names': 'off',
-    '@typescript-eslint/ban-ts-ignore': 'off',
-    '@typescript-eslint/explicit-function-return-type': 'off',
     '@typescript-eslint/no-explicit-any': 'off',
-    '@typescript-eslint/no-var-requires': 'off',
-    '@typescript-eslint/no-empty-function': 'off',
-    'vue/custom-event-name-casing': 'off',
-    'no-use-before-define': 'off',
-    '@typescript-eslint/no-use-before-define': 'off',
+    '@typescript-eslint/no-unused-vars': 'off',
     '@typescript-eslint/ban-ts-comment': 'off',
-    '@typescript-eslint/ban-types': 'off',
-    '@typescript-eslint/no-non-null-assertion': 'off',
+    '@typescript-eslint/no-empty-function': 'off',
     '@typescript-eslint/explicit-module-boundary-types': 'off',
-    '@typescript-eslint/no-unused-vars': 'off',
-    'no-unused-vars': 'off',
-    'space-before-function-paren': 'off',
-
-    'vue/attributes-order': 'off',
-    'vue/one-component-per-file': 'off',
-    'vue/html-closing-bracket-newline': 'off',
-    'vue/max-attributes-per-line': 'off',
-    'vue/multiline-html-element-content-newline': 'off',
-    'vue/singleline-html-element-content-newline': 'off',
-    'vue/attribute-hyphenation': 'off',
-    'vue/require-default-prop': 'off',
-    'vue/require-explicit-emits': 'off',
-    'vue/html-self-closing': [
-      'error',
-      {
-        html: {
-          void: 'always',
-          normal: 'never',
-          component: 'always'
-        },
-        svg: 'always',
-        math: 'always'
-      }
-    ],
     'vue/multi-word-component-names': 'off',
-    'vue/no-v-html': 'off'
+    'no-console': 'off'
   }
 })

+ 2 - 9
.husky/lintstagedrc.js

@@ -1,9 +1,2 @@
-module.exports = {
-  '*.{js,jsx,ts,tsx}': ['eslint --fix', 'prettier --write'],
-  '{!(package)*.json,*.code-snippets,.!(browserslist)*rc}': ['prettier --write--parser json'],
-  'package.json': ['prettier --write'],
-  '*.vue': ['prettier --write', 'stylelint --fix'],
-  '*.{scss,less,styl,css,html}': ['stylelint --fix', 'prettier --write'],
-  '*.md': ['prettier --write'],
-  '*.hbs': ['prettier --write']
-}
+// 个人开发:提交时不自动跑 lint,需要时手动执行 pnpm lint:eslint / lint:format
+module.exports = {}

+ 3 - 3
.husky/pre-commit

@@ -3,6 +3,6 @@
 
 [ -n "$CI" ] && exit 0
 
-# Format and submit code according to lintstagedrc.js configuration
-npm run ts:check
-npm run lint:lint-staged
+# 个人开发:提交不做检查,需要时手动执行 pnpm ts:check / lint:eslint
+# npm run ts:check
+# npm run lint:lint-staged

+ 0 - 0
backend/go.mod


+ 81 - 0
mock/channel/index.mock.ts

@@ -0,0 +1,81 @@
+import { SUCCESS_CODE } from '@/constants'
+import { toAnyString } from '@/utils'
+
+const timeout = 500
+
+interface ChannelItem {
+  id: string
+  name: string
+  code: string
+  description: string
+  status: boolean
+  createTime: string
+}
+
+let channelList: ChannelItem[] = [
+  {
+    id: toAnyString(),
+    name: '微信',
+    code: 'wx',
+    description: '微信小程序渠道',
+    status: true,
+    createTime: '2024-01-01 10:00:00'
+  },
+  {
+    id: toAnyString(),
+    name: '抖音',
+    code: 'douyin',
+    description: '抖音小程序渠道',
+    status: true,
+    createTime: '2024-01-02 10:00:00'
+  },
+  {
+    id: toAnyString(),
+    name: '支付宝',
+    code: 'alipay',
+    description: '支付宝小程序渠道',
+    status: false,
+    createTime: '2024-01-03 10:00:00'
+  }
+]
+
+const listHandler = ({ query }) => {
+  const { name, pageIndex = 1, pageSize = 10 } = query
+  const filtered = channelList.filter((item) => {
+    if (name && !item.name.includes(name) && !item.code.includes(name)) return false
+    return true
+  })
+  const start = (pageIndex - 1) * pageSize
+  const list = filtered.slice(start, start + pageSize)
+  return { code: SUCCESS_CODE, data: { total: filtered.length, list } }
+}
+
+const saveHandler = ({ body }) => {
+  const item: ChannelItem = {
+    id: toAnyString(),
+    name: body.name,
+    code: body.code,
+    description: body.description || '',
+    status: body.status !== false,
+    createTime: new Date().toLocaleString('zh-CN')
+  }
+  channelList.unshift(item)
+  return { code: SUCCESS_CODE, data: item.id }
+}
+
+const updateHandler = ({ body }) => {
+  const idx = channelList.findIndex((item) => item.id === body.id)
+  if (idx >= 0) channelList[idx] = { ...channelList[idx], ...body }
+  return { code: SUCCESS_CODE, data: 'success' }
+}
+
+const deleteHandler = ({ body }) => {
+  const { ids } = body
+  if (!ids?.length) return { code: 500, message: '请选择需要删除的数据' }
+  channelList = channelList.filter((item) => !ids.includes(item.id))
+  return { code: SUCCESS_CODE, data: 'success' }
+}
+
+export default [
+  // 渠道走 Go 后端,不 mock
+]

+ 22 - 0
mock/role/index.mock.ts

@@ -56,6 +56,28 @@ const adminList = [
         }
       }
     ]
+  },
+  {
+    path: '/channel',
+    name: 'Channel',
+    redirect: '/channel/manage',
+    component: '#',
+    meta: {
+      title: 'router.channel',
+      icon: 'ep:share',
+      alwaysShow: true
+    },
+    children: [
+      {
+        path: 'manage',
+        component: 'views/Channel/ChannelManage',
+        name: 'ChannelManage',
+        meta: {
+          title: 'router.channel_manage',
+          noCache: true
+        }
+      }
+    ]
   }
 ]
 

+ 22 - 0
src/api/channel/index.ts

@@ -0,0 +1,22 @@
+import request from '@/axios'
+import type { ChannelData } from './types'
+
+export const getChannelListApi = (params?: {
+  name?: string
+  pageIndex?: number
+  pageSize?: number
+}): Promise<IResponse<{ list: ChannelData[]; total: number }>> => {
+  return request.get({ url: '/web/v1/channel/list', params })
+}
+
+export const addChannelApi = (data: Partial<ChannelData>): Promise<IResponse> => {
+  return request.post({ url: '/web/v1/channel', data })
+}
+
+export const editChannelApi = (data: Partial<ChannelData>): Promise<IResponse> => {
+  return request.put({ url: '/web/v1/channel', data })
+}
+
+export const delChannelApi = (ids: string[]): Promise<IResponse> => {
+  return request.post({ url: '/web/v1/channel/delete', data: { ids } })
+}

+ 8 - 0
src/api/channel/types.ts

@@ -0,0 +1,8 @@
+export interface ChannelData {
+  id?: string
+  name: string
+  code: string
+  description?: string
+  status?: boolean
+  createTime?: string
+}

+ 60 - 1
src/locales/en.ts

@@ -118,6 +118,11 @@ export default {
   },
   router: {
     login: 'Login',
+    version: 'Version',
+    version_list: 'Version list',
+    version_server: 'Backend version',
+    version_client: 'Frontend version',
+    version_test: 'API test',
     level: 'Multi level menu',
     menu: 'Menu',
     menu1: 'Menu1',
@@ -163,7 +168,61 @@ export default {
     treeTable: 'Tree table',
     PicturePreview: 'Table Image Preview',
     department: 'Department management',
-    menuManagement: 'Menu management'
+    menuManagement: 'Menu management',
+    channel: 'Channel',
+    channel_manage: 'Channel list'
+  },
+  channel: {
+    name: 'Name',
+    code: 'Code',
+    description: 'Description',
+    status: 'Status',
+    createTime: 'Create Time',
+    action: 'Action',
+    add: 'Add',
+    edit: 'Edit',
+    delete: 'Delete',
+    searchPlaceholder: 'Name/Code',
+    namePlaceholder: 'Please enter name',
+    codePlaceholder: 'Please enter code',
+    descriptionPlaceholder: 'Please enter description',
+    nameRequired: 'Please enter name',
+    codeRequired: 'Please enter code',
+    deleteConfirm: 'Are you sure to delete this channel?',
+    addSuccess: 'Added successfully',
+    editSuccess: 'Updated successfully',
+    deleteSuccess: 'Deleted successfully',
+    updateSuccess: 'Status updated'
+  },
+  version_server: {
+    title: 'Name',
+    number: 'Version',
+    url: 'URL',
+    cdn: 'CDN',
+    default: 'Default',
+    action: 'Action',
+    edit: 'Edit',
+    new: 'Add',
+    delete: 'Delete',
+    channelSelect: 'Select channel',
+    channel: 'Channel',
+    channelEmpty: 'No channels',
+    channelEmptyTip: 'Please add channels in Channel Management first',
+    allChannels: 'All channels',
+    confirm: 'Confirm'
+  },
+  version_client: {
+    proj: 'Project',
+    os: 'Platform',
+    packageUrl: 'Package URL',
+    remoteManifestUrl: 'Remote manifest',
+    remoteVersionUrl: 'Remote version',
+    default: 'Status',
+    action: 'Action',
+    edit: 'Edit',
+    new: 'Add',
+    delete: 'Delete',
+    refresh: 'Refresh'
   },
   permission: {
     hasPermission: 'Please set the operation permission value'

+ 27 - 0
src/locales/zh-CN.ts

@@ -124,6 +124,8 @@ export default {
     version_server: '后端版本',
     version_client: '前端版本',
     version_test: '接口测试',
+    channel: '渠道管理',
+    channel_manage: '渠道列表',
     level: '多级菜单',
     menu: '菜单',
     menu1: '菜单1',
@@ -233,6 +235,28 @@ export default {
     pushCode: 'Archer 推送 代码到 Github',
     follow: '关注'
   },
+  channel: {
+    name: '渠道名称',
+    code: '渠道编码',
+    description: '描述',
+    status: '状态',
+    createTime: '创建时间',
+    action: '操作',
+    add: '新增',
+    edit: '编辑',
+    delete: '删除',
+    searchPlaceholder: '渠道名称/编码',
+    namePlaceholder: '请输入渠道名称',
+    codePlaceholder: '请输入渠道编码',
+    descriptionPlaceholder: '请输入描述',
+    nameRequired: '请输入渠道名称',
+    codeRequired: '请输入渠道编码',
+    deleteConfirm: '确定删除该渠道吗?',
+    addSuccess: '新增成功',
+    editSuccess: '修改成功',
+    deleteSuccess: '删除成功',
+    updateSuccess: '状态更新成功'
+  },
   version_server: {
     title: '描述',
     number: '版本号',
@@ -245,6 +269,9 @@ export default {
     delete: '删除',
     refresh: '刷新',
     channelSelect: '渠道选择',
+    channel: '渠道',
+    channelEmpty: '暂无渠道',
+    channelEmptyTip: '请先在渠道管理中添加渠道后再新增版本',
     allChannels: '全部渠道',
     confirm: '确定'
   },

+ 22 - 0
src/router/index.ts

@@ -107,6 +107,28 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
         }
       }
     ]
+  },
+  {
+    path: '/channel',
+    name: 'Channel',
+    component: Layout,
+    redirect: '/channel/manage',
+    meta: {
+      title: t('router.channel'),
+      icon: 'ep:share',
+      alwaysShow: true
+    },
+    children: [
+      {
+        path: 'manage',
+        name: 'ChannelManage',
+        component: () => import('@/views/Channel/ChannelManage.vue'),
+        meta: {
+          title: t('router.channel_manage'),
+          noCache: true
+        }
+      }
+    ]
   }
 ]
 

+ 248 - 0
src/views/Channel/ChannelManage.vue

@@ -0,0 +1,248 @@
+<script setup lang="tsx">
+import {
+  ElButton,
+  ElSwitch,
+  ElDrawer,
+  ElForm,
+  ElFormItem,
+  ElInput,
+  ElMessageBox,
+  ElMessage
+} from 'element-plus'
+import { ContentWrap } from '@/components/ContentWrap'
+import { useI18n } from '@/hooks/web/useI18n'
+import { Table, TableColumn } from '@/components/Table'
+import { BaseButton } from '@/components/Button'
+import type { ChannelData } from '@/api/channel/types'
+import { ref, reactive } from 'vue'
+import { getChannelListApi, addChannelApi, editChannelApi, delChannelApi } from '@/api/channel'
+
+const { t } = useI18n()
+
+const columns: TableColumn[] = [
+  {
+    field: 'name',
+    label: t('channel.name')
+  },
+  {
+    field: 'code',
+    label: t('channel.code')
+  },
+  {
+    field: 'description',
+    label: t('channel.description'),
+    showOverflowTooltip: true
+  },
+  {
+    field: 'status',
+    label: t('channel.status'),
+    slots: {
+      default: (data) => {
+        return (
+          <ElSwitch
+            modelValue={data.row.status}
+            onUpdate:modelValue={(val) => updateStatus(data.row, val)}
+          />
+        )
+      }
+    }
+  },
+  {
+    field: 'createTime',
+    label: t('channel.createTime')
+  },
+  {
+    field: 'action',
+    label: t('channel.action'),
+    width: 180,
+    slots: {
+      default: (data) => {
+        return (
+          <>
+            <BaseButton type="primary" onClick={() => actionFn('edit', data.row)}>
+              {t('channel.edit')}
+            </BaseButton>
+            <BaseButton type="danger" onClick={() => deleteFn(data.row)}>
+              {t('channel.delete')}
+            </BaseButton>
+          </>
+        )
+      }
+    }
+  }
+]
+
+const loading = ref(true)
+const channelList = ref<ChannelData[]>([])
+const drawer = ref(false)
+const drawerTitle = ref('')
+const searchName = ref('')
+
+const form = reactive({
+  type: 'add',
+  data: {
+    id: '',
+    name: '',
+    code: '',
+    description: '',
+    status: true
+  }
+})
+
+const formReset = () => {
+  form.data.id = ''
+  form.data.name = ''
+  form.data.code = ''
+  form.data.description = ''
+  form.data.status = true
+}
+
+const getList = async () => {
+  loading.value = true
+  const res = await getChannelListApi({
+    name: searchName.value || undefined,
+    pageIndex: 1,
+    pageSize: 100
+  })
+    .catch(() => ({}))
+    .finally(() => {
+      loading.value = false
+    })
+  if (res?.data) {
+    channelList.value = res.data.list || []
+  }
+}
+
+const updateStatus = async (row: ChannelData, val: boolean) => {
+  if (!row.id) return
+  await editChannelApi({ id: row.id, status: val })
+    .then(() => {
+      ElMessage.success(t('channel.updateSuccess'))
+      row.status = val
+    })
+    .catch(() => {})
+}
+
+const actionFn = (type: 'add' | 'edit', row?: ChannelData) => {
+  formReset()
+  if (type === 'add') {
+    form.type = 'add'
+    drawerTitle.value = t('channel.add')
+  } else if (row) {
+    form.type = 'edit'
+    drawerTitle.value = t('channel.edit')
+    form.data.id = row.id!
+    form.data.name = row.name
+    form.data.code = row.code
+    form.data.description = row.description || ''
+    form.data.status = row.status !== false
+  }
+  drawer.value = true
+}
+
+const onSubmit = async () => {
+  if (!form.data.name?.trim()) {
+    ElMessage.warning(t('channel.nameRequired'))
+    return
+  }
+  if (!form.data.code?.trim()) {
+    ElMessage.warning(t('channel.codeRequired'))
+    return
+  }
+  if (form.type === 'add') {
+    await addChannelApi(form.data)
+      .then(() => {
+        ElMessage.success(t('channel.addSuccess'))
+        drawer.value = false
+        getList()
+      })
+      .catch(() => {})
+  } else {
+    await editChannelApi(form.data)
+      .then(() => {
+        ElMessage.success(t('channel.editSuccess'))
+        drawer.value = false
+        getList()
+      })
+      .catch(() => {})
+  }
+}
+
+const deleteFn = (row: ChannelData) => {
+  ElMessageBox.confirm(t('channel.deleteConfirm'), t('common.delWarning'), {
+    confirmButtonText: t('common.delOk'),
+    cancelButtonText: t('common.cancel'),
+    type: 'warning'
+  })
+    .then(async () => {
+      await delChannelApi([row.id!])
+      ElMessage.success(t('channel.deleteSuccess'))
+      getList()
+    })
+    .catch(() => {})
+}
+
+const onSearch = () => {
+  getList()
+}
+
+const onReset = () => {
+  searchName.value = ''
+  getList()
+}
+
+getList()
+</script>
+
+<template>
+  <ContentWrap>
+    <div class="mb-4 flex flex-wrap items-center gap-3">
+      <ElButton type="primary" @click="actionFn('add')">
+        {{ t('channel.add') }}
+      </ElButton>
+      <div class="flex items-center gap-2">
+        <el-input
+          v-model="searchName"
+          :placeholder="t('channel.searchPlaceholder')"
+          clearable
+          style="width: 200px"
+          @keyup.enter="onSearch"
+        />
+        <ElButton type="primary" @click="onSearch">{{ t('common.query') }}</ElButton>
+        <ElButton @click="onReset">{{ t('common.reset') }}</ElButton>
+      </div>
+    </div>
+  </ContentWrap>
+  <ContentWrap>
+    <Table :columns="columns" :data="channelList" :loading="loading" row-key="id" />
+  </ContentWrap>
+  <ElDrawer v-model="drawer" direction="rtl" size="40%" :title="drawerTitle">
+    <ElForm :model="form.data" label-width="auto" style="max-width: 500px">
+      <ElFormItem :label="t('channel.name')" required>
+        <ElInput v-model="form.data.name" :placeholder="t('channel.namePlaceholder')" />
+      </ElFormItem>
+      <ElFormItem :label="t('channel.code')" required>
+        <ElInput
+          v-model="form.data.code"
+          :placeholder="t('channel.codePlaceholder')"
+          :disabled="form.type === 'edit'"
+        />
+      </ElFormItem>
+      <ElFormItem :label="t('channel.description')">
+        <ElInput
+          v-model="form.data.description"
+          type="textarea"
+          :rows="3"
+          :placeholder="t('channel.descriptionPlaceholder')"
+        />
+      </ElFormItem>
+      <ElFormItem :label="t('channel.status')">
+        <ElSwitch v-model="form.data.status" />
+      </ElFormItem>
+      <ElFormItem>
+        <ElButton type="primary" @click="onSubmit">{{ t('common.ok') }}</ElButton>
+        <ElButton @click="drawer = false">{{ t('common.cancel') }}</ElButton>
+      </ElFormItem>
+    </ElForm>
+  </ElDrawer>
+</template>

+ 54 - 9
src/views/Version/VersionServer.vue

@@ -24,6 +24,8 @@ import {
   newVersionApi,
   syncVersionApi
 } from '@/api/version'
+import { getChannelListApi } from '@/api/channel'
+import type { ChannelData } from '@/api/channel/types'
 
 interface Params {
   pageIndex?: number
@@ -32,10 +34,16 @@ interface Params {
 
 const { t } = useI18n()
 
+const getChannelLabel = (code: string) => {
+  const c = channelOptions.value.find((x) => x.code === code)
+  return c ? c.name : code
+}
+
 const columns: TableColumn[] = [
   {
     field: 'name',
-    label: t('version_server.title')
+    label: t('version_server.channel'),
+    formatter: (row) => getChannelLabel(row.name)
   },
   {
     field: 'version',
@@ -89,13 +97,24 @@ const loading = ref(true)
 const drawerText = ref('')
 
 const versionDataList = ref<VersionData[]>([])
-const selectedChannel = ref<string>('wx')
-const appliedChannel = ref<string>('wx')
+const selectedChannel = ref<string>('')
+const appliedChannel = ref<string>('')
 
-// 渠道选项:写死 wx 和 抖音
-const channelOptions = ['wx', '抖音']
+// 渠道选项:从后端获取
+const channelOptions = ref<ChannelData[]>([])
+
+const getChannelList = async () => {
+  try {
+    const res = await getChannelListApi({ pageIndex: 1, pageSize: 100 })
+    const list = res?.data?.list ?? (Array.isArray(res?.data) ? res.data : [])
+    channelOptions.value = list.filter((c) => c && c.status !== false)
+  } catch (e) {
+    console.warn('[渠道列表] 请求失败:', e)
+    channelOptions.value = []
+  }
+}
 
-// 根据渠道筛选后的列表(点击确定后应用)
+// 根据渠道筛选后的列表(点击确定后应用),version.name 存渠道 code
 const filteredVersionList = computed(() => {
   if (!appliedChannel.value) return versionDataList.value
   return versionDataList.value.filter((item) => item.name === appliedChannel.value)
@@ -167,6 +186,7 @@ const editVersion = async (params) => {
     })
 }
 
+getChannelList()
 getVersionList()
 
 const updateDefaultStatus = async (row: VersionData, val) => {
@@ -209,6 +229,7 @@ const syncVersion = async () => {
 }
 
 const actionFn = (type: string, data: any) => {
+  getChannelList()
   if (type == 'new') {
     formReset()
     form.type = 'new'
@@ -256,7 +277,8 @@ const closeDrawer = () => {
             :placeholder="t('version_server.channelSelect')"
             style="width: 180px"
           >
-            <ElOption v-for="opt in channelOptions" :key="opt" :label="opt" :value="opt" />
+            <ElOption :label="t('version_server.allChannels')" value="" />
+            <ElOption v-for="c in channelOptions" :key="c.code" :label="c.name" :value="c.code" />
           </ElSelect>
           <ElButton type="primary" @click="applyChannelFilter">
             {{ t('version_server.confirm') }}
@@ -285,8 +307,31 @@ const closeDrawer = () => {
       <span class="text-16px font-700">{{ drawerText }}</span>
     </template>
     <ElForm :model="form" label-width="auto" style="max-width: 600px">
-      <ElFormItem :label="t('version_server.title')">
-        <ElInput v-model="form.data.name" />
+      <ElFormItem :label="t('version_server.channel')" required>
+        <ElSelect
+          v-model="form.data.name"
+          :placeholder="
+            channelOptions.length ? t('version_server.channelSelect') : t('version_server.channelEmpty')
+          "
+          style="width: 100%"
+        >
+          <ElOption
+            v-for="c in channelOptions"
+            :key="c.code"
+            :label="c.name"
+            :value="c.code"
+          />
+          <!-- 编辑时若渠道已删除,保留原值选项 -->
+          <ElOption
+            v-if="form.type === 'edit' && form.data.name && !channelOptions.some((x) => x.code === form.data.name)"
+            :key="form.data.name"
+            :label="form.data.name"
+            :value="form.data.name"
+          />
+        </ElSelect>
+        <div v-if="!channelOptions.length" class="el-form-item__error">
+          {{ t('version_server.channelEmptyTip') }}
+        </div>
       </ElFormItem>
       <ElFormItem :label="t('version_server.number')">
         <el-input v-model="form.data.version" />

+ 3 - 1
vite.config.ts

@@ -59,7 +59,9 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
         : undefined,
       EslintPlugin({
         cache: false,
-        include: ['src/**/*.vue', 'src/**/*.ts', 'src/**/*.tsx'] // 检查的文件
+        include: ['src/**/*.vue', 'src/**/*.ts', 'src/**/*.tsx'],
+        failOnError: false,
+        failOnWarning: false
       }),
       VueI18nPlugin({
         runtimeOnly: true,