Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 
 
 
 

411 Zeilen
16 KiB

  1. {% extends "../layout.html" %}
  2. {% block title %}用户管理{% endblock %}
  3. {% block content %}
  4. <div id="userApp" v-cloak>
  5. <!-- 搜索栏 -->
  6. <el-card shadow="never" class="mb-4">
  7. <el-form :inline="true" @submit.prevent="loadList()" class="flex items-center flex-wrap gap-2">
  8. <el-form-item label="关键词" class="!mb-0">
  9. <el-input v-model="keyword" placeholder="用户名/姓名..." clearable style="width:180px;" />
  10. </el-form-item>
  11. <el-form-item label="角色" class="!mb-0">
  12. <el-select v-model="roleFilter" placeholder="全部" clearable style="width:120px;">
  13. <el-option v-for="role in roles" :key="role.id" :label="role.name" :value="role.id" />
  14. </el-select>
  15. </el-form-item>
  16. <el-form-item label="状态" class="!mb-0">
  17. <el-select v-model="statusFilter" placeholder="全部" clearable style="width:100px;">
  18. <el-option label="启用" :value="1"></el-option>
  19. <el-option label="禁用" :value="0"></el-option>
  20. </el-select>
  21. </el-form-item>
  22. <el-form-item class="!mb-0">
  23. <el-button type="primary" @click="loadList()">搜索</el-button>
  24. <el-button @click="resetFilter">重置</el-button>
  25. </el-form-item>
  26. </el-form>
  27. </el-card>
  28. <!-- 用户列表 -->
  29. <el-card shadow="never">
  30. <template #header>
  31. <el-button type="primary" @click="showAddModal">+ 新增用户</el-button>
  32. </template>
  33. <el-table :data="tableData" v-loading="loading" stripe border>
  34. <el-table-column prop="id" label="ID" width="60" ></el-table-column>
  35. <el-table-column prop="username" label="用户名" ></el-table-column>
  36. <el-table-column prop="nickname" label="姓名">
  37. <template #default="{ row }">${ row.nickname || '-' }</template>
  38. </el-table-column>
  39. <el-table-column prop="role_name" label="角色" width="120">
  40. <template #default="{ row }">
  41. <el-tag type="primary" size="small">${ row.role_name || '-' }</el-tag>
  42. </template>
  43. </el-table-column>
  44. <el-table-column prop="email" label="邮箱" width="180">
  45. <template #default="{ row }">${ row.email || '-' }</template>
  46. </el-table-column>
  47. <el-table-column prop="status" label="状态" width="120" align="center">
  48. <template #default="{ row }">
  49. <el-tag v-if="row.is_locked" type="danger" size="small">已锁定</el-tag>
  50. <el-tag v-else-if="row.status === 1" type="success" size="small">启用</el-tag>
  51. <el-tag v-else type="info" size="small">禁用</el-tag>
  52. </template>
  53. </el-table-column>
  54. <el-table-column prop="last_login_time" label="最后登录" width="160">
  55. <template #default="{ row }">${ row.last_login_time || '-' }</template>
  56. </el-table-column>
  57. <el-table-column label="操作" width="260" align="center">
  58. <template #default="{ row }">
  59. <el-button type="primary" link @click="editUser(row)">编辑</el-button>
  60. <el-button type="primary" link @click="showResetModal(row)">重置密码</el-button>
  61. <el-button v-if="row.is_locked" type="warning" link @click="unlockUser(row)">解锁</el-button>
  62. <el-button :type="row.status === 1 ? 'danger' : 'success'" link @click="toggleStatus(row)">
  63. ${ row.status === 1 ? '禁用' : '启用' }
  64. </el-button>
  65. </template>
  66. </el-table-column>
  67. </el-table>
  68. <div class="flex justify-end mt-4">
  69. <el-pagination
  70. v-model:current-page="pagination.page"
  71. :page-size="pagination.pageSize"
  72. :total="pagination.total"
  73. layout="total, prev, pager, next"
  74. @current-change="loadList"
  75. />
  76. </div>
  77. </el-card>
  78. <!-- 新增/编辑弹窗 -->
  79. <el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close draggable :close-on-click-modal="false">
  80. <el-form :model="form" label-width="80px">
  81. <el-form-item label="用户名" required>
  82. <el-input v-model="form.username" placeholder="请输入用户名" :disabled="!!form.id" />
  83. </el-form-item>
  84. <el-form-item v-if="!form.id" label="密码" required>
  85. <el-input v-model="form.password" type="password" placeholder="至少8位,含大小写字母/数字中两种" show-password />
  86. <div v-if="form.password" style="margin-top:4px;">
  87. <div style="display:flex;gap:4px;margin-bottom:2px;">
  88. <span :style="{flex:1,height:'4px',borderRadius:'2px',background: pwdStrength>=1?pwdStrengthColor:'#e4e7ed'}"></span>
  89. <span :style="{flex:1,height:'4px',borderRadius:'2px',background: pwdStrength>=2?pwdStrengthColor:'#e4e7ed'}"></span>
  90. <span :style="{flex:1,height:'4px',borderRadius:'2px',background: pwdStrength>=3?pwdStrengthColor:'#e4e7ed'}"></span>
  91. </div>
  92. <span :style="{fontSize:'12px',color:pwdStrengthColor}">${ pwdStrengthText }</span>
  93. </div>
  94. </el-form-item>
  95. <el-form-item label="姓名">
  96. <el-input v-model="form.nickname" placeholder="请输入姓名" />
  97. </el-form-item>
  98. <el-form-item label="邮箱">
  99. <el-input v-model="form.email" type="email" placeholder="请输入邮箱" />
  100. </el-form-item>
  101. <el-form-item label="手机号">
  102. <el-input v-model="form.phone" placeholder="请输入手机号" />
  103. </el-form-item>
  104. <el-form-item label="角色">
  105. <el-select v-model="form.role_id" placeholder="请选择角色" style="width:100%;">
  106. <el-option v-for="role in roles" :key="role.id" :label="role.name" :value="role.id" />
  107. </el-select>
  108. </el-form-item>
  109. <el-form-item label="状态">
  110. <el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
  111. </el-form-item>
  112. </el-form>
  113. <template #footer>
  114. <el-button @click="dialogVisible = false">取消</el-button>
  115. <el-button type="primary" @click="saveUser" :loading="saving">确定</el-button>
  116. </template>
  117. </el-dialog>
  118. <!-- 重置密码弹窗 -->
  119. <el-dialog v-model="resetPwdVisible" title="重置密码" width="400px" destroy-on-close draggable :close-on-click-modal="false">
  120. <el-form label-width="80px">
  121. <el-form-item label="新密码" required>
  122. <el-input v-model="newPassword" type="password" placeholder="至少8位,含大小写字母/数字中两种" show-password />
  123. <div v-if="newPassword" style="margin-top:4px;">
  124. <div style="display:flex;gap:4px;margin-bottom:2px;">
  125. <span :style="{flex:1,height:'4px',borderRadius:'2px',background: resetPwdStrength>=1?resetPwdStrengthColor:'#e4e7ed'}"></span>
  126. <span :style="{flex:1,height:'4px',borderRadius:'2px',background: resetPwdStrength>=2?resetPwdStrengthColor:'#e4e7ed'}"></span>
  127. <span :style="{flex:1,height:'4px',borderRadius:'2px',background: resetPwdStrength>=3?resetPwdStrengthColor:'#e4e7ed'}"></span>
  128. </div>
  129. <span :style="{fontSize:'12px',color:resetPwdStrengthColor}">${ resetPwdStrengthText }</span>
  130. </div>
  131. </el-form-item>
  132. </el-form>
  133. <template #footer>
  134. <el-button @click="resetPwdVisible = false">取消</el-button>
  135. <el-button type="primary" @click="resetPassword" :loading="resetPwdSaving">确定</el-button>
  136. </template>
  137. </el-dialog>
  138. </div>
  139. {% endblock %}
  140. {% block js %}
  141. <script>
  142. const rolesData = {{ roles | dump | safe }};
  143. const { createApp, ref, reactive, computed, onMounted } = Vue;
  144. const app = createApp({
  145. delimiters: ['${', '}'],
  146. setup() {
  147. const keyword = ref('');
  148. const roleFilter = ref('');
  149. const statusFilter = ref('');
  150. const loading = ref(false);
  151. const tableData = ref([]);
  152. const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
  153. const roles = ref(rolesData || []);
  154. // 弹窗
  155. const dialogVisible = ref(false);
  156. const dialogTitle = ref('新增用户');
  157. const saving = ref(false);
  158. const form = reactive({ id: null, username: '', password: '', nickname: '', email: '', phone: '', role_id: '', status: 1 });
  159. // 重置密码弹窗
  160. const resetPwdVisible = ref(false);
  161. const resetPwdSaving = ref(false);
  162. const newPassword = ref('');
  163. const resetUserId = ref(null);
  164. // 密码强度计算
  165. function calcPwdStrength(pwd) {
  166. if (!pwd) return 0;
  167. var s = 0;
  168. if (pwd.length >= 8) s++;
  169. var types = 0;
  170. if (/[a-z]/.test(pwd)) types++;
  171. if (/[A-Z]/.test(pwd)) types++;
  172. if (/\d/.test(pwd)) types++;
  173. if (types >= 2) s++;
  174. if (types >= 3 && pwd.length >= 10) s++;
  175. return s;
  176. }
  177. const pwdStrength = computed(function() { return calcPwdStrength(form.password); });
  178. const pwdStrengthColor = computed(function() { return pwdStrength.value <= 1 ? '#f56c6c' : pwdStrength.value === 2 ? '#e6a23c' : '#67c23a'; });
  179. const pwdStrengthText = computed(function() { return pwdStrength.value <= 1 ? '弱' : pwdStrength.value === 2 ? '中' : '强'; });
  180. const resetPwdStrength = computed(function() { return calcPwdStrength(newPassword.value); });
  181. const resetPwdStrengthColor = computed(function() { return resetPwdStrength.value <= 1 ? '#f56c6c' : resetPwdStrength.value === 2 ? '#e6a23c' : '#67c23a'; });
  182. const resetPwdStrengthText = computed(function() { return resetPwdStrength.value <= 1 ? '弱' : resetPwdStrength.value === 2 ? '中' : '强'; });
  183. // 前端密码校验
  184. function validatePassword(pwd) {
  185. if (!pwd || pwd.length < 8) return '密码长度不能少于8位';
  186. var types = 0;
  187. if (/[a-z]/.test(pwd)) types++;
  188. if (/[A-Z]/.test(pwd)) types++;
  189. if (/\d/.test(pwd)) types++;
  190. if (types < 2) return '密码需包含大写字母、小写字母、数字中的至少两种';
  191. return null;
  192. }
  193. // 加载列表
  194. async function loadList(page) {
  195. if (typeof page === 'number') pagination.page = page;
  196. loading.value = true;
  197. try {
  198. const params = new URLSearchParams({
  199. page: pagination.page,
  200. pageSize: pagination.pageSize,
  201. keyword: keyword.value,
  202. role_id: roleFilter.value,
  203. status: statusFilter.value
  204. });
  205. const res = await fetch('/admin/system/user/list?' + params).then(r => r.json());
  206. if (res.code === 0) {
  207. var today = new Date().toISOString().slice(0, 10);
  208. (res.data.data || []).forEach(function(item) {
  209. var failDate = item.login_fail_date ? item.login_fail_date.slice(0, 10) : null;
  210. item.is_locked = (failDate === today && item.login_fail_count >= 5);
  211. });
  212. tableData.value = res.data.data || [];
  213. pagination.total = res.data.count || 0;
  214. } else {
  215. ElementPlus.ElMessage.error(res.msg || '加载失败');
  216. }
  217. } finally {
  218. loading.value = false;
  219. }
  220. }
  221. // 重置筛选
  222. function resetFilter() {
  223. keyword.value = '';
  224. roleFilter.value = '';
  225. statusFilter.value = '';
  226. pagination.page = 1;
  227. loadList();
  228. }
  229. // 显示新增弹窗
  230. function showAddModal() {
  231. dialogTitle.value = '新增用户';
  232. Object.assign(form, { id: null, username: '', password: '', nickname: '', email: '', phone: '', role_id: '', status: 1 });
  233. dialogVisible.value = true;
  234. }
  235. // 编辑用户
  236. async function editUser(row) {
  237. const res = await fetch('/admin/system/user/detail?id=' + row.id).then(r => r.json());
  238. if (res.code !== 0) {
  239. ElementPlus.ElMessage.error(res.msg || '获取失败');
  240. return;
  241. }
  242. const user = res.data;
  243. dialogTitle.value = '编辑用户';
  244. Object.assign(form, {
  245. id: user.id,
  246. username: user.username,
  247. password: '',
  248. nickname: user.nickname || '',
  249. email: user.email || '',
  250. phone: user.phone || '',
  251. role_id: user.role_id || '',
  252. status: user.status
  253. });
  254. dialogVisible.value = true;
  255. }
  256. // 保存用户
  257. async function saveUser() {
  258. if (!form.username.trim()) {
  259. ElementPlus.ElMessage.warning('用户名不能为空');
  260. return;
  261. }
  262. if (!form.id && !form.password) {
  263. ElementPlus.ElMessage.warning('密码不能为空');
  264. return;
  265. }
  266. if (!form.id) {
  267. var pwdErr = validatePassword(form.password);
  268. if (pwdErr) { ElementPlus.ElMessage.warning(pwdErr); return; }
  269. }
  270. saving.value = true;
  271. try {
  272. const url = form.id ? '/admin/system/user/edit' : '/admin/system/user/add';
  273. const body = {
  274. username: form.username,
  275. password: form.password,
  276. nickname: form.nickname,
  277. email: form.email,
  278. phone: form.phone,
  279. role_id: form.role_id,
  280. status: form.status
  281. };
  282. if (form.id) body.id = form.id;
  283. const res = await fetch(url, {
  284. method: 'POST',
  285. headers: { 'Content-Type': 'application/json' },
  286. body: JSON.stringify(body)
  287. }).then(r => r.json());
  288. if (res.code === 0) {
  289. ElementPlus.ElMessage.success('保存成功');
  290. dialogVisible.value = false;
  291. loadList();
  292. } else {
  293. ElementPlus.ElMessage.error(res.msg || '保存失败');
  294. }
  295. } finally {
  296. saving.value = false;
  297. }
  298. }
  299. // 显示重置密码弹窗
  300. function showResetModal(row) {
  301. resetUserId.value = row.id;
  302. newPassword.value = '';
  303. resetPwdVisible.value = true;
  304. }
  305. // 重置密码
  306. async function resetPassword() {
  307. if (!newPassword.value) {
  308. ElementPlus.ElMessage.warning('请输入新密码');
  309. return;
  310. }
  311. var pwdErr = validatePassword(newPassword.value);
  312. if (pwdErr) { ElementPlus.ElMessage.warning(pwdErr); return; }
  313. resetPwdSaving.value = true;
  314. try {
  315. const res = await fetch('/admin/system/user/resetPassword', {
  316. method: 'POST',
  317. headers: { 'Content-Type': 'application/json' },
  318. body: JSON.stringify({ id: resetUserId.value, password: newPassword.value })
  319. }).then(r => r.json());
  320. if (res.code === 0) {
  321. ElementPlus.ElMessage.success('密码重置成功');
  322. resetPwdVisible.value = false;
  323. } else {
  324. ElementPlus.ElMessage.error(res.msg || '重置失败');
  325. }
  326. } finally {
  327. resetPwdSaving.value = false;
  328. }
  329. }
  330. // 解锁用户
  331. async function unlockUser(row) {
  332. try {
  333. await ElementPlus.ElMessageBox.confirm('确定要解锁该用户吗?', '提示', { type: 'warning' });
  334. const res = await fetch('/admin/system/user/unlock', {
  335. method: 'POST',
  336. headers: { 'Content-Type': 'application/json' },
  337. body: JSON.stringify({ id: row.id })
  338. }).then(r => r.json());
  339. if (res.code === 0) {
  340. ElementPlus.ElMessage.success('解锁成功');
  341. loadList();
  342. } else {
  343. ElementPlus.ElMessage.error(res.msg || '解锁失败');
  344. }
  345. } catch {}
  346. }
  347. // 切换状态
  348. async function toggleStatus(row) {
  349. try {
  350. await ElementPlus.ElMessageBox.confirm('确定要切换该用户状态吗?', '提示', { type: 'warning' });
  351. const res = await fetch('/admin/system/user/toggleStatus', {
  352. method: 'POST',
  353. headers: { 'Content-Type': 'application/json' },
  354. body: JSON.stringify({ id: row.id })
  355. }).then(r => r.json());
  356. if (res.code === 0) {
  357. ElementPlus.ElMessage.success('操作成功');
  358. loadList();
  359. } else {
  360. ElementPlus.ElMessage.error(res.msg || '操作失败');
  361. }
  362. } catch {}
  363. }
  364. onMounted(() => loadList());
  365. return {
  366. keyword, roleFilter, statusFilter, loading, tableData, pagination, roles,
  367. dialogVisible, dialogTitle, saving, form,
  368. resetPwdVisible, resetPwdSaving, newPassword,
  369. pwdStrength, pwdStrengthColor, pwdStrengthText,
  370. resetPwdStrength, resetPwdStrengthColor, resetPwdStrengthText,
  371. loadList, resetFilter, showAddModal, editUser, saveUser,
  372. showResetModal, resetPassword, unlockUser, toggleStatus
  373. };
  374. }
  375. });
  376. app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
  377. app.mount('#userApp');
  378. </script>
  379. {% endblock %}