lmx 22 часов назад
Родитель
Сommit
2e4931f926

+ 7 - 3
src/api/company/companyVoiceApi.js

@@ -172,12 +172,16 @@ export function getVoiceApiListV2() {
   })
 }
 
-/** 分配接口给租户(新版) */
-export function assignTenantsV2(apiId, tenantIds) {
+/** 分配接口给租户(新版,可选 tenants 携带定价字段) */
+export function assignTenantsV2(apiId, tenantIds, extra = {}) {
   return request({
     url: '/admin/companyVoiceApiTenant/assign',
     method: 'post',
-    data: { apiId, tenantIds }
+    data: {
+      apiId,
+      tenantIds,
+      tenants: extra.tenants
+    }
   })
 }
 

+ 27 - 0
src/api/system/smsApi.js

@@ -77,6 +77,15 @@ export function addSmsApiTenant(data) {
   })
 }
 
+// 批量新增租户绑定
+export function batchAddSmsApiTenant(data) {
+  return request({
+    url: '/admin/smsApiTenant/batch',
+    method: 'post',
+    data: data
+  })
+}
+
 // 修改租户绑定
 export function updateSmsApiTenant(data) {
   return request({
@@ -93,3 +102,21 @@ export function delSmsApiTenant(id) {
     method: 'delete'
   })
 }
+
+// 批量更新租户定价
+export function batchUpdateSmsApiTenantPricing(data) {
+  return request({
+    url: '/admin/smsApiTenant/batchPricing',
+    method: 'put',
+    data: data
+  })
+}
+
+// 批量更新租户状态
+export function batchUpdateSmsApiTenantStatus(data) {
+  return request({
+    url: '/admin/smsApiTenant/batchStatus',
+    method: 'put',
+    data: data
+  })
+}

+ 13 - 8
src/views/admin/smsApi/index.vue

@@ -321,11 +321,7 @@ export default {
       portDialogTitle: '',
       currentApiId: null,
       currentApiProvider: '',
-      smsTypeOptions: [
-        { value: 1, label: '行业验证码通知短信' },
-        { value: 2, label: '营销短信' },
-        { value: 3, label: '5G消息' }
-      ],
+      smsTypeOptions: [],
       queryParams: { apiName: undefined, smsType: undefined, provider: undefined },
       form: {},
       rules: {
@@ -349,12 +345,21 @@ export default {
     }
   },
   created() {
+    this.loadSmsTypeOptions()
     this.getList()
   },
   methods: {
+    loadSmsTypeOptions() {
+      this.getDicts('sys_company_sms_temp_type').then(response => {
+        this.smsTypeOptions = (response.data || []).map(item => ({
+          value: Number(item.dictValue),
+          label: item.dictLabel
+        }))
+      })
+    },
     smsTypeFormat(type) {
-      const map = { 1: '行业验证码通知', 2: '营销短信', 3: '5G消息' }
-      return map[type] || '未知'
+      const item = this.smsTypeOptions.find(o => o.value === Number(type))
+      return item ? item.label : '未知'
     },
     providerFormat(p) {
       const map = { my: '迈远', card: '手机卡' }
@@ -373,7 +378,7 @@ export default {
     },
     resetForm() {
       this.form = {
-        apiId: undefined, apiName: '', smsType: 1, provider: 'my',
+        apiId: undefined, apiName: '', smsType: undefined, provider: 'my',
         account: '', password: '', url: '', code: '', sign: '',
         costPrice: 0, isDefault: 0, status: 1, remark: ''
       }

+ 262 - 39
src/views/admin/smsApiTenant/index.vue

@@ -3,7 +3,7 @@
     <el-form :model="queryParams" ref="queryForm" :inline="true" size="small">
       <el-form-item label="租户" prop="tenantId">
         <el-select v-model="queryParams.tenantId" placeholder="选择租户" clearable filterable>
-          <el-option v-for="c in companyList" :key="c.companyId" :label="c.companyName" :value="c.companyId" />
+          <el-option v-for="c in companyList" :key="c.tenantId" :label="c.tenantName" :value="c.tenantId" />
         </el-select>
       </el-form-item>
       <el-form-item label="短信类型" prop="smsType">
@@ -21,11 +21,24 @@
       <el-col :span="1.5">
         <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增绑定</el-button>
       </el-col>
+      <el-col :span="1.5">
+        <el-button type="success" plain icon="el-icon-edit-outline" size="mini" :disabled="!selectedRows.length" @click="handleBatchPricing">批量定价</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="warning" plain icon="el-icon-open" size="mini" :disabled="!selectedRows.length" @click="handleBatchStatus(1)">批量启用</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="info" plain icon="el-icon-turn-off" size="mini" :disabled="!selectedRows.length" @click="handleBatchStatus(0)">批量禁用</el-button>
+      </el-col>
+      <el-col :span="1.5" v-if="selectedRows.length">
+        <span class="selected-tip">已选 {{ selectedRows.length }} 条</span>
+      </el-col>
     </el-row>
 
-    <el-table v-loading="loading" :data="bindList" border size="small">
+    <el-table v-loading="loading" :data="bindList" border size="small" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="45" align="center" />
       <el-table-column label="ID" align="center" prop="id" width="60" />
-      <el-table-column label="租户名称" align="center" prop="companyName" min-width="140" show-overflow-tooltip />
+      <el-table-column label="租户名称" align="center" prop="tenantName" min-width="140" show-overflow-tooltip />
       <el-table-column label="接口名称" align="center" prop="apiName" min-width="140" show-overflow-tooltip />
       <el-table-column label="短信类型" align="center" prop="smsType" width="140">
         <template slot-scope="scope">
@@ -75,19 +88,44 @@
       </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="新增短信接口-租户绑定" :visible.sync="addDialogVisible" width="500px" append-to-body>
+    <el-dialog title="新增短信接口-租户绑定" :visible.sync="addDialogVisible" width="560px" append-to-body>
       <el-form ref="addForm" :model="addForm" :rules="addRules" label-width="100px" size="small">
-        <el-form-item label="选择接口" prop="apiId">
-          <el-select v-model="addForm.apiId" placeholder="请选择短信接口" style="width:100%" @change="onApiChange">
+        <el-form-item label="选择接口" prop="apiIds">
+          <el-select
+            v-model="addForm.apiIds"
+            multiple
+            collapse-tags
+            placeholder="请选择短信接口(可多选)"
+            style="width:100%"
+            @change="onApiChange"
+          >
             <el-option v-for="api in apiOptions" :key="api.apiId" :label="api.apiName + ' (' + smsTypeFormat(api.smsType) + ')'" :value="api.apiId" />
           </el-select>
         </el-form-item>
-        <el-form-item label="选择租户" prop="tenantId">
-          <el-select v-model="addForm.tenantId" placeholder="请选择租户" filterable style="width:100%">
-            <el-option v-for="c in companyList" :key="c.tenantId" :label="c.companyName" :value="c.tenantId" />
+        <el-form-item label="选择租户" prop="tenantIds">
+          <el-select
+            v-model="addForm.tenantIds"
+            multiple
+            collapse-tags
+            filterable
+            placeholder="请选择租户(可多选)"
+            style="width:100%"
+          >
+            <el-option v-for="c in companyList" :key="c.tenantId" :label="c.tenantName" :value="c.tenantId" />
           </el-select>
         </el-form-item>
+        <el-form-item v-if="addBindPreviewCount > 0" label="">
+          <span class="add-bind-preview">将创建 {{ addBindPreviewCount }} 条绑定关系</span>
+        </el-form-item>
         <el-form-item label="租户售价" prop="price">
           <el-input-number v-model="addForm.price" :precision="4" :step="0.001" :min="0" style="width:100%" />
           <span style="color:#909399;font-size:12px;margin-left:8px">元/条</span>
@@ -100,13 +138,42 @@
           <el-switch v-model="addForm.allowManual" :active-value="1" :inactive-value="0" active-text="允许" inactive-text="否" />
           <span style="color:#909399;font-size:12px;margin-left:8px">是否允许销售手动选择此接口发送</span>
         </el-form-item>
-        <el-form-item v-if="selectedApiCost" label="">
-          <span style="color:#909399;font-size:12px">该接口成本价: {{ selectedApiCost }} 元/条,建议售价 >= 成本价</span>
+        <el-form-item v-if="selectedApiCostText" label="">
+          <span style="color:#909399;font-size:12px">所选接口成本价: {{ selectedApiCostText }} 元/条,建议售价 >= 成本价</span>
         </el-form-item>
       </el-form>
       <div slot="footer">
-        <el-button type="primary" @click="submitAdd">确 定</el-button>
-        <el-button @click="addDialogVisible = false">取 消</el-button>
+        <el-button type="primary" :loading="addSubmitting" :disabled="addSubmitting" @click="submitAdd">确 定</el-button>
+        <el-button :disabled="addSubmitting" @click="addDialogVisible = false">取 消</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 批量定价弹窗 -->
+    <el-dialog title="批量定价" :visible.sync="batchPricingOpen" width="520px" append-to-body>
+      <div class="batch-tip">将对已选 {{ selectedRows.length }} 条记录批量更新,仅填写需要修改的项,留空则保持原值不变。</div>
+      <el-form ref="batchPricingForm" :model="batchPricingForm" label-width="140px" size="small">
+        <el-form-item label="租户售价(元/条)">
+          <el-input-number v-model="batchPricingForm.price" :precision="4" :min="0" :step="0.001" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="优先级">
+          <el-input-number v-model="batchPricingForm.priority" :min="1" :max="99" :step="1" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="允许手动选择">
+          <el-checkbox v-model="batchPricingForm.applyAllowManual">更新手动选择</el-checkbox>
+          <el-switch
+            v-model="batchPricingForm.allowManual"
+            :disabled="!batchPricingForm.applyAllowManual"
+            :active-value="1"
+            :inactive-value="0"
+            active-text="允许"
+            inactive-text="否"
+            style="margin-left:12px"
+          />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" :loading="batchPricingSubmitting" @click="submitBatchPricing">确 定</el-button>
+        <el-button :disabled="batchPricingSubmitting" @click="batchPricingOpen = false">取 消</el-button>
       </div>
     </el-dialog>
 
@@ -114,7 +181,7 @@
     <el-dialog title="调整租户售价" :visible.sync="editDialogVisible" width="450px" append-to-body>
       <el-form ref="editForm" :model="editForm" label-width="100px" size="small">
         <el-form-item label="租户">
-          <span>{{ editForm.companyName }}</span>
+          <span>{{ editForm.tenantName }}</span>
         </el-form-item>
         <el-form-item label="接口">
           <span>{{ editForm.apiName }}</span>
@@ -145,7 +212,10 @@
 </template>
 
 <script>
-import { listSmsApiTenant, addSmsApiTenant, updateSmsApiTenant, delSmsApiTenant, listSmsApi } from '@/api/system/smsApi'
+import {
+  listSmsApiTenant, batchAddSmsApiTenant, updateSmsApiTenant, delSmsApiTenant, listSmsApi,
+  batchUpdateSmsApiTenantPricing, batchUpdateSmsApiTenantStatus
+} from '@/api/system/smsApi'
 import { listAllCompanies } from '@/api/admin/sysCompany'
 
 export default {
@@ -153,7 +223,9 @@ export default {
   data() {
     return {
       loading: false,
+      total: 0,
       bindList: [],
+      selectedRows: [],
       companyList: [],
       apiOptions: [],
       smsTypeOptions: [
@@ -161,22 +233,43 @@ export default {
         { value: 2, label: '营销短信' },
         { value: 3, label: '5G消息' }
       ],
-      queryParams: { tenantId: undefined, smsType: undefined },
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        tenantId: undefined,
+        smsType: undefined
+      },
       addDialogVisible: false,
+      addSubmitting: false,
       editDialogVisible: false,
-      addForm: { apiId: undefined, tenantId: undefined, price: 0, priority: 1, allowManual: 0 },
-      editForm: { id: undefined, price: 0, priority: 1, allowManual: 0, status: 1, companyName: '', apiName: '', costPrice: 0 },
+      batchPricingOpen: false,
+      batchPricingSubmitting: false,
+      batchPricingForm: {
+        price: undefined,
+        priority: undefined,
+        applyAllowManual: false,
+        allowManual: 0
+      },
+      addForm: { apiIds: [], tenantIds: [], price: 0, priority: 1, allowManual: 0 },
+      editForm: { id: undefined, price: 0, priority: 1, allowManual: 0, status: 1, tenantName: '', apiName: '', costPrice: 0 },
       addRules: {
-        apiId: [{ required: true, message: '请选择接口', trigger: 'change' }],
-        tenantId: [{ required: true, message: '请选择租户', trigger: 'change' }],
+        apiIds: [{ type: 'array', required: true, min: 1, message: '请选择至少一个接口', trigger: 'change' }],
+        tenantIds: [{ type: 'array', required: true, min: 1, message: '请选择至少一个租户', trigger: 'change' }],
         price: [{ required: true, message: '请填写售价', trigger: 'blur' }]
       }
     }
   },
   computed: {
-    selectedApiCost() {
-      const api = this.apiOptions.find(a => a.apiId === this.addForm.apiId)
-      return api && api.costPrice ? api.costPrice : ''
+    addBindPreviewCount() {
+      const apiCount = (this.addForm.apiIds || []).length
+      const tenantCount = (this.addForm.tenantIds || []).length
+      return apiCount * tenantCount
+    },
+    selectedApiCostText() {
+      const apiIds = this.addForm.apiIds || []
+      if (apiIds.length !== 1) return ''
+      const api = this.apiOptions.find(a => a.apiId === apiIds[0])
+      return api && api.costPrice != null ? api.costPrice : ''
     }
   },
   created() {
@@ -202,12 +295,16 @@ export default {
     getList() {
       this.loading = true
       listSmsApiTenant(this.queryParams).then(res => {
-        this.bindList = res.data || []
+        this.bindList = res.rows || res.data || []
+        this.total = res.total || 0
       }).finally(() => { this.loading = false })
     },
     loadCompanyList() {
       listAllCompanies({ pageNum: 1, pageSize: 1000 }).then(res => {
-        this.companyList = res.rows || res.data || []
+        this.companyList = (res.rows || res.data || []).map(c => ({
+          tenantId: c.id || c.companyId || c.tenantId,
+          tenantName: c.tenantName || c.companyName
+        }))
       })
     },
     loadApiOptions() {
@@ -215,29 +312,72 @@ export default {
         this.apiOptions = res.data || []
       })
     },
-    onApiChange(apiId) {
-      // 当选择接口时,自动填充默认售价
-      const api = this.apiOptions.find(a => a.apiId === apiId)
-      if (api && api.costPrice) {
-        this.addForm.price = api.costPrice
+    onApiChange(apiIds) {
+      const ids = apiIds || this.addForm.apiIds || []
+      if (ids.length === 1) {
+        const api = this.apiOptions.find(a => a.apiId === ids[0])
+        if (api && api.costPrice != null) {
+          this.addForm.price = api.costPrice
+        }
       }
     },
-    handleQuery() { this.getList() },
+    handleSelectionChange(rows) {
+      this.selectedRows = rows || []
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      this.getList()
+    },
     resetQuery() {
-      this.queryParams = { tenantId: undefined, smsType: undefined }
+      this.resetForm('queryForm')
+      this.queryParams.pageNum = 1
+      this.queryParams.pageSize = 10
       this.handleQuery()
     },
     handleAdd() {
-      this.addForm = { apiId: undefined, tenantId: undefined, price: 0, priority: 1, allowManual: 0 }
+      this.addForm = { apiIds: [], tenantIds: [], price: 0, priority: 1, allowManual: 0 }
+      this.addSubmitting = false
       this.addDialogVisible = true
+      this.$nextTick(() => {
+        if (this.$refs.addForm) this.$refs.addForm.clearValidate()
+      })
+    },
+    showBatchAddResult(res) {
+      const data = res.data || {}
+      const successCount = data.successCount || 0
+      const failCount = data.failCount || 0
+      const failMessages = data.failMessages || []
+      if (failCount > 0 && failMessages.length) {
+        const detail = failMessages.map((msg, index) => `${index + 1}. ${msg}`).join('<br/>')
+        this.$alert(detail, `失败 ${failCount} 条`, {
+          dangerouslyUseHTMLString: true,
+          customClass: 'batch-add-fail-alert'
+        })
+      }
+      if (successCount > 0) {
+        this.$message.success(res.msg || `成功绑定 ${successCount} 条`)
+        this.addDialogVisible = false
+        this.getList()
+      } else if (failCount > 0) {
+        this.$message.warning(res.msg || `绑定失败 ${failCount} 条`)
+      }
     },
     submitAdd() {
+      if (this.addSubmitting) return
       this.$refs.addForm.validate(valid => {
         if (!valid) return
-        addSmsApiTenant({ ...this.addForm, status: 1 }).then(() => {
-          this.$message.success('绑定成功')
-          this.addDialogVisible = false
-          this.getList()
+        this.addSubmitting = true
+        batchAddSmsApiTenant({
+          apiIds: this.addForm.apiIds,
+          tenantIds: this.addForm.tenantIds,
+          price: this.addForm.price,
+          priority: this.addForm.priority,
+          allowManual: this.addForm.allowManual,
+          status: 1
+        }).then(res => {
+          this.showBatchAddResult(res)
+        }).finally(() => {
+          this.addSubmitting = false
         })
       })
     },
@@ -248,7 +388,7 @@ export default {
         priority: row.priority || 1,
         allowManual: row.allowManual || 0,
         status: row.status,
-        companyName: row.companyName,
+        tenantName: row.tenantName,
         apiName: row.apiName,
         costPrice: row.costPrice
       }
@@ -261,8 +401,64 @@ export default {
         this.getList()
       })
     },
+    handleBatchPricing() {
+      if (!this.selectedRows.length) {
+        this.$message.warning('请先选择要批量定价的记录')
+        return
+      }
+      this.batchPricingForm = {
+        price: undefined,
+        priority: undefined,
+        applyAllowManual: false,
+        allowManual: 0
+      }
+      this.batchPricingOpen = true
+    },
+    submitBatchPricing() {
+      const data = { ids: this.selectedRows.map(row => row.id) }
+      if (this.batchPricingForm.price != null && this.batchPricingForm.price !== '') {
+        data.price = this.batchPricingForm.price
+      }
+      if (this.batchPricingForm.priority != null && this.batchPricingForm.priority !== '') {
+        data.priority = this.batchPricingForm.priority
+      }
+      if (this.batchPricingForm.applyAllowManual) {
+        data.allowManual = this.batchPricingForm.allowManual
+      }
+      if (data.price == null && data.priority == null && data.allowManual == null) {
+        this.$message.warning('请至少填写或勾选一项要批量更新的配置')
+        return
+      }
+      if (this.batchPricingSubmitting) return
+      this.batchPricingSubmitting = true
+      batchUpdateSmsApiTenantPricing(data).then(() => {
+        this.$message.success('批量定价成功')
+        this.batchPricingOpen = false
+        this.getList()
+      }).finally(() => {
+        this.batchPricingSubmitting = false
+      })
+    },
+    handleBatchStatus(status) {
+      if (!this.selectedRows.length) {
+        this.$message.warning('请先选择要操作的记录')
+        return
+      }
+      const actionText = status === 1 ? '启用' : '禁用'
+      this.$confirm('确认批量' + actionText + '已选 ' + this.selectedRows.length + ' 条记录?', '提示', {
+        confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
+      }).then(() => {
+        return batchUpdateSmsApiTenantStatus({
+          ids: this.selectedRows.map(row => row.id),
+          status
+        })
+      }).then(() => {
+        this.$message.success('批量' + actionText + '成功')
+        this.getList()
+      }).catch(() => {})
+    },
     handleDelete(row) {
-      this.$confirm('确认解除租户"' + row.companyName + '"与接口"' + row.apiName + '"的绑定?', '提示', {
+      this.$confirm('确认解除租户"' + row.tenantName + '"与接口"' + row.apiName + '"的绑定?', '提示', {
         confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
       }).then(() => {
         delSmsApiTenant(row.id).then(() => {
@@ -276,4 +472,31 @@ export default {
 </script>
 
 <style scoped>
+.mb8 { margin-bottom: 8px; }
+.selected-tip {
+  color: #909399;
+  font-size: 13px;
+  line-height: 28px;
+}
+.batch-tip {
+  margin-bottom: 16px;
+  padding: 10px 12px;
+  background: #f4f4f5;
+  border-radius: 4px;
+  color: #606266;
+  font-size: 13px;
+  line-height: 1.5;
+}
+.add-bind-preview {
+  color: #409eff;
+  font-size: 13px;
+}
+</style>
+
+<style>
+.batch-add-fail-alert .el-message-box__message {
+  max-height: 320px;
+  overflow-y: auto;
+  word-break: break-all;
+}
 </style>

+ 160 - 12
src/views/admin/voiceApi/index.vue

@@ -184,6 +184,7 @@
         <div class="assign-tenant-add">
           <el-divider content-position="left">添加租户</el-divider>
           <el-select
+            ref="assignTenantSelect"
             v-model="selectedTenantIds"
             multiple
             filterable
@@ -191,13 +192,14 @@
             reserve-keyword
             collapse-tags
             popper-append-to-body
-            popper-class="assign-tenant-select-popper"
+            :popper-class="assignTenantPopperClass"
             placeholder="输入租户名称搜索"
             :remote-method="searchTenants"
             :loading="tenantSearchLoading"
             :disabled="!!unassigningTenantId"
             style="width:100%"
             size="small"
+            @visible-change="handleAssignTenantDropdownVisible"
           >
             <el-option
               v-for="item in tenantOptions"
@@ -205,6 +207,22 @@
               :label="formatTenantOption(item)"
               :value="item.tenantId"
             />
+            <el-option
+              v-if="tenantLoadingMore"
+              key="assign-tenant-loading"
+              disabled
+              class="assign-tenant-status-option"
+            >
+              <span class="assign-tenant-status-text"><i class="el-icon-loading" /> 加载中...</span>
+            </el-option>
+            <el-option
+              v-if="showAssignTenantNoMore"
+              key="assign-tenant-no-more"
+              disabled
+              class="assign-tenant-status-option"
+            >
+              <span class="assign-tenant-status-text">没有更多数据了</span>
+            </el-option>
           </el-select>
         </div>
       </div>
@@ -294,6 +312,16 @@ export default {
       selectedTenantIds: [],
       tenantOptions: [],
       tenantSearchLoading: false,
+      tenantLoadingMore: false,
+      hasMoreTenants: true,
+      tenantOptionsTotal: 0,
+      tenantQueryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        tenantName: ''
+      },
+      assignTenantScrollWrap: null,
+      assignTenantScrollHandler: null,
       // 查看租户列表
       tenantListOpen: false,
       viewTenants: [],
@@ -303,6 +331,22 @@ export default {
   created() {
     this.getList()
   },
+  computed: {
+    assignTenantPopperClass() {
+      const base = 'assign-tenant-select-popper'
+      if (!this.hasMoreTenants && this.tenantOptions.length > 0) {
+        return base + ' assign-tenant-select-ended'
+      }
+      return base
+    },
+    showAssignTenantNoMore() {
+      return !this.hasMoreTenants && this.tenantOptions.length > 0 &&
+        !this.tenantSearchLoading && !this.tenantLoadingMore
+    }
+  },
+  beforeDestroy() {
+    this.unbindAssignTenantSelectScrollGuard()
+  },
   methods: {
     getList() {
       this.loading = true
@@ -434,26 +478,116 @@ export default {
       this.assignOpen = true
       this.selectedTenantIds = []
       this.tenantOptions = []
+      this.resetTenantSearchState()
       this.unassigningTenantId = null
       this.loadAssignedTenants()
     },
+    resetTenantSearchState() {
+      this.tenantQueryParams = {
+        pageNum: 1,
+        pageSize: 10,
+        tenantName: ''
+      }
+      this.hasMoreTenants = true
+      this.tenantOptionsTotal = 0
+      this.tenantLoadingMore = false
+    },
+    mapCompanyToTenantOption(c) {
+      return {
+        tenantId: c.id || c.companyId,
+        tenantName: c.tenantName || c.companyName,
+        tenantCode: c.tenantCode || c.companyCode || null
+      }
+    },
+    fetchTenantOptions(isLoadMore = false) {
+      if (!isLoadMore) {
+        this.tenantSearchLoading = true
+      } else {
+        this.tenantLoadingMore = true
+      }
+      return listAllCompanies(this.tenantQueryParams).then(response => {
+        const rows = (response.rows || []).map(c => this.mapCompanyToTenantOption(c))
+        if (isLoadMore) {
+          const existIds = new Set(this.tenantOptions.map(o => o.tenantId))
+          const append = rows.filter(r => !existIds.has(r.tenantId))
+          this.tenantOptions = this.tenantOptions.concat(append)
+        } else {
+          this.tenantOptions = rows
+        }
+        this.tenantOptionsTotal = response.total || 0
+        this.hasMoreTenants = this.tenantOptions.length < this.tenantOptionsTotal
+      }).finally(() => {
+        this.tenantSearchLoading = false
+        this.tenantLoadingMore = false
+        this.$nextTick(() => {
+          if (this.assignOpen) {
+            this.bindAssignTenantSelectScrollGuard()
+          }
+        })
+      })
+    },
+    searchTenants(query) {
+      if (query === undefined) return
+      this.tenantQueryParams.tenantName = (query || '').trim()
+      this.tenantQueryParams.pageNum = 1
+      this.hasMoreTenants = true
+      this.fetchTenantOptions(false)
+    },
+    loadMoreAssignTenants() {
+      if (this.tenantLoadingMore || this.tenantSearchLoading || !this.hasMoreTenants) return
+      this.tenantQueryParams.pageNum += 1
+      this.fetchTenantOptions(true)
+    },
+    getAssignTenantDropdownWrap() {
+      const root = this.$refs.assignTenantSelect && this.$refs.assignTenantSelect.$el
+      if (!root) return null
+      const dropdown = document.querySelector('.assign-tenant-select-popper .el-select-dropdown__wrap')
+      return dropdown || root.querySelector('.el-select-dropdown__wrap')
+    },
+    bindAssignTenantSelectScrollGuard() {
+      this.unbindAssignTenantSelectScrollGuard()
+      const wrap = this.getAssignTenantDropdownWrap()
+      if (!wrap) return
+      this.assignTenantScrollWrap = wrap
+      this.assignTenantScrollHandler = () => {
+        const atBottom = wrap.scrollHeight - wrap.scrollTop <= wrap.clientHeight + 2
+        if (atBottom) {
+          this.loadMoreAssignTenants()
+        }
+        if (!this.hasMoreTenants) {
+          const maxScroll = wrap.scrollHeight - wrap.clientHeight
+          if (maxScroll > 0 && wrap.scrollTop > maxScroll) {
+            wrap.scrollTop = maxScroll
+          }
+        }
+      }
+      wrap.addEventListener('scroll', this.assignTenantScrollHandler)
+    },
+    unbindAssignTenantSelectScrollGuard() {
+      if (this.assignTenantScrollWrap && this.assignTenantScrollHandler) {
+        this.assignTenantScrollWrap.removeEventListener('scroll', this.assignTenantScrollHandler)
+      }
+      this.assignTenantScrollWrap = null
+      this.assignTenantScrollHandler = null
+    },
+    handleAssignTenantDropdownVisible(visible) {
+      if (visible) {
+        if (this.tenantOptions.length === 0) {
+          this.searchTenants('')
+        }
+        this.$nextTick(() => {
+          this.bindAssignTenantSelectScrollGuard()
+        })
+      } else {
+        this.unbindAssignTenantSelectScrollGuard()
+      }
+    },
     loadAssignedTenants() {
       this.assignLoading = true
       return getAssignedTenants(this.assignApi.apiId).then(response => {
         this.assignedTenants = response.data || []
       }).finally(() => { this.assignLoading = false })
     },
-    searchTenants(query) {
-      if (query.length < 1) return
-      this.tenantSearchLoading = true
-      listAllCompanies({ companyName: query, pageNum: 1, pageSize: 20 }).then(response => {
-        this.tenantOptions = (response.rows || []).map(c => ({
-          tenantId: c.id || c.companyId,
-          tenantName: c.tenantName || c.companyName,
-          tenantCode: c.tenantCode || c.companyCode || null
-        }))
-      }).finally(() => { this.tenantSearchLoading = false })
-    },
     submitAssign() {
       if (this.selectedTenantIds.length === 0) return
       this.assignSubmitting = true
@@ -548,6 +682,20 @@ export default {
 .assign-tenant-select-popper {
   z-index: 3000 !important;
 }
+.assign-tenant-select-popper .assign-tenant-status-option {
+  cursor: default;
+}
+.assign-tenant-select-popper .assign-tenant-status-text {
+  display: block;
+  text-align: center;
+  color: #909399;
+  font-size: 12px;
+  line-height: 1.5;
+  padding: 4px 0;
+}
+.assign-tenant-select-ended .el-select-dropdown__wrap {
+  overscroll-behavior: contain;
+}
 .assign-tenant-dialog .el-dialog__body {
   padding-bottom: 10px;
 }

+ 194 - 24
src/views/admin/voiceApiTenant/index.vue

@@ -3,9 +3,43 @@
     <el-card shadow="never" class="mb16 filter-card">
       <el-form :model="queryParams" ref="queryForm" :inline="true" size="small">
         <el-form-item label="租户" prop="tenantId">
-          <el-select v-model="queryParams.tenantId" placeholder="选择租户" clearable filterable remote
-            reserve-keyword :remote-method="searchTenants" :loading="tenantSearchLoading" style="width:200px">
-            <el-option v-for="c in tenantOptions" :key="c.tenantId" :label="c.tenantName" :value="c.tenantId" />
+          <el-select
+            ref="filterTenantSelect"
+            v-model="queryParams.tenantId"
+            placeholder="输入租户名称搜索"
+            clearable
+            filterable
+            remote
+            reserve-keyword
+            popper-append-to-body
+            :popper-class="filterTenantPopperClass"
+            :remote-method="searchFilterTenants"
+            :loading="tenantSearchLoading"
+            style="width:200px"
+            @visible-change="handleFilterTenantDropdownVisible"
+          >
+            <el-option
+              v-for="c in tenantOptions"
+              :key="c.tenantId"
+              :label="formatTenantOption(c)"
+              :value="c.tenantId"
+            />
+            <el-option
+              v-if="tenantLoadingMore"
+              key="filter-tenant-loading"
+              disabled
+              class="filter-tenant-status-option"
+            >
+              <span class="filter-tenant-status-text"><i class="el-icon-loading" /> 加载中...</span>
+            </el-option>
+            <el-option
+              v-if="showFilterTenantNoMore"
+              key="filter-tenant-no-more"
+              disabled
+              class="filter-tenant-status-option"
+            >
+              <span class="filter-tenant-status-text">没有更多数据了</span>
+            </el-option>
           </el-select>
         </el-form-item>
         <el-form-item label="接口" prop="apiId">
@@ -256,6 +290,16 @@ export default {
       },
       tenantOptions: [],
       tenantSearchLoading: false,
+      tenantLoadingMore: false,
+      hasMoreTenants: true,
+      tenantOptionsTotal: 0,
+      tenantQueryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        tenantName: ''
+      },
+      filterTenantScrollWrap: null,
+      filterTenantScrollHandler: null,
       apiOptions: [],
       addDialogVisible: false,
       addSubmitting: false,
@@ -295,8 +339,22 @@ export default {
     selectedApiCost() {
       const api = this.apiOptions.find(a => a.apiId === this.addForm.apiId)
       return api && api.costPrice ? api.costPrice : ''
+    },
+    filterTenantPopperClass() {
+      const base = 'voice-api-tenant-filter-select-popper'
+      if (!this.hasMoreTenants && this.tenantOptions.length > 0) {
+        return base + ' voice-api-tenant-filter-select-ended'
+      }
+      return base
+    },
+    showFilterTenantNoMore() {
+      return !this.hasMoreTenants && this.tenantOptions.length > 0 &&
+        !this.tenantSearchLoading && !this.tenantLoadingMore
     }
   },
+  beforeDestroy() {
+    this.unbindFilterTenantSelectScrollGuard()
+  },
   created() {
     this.getList()
     this.loadApiOptions()
@@ -360,18 +418,16 @@ export default {
       this.$refs.addForm.validate(valid => {
         if (!valid) return
         this.addSubmitting = true
-        assignTenantsV2(this.addForm.apiId, [this.addForm.tenantId]).then(() => {
-          if (this.addForm.salePrice || this.addForm.priority > 1 || this.addForm.isPrimary || this.addForm.selectable === '1') {
-            updateTenantPricingV2({
-              apiId: this.addForm.apiId,
-              tenantId: this.addForm.tenantId,
-              salePrice: this.addForm.salePrice,
-              priority: this.addForm.priority,
-              isPrimary: this.addForm.isPrimary,
-              selectable: this.addForm.selectable,
-              status: 1
-            }).catch(() => {})
-          }
+        const tenantId = this.addForm.tenantId
+        assignTenantsV2(this.addForm.apiId, [tenantId], {
+          tenants: [{
+            tenantId,
+            salePrice: this.addForm.salePrice,
+            priority: this.addForm.priority,
+            isPrimary: this.addForm.isPrimary,
+            selectable: this.addForm.selectable
+          }]
+        }).then(() => {
           this.$message.success('绑定成功')
           this.addDialogVisible = false
           this.getList()
@@ -388,15 +444,109 @@ export default {
         })
       }).catch(() => {})
     },
-    searchTenants(query) {
-      if (query.length < 1) { this.tenantOptions = []; return }
-      this.tenantSearchLoading = true
-      listAllCompanies({ companyName: query, pageNum: 1, pageSize: 20 }).then(response => {
-        this.tenantOptions = (response.rows || []).map(c => ({
-          tenantId: c.id || c.companyId,
-          tenantName: c.tenantName || c.companyName
-        }))
-      }).finally(() => { this.tenantSearchLoading = false })
+    formatTenantOption(item) {
+      if (item.tenantCode) {
+        return `${item.tenantName || '-'} (${item.tenantCode})`
+      }
+      return item.tenantName || String(item.tenantId)
+    },
+    resetFilterTenantSearchState() {
+      this.tenantQueryParams = {
+        pageNum: 1,
+        pageSize: 10,
+        tenantName: ''
+      }
+      this.hasMoreTenants = true
+      this.tenantOptionsTotal = 0
+      this.tenantLoadingMore = false
+    },
+    mapCompanyToTenantOption(c) {
+      return {
+        tenantId: c.id || c.companyId,
+        tenantName: c.tenantName || c.companyName,
+        tenantCode: c.tenantCode || c.companyCode || null
+      }
+    },
+    fetchFilterTenantOptions(isLoadMore = false) {
+      if (!isLoadMore) {
+        this.tenantSearchLoading = true
+      } else {
+        this.tenantLoadingMore = true
+      }
+      return listAllCompanies(this.tenantQueryParams).then(response => {
+        const rows = (response.rows || []).map(c => this.mapCompanyToTenantOption(c))
+        if (isLoadMore) {
+          const existIds = new Set(this.tenantOptions.map(o => o.tenantId))
+          const append = rows.filter(r => !existIds.has(r.tenantId))
+          this.tenantOptions = this.tenantOptions.concat(append)
+        } else {
+          this.tenantOptions = rows
+        }
+        this.tenantOptionsTotal = response.total || 0
+        this.hasMoreTenants = this.tenantOptions.length < this.tenantOptionsTotal
+      }).finally(() => {
+        this.tenantSearchLoading = false
+        this.tenantLoadingMore = false
+        this.$nextTick(() => {
+          this.bindFilterTenantSelectScrollGuard()
+        })
+      })
+    },
+    searchFilterTenants(query) {
+      if (query === undefined) return
+      this.tenantQueryParams.tenantName = (query || '').trim()
+      this.tenantQueryParams.pageNum = 1
+      this.hasMoreTenants = true
+      this.fetchFilterTenantOptions(false)
+    },
+    loadMoreFilterTenants() {
+      if (this.tenantLoadingMore || this.tenantSearchLoading || !this.hasMoreTenants) return
+      this.tenantQueryParams.pageNum += 1
+      this.fetchFilterTenantOptions(true)
+    },
+    getFilterTenantDropdownWrap() {
+      const dropdown = document.querySelector('.voice-api-tenant-filter-select-popper .el-select-dropdown__wrap')
+      if (dropdown) return dropdown
+      const root = this.$refs.filterTenantSelect && this.$refs.filterTenantSelect.$el
+      return root ? root.querySelector('.el-select-dropdown__wrap') : null
+    },
+    bindFilterTenantSelectScrollGuard() {
+      this.unbindFilterTenantSelectScrollGuard()
+      const wrap = this.getFilterTenantDropdownWrap()
+      if (!wrap) return
+      this.filterTenantScrollWrap = wrap
+      this.filterTenantScrollHandler = () => {
+        const atBottom = wrap.scrollHeight - wrap.scrollTop <= wrap.clientHeight + 2
+        if (atBottom) {
+          this.loadMoreFilterTenants()
+        }
+        if (!this.hasMoreTenants) {
+          const maxScroll = wrap.scrollHeight - wrap.clientHeight
+          if (maxScroll > 0 && wrap.scrollTop > maxScroll) {
+            wrap.scrollTop = maxScroll
+          }
+        }
+      }
+      wrap.addEventListener('scroll', this.filterTenantScrollHandler)
+    },
+    unbindFilterTenantSelectScrollGuard() {
+      if (this.filterTenantScrollWrap && this.filterTenantScrollHandler) {
+        this.filterTenantScrollWrap.removeEventListener('scroll', this.filterTenantScrollHandler)
+      }
+      this.filterTenantScrollWrap = null
+      this.filterTenantScrollHandler = null
+    },
+    handleFilterTenantDropdownVisible(visible) {
+      if (visible) {
+        if (this.tenantOptions.length === 0) {
+          this.searchFilterTenants('')
+        }
+        this.$nextTick(() => {
+          this.bindFilterTenantSelectScrollGuard()
+        })
+      } else {
+        this.unbindFilterTenantSelectScrollGuard()
+      }
     },
     handleEditPricing(row) {
       this.pricingForm = {
@@ -530,3 +680,23 @@ export default {
   vertical-align: middle;
 }
 </style>
+
+<style>
+.voice-api-tenant-filter-select-popper {
+  z-index: 3000 !important;
+}
+.voice-api-tenant-filter-select-popper .filter-tenant-status-option {
+  cursor: default;
+}
+.voice-api-tenant-filter-select-popper .filter-tenant-status-text {
+  display: block;
+  text-align: center;
+  color: #909399;
+  font-size: 12px;
+  line-height: 1.5;
+  padding: 4px 0;
+}
+.voice-api-tenant-filter-select-ended .el-select-dropdown__wrap {
+  overscroll-behavior: contain;
+}
+</style>