index.vue 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981
  1. <template>
  2. <div class="app-container">
  3. <!-- ===== 搜索栏 ===== -->
  4. <el-card shadow="never" class="mb16 filter-card">
  5. <el-form :model="queryParams" ref="queryForm" :inline="true" size="small">
  6. <el-form-item label="租户名称" prop="tenantName">
  7. <el-input v-model="queryParams.tenantName" placeholder="租户编码/名称" clearable @keyup.enter.native="handleQuery" />
  8. </el-form-item>
  9. <el-form-item label="状态" prop="status">
  10. <el-select v-model="queryParams.status" placeholder="全部" clearable style="width:120px">
  11. <el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
  12. </el-select>
  13. </el-form-item>
  14. <el-form-item>
  15. <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">查询</el-button>
  16. <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
  17. </el-form-item>
  18. </el-form>
  19. </el-card>
  20. <!-- ===== 操作栏 ===== -->
  21. <el-row :gutter="10" class="mb8">
  22. <el-col :span="1.5">
  23. <el-button type="primary" icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['admin:platform:edit']">新增租户</el-button>
  24. </el-col>
  25. <el-col :span="1.5">
  26. <el-button type="warning" plain icon="el-icon-download" size="mini" :loading="exportLoading" @click="handleExport">导出</el-button>
  27. </el-col>
  28. <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
  29. </el-row>
  30. <!-- ===== 租户列表 ===== -->
  31. <el-table border v-loading="loading" :data="companyList" size="small" style="width:100%">
  32. <el-table-column label="租户编码" prop="tenantCode" min-width="80" align="center" />
  33. <el-table-column label="租户名称" prop="tenantName" min-width="120" />
  34. <el-table-column label="联系人" prop="contactName" min-width="80" align="center" />
  35. <el-table-column label="联系电话" prop="contactPhone" min-width="110" align="center" />
  36. <el-table-column label="余额" align="center" min-width="100">
  37. <template slot-scope="scope">
  38. <span style="color:#1890ff;font-weight:bold">{{ scope.row.balance ? '¥' + Number(scope.row.balance).toFixed(2) : '¥0.00' }}</span>
  39. </template>
  40. </el-table-column>
  41. <el-table-column label="已消费总额" align="center" min-width="100">
  42. <template slot-scope="scope">
  43. <span>{{ scope.row.totalConsumption ? '¥' + Number(scope.row.totalConsumption).toFixed(2) : '¥0.00' }}</span>
  44. </template>
  45. </el-table-column>
  46. <el-table-column label="开通账户数" align="center" prop="accountCount" min-width="90" />
  47. <el-table-column label="绑定企微账户数" align="center" prop="qwAccountCount" min-width="110" />
  48. <el-table-column label="绑定个微账户数" align="center" prop="wxAccountCount" min-width="110" />
  49. <el-table-column label="客户数" align="center" prop="customerCount" min-width="80" />
  50. <el-table-column label="企微用户数" align="center" prop="qwUserCount" min-width="90" />
  51. <el-table-column label="过期时间" align="center" prop="expireTime" min-width="100" />
  52. <el-table-column label="归属代理" align="center" prop="proxyName" min-width="90" />
  53. <el-table-column label="状态" align="center" min-width="70">
  54. <template slot-scope="scope">
  55. <el-tag v-if="scope.row.status == 1" type="success" size="mini">正常</el-tag>
  56. <el-tag v-else type="danger" size="mini">禁用</el-tag>
  57. </template>
  58. </el-table-column>
  59. <el-table-column label="操作" align="center" width="380" fixed="right" class-name="small-padding fixed-width">
  60. <template slot-scope="scope">
  61. <el-button size="mini" type="text" icon="el-icon-edit" @click="handleView(scope.row)">编辑</el-button>
  62. <el-button size="mini" type="text" icon="el-icon-key" style="color:#722ed1" @click="handleResetPwd(scope.row)">重置密码</el-button>
  63. <el-button size="mini" type="text" style="color:#52c41a" icon="el-icon-coin" @click="handleRecharge(scope.row)">充值/扣款</el-button>
  64. <el-button size="mini" type="text" style="color:#722ed1" icon="el-icon-menu" @click="handleEditMenu(scope.row, 'sys')">管理端菜单</el-button>
  65. <el-button size="mini" type="text" style="color:#e6a23c" icon="el-icon-price-tag" @click="handleModulePricing(scope.row)">模块定价</el-button>
  66. <el-button size="mini" type="text" style="color:#13c2c2" icon="el-icon-sell" @click="handleEditMenu(scope.row, 'com')">销售菜单</el-button>
  67. <el-button
  68. v-if="scope.row.status == 1"
  69. size="mini" type="text" style="color:#fa8c16"
  70. icon="el-icon-lock"
  71. @click="handleDisable(scope.row)"
  72. v-hasPermi="['admin:platform:edit']"
  73. >禁用</el-button>
  74. <el-button
  75. v-else
  76. size="mini" type="text" style="color:#52c41a"
  77. icon="el-icon-unlock"
  78. @click="handleEnable(scope.row)"
  79. v-hasPermi="['admin:platform:edit']"
  80. >启用</el-button>
  81. </template>
  82. </el-table-column>
  83. </el-table>
  84. <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
  85. <!-- ===== 编辑租户弹窗 ===== -->
  86. <el-dialog title="编辑租户" :visible.sync="viewOpen" width="720px" append-to-body destroy-on-close class="tenant-dialog">
  87. <el-form ref="editForm" :model="viewForm" :rules="editRules" label-width="112px" size="small" class="tenant-dialog-form">
  88. <el-row :gutter="16">
  89. <el-col :span="12">
  90. <el-form-item label="租户ID">
  91. <span>{{ viewForm.id }}</span>
  92. </el-form-item>
  93. </el-col>
  94. <el-col :span="12">
  95. <el-form-item label="租户编码">
  96. <span>{{ viewForm.tenantCode || '-' }}</span>
  97. </el-form-item>
  98. </el-col>
  99. <el-col :span="12">
  100. <el-form-item label="租户名称" prop="tenantName">
  101. <el-input v-model="viewForm.tenantName" placeholder="请输入租户名称" />
  102. </el-form-item>
  103. </el-col>
  104. <el-col :span="12">
  105. <el-form-item label="联系电话" prop="contactPhone">
  106. <el-input v-model="viewForm.contactPhone" placeholder="请输入联系电话" />
  107. </el-form-item>
  108. </el-col>
  109. <el-col :span="12">
  110. <el-form-item label="联系人" prop="contactName">
  111. <el-input v-model="viewForm.contactName" placeholder="请输入联系人" />
  112. </el-form-item>
  113. </el-col>
  114. <el-col :span="12">
  115. <el-form-item label="过期时间" prop="expireTime">
  116. <el-date-picker v-model="viewForm.expireTime" type="date" value-format="yyyy-MM-dd" placeholder="选择过期时间" style="width:100%" />
  117. </el-form-item>
  118. </el-col>
  119. <el-col :span="12">
  120. <el-form-item label="归属代理" prop="proxyId">
  121. <el-select v-model="viewForm.proxyId" placeholder="请选择代理" clearable filterable style="width:100%">
  122. <el-option v-for="p in proxyOptions" :key="p.proxyId" :label="p.proxyName" :value="p.proxyId" />
  123. </el-select>
  124. </el-form-item>
  125. </el-col>
  126. <el-col :span="12">
  127. <el-form-item label="租户状态">
  128. <el-switch v-model="viewForm.statusBool" active-text="正常" inactive-text="禁用"
  129. :active-value="1" :inactive-value="0" />
  130. </el-form-item>
  131. </el-col>
  132. <el-col :span="12">
  133. <el-form-item label="公司数量" prop="companyNum">
  134. <el-input-number v-model="viewForm.companyNum" :min="1" :precision="0" controls-position="right" style="width:100%" />
  135. </el-form-item>
  136. </el-col>
  137. <el-col :span="12">
  138. <el-form-item prop="accountNum">
  139. <span slot="label" class="tenant-form-label" title="公司员工账户总数量">员工账户数</span>
  140. <el-input-number v-model="viewForm.accountNum" :min="1" :precision="0" controls-position="right" style="width:100%" />
  141. </el-form-item>
  142. </el-col>
  143. <el-col :span="24">
  144. <el-form-item label="租户余额" class="tenant-balance-item">
  145. <span style="color:#1890ff;font-weight:bold">¥{{ viewForm.balance || '0.00' }}</span>
  146. <span class="tenant-balance-hint">(通过充值/扣款调整)</span>
  147. </el-form-item>
  148. </el-col>
  149. </el-row>
  150. </el-form>
  151. <div slot="footer">
  152. <el-button @click="viewOpen = false">取 消</el-button>
  153. <el-button type="primary" :loading="editSubmitting" @click="submitEdit">保 存</el-button>
  154. </div>
  155. </el-dialog>
  156. <!-- ===== 新增租户弹窗 ===== -->
  157. <el-dialog title="新增租户" :visible.sync="addDialog.visible" width="720px" append-to-body destroy-on-close class="tenant-dialog">
  158. <el-form ref="addForm" :model="addDialog.form" :rules="addDialog.rules" label-width="112px" size="small" class="tenant-dialog-form">
  159. <el-row :gutter="16">
  160. <el-col :span="12">
  161. <el-form-item label="企业名称" prop="tenantName">
  162. <el-input v-model="addDialog.form.tenantName" placeholder="请输入企业名称" />
  163. </el-form-item>
  164. </el-col>
  165. <el-col :span="12">
  166. <el-form-item label="联系电话" prop="contactPhone">
  167. <el-input v-model="addDialog.form.contactPhone" placeholder="请输入联系电话" />
  168. </el-form-item>
  169. </el-col>
  170. <el-col :span="12">
  171. <el-form-item label="联系人" prop="contactName">
  172. <el-input v-model="addDialog.form.contactName" placeholder="请输入联系人" />
  173. </el-form-item>
  174. </el-col>
  175. <el-col :span="12">
  176. <el-form-item label="商务负责人" prop="manager">
  177. <el-input v-model="addDialog.form.manager" placeholder="请输入商务负责人" />
  178. </el-form-item>
  179. </el-col>
  180. <el-col :span="12">
  181. <el-form-item label="管理员账号" prop="userName">
  182. <el-input v-model="addDialog.form.userName" placeholder="请输入管理员账号" />
  183. </el-form-item>
  184. </el-col>
  185. <el-col :span="12">
  186. <el-form-item label="密码" prop="password">
  187. <el-input v-model="addDialog.form.password" type="password" show-password placeholder="请输入密码">
  188. <el-button slot="append" icon="el-icon-refresh" @click="generateAddPwd" title="随机生成密码">生成</el-button>
  189. </el-input>
  190. </el-form-item>
  191. </el-col>
  192. <el-col :span="12">
  193. <el-form-item label="开始时间" prop="startTime">
  194. <el-date-picker v-model="addDialog.form.startTime" type="date" value-format="yyyy-MM-dd" placeholder="选择开始时间" style="width:100%" />
  195. </el-form-item>
  196. </el-col>
  197. <el-col :span="12">
  198. <el-form-item label="到期时间" prop="expireTime">
  199. <el-date-picker v-model="addDialog.form.expireTime" type="date" value-format="yyyy-MM-dd" placeholder="选择到期时间" style="width:100%" />
  200. </el-form-item>
  201. </el-col>
  202. <el-col :span="12">
  203. <el-form-item label="归属代理" prop="proxyId">
  204. <el-select v-model="addDialog.form.proxyId" placeholder="请选择代理" clearable filterable style="width:100%">
  205. <el-option v-for="p in proxyOptions" :key="p.proxyId" :label="p.proxyName" :value="p.proxyId" />
  206. </el-select>
  207. </el-form-item>
  208. </el-col>
  209. <el-col :span="12">
  210. <el-form-item label="公司数量" prop="companyNum">
  211. <el-input-number v-model="addDialog.form.companyNum" :min="1" :precision="0" controls-position="right" style="width:100%" />
  212. </el-form-item>
  213. </el-col>
  214. <el-col :span="12">
  215. <el-form-item prop="accountNum">
  216. <span slot="label" class="tenant-form-label" title="公司员工账户总数量">员工账户数</span>
  217. <el-input-number v-model="addDialog.form.accountNum" :min="1" :precision="0" controls-position="right" style="width:100%" />
  218. </el-form-item>
  219. </el-col>
  220. <el-col :span="12">
  221. <el-form-item label="状态" prop="status" class="tenant-status-item">
  222. <el-radio-group v-model="addDialog.form.status">
  223. <el-radio :label="1">启用</el-radio>
  224. <el-radio :label="0">禁用</el-radio>
  225. </el-radio-group>
  226. </el-form-item>
  227. </el-col>
  228. </el-row>
  229. </el-form>
  230. <div slot="footer">
  231. <el-button @click="addDialog.visible = false">取 消</el-button>
  232. <el-button type="primary" :loading="addDialog.submitting" @click="submitAdd">确 定</el-button>
  233. </div>
  234. </el-dialog>
  235. <!-- ===== 充值/扣款弹窗 ===== -->
  236. <el-dialog :title="rechargeTitle" :visible.sync="rechargeOpen" width="420px" append-to-body>
  237. <el-form ref="rechargeForm" :model="rechargeForm" :rules="rechargeRules" label-width="90px">
  238. <el-form-item label="租户名称">
  239. <span>{{ rechargeForm.companyName }}</span>
  240. </el-form-item>
  241. <el-form-item label="当前余额">
  242. <span style="color:#1890ff;font-weight:bold">¥{{ rechargeForm.currentBalance || '0.00' }}</span>
  243. </el-form-item>
  244. <el-form-item label="操作类型" prop="operateType">
  245. <el-radio-group v-model="rechargeForm.operateType">
  246. <el-radio label="recharge">充值</el-radio>
  247. <el-radio label="deduct">扣款</el-radio>
  248. </el-radio-group>
  249. </el-form-item>
  250. <el-form-item label="金额" prop="amount">
  251. <el-input-number v-model="rechargeForm.amount" :min="0.01" :precision="2" style="width:200px" />
  252. </el-form-item>
  253. <el-form-item label="备注" prop="remark">
  254. <el-input v-model="rechargeForm.remark" type="textarea" :rows="2" placeholder="请输入备注" />
  255. </el-form-item>
  256. </el-form>
  257. <div slot="footer">
  258. <el-button @click="rechargeOpen = false">取 消</el-button>
  259. <el-button type="primary" @click="submitRecharge">确 定</el-button>
  260. </div>
  261. </el-dialog>
  262. <!-- ===== 菜单编辑弹窗(租户列表:管理端菜单 / 销售菜单) ===== -->
  263. <el-dialog :title="menuDialog.title" :visible.sync="menuDialog.visible" width="560px" append-to-body destroy-on-close @opened="onMenuDialogOpened">
  264. <div v-loading="menuDialog.loading" class="menu-tree-scroll">
  265. <div v-if="menuDialog.treeReady && menuDialog.treeData.length" class="menu-tree-toolbar">
  266. <el-button type="text" size="mini" @click="checkAllMenuNodes">全选</el-button>
  267. <el-button type="text" size="mini" @click="uncheckAllMenuNodes">全不选</el-button>
  268. <el-button type="text" size="mini" @click="expandAllMenuNodes">展开全部</el-button>
  269. <el-button type="text" size="mini" @click="collapseAllMenuNodes">收起全部</el-button>
  270. </div>
  271. <el-tree
  272. v-if="menuDialog.treeReady"
  273. ref="menuTree"
  274. :data="menuDialog.treeData"
  275. show-checkbox
  276. node-key="menuId"
  277. :props="menuTreeProps"
  278. :expand-on-click-node="false"
  279. >
  280. <span slot-scope="{ data }" class="custom-tree-node">
  281. <span>{{ data.menuName }}</span>
  282. <el-tag v-if="data.menuType === 'M'" size="mini" style="margin-left:6px">目录</el-tag>
  283. <el-tag v-else-if="data.menuType === 'C'" type="success" size="mini" style="margin-left:6px">菜单</el-tag>
  284. <el-tag v-else-if="data.menuType === 'F'" type="warning" size="mini" style="margin-left:6px">按钮</el-tag>
  285. <el-tag v-if="data.visible === '0'" type="success" size="mini" style="margin-left:6px">已分配</el-tag>
  286. <el-tag v-else type="info" size="mini" style="margin-left:6px">未分配</el-tag>
  287. </span>
  288. </el-tree>
  289. <el-empty v-if="!menuDialog.loading && menuDialog.treeReady && !menuDialog.treeData.length" description="暂无菜单数据" />
  290. </div>
  291. <div slot="footer">
  292. <el-button @click="menuDialog.visible = false">取 消</el-button>
  293. <el-button type="primary" :loading="menuDialog.submitting" @click="submitMenuEdit">确 定</el-button>
  294. </div>
  295. </el-dialog>
  296. <!-- ===== 模块定价弹窗 ===== -->
  297. <el-dialog :title="'模块定价 - ' + pricingDialog.tenantName" :visible.sync="pricingDialog.visible" width="800px" append-to-body destroy-on-close>
  298. <div v-loading="pricingDialog.loading">
  299. <el-table :data="pricingDialog.modules" border size="small" style="width:100%">
  300. <el-table-column label="模块" align="center" prop="moduleName" min-width="120" />
  301. <el-table-column label="全局售价" align="center" min-width="100">
  302. <template slot-scope="scope">
  303. <span style="color:#909399">{{ scope.row.globalPrice != null ? scope.row.globalPrice : '-' }}</span>
  304. </template>
  305. </el-table-column>
  306. <el-table-column label="全局成本" align="center" min-width="100">
  307. <template slot-scope="scope">
  308. <span style="color:#909399">{{ scope.row.globalCost != null ? scope.row.globalCost : '-' }}</span>
  309. </template>
  310. </el-table-column>
  311. <el-table-column label="租户售价" align="center" min-width="130">
  312. <template slot-scope="scope">
  313. <el-input-number v-model="scope.row.price" :precision="4" :min="0" :step="0.01" size="small" style="width:150px" placeholder="跟随全局" />
  314. </template>
  315. </el-table-column>
  316. <el-table-column label="成本价" align="center" min-width="130">
  317. <template slot-scope="scope">
  318. <el-input-number v-model="scope.row.costPrice" :precision="4" :min="0" :step="0.01" size="small" style="width:150px" placeholder="跟随全局" />
  319. </template>
  320. </el-table-column>
  321. <el-table-column label="状态" align="center" min-width="80">
  322. <template slot-scope="scope">
  323. <el-switch v-model="scope.row.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁" size="small" />
  324. </template>
  325. </el-table-column>
  326. </el-table>
  327. <div style="color:#909399;font-size:12px;margin-top:8px">* 租户售价/成本价留空或填0则使用全局定价</div>
  328. </div>
  329. <div slot="footer">
  330. <el-button @click="pricingDialog.visible = false">取 消</el-button>
  331. <el-button type="primary" :loading="pricingDialog.submitting" @click="submitModulePricing">保 存</el-button>
  332. </div>
  333. </el-dialog>
  334. </div>
  335. </template>
  336. <script>
  337. import { listAllCompanies, getCompanyInfo, addCompany, updateTenant, disableCompany, enableCompany, rechargeCompany, exportCompany, getTenantMenuTree, editTenantMenu, resetTenantPwd } from '@/api/admin/sysCompany'
  338. import { listTrafficPricing, addTrafficPricing, updateTrafficPricing } from '@/api/admin/trafficPricing'
  339. import { allEnabledProxies } from '@/api/admin/proxy'
  340. import { listServiceCost } from '@/api/admin/serviceCost'
  341. import { generateRandomPwd } from '@/utils/index'
  342. export default {
  343. name: 'SysCompanyAdmin',
  344. data() {
  345. return {
  346. loading: true,
  347. exportLoading: false,
  348. showSearch: true,
  349. total: 0,
  350. companyList: [],
  351. statusOptions: [
  352. { value: 1, label: '正常' },
  353. { value: 0, label: '禁用' }
  354. ],
  355. queryParams: {
  356. pageNum: 1,
  357. pageSize: 10,
  358. tenantName: null,
  359. status: null
  360. },
  361. // 详情弹窗
  362. viewOpen: false,
  363. viewForm: {},
  364. editSubmitting: false,
  365. editRules: {
  366. tenantName: [{ required: true, message: '请输入租户名称', trigger: 'blur' }],
  367. contactPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
  368. companyNum: [{ required: true, message: '请输入公司数量', trigger: 'blur' }],
  369. accountNum: [{ required: true, message: '请输入员工账户数', trigger: 'blur' }]
  370. },
  371. // 新增租户弹窗
  372. addDialog: {
  373. visible: false,
  374. submitting: false,
  375. form: {
  376. tenantName: '',
  377. contactPhone: '',
  378. contactName: '',
  379. userName: '',
  380. password: '',
  381. manager: '',
  382. startTime: null,
  383. expireTime: null,
  384. status: 1,
  385. companyNum: 1,
  386. accountNum: 1
  387. },
  388. rules: {
  389. tenantName: [{ required: true, message: '请输入企业名称', trigger: 'blur' }],
  390. contactPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
  391. userName: [{ required: true, message: '请输入管理员账号', trigger: 'blur' }],
  392. password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
  393. companyNum: [{ required: true, message: '请输入公司数量', trigger: 'blur' }],
  394. accountNum: [{ required: true, message: '请输入员工账户数', trigger: 'blur' }]
  395. }
  396. },
  397. // 充值/扣款弹窗
  398. rechargeOpen: false,
  399. rechargeTitle: '',
  400. rechargeForm: { companyId: null, companyName: '', currentBalance: 0, operateType: 'recharge', amount: null, remark: '' },
  401. rechargeRules: {
  402. operateType: [{ required: true, message: '请选择操作类型', trigger: 'change' }],
  403. amount: [{ required: true, message: '请输入金额', trigger: 'blur' }]
  404. },
  405. menuTreeProps: { label: 'menuName', children: 'children' },
  406. // 菜单编辑弹窗
  407. menuDialog: {
  408. visible: false,
  409. title: '',
  410. flag: 'sys',
  411. companyId: null,
  412. companyName: '',
  413. treeData: [],
  414. treeReady: false,
  415. checkedKeys: [],
  416. checkedKeySet: null,
  417. initialAssignedIds: null,
  418. loading: false,
  419. submitting: false
  420. },
  421. // 模块定价弹窗
  422. pricingDialog: {
  423. visible: false,
  424. tenantId: null,
  425. tenantName: '',
  426. loading: false,
  427. submitting: false,
  428. modules: []
  429. },
  430. // 代理列表(下拉框用)
  431. proxyOptions: []
  432. }
  433. },
  434. created() {
  435. this.getList()
  436. this.loadProxyOptions()
  437. },
  438. methods: {
  439. loadProxyOptions() {
  440. allEnabledProxies().then(res => {
  441. this.proxyOptions = res.data || []
  442. }).catch(() => {})
  443. },
  444. getList() {
  445. this.loading = true
  446. listAllCompanies(this.queryParams).then(response => {
  447. this.companyList = response.rows
  448. this.total = response.total
  449. this.loading = false
  450. })
  451. },
  452. handleQuery() {
  453. this.queryParams.pageNum = 1
  454. this.getList()
  455. },
  456. resetQuery() {
  457. this.resetForm('queryForm')
  458. this.handleQuery()
  459. },
  460. /** 新增租户 */
  461. handleAdd() {
  462. this.addDialog.form = {
  463. tenantName: '',
  464. contactPhone: '',
  465. contactName: '',
  466. userName: '',
  467. password: generateRandomPwd(12),
  468. manager: '',
  469. proxyId: null,
  470. startTime: null,
  471. expireTime: null,
  472. status: 1,
  473. companyNum: 1,
  474. accountNum: 1
  475. }
  476. this.addDialog.visible = true
  477. this.$nextTick(() => {
  478. if (this.$refs.addForm) this.$refs.addForm.clearValidate()
  479. })
  480. },
  481. /** 随机生成新增密码 */
  482. generateAddPwd() {
  483. this.addDialog.form.password = generateRandomPwd(12)
  484. },
  485. /** 重置租户管理员密码 */
  486. handleResetPwd(row) {
  487. const newPwd = generateRandomPwd(12)
  488. this.$prompt('为租户 "' + row.tenantName + '" 重置管理员密码', '重置密码', {
  489. confirmButtonText: '确定',
  490. cancelButtonText: '取消',
  491. inputValue: newPwd,
  492. inputPattern: /^.{6,30}$/,
  493. inputErrorMessage: '密码长度必须介于 6 和 30 之间',
  494. inputType: 'text'
  495. }).then(({ value }) => {
  496. resetTenantPwd(row.id, value).then(() => {
  497. this.$message.success('密码重置成功,新密码为:' + value)
  498. }).catch(() => {
  499. this.$message.error('密码重置失败')
  500. })
  501. }).catch(() => {})
  502. },
  503. /** 提交新增租户 */
  504. submitAdd() {
  505. this.$refs['addForm'].validate(valid => {
  506. if (!valid) return
  507. this.addDialog.submitting = true
  508. addCompany(this.addDialog.form).then(() => {
  509. this.$message.success('新增成功')
  510. this.addDialog.visible = false
  511. this.getList()
  512. }).catch(() => {
  513. this.$message.error('新增失败,请重试')
  514. }).finally(() => {
  515. this.addDialog.submitting = false
  516. })
  517. })
  518. },
  519. /** 编辑租户 */
  520. handleView(row) {
  521. getCompanyInfo(row.id).then(response => {
  522. this.viewForm = response.data
  523. if (this.viewForm.companyNum == null) {
  524. this.$set(this.viewForm, 'companyNum', 1)
  525. }
  526. if (this.viewForm.accountNum == null) {
  527. this.$set(this.viewForm, 'accountNum', 1)
  528. }
  529. // 为 el-switch 提供 number 类型的 status
  530. this.$set(this.viewForm, 'statusBool', this.viewForm.status)
  531. this.viewOpen = true
  532. this.$nextTick(() => {
  533. if (this.$refs.editForm) this.$refs.editForm.clearValidate()
  534. })
  535. }).catch(() => {
  536. this.$message.error('获取租户信息失败')
  537. })
  538. },
  539. /** 提交编辑租户 */
  540. submitEdit() {
  541. this.$refs['editForm'].validate(valid => {
  542. if (!valid) return
  543. this.editSubmitting = true
  544. const data = {
  545. id: this.viewForm.id,
  546. tenantName: this.viewForm.tenantName,
  547. contactName: this.viewForm.contactName,
  548. contactPhone: this.viewForm.contactPhone,
  549. expireTime: this.viewForm.expireTime,
  550. status: this.viewForm.statusBool,
  551. proxyId: this.viewForm.proxyId,
  552. companyNum: this.viewForm.companyNum,
  553. accountNum: this.viewForm.accountNum
  554. }
  555. updateTenant(data).then(() => {
  556. this.$message.success('保存成功')
  557. this.viewOpen = false
  558. this.getList()
  559. }).catch(() => {
  560. this.$message.error('保存失败,请重试')
  561. }).finally(() => {
  562. this.editSubmitting = false
  563. })
  564. })
  565. },
  566. /** 充值/扣款 */
  567. handleRecharge(row) {
  568. this.rechargeForm = {
  569. companyId: row.id,
  570. companyName: row.tenantName,
  571. currentBalance: row.balance || 0,
  572. operateType: 'recharge',
  573. amount: null,
  574. remark: ''
  575. }
  576. this.rechargeTitle = `充值/扣款 - ${row.tenantName}`
  577. this.rechargeOpen = true
  578. },
  579. submitRecharge() {
  580. this.$refs['rechargeForm'].validate(valid => {
  581. if (!valid) return
  582. const { companyId, operateType, amount, remark } = this.rechargeForm
  583. rechargeCompany(companyId, { operateType, amount, remark }).then(res => {
  584. if (res.code === 200) {
  585. this.$message.success(res.msg || (operateType === 'recharge' ? '充值成功' : '扣款成功'))
  586. this.rechargeOpen = false
  587. this.getList()
  588. } else {
  589. this.$message.error(res.msg || '操作失败')
  590. }
  591. }).catch(() => {
  592. this.$message.error('操作失败,请重试')
  593. })
  594. })
  595. },
  596. /** 禁用租户 */
  597. handleDisable(row) {
  598. this.$confirm('确定禁用租户 [' + row.tenantName + '] 吗?', '提示', {
  599. confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
  600. }).then(() => {
  601. disableCompany(row.id).then(() => {
  602. this.$message.success('禁用成功')
  603. this.getList()
  604. })
  605. })
  606. },
  607. /** 启用租户 */
  608. handleEnable(row) {
  609. this.$confirm('确定启用租户 [' + row.tenantName + '] 吗?', '提示', {
  610. confirmButtonText: '确定', cancelButtonText: '取消', type: 'info'
  611. }).then(() => {
  612. enableCompany(row.id).then(() => {
  613. this.$message.success('启用成功')
  614. this.getList()
  615. })
  616. })
  617. },
  618. handleExport() {
  619. this.exportLoading = true
  620. exportCompany(this.queryParams).then(response => {
  621. this.download(response.msg)
  622. this.exportLoading = false
  623. }).catch(() => { this.exportLoading = false })
  624. },
  625. normalizeMenuId(id) {
  626. if (id == null || id === '') return null
  627. const n = Number(id)
  628. return Number.isNaN(n) ? id : n
  629. },
  630. collectAssignedMenuIds(nodes) {
  631. const ids = []
  632. const walk = (list) => {
  633. (list || []).forEach(n => {
  634. if (n.visible === '0') {
  635. const mid = this.normalizeMenuId(n.menuId)
  636. if (mid != null) ids.push(mid)
  637. }
  638. if (n.children && n.children.length) walk(n.children)
  639. })
  640. }
  641. walk(nodes)
  642. return ids
  643. },
  644. applyMenuTreeChecked(menus) {
  645. const tree = this.$refs.menuTree
  646. const d = this.menuDialog
  647. if (!tree) return
  648. // 勿用 setCheckedKeys(父节点):会级联勾选未分配子节点,导致提交 diff 为空
  649. tree.setCheckedKeys([])
  650. this.collectAssignedMenuIds(menus).forEach(menuId => {
  651. const node = tree.getNode(menuId)
  652. if (node) node.setChecked(true, false)
  653. })
  654. const keys = tree.getCheckedKeys().concat(tree.getHalfCheckedKeys()).map(id => this.normalizeMenuId(id))
  655. d.checkedKeys = keys
  656. d.checkedKeySet = new Set(keys)
  657. },
  658. walkMenuTreeNodes(nodes, fn) {
  659. (nodes || []).forEach(n => {
  660. fn(n)
  661. if (n.children && n.children.length) this.walkMenuTreeNodes(n.children, fn)
  662. })
  663. },
  664. collectAllMenuIds(nodes) {
  665. const ids = []
  666. this.walkMenuTreeNodes(nodes, n => {
  667. const mid = this.normalizeMenuId(n.menuId)
  668. if (mid != null) ids.push(mid)
  669. })
  670. return ids
  671. },
  672. checkAllMenuNodes() {
  673. const tree = this.$refs.menuTree
  674. if (!tree) return
  675. tree.setCheckedKeys(this.collectAllMenuIds(this.menuDialog.treeData))
  676. },
  677. uncheckAllMenuNodes() {
  678. const tree = this.$refs.menuTree
  679. if (!tree) return
  680. tree.setCheckedKeys([])
  681. },
  682. expandAllMenuNodes() {
  683. const tree = this.$refs.menuTree
  684. if (!tree) return
  685. this.walkMenuTreeNodes(this.menuDialog.treeData, n => {
  686. const node = tree.getNode(n.menuId)
  687. if (node) node.expanded = true
  688. })
  689. },
  690. collapseAllMenuNodes() {
  691. const tree = this.$refs.menuTree
  692. if (!tree) return
  693. this.walkMenuTreeNodes(this.menuDialog.treeData, n => {
  694. const node = tree.getNode(n.menuId)
  695. if (node) node.expanded = false
  696. })
  697. },
  698. onMenuDialogOpened() {
  699. if (this.menuDialog.companyId) {
  700. this.loadTenantMenuTree()
  701. }
  702. },
  703. loadTenantMenuTree() {
  704. const d = this.menuDialog
  705. if (!d.companyId) return
  706. d.loading = true
  707. d.treeReady = false
  708. d.treeData = []
  709. getTenantMenuTree(d.companyId, d.flag).then(res => {
  710. if (res.code !== 200) {
  711. return Promise.reject(new Error(res.msg || '加载菜单失败'))
  712. }
  713. const flat = res.menus || []
  714. d.initialAssignedIds = new Set(
  715. flat.filter(m => m.visible === '0').map(m => this.normalizeMenuId(m.menuId)).filter(id => id != null)
  716. )
  717. // 与模板维护页相同:扁平数据 + handleTree(menuId) 建树,保证层级一致
  718. const tree = this.handleTree(flat, 'menuId')
  719. d.treeData = tree
  720. requestAnimationFrame(() => {
  721. d.treeReady = true
  722. this.$nextTick(() => this.applyMenuTreeChecked(tree))
  723. })
  724. }).catch(err => {
  725. this.$message.error(err.message || '加载菜单失败')
  726. }).finally(() => {
  727. d.loading = false
  728. })
  729. },
  730. /** 编辑菜单(管理端/销售端) */
  731. handleEditMenu(row, flag) {
  732. this.menuDialog = {
  733. visible: true,
  734. title: (flag === 'sys' ? '编辑管理端菜单 - ' : '编辑销售菜单 - ') + row.tenantName,
  735. flag: flag,
  736. companyId: row.id,
  737. companyName: row.tenantName,
  738. treeData: [],
  739. treeReady: false,
  740. checkedKeys: [],
  741. checkedKeySet: null,
  742. initialAssignedIds: null,
  743. loading: false,
  744. submitting: false
  745. }
  746. },
  747. /** 模块定价 */
  748. handleModulePricing(row) {
  749. this.pricingDialog = {
  750. visible: true,
  751. tenantId: row.id,
  752. tenantName: row.tenantName,
  753. loading: true,
  754. submitting: false,
  755. modules: []
  756. }
  757. // 从收费配置API获取启用的模块列表(排除手拨外呼serviceType=6)
  758. Promise.all([
  759. listServiceCost(),
  760. listTrafficPricing({ tenantId: row.id, pageSize: 100 })
  761. ]).then(([costRes, pricingRes]) => {
  762. const costList = (costRes.data || []).filter(c => c.enabled === 1 && c.serviceType !== 6)
  763. const tenantPricings = pricingRes.rows || []
  764. const pricingMap = {}
  765. tenantPricings.forEach(p => { pricingMap[p.serviceType] = p })
  766. this.pricingDialog.modules = costList.map(c => {
  767. const existing = pricingMap[c.serviceType]
  768. return {
  769. serviceType: c.serviceType,
  770. moduleName: c.configName,
  771. unit: c.feeUnit,
  772. globalPrice: c.feeStandard,
  773. globalCost: c.platformCost,
  774. price: existing ? existing.price : null,
  775. costPrice: existing ? existing.costPrice : null,
  776. status: existing ? existing.status : 1,
  777. id: existing ? existing.id : null
  778. }
  779. })
  780. this.pricingDialog.loading = false
  781. }).catch(() => {
  782. this.pricingDialog.loading = false
  783. this.$message.error('加载定价数据失败')
  784. })
  785. },
  786. /** 提交模块定价 */
  787. submitModulePricing() {
  788. this.pricingDialog.submitting = true
  789. const tenantId = this.pricingDialog.tenantId
  790. const promises = this.pricingDialog.modules.map(m => {
  791. const data = {
  792. tenantId: tenantId,
  793. serviceType: m.serviceType,
  794. price: m.price,
  795. costPrice: m.costPrice,
  796. status: m.status
  797. }
  798. if (m.id) {
  799. data.id = m.id
  800. return updateTrafficPricing(data)
  801. } else {
  802. return addTrafficPricing(data)
  803. }
  804. })
  805. Promise.all(promises).then(() => {
  806. this.$message.success('模块定价保存成功')
  807. this.pricingDialog.visible = false
  808. }).catch(() => {
  809. this.$message.error('部分定价保存失败,请重试')
  810. }).finally(() => {
  811. this.pricingDialog.submitting = false
  812. })
  813. },
  814. /** 模块定价 */
  815. handleModulePricing(row) {
  816. this.pricingDialog = {
  817. visible: true,
  818. tenantId: row.id,
  819. tenantName: row.tenantName,
  820. loading: true,
  821. submitting: false,
  822. modules: []
  823. }
  824. // 从收费配置API获取启用的模块列表(排除手拨外呼serviceType=6)
  825. Promise.all([
  826. listServiceCost(),
  827. listTrafficPricing({ tenantId: row.id, pageSize: 100 })
  828. ]).then(([costRes, pricingRes]) => {
  829. const costList = (costRes.data || []).filter(c => c.enabled === 1 && c.serviceType !== 6)
  830. const tenantPricings = pricingRes.rows || []
  831. const pricingMap = {}
  832. tenantPricings.forEach(p => { pricingMap[p.serviceType] = p })
  833. this.pricingDialog.modules = costList.map(c => {
  834. const existing = pricingMap[c.serviceType]
  835. return {
  836. serviceType: c.serviceType,
  837. moduleName: c.configName,
  838. unit: c.feeUnit,
  839. globalPrice: c.feeStandard,
  840. globalCost: c.platformCost,
  841. price: existing ? existing.price : null,
  842. costPrice: existing ? existing.costPrice : null,
  843. status: existing ? existing.status : 1,
  844. id: existing ? existing.id : null
  845. }
  846. })
  847. this.pricingDialog.loading = false
  848. }).catch(() => {
  849. this.pricingDialog.loading = false
  850. this.$message.error('加载定价数据失败')
  851. })
  852. },
  853. /** 提交模块定价 */
  854. submitModulePricing() {
  855. this.pricingDialog.submitting = true
  856. const tenantId = this.pricingDialog.tenantId
  857. const promises = this.pricingDialog.modules.map(m => {
  858. const data = {
  859. tenantId: tenantId,
  860. serviceType: m.serviceType,
  861. price: m.price,
  862. costPrice: m.costPrice,
  863. status: m.status
  864. }
  865. if (m.id) {
  866. data.id = m.id
  867. return updateTrafficPricing(data)
  868. } else {
  869. return addTrafficPricing(data)
  870. }
  871. })
  872. Promise.all(promises).then(() => {
  873. this.$message.success('模块定价保存成功')
  874. this.pricingDialog.visible = false
  875. }).catch(() => {
  876. this.$message.error('部分定价保存失败,请重试')
  877. }).finally(() => {
  878. this.pricingDialog.submitting = false
  879. })
  880. },
  881. /** 提交菜单编辑 */
  882. submitMenuEdit() {
  883. const tree = this.$refs.menuTree
  884. if (!tree) return
  885. const initialAssigned = this.menuDialog.initialAssignedIds || new Set()
  886. const currentSelected = tree.getCheckedKeys()
  887. .concat(tree.getHalfCheckedKeys())
  888. .map(id => this.normalizeMenuId(id))
  889. .filter(id => id != null)
  890. const currentSet = new Set(currentSelected)
  891. const selected = currentSelected.filter(id => !initialAssigned.has(id))
  892. const unSelected = [...initialAssigned].filter(id => !currentSet.has(id))
  893. if (selected.length === 0 && unSelected.length === 0) {
  894. this.$message.info('菜单无变化')
  895. this.menuDialog.visible = false
  896. return
  897. }
  898. this.menuDialog.submitting = true
  899. editTenantMenu(this.menuDialog.companyId, {
  900. flag: this.menuDialog.flag,
  901. selected: selected,
  902. unSelected: unSelected
  903. }).then(res => {
  904. if (res.code === 200) {
  905. this.$message.success('菜单更新成功')
  906. this.menuDialog.visible = false
  907. } else {
  908. this.$message.error(res.msg || '菜单更新失败')
  909. }
  910. }).catch(() => {
  911. this.$message.error('菜单更新失败')
  912. }).finally(() => {
  913. this.menuDialog.submitting = false
  914. })
  915. }
  916. }
  917. }
  918. </script>
  919. <style scoped>
  920. .mb8 { margin-bottom: 8px; }
  921. .mb16 { margin-bottom: 16px; }
  922. .filter-card { padding-bottom: 0; }
  923. .menu-tree-scroll {
  924. min-height: 120px;
  925. max-height: 420px;
  926. overflow-y: auto;
  927. }
  928. .menu-tree-toolbar {
  929. margin-bottom: 8px;
  930. display: flex;
  931. flex-wrap: wrap;
  932. align-items: center;
  933. justify-content: flex-end;
  934. gap: 4px;
  935. }
  936. .custom-tree-node {
  937. display: flex;
  938. align-items: center;
  939. font-size: 13px;
  940. }
  941. .tenant-dialog-form ::v-deep .el-form-item__label {
  942. white-space: nowrap;
  943. line-height: 32px;
  944. }
  945. .tenant-dialog-form ::v-deep .el-form-item {
  946. margin-bottom: 16px;
  947. }
  948. .tenant-dialog-form ::v-deep .el-input-number {
  949. width: 100%;
  950. }
  951. .tenant-form-label {
  952. display: inline-block;
  953. max-width: 100%;
  954. overflow: hidden;
  955. text-overflow: ellipsis;
  956. vertical-align: middle;
  957. }
  958. .tenant-balance-hint {
  959. color: #999;
  960. font-size: 12px;
  961. margin-left: 8px;
  962. }
  963. .tenant-balance-item ::v-deep .el-form-item__content {
  964. line-height: 32px;
  965. }
  966. .tenant-status-item ::v-deep .el-form-item__content {
  967. line-height: 32px;
  968. }
  969. </style>