Jelajahi Sumber

大模型配置

xw 6 jam lalu
induk
melakukan
15e9407dd4

+ 89 - 0
src/api/aicall/account.js

@@ -0,0 +1,89 @@
+import request from '@/utils/request'
+
+/** 分页列表,query 可选 companyId */
+export function list(data, query) {
+  return request({
+    url: '/aicall/account/list',
+    method: 'post',
+    data: data,
+    params: query
+  })
+}
+
+/** 详情 */
+export function getAccount(id) {
+  return request({
+    url: '/aicall/account/' + id,
+    method: 'get'
+  })
+}
+
+/** 新增,query 可选 companyId */
+export function add(data, query) {
+  return request({
+    url: '/aicall/account',
+    method: 'post',
+    data: data,
+    params: query
+  })
+}
+
+/** 修改 */
+export function update(data) {
+  return request({
+    url: '/aicall/account',
+    method: 'put',
+    data: data
+  })
+}
+
+/** 删除,ids 逗号分隔 */
+export function remove(ids) {
+  return request({
+    url: '/aicall/account/' + ids,
+    method: 'delete'
+  })
+}
+
+/** 导出 */
+export function exportAccount(query) {
+  return request({
+    url: '/aicall/account/export',
+    method: 'post',
+    params: query
+  })
+}
+
+/** 下拉全量 */
+export function allAccount(query) {
+  return request({
+    url: '/aicall/account/all',
+    method: 'get',
+    params: query
+  })
+}
+
+export function getCidConfig() {
+  return request({
+    url: '/aicall/account/getCidConfig',
+    method: 'get'
+  })
+}
+
+/** 绑定公司 */
+export function bindCompany(modelId, companyId) {
+  return request({
+    url: '/aicall/account/bindCompany',
+    method: 'post',
+    params: { modelId, companyId }
+  })
+}
+
+/** 解绑公司 */
+export function unbindCompany(modelId, companyId) {
+  return request({
+    url: '/aicall/account/unbindCompany',
+    method: 'post',
+    params: { modelId, companyId }
+  })
+}

+ 8 - 0
src/api/aicall/kbcat.js

@@ -0,0 +1,8 @@
+import request from '@/utils/request'
+
+export function all() {
+  return request({
+    url: '/aicall/kbcat/all',
+    method: 'get'
+  })
+}

+ 8 - 0
src/api/aicall/provider.js

@@ -0,0 +1,8 @@
+import request from '@/utils/request'
+
+export function all() {
+  return request({
+    url: '/aicall/provider/all',
+    method: 'get'
+  })
+}

+ 19 - 0
src/api/company/voiceClone.js

@@ -0,0 +1,19 @@
+import request from '@/utils/request'
+
+export function uploadAndTrain(formData) {
+  return request({
+    url: '/company/voiceClone/uploadAndTrain',
+    method: 'post',
+    data: formData,
+    headers: { 'Content-Type': 'multipart/form-data' }
+  })
+}
+
+export function doubaoTtsTest(formData) {
+  return request({
+    url: '/company/voiceClone/doubaoTtsTest',
+    method: 'post',
+    data: formData,
+    headers: { 'Content-Type': 'multipart/form-data' }
+  })
+}

+ 24 - 0
src/api/company/voiceCloneRef.js

@@ -0,0 +1,24 @@
+import request from '@/utils/request'
+
+export function list(params) {
+  return request({
+    url: '/company/voiceClone/ref/list',
+    method: 'get',
+    params: params
+  })
+}
+
+export function remove(id) {
+  return request({
+    url: '/company/voiceClone/ref/' + id,
+    method: 'delete'
+  })
+}
+
+export function changeStatus(data) {
+  return request({
+    url: '/company/voiceClone/ref/changeStatus',
+    method: 'put',
+    data: data
+  })
+}

+ 9 - 0
src/api/his/company.js

@@ -126,3 +126,12 @@ export function batchCreateExtension(data) {
     data: data
     data: data
   })
   })
 }
 }
+
+/** 公司下拉列表 */
+export function companyList(query) {
+  return request({
+    url: '/his/company/companyList',
+    method: 'get',
+    params: query
+  })
+}

+ 63 - 0
src/api/sensitive/word.js

@@ -0,0 +1,63 @@
+import request from '@/utils/request'
+
+export function listSensitiveWord(query) {
+  return request({
+    url: '/sensitive/word/list',
+    method: 'get',
+    params: query
+  })
+}
+
+export function getSensitiveWord(wordId) {
+  return request({
+    url: '/sensitive/word/' + wordId,
+    method: 'get'
+  })
+}
+
+export function addSensitiveWord(data) {
+  return request({
+    url: '/sensitive/word',
+    method: 'post',
+    data: data
+  })
+}
+
+export function updateSensitiveWord(data) {
+  return request({
+    url: '/sensitive/word',
+    method: 'put',
+    data: data
+  })
+}
+
+export function changeSensitiveWordEnabled(wordId, enabled) {
+  return request({
+    url: '/sensitive/word/changeEnabled',
+    method: 'put',
+    data: { wordId, enabled }
+  })
+}
+
+export function delSensitiveWord(wordId) {
+  return request({
+    url: '/sensitive/word/' + wordId,
+    method: 'delete'
+  })
+}
+
+export function exportSensitiveWord(query) {
+  return request({
+    url: '/sensitive/word/export',
+    method: 'get',
+    params: query
+  })
+}
+
+/** 敏感词模块公司下拉 */
+export function getSensitiveWordCompanyList() {
+  return request({
+    url: '/sensitive/word/companyList',
+    method: 'get'
+  })
+}

+ 416 - 0
src/views/aicall/account/index.vue

@@ -0,0 +1,416 @@
+<template>
+  <div class="app-container">
+    <el-form
+      v-show="showSearch"
+      ref="queryForm"
+      :inline="true"
+      :model="queryParams"
+      label-width="100px"
+    >
+      <el-form-item label="公司" prop="companyId">
+        <el-select
+          v-model="queryParams.companyId"
+          clearable
+          filterable
+          placeholder="全部公司"
+          size="small"
+          style="width: 220px"
+        >
+          <el-option
+            v-for="item in companys"
+            :key="item.companyId"
+            :label="item.companyName"
+            :value="item.companyId"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="模型名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          clearable
+          placeholder="请输入"
+          size="small"
+          style="width: 200px"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="实现类" prop="providerClassName">
+        <el-select
+          v-model="queryParams.providerClassName"
+          clearable
+          placeholder="全部"
+          size="small"
+          style="width: 200px"
+        >
+          <el-option label="全部" value="" />
+          <el-option
+            v-for="item in providerClassNameList"
+            :key="item.id"
+            :label="item.providerClassName"
+            :value="item.providerClassName"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button
+          v-hasPermi="['aicall:account:add']"
+          icon="el-icon-plus"
+          plain
+          size="mini"
+          type="primary"
+          @click="handleAdd"
+        >新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          v-hasPermi="['aicall:account:edit']"
+          icon="el-icon-edit"
+          plain
+          size="mini"
+          type="success"
+          @click="handleEdit()"
+        >修改</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          v-hasPermi="['aicall:account:remove']"
+          icon="el-icon-delete"
+          plain
+          size="mini"
+          type="danger"
+          @click="handleDelete()"
+        >删除</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          v-hasPermi="['aicall:account:export']"
+          icon="el-icon-download"
+          plain
+          size="mini"
+          type="warning"
+          :loading="exportLoading"
+          @click="handleExport"
+        >导出</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
+    </el-row>
+
+    <el-alert
+      v-if="!queryParams.companyId"
+      type="info"
+      :closable="false"
+      show-icon
+      class="mb8"
+      title="模型与公司为多对多绑定。未选公司筛选时,若列表接口未返回 companyId/companyIds,绑定公司列会显示「未绑定」;请选择公司筛选,或让后端在列表中附带绑定公司字段。"
+    />
+
+    <el-table v-loading="loading" :data="modelList" border @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="55" align="center" />
+      <el-table-column align="center" label="绑定公司ID" min-width="100" show-overflow-tooltip>
+        <template slot-scope="scope">
+          {{ resolveCompanyIdsText(scope.row) }}
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="绑定公司名称" min-width="140" show-overflow-tooltip>
+        <template slot-scope="scope">
+          {{ resolveCompanyNamesText(scope.row) }}
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="模型名称" prop="name" />
+      <el-table-column align="center" label="实现类" prop="providerClassName" />
+      <el-table-column align="center" label="模型并发数量" prop="concurrentNum">
+        <template slot-scope="scope">
+          {{ (scope.row.concurrentNum === null || scope.row.concurrentNum === undefined || scope.row.concurrentNum === '') ? 0 : scope.row.concurrentNum }}
+        </template>
+      </el-table-column>
+      <el-table-column align="center" class-name="small-padding fixed-width" label="操作" width="220">
+        <template slot-scope="scope">
+          <el-button
+            v-hasPermi="['aicall:account:add']"
+            size="mini"
+            type="text"
+            icon="el-icon-document-copy"
+            @click="handleCopy(scope.row)"
+          >复制</el-button>
+          <el-button
+            v-hasPermi="['aicall:account:edit']"
+            size="mini"
+            type="text"
+            icon="el-icon-edit"
+            @click="handleEdit(scope.row)"
+          >编辑</el-button>
+          <el-button
+            v-hasPermi="['aicall:account:remove']"
+            size="mini"
+            type="text"
+            icon="el-icon-delete"
+            @click="handleDelete(scope.row)"
+          >删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination
+      v-show="total > 0"
+      :limit.sync="queryParams.pageSize"
+      :page.sync="queryParams.pageNum"
+      :total="total"
+      @pagination="getList"
+    />
+
+    <el-drawer
+      :title="dialogTitle"
+      :visible.sync="dialogVisible"
+      direction="rtl"
+      size="min(760px, 96vw)"
+      append-to-body
+      destroy-on-close
+      wrapper-closable
+      custom-class="ai-model-drawer"
+      @close="handleDialogClose"
+    >
+      <account-form
+        v-if="dialogVisible"
+        ref="accountForm"
+        :initial-data="currentRow"
+        :provider-options="providerOptions"
+        :company-options="companys"
+        :default-bind-company-id="queryParams.companyId"
+        @cancel="handleDialogClose"
+        @success="handleFormSuccess"
+      />
+    </el-drawer>
+  </div>
+</template>
+
+<script>
+import { list, remove, exportAccount, getAccount } from '@/api/aicall/account'
+import { all } from '@/api/aicall/provider'
+import { getCompanyList } from '@/api/company/company'
+import AccountForm from './info.vue'
+
+export default {
+  name: 'AicallAccount',
+  components: { AccountForm },
+  data() {
+    return {
+      selectedRows: [],
+      loading: false,
+      exportLoading: false,
+      showSearch: true,
+      total: 0,
+      modelList: [],
+      companys: [],
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        name: undefined,
+        providerClassName: undefined,
+        companyId: undefined
+      },
+      providerClassNameList: [],
+      dialogVisible: false,
+      dialogTitle: '新增模型账户',
+      currentRow: {}
+    }
+  },
+  computed: {
+    providerOptions() {
+      return this.providerClassNameList.map(item => item.providerClassName)
+    },
+    companyMap() {
+      const map = {}
+      this.companys.forEach(c => {
+        map[c.companyId] = c.companyName
+      })
+      return map
+    }
+  },
+  created() {
+    getCompanyList().then(response => {
+      this.companys = response.data || []
+    })
+    this.getList()
+    this.getAllProviders()
+  },
+  methods: {
+    /** 从行数据解析绑定的公司 ID 列表(兼容多种后端字段) */
+    parseCompanyIdsFromRow(row) {
+      if (!row) return []
+      if (row.companyId != null && row.companyId !== '') {
+        return [row.companyId]
+      }
+      if (row.companyIds != null && row.companyIds !== '') {
+        if (Array.isArray(row.companyIds)) {
+          return row.companyIds.filter(id => id != null && id !== '')
+        }
+        return String(row.companyIds).split(/[,,]/).map(s => s.trim()).filter(Boolean)
+      }
+      if (Array.isArray(row.companyIdList) && row.companyIdList.length) {
+        return row.companyIdList.filter(id => id != null && id !== '')
+      }
+      // 按公司筛选时,列表已是该公司下的模型,用筛选条件补全展示
+      if (this.queryParams.companyId) {
+        return [this.queryParams.companyId]
+      }
+      return []
+    },
+    resolveCompanyIdsText(row) {
+      const ids = this.parseCompanyIdsFromRow(row)
+      if (!ids.length) return '未绑定'
+      return ids.map(id => String(id)).join('、')
+    },
+    resolveCompanyNamesText(row) {
+      if (!row) return '未绑定'
+      if (row.companyName) return row.companyName
+      if (row.companyNames) return row.companyNames
+      const ids = this.parseCompanyIdsFromRow(row)
+      if (!ids.length) return '未绑定'
+      return ids.map(id => this.companyMap[id] || id).join('、')
+    },
+    listQuery() {
+      const { companyId, pageNum, pageSize, name, providerClassName } = this.queryParams
+      const body = { pageNum, pageSize, name, providerClassName }
+      const query = {}
+      if (companyId) query.companyId = companyId
+      return { body, query }
+    },
+    getList() {
+      this.loading = true
+      const { body, query } = this.listQuery()
+      list(body, query)
+        .then(response => {
+          this.modelList = response.rows || []
+          this.total = response.total || 0
+          this.loading = false
+        })
+        .catch(() => {
+          this.loading = false
+        })
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      this.getList()
+    },
+    resetQuery() {
+      this.resetForm('queryForm')
+      this.handleQuery()
+    },
+    getAllProviders() {
+      all().then(response => {
+        let data = response.data
+        if (data) {
+          data = data.filter(a => a.providerClassName === 'DeepSeekChat')
+        }
+        this.providerClassNameList = data || []
+      })
+    },
+    handleAdd() {
+      this.dialogTitle = '新增模型账户'
+      this.currentRow = {}
+      this.dialogVisible = true
+    },
+    handleDialogClose() {
+      this.dialogVisible = false
+      this.currentRow = {}
+    },
+    handleFormSuccess() {
+      this.dialogVisible = false
+      this.getList()
+    },
+    handleEdit(row) {
+      let editRow = row
+      if (!editRow) {
+        if (this.selectedRows.length !== 1) {
+          this.$message.warning('请选择一条要修改的数据')
+          return
+        }
+        editRow = this.selectedRows[0]
+      }
+      getAccount(editRow.id).then(res => {
+        this.dialogTitle = '编辑模型账户'
+        this.currentRow = res.data || { ...editRow }
+        this.dialogVisible = true
+      })
+    },
+    handleSelectionChange(selection) {
+      this.selectedRows = selection
+    },
+    handleDelete(row) {
+      let ids = []
+      if (row) {
+        ids = [row.id]
+      } else {
+        if (this.selectedRows.length === 0) {
+          this.$message.warning('请至少选择一条要删除的数据')
+          return
+        }
+        ids = this.selectedRows.map(item => item.id)
+      }
+      this.$confirm(`此操作将永久删除${ids.length > 1 ? '这些' : '该'}记录及全部公司绑定,是否继续?`, '警告', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        remove(ids.join(',')).then(response => {
+          if (response.code === 200) {
+            this.$message.success('删除成功')
+            this.getList()
+          } else {
+            this.$message.error(response.msg || '删除失败')
+          }
+        })
+      }).catch(() => {})
+    },
+    handleCopy(row) {
+      const copiedRow = { ...row, id: -Math.abs(row.id), name: row.name + '-副本' }
+      this.dialogTitle = '复制模型账户'
+      this.currentRow = copiedRow
+      this.dialogVisible = true
+    },
+    handleExport() {
+      const { query } = this.listQuery()
+      this.$confirm('是否确认导出当前筛选条件下的模型配置?', '警告', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        this.exportLoading = true
+        return exportAccount(query)
+      }).then(response => {
+        this.download(response.msg)
+        this.exportLoading = false
+      }).catch(() => {
+        this.exportLoading = false
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.ai-model-drawer.el-drawer /deep/ .el-drawer__body {
+  padding: 0;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+.ai-model-drawer.el-drawer /deep/ .el-drawer__header {
+  padding: 16px 20px;
+  margin-bottom: 0;
+  border-bottom: 1px solid #f0f2f5;
+}
+.ai-model-drawer.el-drawer /deep/ .el-drawer__header span {
+  font-size: 16px;
+  font-weight: 600;
+  color: #303133;
+}
+</style>

+ 890 - 0
src/views/aicall/account/info.vue

@@ -0,0 +1,890 @@
+<template>
+    <div class="ai-account-form-root">
+        <div class="ai-account-form-scroll">
+            <el-form
+                ref="form"
+                :model="form"
+                :rules="rules"
+                label-width="120px"
+                class="account-form"
+                label-position="top"
+            >
+                <!-- 新增/复制:可选绑定公司 -->
+                <div v-if="!form.id || form.id < 0" class="form-section">
+                    <div class="section-title">
+                        <i class="el-icon-office-building" />
+                        <span>公司绑定</span>
+                    </div>
+                    <el-form-item label="绑定公司">
+                        <el-select
+                            v-model="createBindCompanyId"
+                            placeholder="可选,新增后绑定到该公司"
+                            filterable
+                            clearable
+                            style="width: 100%"
+                        >
+                            <el-option
+                                v-for="item in companyOptions"
+                                :key="item.companyId"
+                                :label="item.companyName"
+                                :value="item.companyId"
+                            />
+                        </el-select>
+                    </el-form-item>
+                </div>
+
+                <!-- 编辑:绑定/解绑公司 -->
+                <div v-if="form.id && form.id > 0" class="form-section">
+                    <div class="section-title">
+                        <i class="el-icon-office-building" />
+                        <span>公司绑定管理</span>
+                    </div>
+                    <el-form-item label="目标公司">
+                        <el-select
+                            v-model="bindCompanyId"
+                            placeholder="请选择公司"
+                            filterable
+                            clearable
+                            style="width: 100%; max-width: 360px"
+                        >
+                            <el-option
+                                v-for="item in companyOptions"
+                                :key="item.companyId"
+                                :label="item.companyName"
+                                :value="item.companyId"
+                            />
+                        </el-select>
+                        <el-button
+                            type="primary"
+                            plain
+                            size="small"
+                            style="margin-left: 8px"
+                            :disabled="!bindCompanyId"
+                            @click="handleBindCompany"
+                        >绑定</el-button>
+                        <el-button
+                            type="danger"
+                            plain
+                            size="small"
+                            :disabled="!bindCompanyId"
+                            @click="handleUnbindCompany"
+                        >解绑</el-button>
+                    </el-form-item>
+                </div>
+
+                <!-- 基础信息 -->
+                <div class="form-section">
+                    <div class="section-title">
+                        <i class="el-icon-setting" />
+                        <span>基础信息</span>
+                    </div>
+                    <el-row :gutter="16">
+                        <el-col :xs="24" :sm="24" :md="12">
+                            <el-form-item label="模型名称" prop="name" required>
+                                <el-input
+                                    v-model="form.name"
+                                    placeholder="请输入模型名称"
+                                    clearable
+                                    maxlength="128"
+                                    show-word-limit
+                                />
+                            </el-form-item>
+                        </el-col>
+                        <el-col :xs="24" :sm="24" :md="12">
+                            <el-form-item label="实现类" prop="providerClassName" required>
+                                <el-select
+                                    v-model="form.providerClassName"
+                                    placeholder="请选择实现类"
+                                    filterable
+                                    clearable
+                                    style="width: 100%"
+                                    @change="handleProviderChange"
+                                >
+                                    <el-option
+                                        v-for="item in providerOptions"
+                                        :key="item"
+                                        :label="item"
+                                        :value="item"
+                                    />
+                                </el-select>
+                            </el-form-item>
+                        </el-col>
+                        <el-col :xs="24" :sm="24" :md="12">
+                            <el-form-item prop="concurrentNum" required>
+                                <span slot="label">
+                                    模型并发数
+                                    <el-tooltip
+                                        content="由系统配置自动带出,如需调整请联系管理员"
+                                        placement="top"
+                                    >
+                                        <i class="el-icon-warning-outline hint-icon" />
+                                    </el-tooltip>
+                                </span>
+                                <el-input
+                                    v-model="form.concurrentNum"
+                                    placeholder="请输入并发数"
+                                    disabled
+                                    @input="handleConcurrentNumInput"
+                                />
+                            </el-form-item>
+                        </el-col>
+                    </el-row>
+                </div>
+
+                <!-- 模型连接与参数 -->
+                <div v-if="dynamicFields.length > 0" class="form-section">
+                    <div class="section-title">
+                        <i class="el-icon-link" />
+                        <span>连接与参数</span>
+                        <span class="section-desc">不同实现类需填写的密钥、地址等业务参数</span>
+                    </div>
+                    <div v-for="field in dynamicFields" :key="field.name">
+                        <el-form-item
+                            :label="field.label"
+                            :prop="'accountJson.' + field.name"
+                            :required="field.required"
+                        >
+                            <!-- 文本框 -->
+                            <el-input
+                                v-if="field.type === 'input'"
+                                v-model="form.accountJson[field.name]"
+                                :placeholder="'请输入' + field.label"
+                                :disabled="!!field.disableProp"
+                                clearable
+                            />
+
+                            <!-- 文本域 -->
+                            <el-input
+                                v-else-if="field.type === 'textarea'"
+                                v-model="form.accountJson[field.name]"
+                                type="textarea"
+                                :rows="field.rows || 3"
+                                :placeholder="'请输入' + field.label"
+                                :disabled="!!field.disableProp"
+                            />
+
+                            <!-- 下拉框 -->
+                            <el-select
+                                v-else-if="field.type === 'select'"
+                                v-model="form.accountJson[field.name]"
+                                :placeholder="'请选择' + field.label"
+                                style="width: 100%"
+                                filterable
+                                clearable
+                                :disabled="!!field.disableProp"
+                                @change="(val) => handleSelectChange(field.name, val)"
+                            >
+                                <el-option
+                                    v-for="option in field.options"
+                                    :key="option.value"
+                                    :label="option.label"
+                                    :value="option.value"
+                                />
+                            </el-select>
+
+                            <!-- 大文本域 -->
+                            <el-input
+                                v-else-if="field.type === 'large-textarea'"
+                                v-model="form.accountJson[field.name]"
+                                type="textarea"
+                                :rows="14"
+                                :placeholder="'请输入' + field.label"
+                                class="large-textarea"
+                                :disabled="!!field.disableProp"
+                            />
+                        </el-form-item>
+
+                        <!-- Coze的token类型特殊字段 -->
+                        <template v-if="field.name === 'tokenType' && form.accountJson.tokenType">
+                            <el-form-item
+                                v-if="form.accountJson.tokenType === 'oauth'"
+                                label="OAuth配置"
+                                prop="accountJson.oauthFields"
+                            >
+                                <el-card class="oauth-card" shadow="never">
+                                    <el-form-item label="Client ID" prop="accountJson.oauthClientId" required>
+                                        <el-input v-model="form.accountJson.oauthClientId" />
+                                    </el-form-item>
+                                    <el-form-item label="Private Key" prop="accountJson.oauthPrivateKey" required>
+                                        <el-input v-model="form.accountJson.oauthPrivateKey" />
+                                    </el-form-item>
+                                    <el-form-item label="Public Key ID" prop="accountJson.oauthPublicKeyId" required>
+                                        <el-input v-model="form.accountJson.oauthPublicKeyId" />
+                                    </el-form-item>
+                                </el-card>
+                            </el-form-item>
+
+                            <el-form-item
+                                v-if="form.accountJson.tokenType === 'pat'"
+                                label="PAT Token"
+                                prop="accountJson.patToken"
+                                required
+                            >
+                                <el-input
+                                    v-model="form.accountJson.patToken"
+                                    type="textarea"
+                                    :rows="3"
+                                    placeholder="请输入PAT Token"
+                                />
+                            </el-form-item>
+                        </template>
+                    </div>
+                </div>
+
+                <!-- 通话与打断 -->
+                <div class="form-section">
+                    <div class="section-title">
+                        <i class="el-icon-microphone" />
+                        <span>通话与打断</span>
+                    </div>
+                    <el-form-item label="打断开关" prop="interruptFlag" required>
+                        <el-radio-group
+                            v-model="form.interruptFlag"
+                            class="interrupt-radio-group"
+                            size="small"
+                            @change="onInterruptFlagChange"
+                        >
+                            <el-radio-button :label="0">不打断</el-radio-button>
+                            <el-radio-button :label="1">关键词打断</el-radio-button>
+                            <el-radio-button :label="2">有声音就打断</el-radio-button>
+                        </el-radio-group>
+                        <div class="field-hint">
+                            「关键词打断」需配置下方打断词;「有声音即打断」适合对实时性要求高的场景。
+                        </div>
+                    </el-form-item>
+
+                    <el-form-item
+                        v-if="form.interruptFlag === 1"
+                        label="打断关键词"
+                        prop="interruptKeywords"
+                    >
+                        <el-input
+                            v-model="form.interruptKeywords"
+                            type="textarea"
+                            :rows="4"
+                            placeholder="请输入打断关键词,多条可用换行或逗号分隔"
+                        />
+                    </el-form-item>
+
+                    <el-form-item
+                        v-if="form.interruptFlag === 1"
+                        label="忽略打断关键词"
+                        prop="interruptIgnoreKeywords"
+                    >
+                        <el-input
+                            v-model="form.interruptIgnoreKeywords"
+                            type="textarea"
+                            :rows="4"
+                            placeholder="语气词等可在此配置为不触发打断"
+                        />
+                    </el-form-item>
+
+                    <el-form-item label="转人工数字按键" prop="transferManualDigit">
+                        <el-input
+                            v-model="form.transferManualDigit"
+                            placeholder="例如:1(单键 0–9)"
+                            maxlength="1"
+                            clearable
+                            style="max-width: 120px"
+                            @input="handleTransferManualDigitInput"
+                        />
+                    </el-form-item>
+                </div>
+
+                <!-- 客户意向 -->
+                <div v-if="showIntentionTips" class="form-section">
+                    <div class="section-title">
+                        <i class="el-icon-chat-dot-round" />
+                        <span>客户意向</span>
+                    </div>
+                    <el-form-item label="客户意向提示词" prop="intentionTips">
+                        <el-input
+                            v-model="form.intentionTips"
+                            type="textarea"
+                            :rows="5"
+                            placeholder="请输入客户意向提示词"
+                        />
+                    </el-form-item>
+                </div>
+            </el-form>
+        </div>
+
+        <div class="drawer-footer-bar">
+            <el-button @click="handleCancel">取 消</el-button>
+            <el-button type="primary" :loading="submitting" @click="handleSubmit">确 定</el-button>
+        </div>
+    </div>
+</template>
+
+<script>
+import { add, update, getCidConfig, bindCompany, unbindCompany } from '@/api/aicall/account'
+import { all } from '@/api/aicall/kbcat'
+
+export default {
+    name: 'AccountForm',
+    props: {
+        providerOptions: { type: Array, default: () => [] },
+        companyOptions: { type: Array, default: () => [] },
+        defaultBindCompanyId: { type: [Number, String], default: undefined },
+        initialData: {
+            type: Object,
+            default: () => ({})
+        },
+        errorMsg: {
+            type: String,
+            default: ''
+        }
+    },
+    data() {
+        return {
+            submitting: false,
+            form: {
+                id: undefined,
+                name: '',
+                providerClassName: '',
+                concurrentNum: '',
+                interruptFlag: 0,
+                interruptKeywords: '',
+                interruptIgnoreKeywords: '呃 哦 哦哦 嗯 嗯嗯 嗯好的 好的 对 对对 是的 明白 啊 这样啊 是这样啊这样的 您好 你好',
+                transferManualDigit: '',
+                intentionTips: '',
+                accountJson: {}
+            },
+            kbCatOptions: [],
+            dynamicFields: [],
+            showIntentionTips: false,
+            cidConf: {},
+            createBindCompanyId: undefined,
+            bindCompanyId: undefined
+        }
+    },
+    computed: {
+        rules() {
+            const baseRules = {
+                name: [
+                    { required: true, message: '请输入模型名称', trigger: 'blur' }
+                ],
+                providerClassName: [
+                    { required: true, message: '请选择实现类', trigger: 'change' }
+                ],
+                concurrentNum: [
+                    { required: true, message: '请输入模型并发数', trigger: 'blur' },
+                    { pattern: /^\d+$/, message: '请输入数字', trigger: 'blur' },
+                    { validator: this.validateConcurrentNum, trigger: 'blur' }
+                ],
+                transferManualDigit: [
+                    { pattern: /^[0-9]?$/, message: '请输入单个数字0-9', trigger: 'blur' }
+                ],
+                interruptFlag: [
+                    { required: true, message: '请选择打断开关', trigger: 'change' }
+                ]
+            }
+
+            this.dynamicFields.forEach(field => {
+                if (field.required) {
+                    const prop = 'accountJson.' + field.name
+                    const isSelect = field.type === 'select'
+                    baseRules[prop] = [
+                        {
+                            required: true,
+                            message: isSelect ? `请选择${field.label}` : `请输入${field.label}`,
+                            trigger: isSelect ? 'change' : 'blur'
+                        }
+                    ]
+                }
+            })
+
+            if (this.form.accountJson.tokenType === 'oauth') {
+                baseRules['accountJson.oauthClientId'] = [
+                    { required: true, message: '请输入Client ID', trigger: 'blur' }
+                ]
+                baseRules['accountJson.oauthPrivateKey'] = [
+                    { required: true, message: '请输入Private Key', trigger: 'blur' }
+                ]
+                baseRules['accountJson.oauthPublicKeyId'] = [
+                    { required: true, message: '请输入Public Key ID', trigger: 'blur' }
+                ]
+            }
+
+            if (this.form.accountJson.tokenType === 'pat') {
+                baseRules['accountJson.patToken'] = [
+                    { required: true, message: '请输入PAT Token', trigger: 'blur' }
+                ]
+            }
+
+            return baseRules
+        }
+    },
+    watch: {
+        initialData: {
+            handler(val) {
+                if (val && Object.keys(val).length > 0) {
+                    this.initFormData(val)
+                }
+            },
+            deep: true,
+            immediate: true
+        },
+        errorMsg: {
+            handler(val) {
+                if (val) {
+                    this.$alert(val, '错误', {
+                        type: 'error',
+                        confirmButtonText: '确定'
+                    })
+                }
+            },
+            immediate: true
+        }
+    },
+    created() {
+        this.loadKbCatOptions();
+        this.initCidConfData();
+        if (this.defaultBindCompanyId) {
+            this.createBindCompanyId = this.defaultBindCompanyId
+        }
+    },
+    methods: {
+        stripMaskedFields(obj) {
+            if (!obj || typeof obj !== 'object') return
+            Object.keys(obj).forEach(key => {
+                const val = obj[key]
+                if (typeof val === 'string' && val.indexOf('****') !== -1) {
+                    delete obj[key]
+                } else if (val && typeof val === 'object') {
+                    this.stripMaskedFields(val)
+                }
+            })
+        },
+        handleBindCompany() {
+            if (!this.form.id || !this.bindCompanyId) return
+            bindCompany(this.form.id, this.bindCompanyId).then(res => {
+                this.$message.success(res.msg || '绑定成功')
+            })
+        },
+        handleUnbindCompany() {
+            if (!this.form.id || !this.bindCompanyId) return
+            this.$confirm('确认解绑该公司?', '提示', { type: 'warning' }).then(() => {
+                unbindCompany(this.form.id, this.bindCompanyId).then(res => {
+                    this.$message.success(res.msg || '解绑成功')
+                })
+            }).catch(() => {})
+        },
+        onInterruptFlagChange() {
+            this.$nextTick(() => {
+                if (this.$refs.form) {
+                    this.$refs.form.clearValidate(['interruptKeywords', 'interruptIgnoreKeywords'])
+                }
+            })
+        },
+        initCidConfData() {
+            getCidConfig().then(res => {
+                console.log(JSON.parse(res.data));
+                this.cidConf = JSON.parse(res.data);
+            }).catch(() => {})
+        },
+        initFormData(data) {
+            this.form.id = data.id
+            this.form.name = data.name || ''
+            this.form.providerClassName = data.providerClassName || ''
+            this.form.concurrentNum = (data.concurrentNum === null || data.concurrentNum === undefined || data.concurrentNum === '') ? '0' : String(data.concurrentNum)
+            this.form.interruptFlag = data.interruptFlag || 0
+            this.form.interruptKeywords = data.interruptKeywords || ''
+            this.form.interruptIgnoreKeywords = data.interruptIgnoreKeywords || ''
+            this.form.transferManualDigit = data.transferManualDigit || ''
+            this.form.intentionTips = data.intentionTips || ''
+            try {
+                this.form.accountJson = data.accountJson ?
+                    (typeof data.accountJson === 'string' ? JSON.parse(data.accountJson) : data.accountJson)
+                    : {}
+            } catch (e) {
+                console.error('解析accountJson失败:', e)
+                this.form.accountJson = {}
+            }
+
+            if (this.form.providerClassName) {
+                this.updateDynamicFields(this.form.providerClassName, true)
+            }
+        },
+
+        handleConcurrentNumInput(value) {
+            let val = value.replace(/[^0-9]/g, '')
+            val = val.replace(/^0+(\d)/, '$1')
+            if (parseInt(val, 10) > 200) val = '200'
+            this.$set(this.form, 'concurrentNum', val)
+        },
+
+        handleTransferManualDigitInput(value) {
+            let val = value.replace(/[^0-9]/g, '')
+            if (val.length > 1) val = val.substring(0, 1)
+            this.$set(this.form, 'transferManualDigit', val)
+        },
+
+        validateConcurrentNum(rule, value, callback) {
+            const num = parseInt(value, 10)
+            if (isNaN(num) || num < 0 || num > 200) {
+                callback(new Error('请输入0-200之间的整数'))
+            } else {
+                callback()
+            }
+        },
+
+        handleProviderChange(val) {
+            this.updateDynamicFields(val)
+        },
+
+        updateDynamicFields(providerClassName, isUpdate) {
+            this.dynamicFields = []
+            this.showIntentionTips = false
+
+            const newAccountJson = {}
+
+            if (['DeepSeekChat', 'ChatGpt4o', 'JiutianChat'].includes(providerClassName)) {
+                this.dynamicFields = [
+                    { name: 'serverUrl', label: '服务地址', type: 'input', required: true, disableProp: true  },
+                    { name: 'apiKey', label: 'apiKey', type: 'input', required: true, disableProp: true  },
+                    { name: 'modelName', label: '模型名称', type: 'input', required: true, disableProp: true  },
+                    { name: 'llmTips', label: '大模型提示词', type: 'large-textarea', required: true },
+                    { name: 'faqContext', label: 'FAQ上下文', type: 'large-textarea', required: true },
+                    { name: 'kbCatId', label: '知识库分类', type: 'select', required: false, options: this.kbCatOptions },
+                    { name: 'transferToAgentTips', label: '转人工提示词', type: 'textarea', rows: 3, required: true },
+                    { name: 'hangupTips', label: '挂机提示', type: 'textarea', rows: 3, required: true },
+                    { name: 'customerNoVoiceTips', label: '客户不说话提示', type: 'textarea', rows: 3, required: true },
+                    { name: 'openingRemarks', label: '开场白', type: 'textarea', rows: 3, required: true }
+                ]
+                this.showIntentionTips = true
+            }
+
+            if (['LocalLlmChat'].includes(providerClassName)) {
+                this.dynamicFields = [
+                    { name: 'serverUrl', label: '服务地址', type: 'input', required: true },
+                    { name: 'modelName', label: '模型名称', type: 'input', required: true },
+                    { name: 'transferToAgentTips', label: '转人工提示词', type: 'textarea', rows: 3, required: true },
+                    { name: 'hangupTips', label: '挂机提示', type: 'textarea', rows: 3, required: true },
+                    { name: 'customerNoVoiceTips', label: '客户不说话提示', type: 'textarea', rows: 3, required: true },
+                    { name: 'openingRemarks', label: '开场白', type: 'textarea', rows: 3, required: true }
+                ]
+            }
+
+            if (['LocalNlpChat'].includes(providerClassName)) {
+                this.dynamicFields = [
+                    { name: 'serverUrl', label: '服务地址', type: 'input', required: true },
+                    { name: 'botId', label: 'botId', type: 'input', required: true },
+                    { name: 'transferToAgentTips', label: '转人工提示词', type: 'textarea', rows: 3, required: true },
+                    { name: 'hangupTips', label: '挂机提示', type: 'textarea', rows: 3, required: true },
+                    { name: 'customerNoVoiceTips', label: '客户不说话提示', type: 'textarea', rows: 3, required: true }
+                ]
+            }
+
+            if (['Coze'].includes(providerClassName)) {
+                this.dynamicFields = [
+                    { name: 'serverUrl', label: '服务地址', type: 'input', required: true },
+                    { name: 'botId', label: 'botId', type: 'input', required: true },
+                    {
+                        name: 'tokenType',
+                        label: 'Token类型',
+                        type: 'select',
+                        required: true,
+                        options: [
+                            { label: 'OAuth', value: 'oauth' },
+                            { label: 'PAT', value: 'pat' }
+                        ]
+                    },
+                    { name: 'transferToAgentTips', label: '转人工提示词', type: 'textarea', rows: 3, required: true },
+                    { name: 'hangupTips', label: '挂机提示', type: 'textarea', rows: 3, required: true },
+                    { name: 'customerNoVoiceTips', label: '客户不说话提示', type: 'textarea', rows: 3, required: true },
+                    { name: 'openingRemarks', label: '开场白', type: 'textarea', rows: 3, required: true }
+                ]
+            }
+
+            if (['MaxKB', 'Dify'].includes(providerClassName)) {
+                this.dynamicFields = [
+                    { name: 'serverUrl', label: '服务地址', type: 'input', required: true },
+                    { name: 'apiKey', label: 'apiKey', type: 'input', required: true },
+                    { name: 'transferToAgentTips', label: '转人工提示词', type: 'textarea', rows: 3, required: true },
+                    { name: 'hangupTips', label: '挂机提示', type: 'textarea', rows: 3, required: true },
+                    { name: 'customerNoVoiceTips', label: '客户不说话提示', type: 'textarea', rows: 3, required: true },
+                    { name: 'openingRemarks', label: '开场白', type: 'textarea', rows: 3, required: true }
+                ]
+            }
+
+            if (['JiutianWorkflow', 'JiutianAgent'].includes(providerClassName)) {
+                this.dynamicFields = [
+                    { name: 'serverUrl', label: '服务地址', type: 'input', required: true },
+                    { name: 'apiKey', label: 'apiKey', type: 'input', required: true },
+                    { name: 'botId', label: 'botId', type: 'input', required: true },
+                    { name: 'transferToAgentTips', label: '转人工提示词', type: 'textarea', rows: 3, required: true },
+                    { name: 'hangupTips', label: '挂机提示', type: 'textarea', rows: 3, required: true },
+                    { name: 'customerNoVoiceTips', label: '客户不说话提示', type: 'textarea', rows: 3, required: true },
+                    { name: 'openingRemarks', label: '开场白', type: 'textarea', rows: 3, required: true }
+                ]
+            }
+
+            this.dynamicFields.forEach(field => {
+                if (this.form.accountJson[field.name]) {
+                    newAccountJson[field.name] = this.form.accountJson[field.name]
+                }
+            })
+
+            this.form.accountJson = {
+                ...newAccountJson,
+                ...this.form.accountJson
+            }
+
+            if (providerClassName === 'DeepSeekChat' && !!this.cidConf && !!!isUpdate) {
+                this.$set(this.form.accountJson, 'serverUrl', this.cidConf.serverAddress);
+                this.$set(this.form.accountJson, 'apiKey', this.cidConf.apiKey);
+                this.$set(this.form.accountJson, 'modelName', this.cidConf.modelName);
+                this.$set(this.form, 'concurrentNum', this.cidConf.concurrency);
+                this.dynamicFields = [
+                    { name: 'serverUrl', label: '服务地址', type: 'input', required: true, disableProp: true },
+                    { name: 'apiKey', label: 'apiKey', type: 'input', required: true, disableProp: true },
+                    { name: 'modelName', label: '模型名称', type: 'input', required: true, disableProp: true },
+                    { name: 'llmTips', label: '大模型提示词', type: 'large-textarea', required: true },
+                    { name: 'faqContext', label: 'FAQ上下文', type: 'large-textarea', required: true },
+                    { name: 'kbCatId', label: '知识库分类', type: 'select', required: false, options: this.kbCatOptions },
+                    { name: 'transferToAgentTips', label: '转人工提示词', type: 'textarea', rows: 3, required: true },
+                    { name: 'hangupTips', label: '挂机提示', type: 'textarea', rows: 3, required: true },
+                    { name: 'customerNoVoiceTips', label: '客户不说话提示', type: 'textarea', rows: 3, required: true },
+                    { name: 'openingRemarks', label: '开场白', type: 'textarea', rows: 3, required: true }
+                ]
+            }
+        },
+
+        handleSelectChange(fieldName) {
+            if (fieldName === 'tokenType') {
+                delete this.form.accountJson.oauthClientId
+                delete this.form.accountJson.oauthPrivateKey
+                delete this.form.accountJson.oauthPublicKeyId
+                delete this.form.accountJson.patToken
+            }
+        },
+
+        async loadKbCatOptions() {
+            try {
+                const response = await all();
+                const list = response?.data ?? [];
+                this.kbCatOptions = Array.isArray(list)
+                    ? list.map(item => ({ label: item.cat, value: item.id }))
+                    : [];
+            } catch (error) {
+                console.error('加载知识库分类失败:', error);
+                this.kbCatOptions = [];
+            }
+        },
+
+        handleCancel() {
+            this.$emit('cancel')
+        },
+
+        async handleSubmit() {
+            await this.$refs.form.validate(async (valid) => {
+                if (valid) {
+                    this.submitting = true
+
+                    try {
+                        if (!this.showIntentionTips) {
+                            this.form.intentionTips = ''
+                        }
+
+                        const submitData = {
+                            id: this.form.id,
+                            name: this.form.name,
+                            providerClassName: this.form.providerClassName,
+                            concurrentNum: this.form.concurrentNum,
+                            interruptFlag: this.form.interruptFlag,
+                            interruptKeywords: this.form.interruptKeywords || '',
+                            interruptIgnoreKeywords: this.form.interruptIgnoreKeywords || '',
+                            transferManualDigit: this.form.transferManualDigit || '',
+                            intentionTips: this.form.intentionTips || ''
+                        }
+
+                        const accountJsonPayload = JSON.parse(JSON.stringify(this.form.accountJson || {}))
+                        this.stripMaskedFields(accountJsonPayload)
+
+                        if (accountJsonPayload) {
+                            Object.keys(accountJsonPayload).forEach(key => {
+                                submitData[key] = accountJsonPayload[key]
+                            })
+                        }
+
+                        submitData.accountJson = JSON.stringify(accountJsonPayload)
+
+                        let response
+                        if (submitData.id && submitData.id > 0) {
+                            response = await update(submitData)
+                        } else {
+                            const query = {}
+                            if (this.createBindCompanyId) {
+                                query.companyId = this.createBindCompanyId
+                            }
+                            if (!submitData.id || submitData.id >= 0) {
+                                delete submitData.id
+                            }
+                            response = await add(submitData, query)
+                        }
+
+                        this.$message.success('操作成功!')
+                        this.$emit('success', response)
+                    } catch (error) {
+                        console.error('提交失败:', error)
+                        this.$message.error('操作失败')
+                    } finally {
+                        this.submitting = false
+                    }
+                }
+            })
+        }
+    }
+}
+</script>
+
+<style scoped>
+.ai-account-form-root {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    min-height: 0;
+}
+
+.ai-account-form-scroll {
+    flex: 1;
+    min-height: 0;
+    overflow-y: auto;
+    overflow-x: hidden;
+    padding: 16px 20px;
+    padding-bottom: 8px;
+    background: #f5f7fa;
+}
+
+.account-form {
+    max-width: 100%;
+}
+
+.form-section {
+    background: #fff;
+    border-radius: 8px;
+    padding: 18px 20px;
+    margin-bottom: 16px;
+    box-shadow: 0 1px 6px rgba(0, 0, 0, 0.06);
+    border: 1px solid #eef0f3;
+}
+
+.form-section:last-of-type {
+    margin-bottom: 0;
+}
+
+.section-title {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px;
+    font-size: 15px;
+    font-weight: 600;
+    color: #303133;
+    margin-bottom: 14px;
+    padding-bottom: 12px;
+    border-bottom: 1px solid #f0f2f5;
+}
+
+.section-title i {
+    font-size: 16px;
+    color: #409eff;
+}
+
+.section-desc {
+    font-size: 12px;
+    font-weight: 400;
+    color: #909399;
+    width: 100%;
+    margin-left: 24px;
+    margin-bottom: -4px;
+}
+
+.hint-icon {
+    margin-left: 4px;
+    color: #c0c4cc;
+    cursor: help;
+}
+
+.field-hint {
+    margin-top: 8px;
+    font-size: 12px;
+    color: #909399;
+    line-height: 1.5;
+}
+
+.interrupt-radio-group {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+}
+
+.interrupt-radio-group ::v-deep .el-radio-button__inner {
+    border-radius: 4px !important;
+    border-left: 1px solid #dcdfe6 !important;
+    box-shadow: none !important;
+}
+
+.interrupt-radio-group ::v-deep .el-radio-button:first-child .el-radio-button__inner {
+    border-radius: 4px !important;
+}
+
+.interrupt-radio-group ::v-deep .el-radio-button:last-child .el-radio-button__inner {
+    border-radius: 4px !important;
+}
+
+.drawer-footer-bar {
+    flex-shrink: 0;
+    display: flex;
+    justify-content: flex-end;
+    align-items: center;
+    gap: 12px;
+    padding: 12px 20px;
+    border-top: 1px solid #ebeef5;
+    background: #fff;
+}
+
+.large-textarea ::v-deep textarea {
+    font-family: inherit;
+}
+
+.oauth-card {
+    width: 100%;
+    margin-bottom: 0;
+}
+
+.oauth-card ::v-deep .el-card__body {
+    padding: 12px 16px;
+}
+
+.account-form ::v-deep .el-form-item {
+    margin-bottom: 14px;
+}
+
+.account-form ::v-deep .el-form-item__label {
+    font-weight: 500;
+    color: #606266;
+    padding-bottom: 4px !important;
+    line-height: 1.4;
+}
+
+@media (max-width: 768px) {
+    .ai-account-form-scroll {
+        padding: 12px;
+    }
+
+    .form-section {
+        padding: 14px;
+    }
+
+    .interrupt-radio-group ::v-deep .el-radio-button {
+        flex: 1 1 auto;
+    }
+
+    .interrupt-radio-group ::v-deep .el-radio-button__inner {
+        width: 100%;
+        text-align: center;
+    }
+}
+</style>

+ 881 - 0
src/views/company/voiceClone/index.vue

@@ -0,0 +1,881 @@
+<template>
+  <div class="app-container">
+    <!-- 音色列表 -->
+    <el-card class="box-card" style="margin-bottom: 16px;">
+      <div slot="header"><span>音色关联列表</span></div>
+      <el-form
+        v-show="showSearch"
+        ref="queryForm"
+        :inline="true"
+        :model="queryParams"
+        label-width="90px"
+      >
+        <el-form-item label="公司" prop="companyId">
+          <el-select
+            v-model="queryParams.companyId"
+            clearable
+            filterable
+            placeholder="全部"
+            size="small"
+            style="width: 200px"
+            @change="onQueryCompanyChange"
+          >
+            <el-option
+              v-for="item in companys"
+              :key="item.companyId"
+              :label="item.companyName"
+              :value="item.companyId"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="销售" prop="companyUserId">
+          <el-select
+            v-model="queryParams.companyUserId"
+            clearable
+            filterable
+            placeholder="全部"
+            size="small"
+            style="width: 200px"
+          >
+            <el-option
+              v-for="item in companyUserOptions"
+              :key="item.companyUserId || item.userId"
+              :label="item.nickName || item.userName"
+              :value="item.companyUserId || item.userId"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="音色名称" prop="voiceName">
+          <el-input
+            v-model="queryParams.voiceName"
+            clearable
+            placeholder="请输入"
+            size="small"
+            style="width: 160px"
+            @keyup.enter.native="handleQuery"
+          />
+        </el-form-item>
+        <el-form-item label="状态" prop="status">
+          <el-select v-model="queryParams.status" clearable placeholder="全部" size="small" style="width: 120px">
+            <el-option label="正常" :value="1" />
+            <el-option label="停用" :value="0" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+          <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-row :gutter="10" class="mb8">
+        <el-col :span="1.5">
+          <el-button
+            v-hasPermi="['company:voiceCloneRef:remove']"
+            icon="el-icon-delete"
+            plain
+            size="mini"
+            type="danger"
+            @click="handleDeleteRef()"
+          >删除</el-button>
+        </el-col>
+        <right-toolbar :showSearch.sync="showSearch" @queryTable="getRefList" />
+      </el-row>
+
+      <el-table v-loading="refLoading" :data="refList" border @selection-change="handleRefSelectionChange">
+        <el-table-column type="selection" width="55" align="center" />
+        <el-table-column align="center" label="ID" prop="id" width="80" />
+        <el-table-column align="center" label="公司ID" prop="companyId" width="90" />
+        <el-table-column align="center" label="公司名称" min-width="120" show-overflow-tooltip>
+          <template slot-scope="scope">{{ resolveCompanyName(scope.row.companyId) }}</template>
+        </el-table-column>
+        <el-table-column align="center" label="销售ID" prop="companyUserId" width="90" />
+        <el-table-column align="center" label="音色名称" prop="voiceName" show-overflow-tooltip />
+        <el-table-column align="center" label="音色CODE" prop="voiceCode" show-overflow-tooltip />
+        <el-table-column align="center" label="TTS音色ID" prop="ttsId" width="100" />
+        <el-table-column align="center" label="状态" prop="status" width="80">
+          <template slot-scope="scope">
+            <el-tag v-if="scope.row.status === 1" type="success" size="small">正常</el-tag>
+            <el-tag v-else type="info" size="small">停用</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="创建时间" prop="createTime" width="160">
+          <template slot-scope="scope">
+            <span>{{ parseTime(scope.row.createTime) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="操作" width="160" class-name="small-padding fixed-width">
+          <template slot-scope="scope">
+            <el-button
+              v-hasPermi="['company:voiceCloneRef:update']"
+              v-if="scope.row.status === 0"
+              size="mini"
+              type="text"
+              icon="el-icon-circle-check"
+              @click="handleChangeStatus(scope.row, 1)"
+            >启用</el-button>
+            <el-button
+              v-hasPermi="['company:voiceCloneRef:update']"
+              v-else
+              size="mini"
+              type="text"
+              icon="el-icon-circle-close"
+              @click="handleChangeStatus(scope.row, 0)"
+            >禁用</el-button>
+            <el-button
+              v-hasPermi="['company:voiceCloneRef:remove']"
+              size="mini"
+              type="text"
+              icon="el-icon-delete"
+              @click="handleDeleteRef(scope.row)"
+            >删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <pagination
+        v-show="refTotal > 0"
+        :limit.sync="queryParams.pageSize"
+        :page.sync="queryParams.pageNum"
+        :total="refTotal"
+        @pagination="getRefList"
+      />
+    </el-card>
+
+    <!-- 注意事项 -->
+    <el-card class="tips-card" shadow="hover">
+      <div class="tips-header">
+        <i class="el-icon-warning-outline tips-icon"></i>
+        <span class="tips-title">语音克隆注意事项</span>
+      </div>
+      <el-row :gutter="32" class="tips-content">
+        <el-col :span="12">
+          <div class="tip-item">
+            <div class="tip-num">1</div>
+            <div class="tip-text">
+              <div class="tip-label">音色限制</div>
+              <div class="tip-desc">
+                <div class="tip-warning-box">
+                  <i class="el-icon-info"></i>
+                  <span>每个音色最多只能上传并训练 <strong>10</strong> 次</span>
+                </div>
+              </div>
+            </div>
+          </div>
+        </el-col>
+        <el-col :span="12">
+          <div class="tip-item">
+            <div class="tip-num">2</div>
+            <div class="tip-text">
+              <div class="tip-label">录制要求</div>
+              <div class="tip-desc">
+                预先录制 <strong>20~30秒</strong> 左右的声音,确保环境安静且无噪音
+              </div>
+            </div>
+          </div>
+          <div class="tip-item tip-sub">
+            <div class="tip-sub-label">推荐录制方式:</div>
+            <div class="tip-sub-list">
+              <span class="tip-tag"><i class="el-icon-monitor"></i> 电脑+耳机,使用自带录音机</span>
+              <span class="tip-tag"><i class="el-icon-mobile-phone"></i> 手机录音后传到电脑</span>
+            </div>
+          </div>
+        </el-col>
+      </el-row>
+    </el-card>
+
+    <!-- 声音克隆表单 -->
+    <el-card class="box-card" style="margin-bottom: 16px;">
+      <div slot="header"><span>声音克隆配置</span></div>
+      <el-form ref="cloneForm" :model="cloneForm" :rules="cloneRules" label-width="120px">
+        <el-form-item label="目标公司" prop="companyId">
+          <el-select
+            v-model="cloneForm.companyId"
+            filterable
+            placeholder="请选择公司"
+            style="width: 320px;"
+            @change="onCloneCompanyChange"
+          >
+            <el-option
+              v-for="item in companys"
+              :key="item.companyId"
+              :label="item.companyName"
+              :value="item.companyId"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="归属销售" prop="companyUserId">
+          <el-select
+            v-model="cloneForm.companyUserId"
+            clearable
+            filterable
+            placeholder="可选"
+            style="width: 320px;"
+          >
+            <el-option
+              v-for="item in cloneCompanyUserOptions"
+              :key="item.companyUserId || item.userId"
+              :label="item.nickName || item.userName"
+              :value="item.companyUserId || item.userId"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="音色名称" prop="voice_name">
+          <el-input
+            v-model="cloneForm.voice_name"
+            placeholder="请输入音色名称"
+            style="width: 320px;"
+          />
+        </el-form-item>
+
+        <el-form-item label="声音ID" prop="speaker_id">
+          <el-input
+            v-model="cloneForm.speaker_id"
+            placeholder="请输入声音ID"
+            style="width: 320px;"
+          />
+        </el-form-item>
+
+        <el-form-item label="模型类型" prop="model_type">
+          <el-select
+            v-model="cloneForm.model_type"
+            placeholder="请选择模型类型"
+            style="width: 400px;"
+            @change="onModelTypeChange"
+          >
+            <el-option :value="1" label="声音复刻ICL1.0效果" />
+            <!-- <el-option :value="2" label="DiT标准版效果(音色、不还原用户的风格)" />
+            <el-option :value="3" label="DiT还原版效果(音色、还原用户口音、语速等风格)" /> -->
+            <el-option :value="4" label="声音复刻ICL2.0效果" />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="语言" prop="language">
+          <el-select
+            v-model="cloneForm.language"
+            placeholder="请选择语言"
+            style="width: 240px;"
+          >
+            <el-option
+              v-for="item in languageOptions"
+              :key="item.value"
+              :label="item.label"
+              :value="item.value"
+            />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="音频文件">
+          <el-upload
+            ref="audioUpload"
+            action="#"
+            :auto-upload="false"
+            :on-change="onAudioFileChange"
+            :on-remove="onAudioFileRemove"
+            :file-list="audioFileList"
+            accept=".wav,.mp3,.ogg,.m4a,.aac"
+            :limit="1"
+            :on-exceed="onFileExceed"
+          >
+            <el-button size="small" type="primary" icon="el-icon-upload">
+              选择音频文件
+            </el-button>
+            <div slot="tip" class="el-upload__tip" style="color: #909399;">
+              支持 wav、mp3、ogg、m4a、aac 格式,推荐 20-30 秒录音
+            </div>
+          </el-upload>
+        </el-form-item>
+
+        <el-form-item>
+          <el-button
+            v-hasPermi="['company:voiceCloneRef:add']"
+            type="success"
+            icon="el-icon-upload2"
+            :loading="uploadLoading"
+            @click="handleUploadAndTrain"
+          >
+            {{ uploadLoading ? '上传训练中,约15-30秒...' : '上传并训练' }}
+          </el-button>
+          <el-button
+            type="primary"
+            icon="el-icon-headset"
+            plain
+            @click="handleOpenTestPanel"
+          >
+            测试已有声音
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- TTS测试区域(上传训练成功后 或 点击测试按钮后显示) -->
+    <el-card v-if="showTtsArea" class="box-card">
+      <div slot="header" class="tts-card-header">
+        <span>TTS语音合成测试</span>
+        <el-button v-if="isManualTest" type="text" icon="el-icon-close" @click="showTtsArea = false">关闭</el-button>
+      </div>
+      <el-form ref="ttsForm" :model="ttsForm" label-width="120px">
+        <!-- 手动测试时需要输入声音ID -->
+        <el-form-item v-if="isManualTest" label="声音ID" prop="test_speaker_id">
+          <el-input
+            v-model="ttsForm.test_speaker_id"
+            placeholder="请输入要测试的声音ID"
+            style="width: 320px;"
+          />
+        </el-form-item>
+
+        <el-form-item label="测试文本" prop="tts_text">
+          <el-input
+            v-model="ttsForm.tts_text"
+            type="textarea"
+            :rows="5"
+            placeholder="请输入测试语音合成的文本"
+            style="width: 480px;"
+          />
+        </el-form-item>
+
+        <el-form-item label="语言" prop="language_test">
+          <el-select
+            v-model="ttsForm.language_test"
+            placeholder="请选择语言"
+            style="width: 240px;"
+          >
+            <el-option
+              v-for="item in ttsLanguageOptions"
+              :key="item.value"
+              :label="item.label"
+              :value="item.value"
+            />
+          </el-select>
+          <el-button
+            type="primary"
+            icon="el-icon-video-play"
+            style="margin-left: 12px;"
+            :loading="ttsLoading"
+            :disabled="isManualTest && !ttsForm.test_speaker_id"
+            @click="handleTtsTest"
+          >
+            {{ ttsLoading ? '合成中...' : '开始合成' }}
+          </el-button>
+        </el-form-item>
+
+        <el-form-item v-if="audioSrc" label="播放">
+          <audio ref="audioPlayer" controls :src="audioSrc" style="margin-top: 4px;" />
+        </el-form-item>
+      </el-form>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { uploadAndTrain, doubaoTtsTest } from '@/api/company/voiceClone'
+import { list as listRef, remove as removeRef, changeStatus } from '@/api/company/voiceCloneRef'
+import { getCompanyList } from '@/api/company/company'
+import { getCompanyUserList } from '@/api/company/companyUser'
+
+export default {
+  name: 'VoiceClone',
+  data() {
+    return {
+      showSearch: true,
+      refLoading: false,
+      refList: [],
+      refTotal: 0,
+      selectedRefRows: [],
+      companys: [],
+      companyUserOptions: [],
+      cloneCompanyUserOptions: [],
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        companyId: undefined,
+        companyUserId: undefined,
+        voiceName: undefined,
+        status: undefined
+      },
+      cloneForm: {
+        companyId: undefined,
+        companyUserId: undefined,
+        voice_name: '',
+        speaker_id: '',
+        model_type: null,
+        language: 0
+      },
+      cloneRules: {
+        companyId: [{ required: true, message: '请选择目标公司', trigger: 'change' }],
+        voice_name: [{ required: true, message: '请输入音色名称', trigger: 'blur' }],
+        speaker_id: [{ required: true, message: '请输入声音ID', trigger: 'blur' }],
+        model_type: [{ required: true, message: '请选择模型类型', trigger: 'change' }],
+        language: [{ required: true, message: '请选择语言', trigger: 'change' }]
+      },
+
+      // 音频文件列表
+      audioFileList: [],
+      selectedFile: null,
+
+      // 上传训练loading
+      uploadLoading: false,
+
+      // 是否显示TTS测试区域
+      showTtsArea: false,
+
+      // 是否为手动测试模式(区别于上传训练成功后的自动显示)
+      isManualTest: false,
+
+      // TTS测试表单
+      ttsForm: {
+        tts_text: '',
+        language_test: 0,
+        test_speaker_id: '' // 手动测试时的声音ID
+      },
+
+      // TTS testing loading
+      ttsLoading: false,
+
+      // 播放音频src(base64)
+      audioSrc: ''
+    }
+  },
+
+  created() {
+    getCompanyList().then(response => {
+      this.companys = response.data || []
+    })
+    this.getRefList()
+  },
+  computed: {
+    companyMap() {
+      const map = {}
+      this.companys.forEach(c => { map[c.companyId] = c.companyName })
+      return map
+    },
+    languageOptions() {
+      const modelType = this.cloneForm.model_type
+      const options = [
+        { value: 0, label: '中文' },
+        { value: 1, label: '英文' }
+      ]
+      if (modelType <= 2) {
+        options.push(
+          { value: 2, label: '日语' },
+          { value: 3, label: '西班牙语' },
+          { value: 4, label: '印尼语' },
+          { value: 5, label: '葡萄牙语' }
+        )
+      }
+      if (modelType === 2) {
+        options.push(
+          { value: 6, label: '德语' },
+          { value: 7, label: '法语' }
+        )
+      }
+      return options
+    },
+    // TTS测试语言选项(手动测试时显示全部语言)
+    ttsLanguageOptions() {
+      return [
+        { value: 0, label: '中文' },
+        { value: 1, label: '英文' },
+        { value: 2, label: '日语' },
+        { value: 3, label: '西班牙语' },
+        { value: 4, label: '印尼语' },
+        { value: 5, label: '葡萄牙语' },
+        { value: 6, label: '德语' },
+        { value: 7, label: '法语' }
+      ]
+    }
+  },
+
+  methods: {
+    resolveCompanyName(companyId) {
+      if (companyId === null || companyId === undefined || companyId === '') return '-'
+      return this.companyMap[companyId] || companyId
+    },
+    loadCompanyUsers(companyId, target) {
+      if (!companyId) {
+        if (target === 'query') this.companyUserOptions = []
+        else this.cloneCompanyUserOptions = []
+        return
+      }
+      getCompanyUserList({ companyId }).then(res => {
+        const list = res.data || res.rows || []
+        if (target === 'query') this.companyUserOptions = list
+        else this.cloneCompanyUserOptions = list
+      })
+    },
+    onQueryCompanyChange(companyId) {
+      this.queryParams.companyUserId = undefined
+      this.loadCompanyUsers(companyId, 'query')
+    },
+    onCloneCompanyChange(companyId) {
+      this.cloneForm.companyUserId = undefined
+      this.loadCompanyUsers(companyId, 'clone')
+    },
+    getRefList() {
+      this.refLoading = true
+      listRef(this.queryParams).then(response => {
+        this.refList = response.rows || []
+        this.refTotal = response.total || 0
+        this.refLoading = false
+      }).catch(() => {
+        this.refLoading = false
+      })
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      this.getRefList()
+    },
+    resetQuery() {
+      this.resetForm('queryForm')
+      this.companyUserOptions = []
+      this.handleQuery()
+    },
+    handleRefSelectionChange(selection) {
+      this.selectedRefRows = selection
+    },
+    handleDeleteRef(row) {
+      let ids = []
+      if (row) {
+        ids = [row.id]
+      } else {
+        if (this.selectedRefRows.length === 0) {
+          this.$message.warning('请至少选择一条要删除的数据')
+          return
+        }
+        ids = this.selectedRefRows.map(item => item.id)
+      }
+      this.$confirm('此操作将永久删除该记录,是否继续?', '警告', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(async () => {
+        for (const id of ids) {
+          const response = await removeRef(id)
+          if (response.code !== 200) {
+            this.$message.error(response.msg || '删除失败')
+            return
+          }
+        }
+        this.$message.success('删除成功')
+        this.getRefList()
+      }).catch(() => {})
+    },
+    handleChangeStatus(row, status) {
+      const text = status === 1 ? '启用' : '禁用'
+      this.$confirm(`确认${text}该音色关联吗?`, '提示', { type: 'warning' }).then(() => {
+        changeStatus({ id: row.id, status }).then(response => {
+          if (response.code === 200) {
+            this.$message.success(`${text}成功`)
+            this.getRefList()
+          } else {
+            this.$message.error(response.msg || `${text}失败`)
+          }
+        })
+      }).catch(() => {})
+    },
+    onModelTypeChange() {
+      const validValues = this.languageOptions.map(o => o.value)
+      if (!validValues.includes(this.cloneForm.language)) {
+        this.cloneForm.language = 0
+      }
+      if (!validValues.includes(this.ttsForm.language_test)) {
+        this.ttsForm.language_test = 0
+      }
+    },
+
+    // 音频文件选择
+    onAudioFileChange(file) {
+      this.selectedFile = file.raw
+      this.audioFileList = [file]
+    },
+
+    // 音频文件移除
+    onAudioFileRemove() {
+      this.selectedFile = null
+      this.audioFileList = []
+    },
+
+    // 超出文件数量限制
+    onFileExceed() {
+      this.$message.warning('每次只能上传一个音频文件,请先移除已选文件')
+    },
+
+    // 打开手动测试面板
+    handleOpenTestPanel() {
+      this.isManualTest = true
+      this.showTtsArea = true
+      this.ttsForm.test_speaker_id = ''
+      this.ttsForm.tts_text = ''
+      this.ttsForm.language_test = 0
+      this.audioSrc = ''
+    },
+
+    // 上传并训练
+    handleUploadAndTrain() {
+      this.$refs.cloneForm.validate(valid => {
+        if (!valid) return
+        if (!this.selectedFile) {
+          this.$message.error('请选择音频文件')
+          return
+        }
+        this.uploadLoading = true
+
+        const formData = new FormData()
+        formData.append('file', this.selectedFile)
+        formData.append('voice_name', this.cloneForm.voice_name)
+        formData.append('speaker_id', this.cloneForm.speaker_id)
+        formData.append('language', this.cloneForm.language)
+        formData.append('model_type', this.cloneForm.model_type)
+        formData.append('companyId', this.cloneForm.companyId)
+        if (this.cloneForm.companyUserId) {
+          formData.append('companyUserId', this.cloneForm.companyUserId)
+        }
+
+        uploadAndTrain(formData).then(res => {
+          if (res.code === 200) {
+            this.$message.success(res.msg || '上传训练成功!')
+            this.isManualTest = false
+            this.showTtsArea = true
+            this.getRefList()
+          } else {
+            this.$message.error(res.msg || '上传训练失败,请重试')
+          }
+        }).catch(() => {
+          this.$message.error('上传训练失败,请重试')
+        }).finally(() => {
+          this.uploadLoading = false
+        })
+      })
+    },
+
+    // TTS测试
+    handleTtsTest() {
+      if (this.ttsLoading) {
+        this.$message.warning('请等待合成完成!')
+        return
+      }
+      if (!this.ttsForm.tts_text || this.ttsForm.tts_text.trim() === '') {
+        this.$message.error('请输入测试文本')
+        return
+      }
+
+      // 获取声音ID:手动测试用输入框的值,自动测试用表单的值
+      const speakerId = this.isManualTest ? this.ttsForm.test_speaker_id : this.cloneForm.speaker_id
+      if (!speakerId || speakerId.trim() === '') {
+        this.$message.error('请输入声音ID')
+        return
+      }
+
+      this.ttsLoading = true
+
+      const formData = new FormData()
+      formData.append('speakerId', speakerId)
+      formData.append('language', this.ttsForm.language_test)
+      formData.append('text', this.ttsForm.tts_text)
+
+      doubaoTtsTest(formData).then(res => {
+        if (res.code === 200) {
+          this.$message.success('TTS合成成功!')
+          // res.data 是 JSON 字符串 {"code":3000,"data":"...base64..."}
+          try {
+            const audioJson = JSON.parse(res.data)
+            if (audioJson.code === 3000 && audioJson.data) {
+              this.audioSrc = 'data:audio/mp3;base64,' + audioJson.data
+              this.$nextTick(() => {
+                this.$refs.audioPlayer && this.$refs.audioPlayer.play()
+              })
+            }
+          } catch (e) {
+            this.$message.warning('音频解析失败')
+          }
+        } else {
+          this.$message.error(res.msg || 'TTS测试失败,请重试')
+        }
+      }).catch(() => {
+        this.$message.error('TTS测试失败,请重试')
+      }).finally(() => {
+        this.ttsLoading = false
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.box-card {
+  margin-bottom: 16px;
+}
+
+/* TTS测试卡片头部 */
+.tts-card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+/* 注意事项卡片样式 */
+.tips-card {
+  margin-bottom: 20px;
+  border-left: 4px solid #409EFF;
+}
+
+.tips-card /deep/ .el-card__body {
+  padding: 16px 20px;
+}
+
+.tips-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16px;
+  padding-bottom: 12px;
+  border-bottom: 1px dashed #EBEEF5;
+}
+
+.tips-icon {
+  font-size: 22px;
+  color: #409EFF;
+  margin-right: 10px;
+}
+
+.tips-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.tips-content {
+  margin: 0;
+}
+
+.tip-item {
+  display: flex;
+  align-items: flex-start;
+  margin-bottom: 12px;
+}
+
+.tip-item:last-child {
+  margin-bottom: 0;
+}
+
+.tip-num {
+  width: 24px;
+  height: 24px;
+  background: linear-gradient(135deg, #409EFF 0%, #66b1ff 100%);
+  border-radius: 50%;
+  color: #fff;
+  font-size: 13px;
+  font-weight: bold;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 12px;
+  flex-shrink: 0;
+}
+
+.tip-text {
+  flex: 1;
+}
+
+.tip-label {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 4px;
+}
+
+.tip-desc {
+  font-size: 13px;
+  color: #606266;
+  line-height: 1.6;
+}
+
+.tip-desc strong {
+  color: #409EFF;
+}
+
+.link-btn {
+  display: inline-flex;
+  align-items: center;
+  padding: 2px 8px;
+  background: #ecf5ff;
+  border-radius: 4px;
+  color: #409EFF;
+  font-size: 12px;
+  margin: 0 4px;
+  transition: all 0.3s;
+}
+
+.link-btn:hover {
+  background: #409EFF;
+  color: #fff;
+}
+
+.link-btn i {
+  margin-left: 2px;
+}
+
+.tip-warning {
+  display: inline-block;
+  margin-left: 4px;
+  padding: 1px 6px;
+  background: #fef0f0;
+  border-radius: 3px;
+  color: #f56c6c;
+  font-size: 12px;
+}
+
+.tip-warning-box {
+  display: inline-flex;
+  align-items: center;
+  padding: 8px 14px;
+  background: linear-gradient(135deg, #fff6f6 0%, #fef0f0 100%);
+  border: 1px solid #fbc4c4;
+  border-radius: 6px;
+  color: #f56c6c;
+  font-size: 13px;
+}
+
+.tip-warning-box i {
+  margin-right: 8px;
+  font-size: 16px;
+}
+
+.tip-warning-box strong {
+  color: #f56c6c;
+  font-size: 16px;
+  margin: 0 2px;
+}
+
+.tip-sub {
+  margin-top: 12px;
+  padding-left: 36px;
+}
+
+.tip-sub-label {
+  font-size: 13px;
+  color: #909399;
+  margin-bottom: 8px;
+}
+
+.tip-sub-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.tip-tag {
+  text-align: center;
+  line-height: 20px;
+  display: inline-flex;
+  align-items: center;
+  padding: 4px 10px;
+  background: #f4f4f5;
+  border-radius: 4px;
+  font-size: 12px;
+  color: #606266;
+}
+
+.tip-tag i {
+  margin-right: 4px;
+  color: #909399;
+}
+</style>

+ 501 - 0
src/views/sensitive/word/index.vue

@@ -0,0 +1,501 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="80px">
+      <el-form-item label="公司" prop="companyId">
+        <el-select
+          v-model="queryParams.companyId"
+          placeholder="全部公司"
+          clearable
+          filterable
+          size="small"
+          style="width: 220px"
+        >
+          <el-option
+            v-for="item in companyOptions"
+            :key="item.dictValue"
+            :label="item.dictLabel"
+            :value="item.dictValue"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="敏感词" prop="word">
+        <el-input
+          v-model="queryParams.word"
+          placeholder="请输入敏感词"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <!-- 分类/敏感等级/匹配方式/处理方式 字段已下架,不参与当前检测逻辑 -->
+      <!--
+      <el-form-item label="分类" prop="category">
+        <el-select v-model="queryParams.category" placeholder="请选择分类" clearable size="small">
+          <el-option v-for="dict in categoryOptions" :key="dict.value" :label="dict.label" :value="dict.value" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="敏感等级" prop="level">
+        <el-select v-model="queryParams.level" placeholder="请选择敏感等级" clearable size="small">
+          <el-option v-for="dict in levelOptions" :key="dict.value" :label="dict.label" :value="dict.value" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="匹配方式" prop="matchType">
+        <el-select v-model="queryParams.matchType" placeholder="请选择匹配方式" clearable size="small">
+          <el-option v-for="dict in matchTypeOptions" :key="dict.value" :label="dict.label" :value="dict.value" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="处理方式" prop="handleType">
+        <el-select v-model="queryParams.handleType" placeholder="请选择处理方式" clearable size="small">
+          <el-option v-for="dict in handleTypeOptions" :key="dict.value" :label="dict.label" :value="dict.value" />
+        </el-select>
+      </el-form-item>
+      -->
+      <el-form-item label="状态" prop="enabled">
+        <el-select v-model="queryParams.enabled" placeholder="请选择状态" clearable size="small">
+          <el-option label="启用" :value="1" />
+          <el-option label="禁用" :value="0" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="来源" prop="source">
+        <el-select v-model="queryParams.source" placeholder="请选择来源" clearable size="small">
+          <el-option label="手工" :value="1" />
+          <el-option label="批量" :value="2" />
+          <el-option label="内置" :value="3" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间">
+        <el-date-picker
+          v-model="dateRange"
+          size="small"
+          style="width: 240px"
+          value-format="yyyy-MM-dd"
+          type="daterange"
+          range-separator="-"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd"
+                   v-hasPermi="['sensitive:word:add']">新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="success" plain icon="el-icon-edit" size="mini" :disabled="single"
+                   @click="handleUpdate" v-hasPermi="['sensitive:word:edit']">修改</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple"
+                   @click="handleDelete" v-hasPermi="['sensitive:word:remove']">删除</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="warning" plain icon="el-icon-download" size="mini" :loading="exportLoading"
+                   @click="handleExport" v-hasPermi="['sensitive:word:export']">导出</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-table border v-loading="loading" :data="wordList" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="55" align="center" />
+      <el-table-column label="ID" align="center" prop="wordId" width="80" />
+      <el-table-column label="公司" align="center" min-width="120" show-overflow-tooltip>
+        <template slot-scope="scope">
+          {{ resolveCompanyName(scope.row) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="敏感词" align="center" prop="word" show-overflow-tooltip />
+      <el-table-column label="来源" align="center" prop="source" width="90">
+        <template slot-scope="scope">
+          {{ sourceLabel(scope.row.source) }}
+        </template>
+      </el-table-column>
+      <!-- 分类/敏感等级/匹配方式/处理方式/替换文本/适用场景 列已下架,当前检测仅依赖 word/enabled -->
+      <!--
+      <el-table-column label="分类" align="center" prop="category" />
+      <el-table-column label="敏感等级" align="center" prop="level" width="90">
+        <template slot-scope="scope">
+          <el-tag :type="levelTagType(scope.row.level)" size="mini">{{ levelLabel(scope.row.level) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="匹配方式" align="center" prop="matchType" width="90">
+        <template slot-scope="scope">{{ matchTypeLabel(scope.row.matchType) }}</template>
+      </el-table-column>
+      <el-table-column label="处理方式" align="center" prop="handleType" width="90">
+        <template slot-scope="scope">{{ handleTypeLabel(scope.row.handleType) }}</template>
+      </el-table-column>
+      <el-table-column label="替换文本" align="center" prop="replaceText" />
+      <el-table-column label="适用场景" align="center" prop="scene" />
+      -->
+      <!-- 命中次数字段已下架,后端表不再保留 hit_count 列 -->
+      <!--
+      <el-table-column label="命中次数" align="center" prop="hitCount" width="90">
+        <template slot-scope="scope">
+          {{ (scope.row.hitCount === null || scope.row.hitCount === undefined) ? 0 : scope.row.hitCount }}
+        </template>
+      </el-table-column>
+      -->
+      <el-table-column label="状态" align="center" prop="enabled" width="80">
+        <template slot-scope="scope">
+          <el-switch
+            v-model="scope.row.enabled"
+            :active-value="1"
+            :inactive-value="0"
+            @change="handleEnabledChange(scope.row)"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="创建时间" align="center" prop="createTime" width="160">
+        <template slot-scope="scope">
+          <span>{{ parseTime(scope.row.createTime, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="150">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-edit"
+                     @click="handleUpdate(scope.row)" v-hasPermi="['sensitive:word:edit']">修改</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete"
+                     @click="handleDelete(scope.row)" v-hasPermi="['sensitive:word:remove']">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination
+      v-show="total>0"
+      :total="total"
+      :page.sync="queryParams.pageNum"
+      :limit.sync="queryParams.pageSize"
+      @pagination="getList"
+    />
+
+    <!-- 添加或修改对话框 -->
+    <el-dialog :title="title" :visible.sync="open" width="640px" append-to-body @open="loadCompanyOptions">
+      <el-form ref="form" :model="form" :rules="rules" label-width="100px">
+        <el-form-item label="公司" prop="companyId">
+          <el-select
+            v-model="form.companyId"
+            placeholder="请选择公司"
+            filterable
+            clearable
+            :disabled="form.wordId != null"
+            style="width: 100%"
+          >
+            <el-option
+              v-for="item in companyOptions"
+              :key="item.dictValue"
+              :label="item.dictLabel"
+              :value="item.dictValue"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="敏感词" prop="word">
+          <el-input v-model="form.word" placeholder="请输入敏感词" maxlength="255" show-word-limit />
+        </el-form-item>
+        <!-- 分类/敏感等级/匹配方式/处理方式/替换文本/适用场景 字段已下架 -->
+        <!--
+        <el-form-item label="分类" prop="category">
+          <el-select v-model="form.category" placeholder="请选择分类" clearable style="width:100%">
+            <el-option v-for="dict in categoryOptions" :key="dict.value" :label="dict.label" :value="dict.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="敏感等级" prop="level">
+          <el-radio-group v-model="form.level">
+            <el-radio v-for="dict in levelOptions" :key="dict.value" :label="dict.value">{{ dict.label }}</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="匹配方式" prop="matchType">
+          <el-radio-group v-model="form.matchType">
+            <el-radio v-for="dict in matchTypeOptions" :key="dict.value" :label="dict.value">{{ dict.label }}</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="处理方式" prop="handleType">
+          <el-radio-group v-model="form.handleType">
+            <el-radio v-for="dict in handleTypeOptions" :key="dict.value" :label="dict.value">{{ dict.label }}</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="替换文本" prop="replaceText" v-if="form.handleType === 2">
+          <el-input v-model="form.replaceText" placeholder="请输入替换文本" maxlength="255" />
+        </el-form-item>
+        <el-form-item label="适用场景" prop="scene">
+          <el-checkbox-group v-model="sceneList">
+            <el-checkbox label="chat">聊天</el-checkbox>
+            <el-checkbox label="live">直播</el-checkbox>
+            <el-checkbox label="comment">评论</el-checkbox>
+            <el-checkbox label="other">其他</el-checkbox>
+          </el-checkbox-group>
+        </el-form-item>
+        -->
+        <el-form-item label="状态" prop="enabled">
+          <el-radio-group v-model="form.enabled">
+            <el-radio :label="1">启用</el-radio>
+            <el-radio :label="0">禁用</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="备注" prop="remark">
+          <el-input type="textarea" v-model="form.remark" placeholder="请输入备注" maxlength="500" show-word-limit />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  listSensitiveWord,
+  getSensitiveWord,
+  delSensitiveWord,
+  addSensitiveWord,
+  updateSensitiveWord,
+  exportSensitiveWord,
+  changeSensitiveWordEnabled,
+  getSensitiveWordCompanyList
+} from "@/api/sensitive/word";
+
+export default {
+  name: "SensitiveWord",
+  data() {
+    return {
+      companyOptions: [],
+      loading: true,
+      exportLoading: false,
+      ids: [],
+      single: true,
+      multiple: true,
+      showSearch: true,
+      dateRange: [],
+      total: 0,
+      wordList: [],
+      title: "",
+      open: false,
+      sceneList: [],
+      categoryOptions: [
+        { value: '广告', label: '广告' },
+        { value: '辱骂', label: '辱骂' },
+        { value: '政治', label: '政治' },
+        { value: '色情', label: '色情' },
+        { value: '其他', label: '其他' }
+      ],
+      levelOptions: [
+        { value: 1, label: '低' },
+        { value: 2, label: '中' },
+        { value: 3, label: '高' }
+      ],
+      matchTypeOptions: [
+        { value: 1, label: '精确' },
+        { value: 2, label: '模糊' },
+        { value: 3, label: '正则' }
+      ],
+      handleTypeOptions: [
+        { value: 1, label: '禁止' },
+        { value: 2, label: '替换' },
+        { value: 3, label: '警告' }
+      ],
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        companyId: null,
+        word: null,
+        // category: null,
+        // level: null,
+        // matchType: null,
+        // handleType: null,
+        enabled: null,
+        source: null
+      },
+      form: {}
+    };
+  },
+  computed: {
+    companyMap() {
+      const map = {}
+      this.companyOptions.forEach(item => {
+        map[item.dictValue] = item.dictLabel
+      })
+      return map
+    },
+    rules() {
+      const base = {
+        word: [{ required: true, message: "敏感词不能为空", trigger: "blur" }]
+      }
+      if (!this.form.wordId) {
+        base.companyId = [{ required: true, message: "请选择公司", trigger: "change" }]
+      }
+      return base
+    }
+  },
+  created() {
+    this.loadCompanyOptions()
+    this.getList()
+  },
+  methods: {
+    loadCompanyOptions() {
+      getSensitiveWordCompanyList().then(response => {
+        this.companyOptions = response.data || []
+      })
+    },
+    resolveCompanyName(row) {
+      if (!row) return '-'
+      if (row.companyName) return row.companyName
+      if (row.companyId != null && row.companyId !== '') {
+        return this.companyMap[row.companyId] || row.companyId
+      }
+      if (this.queryParams.companyId) {
+        return this.companyMap[this.queryParams.companyId] || this.queryParams.companyId
+      }
+      return '-'
+    },
+    /** 查询列表 */
+    sourceLabel(v) {
+      const map = { 1: '手工', 2: '批量', 3: '内置' };
+      return map[v] || '-';
+    },
+    getList() {
+      this.loading = true;
+      listSensitiveWord(this.addDateRange(this.queryParams, this.dateRange)).then(response => {
+        this.wordList = response.rows;
+        this.total = response.total;
+        this.loading = false;
+      });
+    },
+    levelLabel(v) {
+      const o = this.levelOptions.find(i => i.value === v);
+      return o ? o.label : '-';
+    },
+    levelTagType(v) {
+      return v === 3 ? 'danger' : (v === 2 ? 'warning' : 'info');
+    },
+    matchTypeLabel(v) {
+      const o = this.matchTypeOptions.find(i => i.value === v);
+      return o ? o.label : '-';
+    },
+    handleTypeLabel(v) {
+      const o = this.handleTypeOptions.find(i => i.value === v);
+      return o ? o.label : '-';
+    },
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    reset() {
+      this.form = {
+        wordId: null,
+        companyId: this.queryParams.companyId || null,
+        word: null,
+        // 下架字段默认值保留为注释,以免错改
+        // category: null,
+        // level: 1,
+        // matchType: 1,
+        // handleType: 1,
+        // replaceText: '***',
+        // scene: null,
+        enabled: 1,
+        source: 1,
+        remark: null
+      };
+      this.sceneList = [];
+      this.resetForm("form");
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+    handleSelectionChange(selection) {
+      this.ids = selection.map(item => item.wordId);
+      this.single = selection.length !== 1;
+      this.multiple = !selection.length;
+    },
+    handleAdd() {
+      this.reset();
+      this.open = true;
+      this.title = "添加敏感词";
+    },
+    handleUpdate(row) {
+      this.reset();
+      const wordId = row.wordId || this.ids;
+      getSensitiveWord(wordId).then(response => {
+        this.form = response.data || {};
+        // 下架字段不再处理场景多选回填
+        // this.sceneList = this.form.scene ? this.form.scene.split(',').filter(Boolean) : [];
+        this.open = true;
+        this.title = "修改敏感词";
+      });
+    },
+    submitForm() {
+      this.$refs["form"].validate(valid => {
+        if (valid) {
+          // 下架字段不再拼接场景
+          // this.form.scene = (this.sceneList || []).join(',');
+          if (this.form.wordId != null) {
+            updateSensitiveWord(this.form).then(() => {
+              this.msgSuccess("修改成功");
+              this.open = false;
+              this.getList();
+            });
+          } else {
+            const payload = {
+              companyId: this.form.companyId,
+              word: this.form.word,
+              enabled: this.form.enabled,
+              source: this.form.source != null ? this.form.source : 1,
+              remark: this.form.remark || ''
+            }
+            addSensitiveWord(payload).then(() => {
+              this.msgSuccess("新增成功");
+              this.open = false;
+              this.getList();
+            });
+          }
+        }
+      });
+    },
+    handleEnabledChange(row) {
+      const tip = row.enabled === 1 ? '启用' : '禁用';
+      changeSensitiveWordEnabled(row.wordId, row.enabled).then(() => {
+        this.msgSuccess(tip + '成功');
+      }).catch(() => {
+        row.enabled = row.enabled === 1 ? 0 : 1;
+      });
+    },
+    handleDelete(row) {
+      const wordIds = row.wordId || this.ids;
+      this.$confirm('是否确认删除编号为"' + wordIds + '"的敏感词?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(() => {
+        return delSensitiveWord(wordIds);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("删除成功");
+      }).catch(() => {});
+    },
+    handleExport() {
+      const queryParams = this.addDateRange(this.queryParams, this.dateRange);
+      this.$confirm('是否确认导出所有敏感词数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(() => {
+        this.exportLoading = true;
+        return exportSensitiveWord(queryParams);
+      }).then(response => {
+        this.download(response.msg);
+        this.exportLoading = false;
+      }).catch(() => {});
+    }
+  }
+};
+</script>