UserList.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939
  1. <template>
  2. <div class="user-list-container">
  3. <div class="header">
  4. <div class="header-left">
  5. <h2>用户管理</h2>
  6. </div>
  7. <div class="header-actions">
  8. <el-input
  9. v-model="searchQuery"
  10. placeholder="搜索手机/姓名/英文名"
  11. style="width: 240px"
  12. clearable
  13. @clear="fetchUsers"
  14. @keyup.enter="handleSearch"
  15. >
  16. <template #append>
  17. <el-button :icon="Search" @click="handleSearch" />
  18. </template>
  19. </el-input>
  20. <el-select v-model="statusFilter" placeholder="状态筛选" clearable @change="handleSearch" style="width: 120px">
  21. <el-option label="待审核" value="PENDING" />
  22. <el-option label="正常" value="ACTIVE" />
  23. <el-option label="已禁用" value="DISABLED" />
  24. </el-select>
  25. <el-select v-model="roleFilter" placeholder="角色筛选" clearable @change="handleSearch" style="width: 120px">
  26. <el-option label="开发者" value="DEVELOPER" />
  27. <el-option label="管理员" value="SUPER_ADMIN" />
  28. </el-select>
  29. <el-divider direction="vertical" class="action-divider" />
  30. <el-button type="primary" @click="fetchUsers" circle>
  31. <el-icon><Refresh /></el-icon>
  32. </el-button>
  33. <el-button type="success" @click="handleCreateUser">
  34. <el-icon style="margin-right: 4px"><Plus /></el-icon> 新增用户
  35. </el-button>
  36. <el-button type="primary" plain @click="showImportDialog = true">
  37. <el-icon style="margin-right: 4px"><Upload /></el-icon> Excel导入
  38. </el-button>
  39. <el-button type="warning" plain @click="handleBatchResetClick" :disabled="selectedUsers.length === 0">
  40. <el-icon style="margin-right: 4px"><EditPen /></el-icon> 批量重置英文名
  41. </el-button>
  42. <el-button @click="openLogDrawer">
  43. <el-icon style="margin-right: 4px"><List /></el-icon> 操作日志
  44. </el-button>
  45. </div>
  46. </div>
  47. <el-table
  48. :data="users"
  49. v-loading="loading"
  50. stripe
  51. border
  52. style="width: 100%"
  53. @selection-change="handleSelectionChange"
  54. >
  55. <el-table-column type="selection" width="55" />
  56. <el-table-column prop="id" label="ID" width="80" />
  57. <el-table-column prop="mobile" label="手机号" min-width="120" />
  58. <el-table-column prop="name" label="姓名" min-width="100" />
  59. <el-table-column prop="english_name" label="英文名" min-width="120" />
  60. <el-table-column prop="role" label="角色" width="120" align="center">
  61. <template #default="scope">
  62. <el-tag :type="scope.row.role === 'SUPER_ADMIN' ? 'danger' : (scope.row.role === 'DEVELOPER' ? 'warning' : 'info')">
  63. {{ getRoleLabel(scope.row.role) }}
  64. </el-tag>
  65. </template>
  66. </el-table-column>
  67. <el-table-column prop="status" label="状态" width="100" align="center">
  68. <template #default="scope">
  69. <el-tag :type="getStatusType(scope.row.status)">
  70. {{ getStatusLabel(scope.row.status) }}
  71. </el-tag>
  72. </template>
  73. </el-table-column>
  74. <el-table-column prop="created_at" label="注册时间" min-width="180">
  75. <template #default="scope">
  76. {{ formatDate(scope.row.created_at) }}
  77. </template>
  78. </el-table-column>
  79. <el-table-column label="操作" fixed="right" width="220">
  80. <template #default="scope">
  81. <div class="action-buttons">
  82. <!-- Allow Edit for everyone, including Super Admin -->
  83. <el-button type="primary" link @click="handleEditUser(scope.row)">编辑</el-button>
  84. <!-- Other actions restricted for Super Admin -->
  85. <template v-if="scope.row.role !== 'SUPER_ADMIN'">
  86. <el-divider direction="vertical" />
  87. <!-- PENDING Actions -->
  88. <template v-if="scope.row.status === 'PENDING'">
  89. <el-button type="primary" link @click="handleStatus(scope.row, 'ACTIVE')">通过</el-button>
  90. <el-divider direction="vertical" />
  91. <el-button type="primary" link @click="handleStatus(scope.row, 'DISABLED')">拒绝</el-button>
  92. </template>
  93. <!-- ACTIVE Actions -->
  94. <template v-if="scope.row.status === 'ACTIVE'">
  95. <el-button type="primary" link @click="handleStatus(scope.row, 'DISABLED')">禁用</el-button>
  96. </template>
  97. <!-- DISABLED Actions -->
  98. <template v-if="scope.row.status === 'DISABLED'">
  99. <el-button type="primary" link @click="handleStatus(scope.row, 'ACTIVE')">启用</el-button>
  100. </template>
  101. <el-divider direction="vertical" />
  102. <el-dropdown trigger="click">
  103. <span class="el-dropdown-link">
  104. 更多<el-icon class="el-icon--right"><ArrowDown /></el-icon>
  105. </span>
  106. <template #dropdown>
  107. <el-dropdown-menu>
  108. <el-dropdown-item
  109. @click="handleChangeRole(scope.row)"
  110. >
  111. 变更角色
  112. </el-dropdown-item>
  113. <el-dropdown-item
  114. @click="handleResetPassword(scope.row)"
  115. >
  116. 重置密码
  117. </el-dropdown-item>
  118. </el-dropdown-menu>
  119. </template>
  120. </el-dropdown>
  121. </template>
  122. </div>
  123. </template>
  124. </el-table-column>
  125. </el-table>
  126. <div class="pagination">
  127. <el-pagination
  128. v-model:current-page="currentPage"
  129. v-model:page-size="pageSize"
  130. :page-sizes="[10, 20, 50, 100]"
  131. layout="total, sizes, prev, pager, next, jumper"
  132. :total="total"
  133. @size-change="handleSizeChange"
  134. @current-change="handleCurrentChange"
  135. />
  136. </div>
  137. <!-- Change Role Dialog -->
  138. <el-dialog v-model="changeRoleDialogVisible" title="变更角色" width="400px">
  139. <p>变更用户 <strong>{{ roleTarget?.mobile }}</strong> 的角色。</p>
  140. <el-form :model="roleForm" label-position="top">
  141. <el-form-item label="选择角色">
  142. <el-select v-model="roleForm.role" style="width: 100%">
  143. <el-option label="普通用户" value="ORDINARY_USER" />
  144. <el-option label="开发者" value="DEVELOPER" />
  145. <el-option label="超级管理员" value="SUPER_ADMIN" />
  146. </el-select>
  147. </el-form-item>
  148. <el-form-item label="管理员密码验证" required>
  149. <el-input
  150. v-model="roleForm.admin_password"
  151. type="password"
  152. show-password
  153. placeholder="请输入管理员密码"
  154. :name="dynamicPwdField"
  155. autocomplete="new-password"
  156. />
  157. </el-form-item>
  158. </el-form>
  159. <template #footer>
  160. <el-button @click="changeRoleDialogVisible = false">取消</el-button>
  161. <el-button type="primary" @click="confirmChangeRole" :loading="changingRole">确认变更</el-button>
  162. </template>
  163. </el-dialog>
  164. <!-- Admin Verify Dialog for Status/Reset -->
  165. <el-dialog v-model="verifyDialogVisible" title="安全验证" width="400px">
  166. <p>此操作需要验证管理员权限。</p>
  167. <p v-if="verifyTargetUser">操作对象: <strong>{{ verifyTargetUser.mobile }}</strong></p>
  168. <div style="margin-top: 20px;">
  169. <p style="margin-bottom: 10px;">管理员密码</p>
  170. <el-input
  171. v-model="adminPasswordVerify"
  172. type="password"
  173. show-password
  174. placeholder="请输入管理员密码"
  175. :name="dynamicPwdField"
  176. autocomplete="new-password"
  177. @keyup.enter="confirmVerify"
  178. />
  179. </div>
  180. <template #footer>
  181. <el-button @click="verifyDialogVisible = false">取消</el-button>
  182. <el-button type="primary" @click="confirmVerify" :loading="verifying">确认</el-button>
  183. </template>
  184. </el-dialog>
  185. <!-- Reset Password Dialog -->
  186. <el-dialog v-model="resetPasswordDialogVisible" title="重置密码成功" width="400px">
  187. <div style="text-align: center;">
  188. <p>用户 <b>{{ resetPasswordUserMobile }}</b> 的新密码为:</p>
  189. <div style="margin: 20px 0; font-size: 24px; font-weight: bold; color: #409EFF; background: #f4f4f5; padding: 10px; border-radius: 4px;">
  190. {{ newPassword }}
  191. </div>
  192. <p style="color: #f56c6c; font-size: 12px;">请立即复制并保存,此密码只显示一次!</p>
  193. </div>
  194. <template #footer>
  195. <span class="dialog-footer">
  196. <el-button @click="copyPassword">复制密码</el-button>
  197. <el-button type="primary" @click="resetPasswordDialogVisible = false">关闭</el-button>
  198. </span>
  199. </template>
  200. </el-dialog>
  201. <!-- Batch Reset Dialog -->
  202. <el-dialog v-model="batchResetDialogVisible" title="批量重置英文名" width="400px">
  203. <div class="warning-text" style="margin-bottom: 20px; color: #e6a23c; display: flex; align-items: flex-start; gap: 8px;">
  204. <el-icon style="margin-top: 2px"><Warning /></el-icon>
  205. <span>
  206. 将根据用户的姓名自动生成拼音英文名。如有重复将自动添加数字后缀。
  207. <br>
  208. 已选择 <strong>{{ selectedUsers.length }}</strong> 位用户。
  209. </span>
  210. </div>
  211. <el-form label-position="top">
  212. <el-form-item label="管理员密码验证" required>
  213. <el-input
  214. v-model="batchAdminPassword"
  215. type="password"
  216. show-password
  217. placeholder="请输入管理员密码"
  218. :name="dynamicPwdField"
  219. autocomplete="new-password"
  220. />
  221. </el-form-item>
  222. </el-form>
  223. <template #footer>
  224. <el-button @click="batchResetDialogVisible = false">取消</el-button>
  225. <el-button type="primary" @click="confirmBatchReset" :loading="batchResetting">确认重置</el-button>
  226. </template>
  227. </el-dialog>
  228. <!-- Create User Dialog -->
  229. <el-dialog v-model="createDialogVisible" title="新增用户" width="500px">
  230. <el-form :model="createForm" :rules="createRules" ref="createFormRef" label-width="100px">
  231. <el-form-item label="手机号" prop="mobile">
  232. <el-input v-model="createForm.mobile" placeholder="请输入手机号" />
  233. </el-form-item>
  234. <el-form-item label="姓名" prop="name">
  235. <el-input v-model="createForm.name" placeholder="请输入姓名" />
  236. </el-form-item>
  237. <el-form-item label="英文名" prop="english_name">
  238. <el-input v-model="createForm.english_name" placeholder="请输入英文名(选填,自动生成拼音)" />
  239. </el-form-item>
  240. <el-form-item label="密码" prop="password">
  241. <el-input v-model="createForm.password" type="password" show-password placeholder="请输入密码" />
  242. </el-form-item>
  243. <el-form-item label="角色" prop="role">
  244. <el-select v-model="createForm.role" placeholder="请选择角色" style="width: 100%">
  245. <el-option label="普通用户" value="ORDINARY_USER" />
  246. <el-option label="开发者" value="DEVELOPER" />
  247. <el-option label="超级管理员" value="SUPER_ADMIN" />
  248. </el-select>
  249. </el-form-item>
  250. <el-form-item label="管理员验证" prop="admin_password">
  251. <el-input
  252. v-model="createForm.admin_password"
  253. type="password"
  254. show-password
  255. placeholder="请输入管理员密码"
  256. :name="dynamicPwdField"
  257. autocomplete="new-password"
  258. />
  259. </el-form-item>
  260. </el-form>
  261. <template #footer>
  262. <el-button @click="createDialogVisible = false">取消</el-button>
  263. <el-button type="primary" @click="submitCreateUser" :loading="creating">确定</el-button>
  264. </template>
  265. </el-dialog>
  266. <!-- Edit User Dialog -->
  267. <el-dialog v-model="editDialogVisible" title="编辑用户信息" width="500px">
  268. <el-form :model="editForm" :rules="editRules" ref="editFormRef" label-width="100px">
  269. <el-form-item label="手机号" prop="mobile">
  270. <el-input v-model="editForm.mobile" placeholder="请输入手机号" />
  271. </el-form-item>
  272. <el-form-item label="姓名" prop="name">
  273. <el-input v-model="editForm.name" placeholder="请输入姓名" />
  274. </el-form-item>
  275. <el-form-item label="英文名" prop="english_name">
  276. <el-input v-model="editForm.english_name" placeholder="请输入英文名(选填,自动生成拼音)" />
  277. </el-form-item>
  278. <el-form-item label="管理员验证" prop="admin_password">
  279. <el-input
  280. v-model="editForm.admin_password"
  281. type="password"
  282. show-password
  283. placeholder="请输入管理员密码"
  284. :name="dynamicPwdField"
  285. autocomplete="new-password"
  286. />
  287. </el-form-item>
  288. </el-form>
  289. <template #footer>
  290. <el-button @click="editDialogVisible = false">取消</el-button>
  291. <el-button type="primary" @click="submitEditUser" :loading="editing">保存</el-button>
  292. </template>
  293. </el-dialog>
  294. <!-- Logs Drawer -->
  295. <el-drawer
  296. v-model="logDrawerVisible"
  297. title="操作日志"
  298. direction="rtl"
  299. size="60%"
  300. >
  301. <div class="log-filter" style="margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap;">
  302. <el-select v-model="logFilter.action_type" placeholder="操作类型" clearable style="width: 150px" @change="fetchLogsData">
  303. <el-option label="新增用户" value="MANUAL_ADD" />
  304. <el-option label="删除用户" value="DELETE" />
  305. <el-option label="更新信息" value="UPDATE" />
  306. <el-option label="禁用" value="DISABLE" />
  307. <el-option label="启用" value="ENABLE" />
  308. <el-option label="变更角色" value="CHANGE_ROLE" />
  309. <el-option label="重置密码" value="RESET_PASSWORD" />
  310. </el-select>
  311. <el-input
  312. v-model="logFilter.keyword"
  313. placeholder="搜索目标手机号"
  314. style="width: 200px"
  315. clearable
  316. @clear="fetchLogsData"
  317. @keyup.enter="fetchLogsData"
  318. >
  319. <template #append>
  320. <el-button :icon="Search" @click="fetchLogsData" />
  321. </template>
  322. </el-input>
  323. <el-date-picker
  324. v-model="logFilter.dateRange"
  325. type="daterange"
  326. range-separator="至"
  327. start-placeholder="开始日期"
  328. end-placeholder="结束日期"
  329. value-format="YYYY-MM-DD"
  330. @change="fetchLogsData"
  331. />
  332. <el-button @click="fetchLogsData"><el-icon><Refresh /></el-icon></el-button>
  333. </div>
  334. <el-table :data="logList" v-loading="logLoading" stripe border style="width: 100%">
  335. <el-table-column prop="id" label="ID" width="80" />
  336. <el-table-column prop="operator_mobile" label="操作人" width="120" />
  337. <el-table-column prop="target_mobile" label="目标用户" width="120" />
  338. <el-table-column prop="action_type" label="操作类型" width="120">
  339. <template #default="scope">
  340. {{ getActionLabel(scope.row.action_type) }}
  341. </template>
  342. </el-table-column>
  343. <el-table-column prop="ip_address" label="IP地址" width="130" />
  344. <el-table-column prop="created_at" label="时间" width="170">
  345. <template #default="scope">
  346. {{ formatDate(scope.row.created_at) }}
  347. </template>
  348. </el-table-column>
  349. <el-table-column prop="details" label="详情" min-width="150">
  350. <template #default="scope">
  351. <el-tooltip effect="dark" placement="top">
  352. <template #content>
  353. <div style="max-width: 500px; max-height: 300px; overflow: auto; white-space: pre-wrap;">{{ JSON.stringify(scope.row.details, null, 2) }}</div>
  354. </template>
  355. <div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12px; color: #666; cursor: pointer;">
  356. {{ JSON.stringify(scope.row.details) }}
  357. </div>
  358. </el-tooltip>
  359. </template>
  360. </el-table-column>
  361. </el-table>
  362. <div class="pagination" style="margin-top: 20px; display: flex; justify-content: flex-end;">
  363. <el-pagination
  364. v-model:current-page="logPage.current"
  365. v-model:page-size="logPage.size"
  366. :page-sizes="[10, 20, 50]"
  367. layout="total, sizes, prev, pager, next"
  368. :total="logPage.total"
  369. @size-change="handleLogSizeChange"
  370. @current-change="handleLogCurrentChange"
  371. />
  372. </div>
  373. </el-drawer>
  374. <UserImportDialog v-model="showImportDialog" @success="fetchUsers" />
  375. </div>
  376. </template>
  377. <script setup lang="ts">
  378. import { ref, onMounted, reactive } from 'vue'
  379. import { ElMessage, FormInstance, FormRules } from 'element-plus'
  380. import { Refresh, ArrowDown, Search, Plus, List, Upload, EditPen, Warning } from '@element-plus/icons-vue'
  381. import api from '../utils/request'
  382. import { getLogs, OperationLog } from '../api/logs'
  383. import UserImportDialog from '../components/UserImportDialog.vue'
  384. interface User {
  385. id: number
  386. mobile: string
  387. name?: string
  388. english_name?: string
  389. status: string
  390. role: string
  391. created_at: string
  392. }
  393. const users = ref<User[]>([])
  394. const loading = ref(false)
  395. const statusFilter = ref('')
  396. const roleFilter = ref('')
  397. const searchQuery = ref('')
  398. const currentPage = ref(1)
  399. const pageSize = ref(10)
  400. const total = ref(0)
  401. // Create User
  402. const createDialogVisible = ref(false)
  403. const showImportDialog = ref(false)
  404. const creating = ref(false)
  405. const createFormRef = ref<FormInstance>()
  406. const createForm = reactive({
  407. mobile: '',
  408. name: '',
  409. english_name: '',
  410. password: '',
  411. role: 'ORDINARY_USER',
  412. admin_password: ''
  413. })
  414. // Edit User Logic
  415. const editDialogVisible = ref(false)
  416. const editing = ref(false)
  417. const editFormRef = ref<FormInstance>()
  418. const editForm = reactive({
  419. id: 0,
  420. mobile: '',
  421. name: '',
  422. english_name: '',
  423. admin_password: ''
  424. })
  425. const editRules = reactive<FormRules>({
  426. mobile: [
  427. { required: true, message: '请输入手机号', trigger: 'blur' },
  428. { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
  429. ],
  430. name: [
  431. { required: true, message: '请输入姓名', trigger: 'blur' }
  432. ],
  433. admin_password: [
  434. { required: true, message: '请输入管理员密码验证', trigger: 'blur' }
  435. ]
  436. })
  437. const handleEditUser = (user: User) => {
  438. editForm.id = user.id
  439. editForm.mobile = user.mobile
  440. editForm.name = user.name || ''
  441. editForm.english_name = user.english_name || ''
  442. editForm.admin_password = ''
  443. refreshDynamicField()
  444. editDialogVisible.value = true
  445. }
  446. const submitEditUser = async () => {
  447. if (!editFormRef.value) return
  448. await editFormRef.value.validate(async (valid) => {
  449. if (valid) {
  450. editing.value = true
  451. try {
  452. await api.put(`/users/${editForm.id}`, {
  453. mobile: editForm.mobile,
  454. name: editForm.name,
  455. english_name: editForm.english_name,
  456. admin_password: editForm.admin_password
  457. })
  458. ElMessage.success('用户信息更新成功')
  459. editDialogVisible.value = false
  460. fetchUsers()
  461. } catch (e) {
  462. // handled
  463. } finally {
  464. editing.value = false
  465. }
  466. }
  467. })
  468. }
  469. // Dynamic Field Name logic
  470. const dynamicPwdField = ref('admin_pwd_' + Date.now())
  471. const refreshDynamicField = () => {
  472. dynamicPwdField.value = 'admin_pwd_' + Math.random().toString(36).slice(2)
  473. }
  474. const createRules = reactive<FormRules>({
  475. mobile: [
  476. { required: true, message: '请输入手机号', trigger: 'blur' },
  477. { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
  478. ],
  479. name: [
  480. { required: true, message: '请输入姓名', trigger: 'blur' }
  481. ],
  482. password: [
  483. { required: true, message: '请输入密码', trigger: 'blur' },
  484. { min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
  485. ],
  486. role: [
  487. { required: true, message: '请选择角色', trigger: 'change' }
  488. ],
  489. admin_password: [
  490. { required: true, message: '请输入管理员密码验证', trigger: 'blur' }
  491. ]
  492. })
  493. const handleCreateUser = () => {
  494. createForm.mobile = ''
  495. createForm.name = ''
  496. createForm.english_name = ''
  497. createForm.password = ''
  498. createForm.role = 'ORDINARY_USER'
  499. createForm.admin_password = ''
  500. refreshDynamicField()
  501. createDialogVisible.value = true
  502. }
  503. const submitCreateUser = async () => {
  504. if (!createFormRef.value) return
  505. await createFormRef.value.validate(async (valid) => {
  506. if (valid) {
  507. creating.value = true
  508. try {
  509. await api.post('/users/', createForm)
  510. ElMessage.success('用户创建成功')
  511. createDialogVisible.value = false
  512. fetchUsers()
  513. } catch (e) {
  514. // handled
  515. } finally {
  516. creating.value = false
  517. }
  518. }
  519. })
  520. }
  521. const fetchUsers = async () => {
  522. loading.value = true
  523. try {
  524. const params: any = {
  525. skip: (currentPage.value - 1) * pageSize.value,
  526. limit: pageSize.value
  527. }
  528. if (statusFilter.value) params.status = statusFilter.value
  529. if (roleFilter.value) params.role = roleFilter.value
  530. if (searchQuery.value) params.keyword = searchQuery.value
  531. const res = await api.get('/users/', { params })
  532. if (res.data && Array.isArray(res.data.items)) {
  533. users.value = res.data.items
  534. total.value = res.data.total
  535. } else if (Array.isArray(res.data)) {
  536. users.value = res.data
  537. total.value = res.data.length
  538. }
  539. } catch (e) {
  540. // handled
  541. } finally {
  542. loading.value = false
  543. }
  544. }
  545. const handleSearch = () => {
  546. currentPage.value = 1
  547. fetchUsers()
  548. }
  549. const handleSizeChange = (val: number) => {
  550. pageSize.value = val
  551. fetchUsers()
  552. }
  553. const handleCurrentChange = (val: number) => {
  554. currentPage.value = val
  555. fetchUsers()
  556. }
  557. const getStatusType = (status: string) => {
  558. switch(status) {
  559. case 'ACTIVE': return 'success'
  560. case 'PENDING': return 'warning'
  561. case 'DISABLED': return 'danger'
  562. default: return 'info'
  563. }
  564. }
  565. const getStatusLabel = (status: string) => {
  566. switch(status) {
  567. case 'ACTIVE': return '正常'
  568. case 'PENDING': return '待审核'
  569. case 'DISABLED': return '已禁用'
  570. default: return status
  571. }
  572. }
  573. const getRoleLabel = (role: string) => {
  574. switch(role) {
  575. case 'SUPER_ADMIN': return '超级管理员'
  576. case 'DEVELOPER': return '开发者'
  577. case 'ORDINARY_USER': return '普通用户'
  578. default: return role
  579. }
  580. }
  581. const formatDate = (dateStr: string) => {
  582. if (!dateStr) return ''
  583. return new Date(dateStr).toLocaleString()
  584. }
  585. const handleStatus = (user: User, newStatus: string) => {
  586. verifyTargetUser.value = user
  587. verifyNextStatus.value = newStatus
  588. verifyActionType.value = 'STATUS'
  589. adminPasswordVerify.value = ''
  590. refreshDynamicField()
  591. verifyDialogVisible.value = true
  592. }
  593. const doHandleStatus = async (user: User, newStatus: string, adminPwd: string) => {
  594. try {
  595. await api.put(`/users/${user.id}`, {
  596. status: newStatus,
  597. admin_password: adminPwd
  598. })
  599. ElMessage.success('状态已更新')
  600. fetchUsers()
  601. } catch (e) {
  602. // handled
  603. }
  604. }
  605. // Verify Dialog Logic
  606. const verifyDialogVisible = ref(false)
  607. const adminPasswordVerify = ref('')
  608. const verifyTargetUser = ref<User | null>(null)
  609. const verifyActionType = ref('') // 'STATUS' | 'RESET'
  610. const verifyNextStatus = ref('')
  611. const verifying = ref(false)
  612. const confirmVerify = async () => {
  613. if (!adminPasswordVerify.value) {
  614. ElMessage.warning('请输入管理员密码')
  615. return
  616. }
  617. verifying.value = true
  618. try {
  619. if (verifyActionType.value === 'STATUS' && verifyTargetUser.value) {
  620. await doHandleStatus(verifyTargetUser.value, verifyNextStatus.value, adminPasswordVerify.value)
  621. } else if (verifyActionType.value === 'RESET' && verifyTargetUser.value) {
  622. await doHandleResetPassword(verifyTargetUser.value, adminPasswordVerify.value)
  623. }
  624. verifyDialogVisible.value = false
  625. } catch (e) {
  626. // handled
  627. } finally {
  628. verifying.value = false
  629. }
  630. }
  631. // Reset Password Logic
  632. const resetPasswordDialogVisible = ref(false)
  633. const newPassword = ref('')
  634. const resetPasswordUserMobile = ref('')
  635. const handleResetPassword = (user: User) => {
  636. verifyTargetUser.value = user
  637. verifyActionType.value = 'RESET'
  638. adminPasswordVerify.value = ''
  639. refreshDynamicField()
  640. verifyDialogVisible.value = true
  641. }
  642. const doHandleResetPassword = async (user: User, adminPwd: string) => {
  643. try {
  644. const res = await api.post('/simple/admin/reset-password', {
  645. user_id: user.id,
  646. admin_password: adminPwd
  647. })
  648. newPassword.value = res.data.new_password
  649. resetPasswordUserMobile.value = user.mobile
  650. resetPasswordDialogVisible.value = true
  651. } catch (e) {
  652. // handled
  653. }
  654. }
  655. const copyPassword = async () => {
  656. try {
  657. await navigator.clipboard.writeText(newPassword.value)
  658. ElMessage.success('密码已复制到剪贴板')
  659. } catch (err) {
  660. ElMessage.error('复制失败,请手动复制')
  661. }
  662. }
  663. // Change Role Logic
  664. const changeRoleDialogVisible = ref(false)
  665. const roleTarget = ref<User | null>(null)
  666. const changingRole = ref(false)
  667. const roleForm = reactive({
  668. role: '',
  669. admin_password: ''
  670. })
  671. const handleChangeRole = (user: User) => {
  672. roleTarget.value = user
  673. roleForm.role = user.role
  674. roleForm.admin_password = ''
  675. refreshDynamicField()
  676. changeRoleDialogVisible.value = true
  677. }
  678. const confirmChangeRole = async () => {
  679. if (!roleTarget.value) return
  680. if (!roleForm.role) {
  681. ElMessage.warning('请选择角色')
  682. return
  683. }
  684. if (!roleForm.admin_password) {
  685. ElMessage.warning('请输入管理员密码')
  686. return
  687. }
  688. changingRole.value = true
  689. try {
  690. await api.put(`/users/${roleTarget.value.id}`, {
  691. role: roleForm.role,
  692. admin_password: roleForm.admin_password
  693. })
  694. ElMessage.success('角色变更成功')
  695. changeRoleDialogVisible.value = false
  696. fetchUsers()
  697. } catch (e) {
  698. // handled
  699. } finally {
  700. changingRole.value = false
  701. }
  702. }
  703. // Batch Reset Logic
  704. const batchResetDialogVisible = ref(false)
  705. const batchAdminPassword = ref('')
  706. const batchResetting = ref(false)
  707. const selectedUsers = ref<User[]>([])
  708. const handleSelectionChange = (val: User[]) => {
  709. selectedUsers.value = val
  710. }
  711. const handleBatchResetClick = () => {
  712. if (selectedUsers.value.length === 0) {
  713. ElMessage.warning('请先选择用户')
  714. return
  715. }
  716. batchAdminPassword.value = ''
  717. refreshDynamicField()
  718. batchResetDialogVisible.value = true
  719. }
  720. const confirmBatchReset = async () => {
  721. if (!batchAdminPassword.value) {
  722. ElMessage.warning('请输入管理员密码')
  723. return
  724. }
  725. batchResetting.value = true
  726. try {
  727. const res = await api.post('/users/batch/reset-english-name', {
  728. user_ids: selectedUsers.value.map(u => u.id),
  729. admin_password: batchAdminPassword.value
  730. })
  731. ElMessage.success(`操作成功,已重置 ${res.data.count} 位用户的英文名`)
  732. batchResetDialogVisible.value = false
  733. fetchUsers()
  734. selectedUsers.value = []
  735. } catch (e) {
  736. // handled
  737. } finally {
  738. batchResetting.value = false
  739. }
  740. }
  741. // Log Logic
  742. const logDrawerVisible = ref(false)
  743. const logLoading = ref(false)
  744. const logList = ref<OperationLog[]>([])
  745. const logFilter = reactive({
  746. action_type: '',
  747. keyword: '',
  748. dateRange: [] as string[]
  749. })
  750. const logPage = reactive({
  751. current: 1,
  752. size: 20,
  753. total: 0
  754. })
  755. const openLogDrawer = () => {
  756. logDrawerVisible.value = true
  757. fetchLogsData()
  758. }
  759. const fetchLogsData = async () => {
  760. logLoading.value = true
  761. try {
  762. const params: any = {
  763. skip: (logPage.current - 1) * logPage.size,
  764. limit: logPage.size,
  765. action_type: logFilter.action_type || undefined,
  766. keyword: logFilter.keyword || undefined,
  767. }
  768. if (logFilter.dateRange && logFilter.dateRange.length === 2) {
  769. params.start_date = logFilter.dateRange[0]
  770. params.end_date = logFilter.dateRange[1] + ' 23:59:59'
  771. }
  772. const res = await getLogs(params)
  773. logList.value = res.data.items
  774. logPage.total = res.data.total
  775. } catch (e) {
  776. // handled
  777. } finally {
  778. logLoading.value = false
  779. }
  780. }
  781. const handleLogSizeChange = (val: number) => {
  782. logPage.size = val
  783. fetchLogsData()
  784. }
  785. const handleLogCurrentChange = (val: number) => {
  786. logPage.current = val
  787. fetchLogsData()
  788. }
  789. const getActionLabel = (type: string) => {
  790. const map: Record<string, string> = {
  791. 'MANUAL_ADD': '新增用户',
  792. 'DELETE': '删除用户',
  793. 'UPDATE': '更新信息',
  794. 'IMPORT': '批量导入',
  795. 'DISABLE': '禁用',
  796. 'ENABLE': '启用',
  797. 'RESET_PASSWORD': '重置密码',
  798. 'CHANGE_ROLE': '变更角色',
  799. 'SYNC_M2M': 'M2M 同步'
  800. }
  801. return map[type] || type
  802. }
  803. onMounted(() => {
  804. fetchUsers()
  805. })
  806. </script>
  807. <style scoped>
  808. .user-list-container {
  809. padding: 20px;
  810. background: #fff;
  811. border-radius: 4px;
  812. }
  813. .header {
  814. display: flex;
  815. justify-content: space-between;
  816. align-items: center;
  817. margin-bottom: 24px;
  818. flex-wrap: wrap;
  819. gap: 16px;
  820. }
  821. .header-left h2 {
  822. margin: 0;
  823. font-size: 24px;
  824. color: #303133;
  825. }
  826. .header-actions {
  827. display: flex;
  828. align-items: center;
  829. gap: 12px;
  830. flex-wrap: wrap;
  831. }
  832. .action-divider {
  833. height: 24px;
  834. margin: 0 4px;
  835. }
  836. .action-buttons {
  837. display: flex;
  838. align-items: center;
  839. justify-content: flex-start;
  840. }
  841. .el-dropdown-link {
  842. cursor: pointer;
  843. color: #409eff;
  844. display: flex;
  845. align-items: center;
  846. font-size: 14px;
  847. margin-left: 10px;
  848. }
  849. .text-gray {
  850. color: #909399;
  851. font-size: 12px;
  852. }
  853. .pagination {
  854. margin-top: 20px;
  855. display: flex;
  856. justify-content: flex-end;
  857. }
  858. .captcha-item {
  859. margin-bottom: 0;
  860. }
  861. </style>