feat(system): 完成前端系统管理(布局框架、三个管理页面、e2e)
- BasicLayout:侧边栏/顶栏/面包屑/移动端抽屉;菜单从路由表派生; 路由守卫新增 requiresAdmin 层(菜单隐藏 + 直连 URL 弹回) - 用户/角色/日志三个页面:分页请求序号防竞态、确认动作流抽 useConfirmAction、 时间范围配 default-time、日志详情按需懒加载并缓存 - 空 catch 统一改 handledError 显式声明;词表/选项常量集中(与后端对齐), 「停用」文案全站统一为「禁用」 - 品牌色提升为全局 token,Element Plus 主色指向赭金 - e2e:公共工具抽 common.py;系统管理 13 项(含详情懒加载脱敏断言), 管理员密码改 E2E_ADMIN_PASSWORD 环境变量;login_flow 8 项回归通过 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
c83287819a
commit
8355e0cc7d
26 files changed
+2785
-180
No files matched your search
@@ -1,98 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
/**
|
||||
* 首页:登录链路验证页。
|
||||
*
|
||||
* 顶部栏(用户信息、退出登录)已由 BasicLayout 提供,本页只保留内容区;
|
||||
* 用户名等字段的展示是端到端脚本的断言对象,调整时需同步 e2e。
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
await userStore.logout()
|
||||
ElMessage.success('已退出登录')
|
||||
await router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="layout">
|
||||
<el-header class="layout-header">
|
||||
<span class="layout-title">衍水集团营销人员工资管理系统</span>
|
||||
<div class="layout-user">
|
||||
<el-tag v-for="role in userStore.roles" :key="role" size="small" type="info">
|
||||
{{ role }}
|
||||
</el-tag>
|
||||
<span class="layout-username">{{ userStore.displayName }}</span>
|
||||
<el-button link type="primary" @click="handleLogout">退出登录</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>登录状态验证通过</span>
|
||||
</template>
|
||||
|
||||
<el-main class="layout-main">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>登录状态验证通过</span>
|
||||
</template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="用户 ID">{{ userStore.userInfo?.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名">{{ userStore.userInfo?.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="姓名">
|
||||
{{ userStore.userInfo?.real_name || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="角色">
|
||||
{{ userStore.roles.join('、') || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="上次登录">
|
||||
{{ formatDateTime(userStore.userInfo?.last_login_time) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="用户 ID">{{ userStore.userInfo?.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名">{{ userStore.userInfo?.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="姓名">
|
||||
{{ userStore.userInfo?.real_name || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="角色">
|
||||
{{ userStore.roles.join('、') || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="上次登录">
|
||||
{{ userStore.userInfo?.last_login_time || '—' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-alert
|
||||
class="layout-hint"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="这是登录链路的验证页"
|
||||
description="刷新页面可验证会话恢复(守卫会调用 /me);点击右上角退出登录可验证令牌即时失效。业务模块页面将在后续阶段接入。"
|
||||
/>
|
||||
</el-card>
|
||||
</el-main>
|
||||
</el-container>
|
||||
<el-alert
|
||||
class="dashboard-hint"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="这是登录链路的验证页"
|
||||
description="刷新页面可验证会话恢复(守卫会调用 /me);「系统管理」菜单已接入用户管理、角色管理与日志查询。"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #fff;
|
||||
background-color: #1f4e79;
|
||||
}
|
||||
|
||||
.layout-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.layout-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.layout-username {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.layout-hint {
|
||||
.dashboard-hint {
|
||||
margin-top: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -182,19 +182,19 @@ onMounted(refreshCaptcha)
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 配色说明:金色分两档 —— 亮金只用于深色底、深金只用于浅色底,
|
||||
* 保证两处对比度都达标(深金配白字约 6.2:1)。
|
||||
* 配色:品牌 token 已提升为全局(见 assets/styles/index.css,布局框架与
|
||||
* 系统管理页面共用),此处只做局部别名,用法与取值均与原先一致。
|
||||
*/
|
||||
.login {
|
||||
--ink-900: #061a22;
|
||||
--ink-800: #0a2833;
|
||||
--ink-700: #0f3646;
|
||||
--ink-600: #1a4f63;
|
||||
--gold-bright: #c9a227;
|
||||
--gold-deep: #8f6520;
|
||||
--paper: #fafaf7;
|
||||
--text: #16262d;
|
||||
--muted: #6b7b84;
|
||||
--ink-900: var(--ys-ink-900);
|
||||
--ink-800: var(--ys-ink-800);
|
||||
--ink-700: var(--ys-ink-700);
|
||||
--ink-600: var(--ys-ink-600);
|
||||
--gold-bright: var(--ys-gold-bright);
|
||||
--gold-deep: var(--ys-gold-deep);
|
||||
--paper: var(--ys-paper);
|
||||
--text: var(--ys-text);
|
||||
--muted: var(--ys-muted);
|
||||
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import * as logApi from '@/api/logs'
|
||||
import type {
|
||||
LoginLogItem,
|
||||
LoginLogQueryParams,
|
||||
OperationLogDetail,
|
||||
OperationLogItem,
|
||||
OperationLogQueryParams,
|
||||
} from '@/api/logs'
|
||||
import StatusTag from '@/components/table/StatusTag.vue'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { ACTION_OPTIONS, MODULE_OPTIONS, RESULT_OPTIONS } from '@/utils/constants'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
/**
|
||||
* 日志查询(需求 2.5.1「支持日志查询」)。
|
||||
*
|
||||
* 操作日志与登录日志分开两个 tab(后端也是两个端点:字段与筛选项不同)。
|
||||
*
|
||||
* 时间筛选的两个关键点:
|
||||
* - 必须给 el-date-picker 指定 `value-format` —— 否则绑定值是 Date 对象,
|
||||
* axios 会序列化为 UTC ISO 串,随本地时区产生 8 小时偏移
|
||||
* - 必须配 `default-time` —— datetimerange 只选日期时时刻默认 00:00:00,
|
||||
* 跨天查询会漏掉结束日整天(起点补 00:00:00、终点补 23:59:59)
|
||||
*
|
||||
* 操作日志列表**不含请求参数**(大字段,随列表返回会让翻页负担随日志表
|
||||
* 增长);展开行时按 id 调详情端点懒加载并缓存。
|
||||
*/
|
||||
|
||||
/** datetimerange 的起止时刻补全(与 value-format 配合) */
|
||||
const RANGE_DEFAULT_TIME: [Date, Date] = [
|
||||
new Date(2000, 0, 1, 0, 0, 0),
|
||||
new Date(2000, 0, 1, 23, 59, 59),
|
||||
]
|
||||
|
||||
const activeTab = ref('operation')
|
||||
|
||||
// ==================== 操作日志 ====================
|
||||
|
||||
const opFilters = reactive({
|
||||
username: '',
|
||||
module: undefined as string | undefined,
|
||||
action: undefined as string | undefined,
|
||||
success: undefined as number | undefined,
|
||||
})
|
||||
const opTimeRange = ref<[string, string] | null>(null)
|
||||
|
||||
const {
|
||||
records: opRecords,
|
||||
total: opTotal,
|
||||
pageNum: opPageNum,
|
||||
pageSize: opPageSize,
|
||||
loading: opLoading,
|
||||
load: loadOperationLogs,
|
||||
search: searchOperationLogs,
|
||||
onPageChange: onOpPageChange,
|
||||
onSizeChange: onOpSizeChange,
|
||||
} = usePagedList<OperationLogItem, OperationLogQueryParams>(logApi.listOperationLogs, () => ({
|
||||
username: opFilters.username,
|
||||
module: opFilters.module,
|
||||
action: opFilters.action,
|
||||
success: opFilters.success,
|
||||
start_time: opTimeRange.value?.[0],
|
||||
end_time: opTimeRange.value?.[1],
|
||||
}))
|
||||
|
||||
function resetOpFilters(): void {
|
||||
opFilters.username = ''
|
||||
opFilters.module = undefined
|
||||
opFilters.action = undefined
|
||||
opFilters.success = undefined
|
||||
opTimeRange.value = null
|
||||
searchOperationLogs()
|
||||
}
|
||||
|
||||
/** 详情缓存与加载中集合(列表无 request_data,展开时按需拉取) */
|
||||
const detailMap = reactive(new Map<number, OperationLogDetail>())
|
||||
const detailLoading = reactive(new Set<number>())
|
||||
|
||||
async function onExpandChange(row: OperationLogItem, expandedRows: OperationLogItem[]): Promise<void> {
|
||||
const isExpanded = expandedRows.some((item) => item.id === row.id)
|
||||
if (!isExpanded || detailMap.has(row.id)) return
|
||||
detailLoading.add(row.id)
|
||||
try {
|
||||
detailMap.set(row.id, await logApi.getOperationLog(row.id))
|
||||
} catch (error) {
|
||||
handledError(error) // 拦截器已提示,展开处显示占位
|
||||
} finally {
|
||||
detailLoading.delete(row.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** 展开行展示的请求参数:加载中 / 暂无 / 格式化后的 JSON(已脱敏) */
|
||||
function detailText(logId: number): string {
|
||||
if (detailLoading.has(logId)) return '加载中…'
|
||||
const detail = detailMap.get(logId)
|
||||
if (!detail) return '—'
|
||||
return formatRequestData(detail.request_data)
|
||||
}
|
||||
|
||||
function formatRequestData(raw: string | null): string {
|
||||
if (!raw) return '—'
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 登录日志 ====================
|
||||
|
||||
const loginFilters = reactive({
|
||||
username: '',
|
||||
ip: '',
|
||||
success: undefined as number | undefined,
|
||||
})
|
||||
const loginTimeRange = ref<[string, string] | null>(null)
|
||||
|
||||
const {
|
||||
records: loginRecords,
|
||||
total: loginTotal,
|
||||
pageNum: loginPageNum,
|
||||
pageSize: loginPageSize,
|
||||
loading: loginLoading,
|
||||
load: loadLoginLogs,
|
||||
search: searchLoginLogs,
|
||||
onPageChange: onLoginPageChange,
|
||||
onSizeChange: onLoginSizeChange,
|
||||
} = usePagedList<LoginLogItem, LoginLogQueryParams>(logApi.listLoginLogs, () => ({
|
||||
username: loginFilters.username,
|
||||
ip: loginFilters.ip,
|
||||
success: loginFilters.success,
|
||||
start_time: loginTimeRange.value?.[0],
|
||||
end_time: loginTimeRange.value?.[1],
|
||||
}))
|
||||
|
||||
function resetLoginFilters(): void {
|
||||
loginFilters.username = ''
|
||||
loginFilters.ip = ''
|
||||
loginFilters.success = undefined
|
||||
loginTimeRange.value = null
|
||||
searchLoginLogs()
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
onMounted(loadOperationLogs)
|
||||
|
||||
// 登录日志 tab 每次激活都刷新(首屏不打多余请求;此前"只加载一次"的
|
||||
// 标志在首次失败后不会重置,切回时停在空表且无从自查)
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'login') {
|
||||
void loadLoginLogs()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- 操作日志 -->
|
||||
<el-tab-pane label="操作日志" name="operation" lazy>
|
||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
||||
<el-form-item label="操作人">
|
||||
<el-input
|
||||
v-model="opFilters.username"
|
||||
placeholder="模糊匹配"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@keyup.enter="searchOperationLogs"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="模块">
|
||||
<el-select v-model="opFilters.module" placeholder="全部" clearable style="width: 130px">
|
||||
<el-option v-for="item in MODULE_OPTIONS" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="操作">
|
||||
<el-select v-model="opFilters.action" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option v-for="item in ACTION_OPTIONS" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="结果">
|
||||
<el-select v-model="opFilters.success" placeholder="全部" clearable style="width: 110px">
|
||||
<el-option
|
||||
v-for="item in RESULT_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="opTimeRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:default-time="RANGE_DEFAULT_TIME"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="searchOperationLogs">查询</el-button>
|
||||
<el-button @click="resetOpFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table
|
||||
v-loading="opLoading"
|
||||
:data="opRecords"
|
||||
stripe
|
||||
@expand-change="onExpandChange"
|
||||
>
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<div class="log-detail">
|
||||
<p>
|
||||
<span class="log-detail__label">请求:</span>
|
||||
{{ row.method || '—' }} {{ row.path || '—' }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="log-detail__label">User-Agent:</span>
|
||||
{{ row.user_agent || '—' }}
|
||||
</p>
|
||||
<p class="log-detail__label">请求参数(已脱敏):</p>
|
||||
<pre class="log-detail__data">{{ detailText(row.id) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" min-width="165">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="操作人" min-width="110" />
|
||||
<el-table-column prop="module" label="模块" width="100" />
|
||||
<el-table-column prop="action" label="操作" width="90" />
|
||||
<el-table-column prop="summary" label="摘要" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="结果" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
:content="row.error_message ?? ''"
|
||||
:disabled="row.success !== 0 || !row.error_message"
|
||||
placement="top"
|
||||
>
|
||||
<StatusTag
|
||||
:value="row.success"
|
||||
active-text="成功"
|
||||
inactive-text="失败"
|
||||
inactive-type="danger"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="耗时(ms)" width="90" align="right">
|
||||
<template #default="{ row }">{{ row.duration_ms ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip" label="IP" min-width="120">
|
||||
<template #default="{ row }">{{ row.ip || '—' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
:total="opTotal"
|
||||
:page-num="opPageNum"
|
||||
:page-size="opPageSize"
|
||||
@page-change="onOpPageChange"
|
||||
@size-change="onOpSizeChange"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 登录日志 -->
|
||||
<el-tab-pane label="登录日志" name="login" lazy>
|
||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
v-model="loginFilters.username"
|
||||
placeholder="模糊匹配"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
@keyup.enter="searchLoginLogs"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="IP">
|
||||
<el-input
|
||||
v-model="loginFilters.ip"
|
||||
placeholder="精确匹配"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@keyup.enter="searchLoginLogs"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="结果">
|
||||
<el-select v-model="loginFilters.success" placeholder="全部" clearable style="width: 110px">
|
||||
<el-option
|
||||
v-for="item in RESULT_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="loginTimeRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:default-time="RANGE_DEFAULT_TIME"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="searchLoginLogs">查询</el-button>
|
||||
<el-button @click="resetLoginFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loginLoading" :data="loginRecords" stripe>
|
||||
<el-table-column label="时间" min-width="165">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="用户名" min-width="130" />
|
||||
<el-table-column label="结果" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<StatusTag
|
||||
:value="row.success"
|
||||
active-text="成功"
|
||||
inactive-text="失败"
|
||||
inactive-type="danger"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="fail_reason" label="失败原因" min-width="150">
|
||||
<template #default="{ row }">{{ row.fail_reason || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip" label="IP" min-width="120">
|
||||
<template #default="{ row }">{{ row.ip || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="User-Agent" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="ua-cell" :title="row.user_agent || ''">{{ row.user_agent || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
:total="loginTotal"
|
||||
:page-num="loginPageNum"
|
||||
:page-size="loginPageSize"
|
||||
@page-change="onLoginPageChange"
|
||||
@size-change="onLoginSizeChange"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.log-detail {
|
||||
padding: 4px 16px;
|
||||
color: var(--ys-text);
|
||||
}
|
||||
|
||||
.log-detail__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-detail__data {
|
||||
padding: 8px 12px;
|
||||
margin: 6px 0 12px;
|
||||
overflow-x: auto;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
background-color: var(--ys-paper);
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ua-cell {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,305 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
import * as roleApi from '@/api/roles'
|
||||
import type { RoleItem, RoleQueryParams } from '@/api/roles'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import StatusTag from '@/components/table/StatusTag.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { ROLE_ADMIN, STATUS_OPTIONS } from '@/utils/constants'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
* 角色管理(轻量版:不含权限点分配,权限清单待业务方给出后启用)。
|
||||
*
|
||||
* 与后端的保护规则对齐(后端是唯一防线,此处禁用按钮只是体验):
|
||||
* - 内置 admin 角色不可删除、不可禁用
|
||||
* - 仍有绑定用户的角色不可删除(`user_roles` 外键是 CASCADE,
|
||||
* 直接删会静默剥离这些用户的权限)
|
||||
* - 编码创建后不可修改(授权判定的锚点)
|
||||
* - 禁用角色不再出现在用户表单下拉中,且**不可再分配给用户**;
|
||||
* 已绑定用户的既有授权不受影响
|
||||
*/
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
usePagedList<RoleItem, RoleQueryParams>(roleApi.listRoles, () => ({
|
||||
keyword: filters.keyword,
|
||||
status: filters.status,
|
||||
}))
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.keyword = ''
|
||||
filters.status = undefined
|
||||
search()
|
||||
}
|
||||
|
||||
/** 内置管理员角色 */
|
||||
function isBuiltin(row: RoleItem): boolean {
|
||||
return row.role_code === ROLE_ADMIN
|
||||
}
|
||||
|
||||
/** 删除保护的悬停说明(禁用按钮自身不触发悬浮事件,由外层 span 提供 title) */
|
||||
function deleteHint(row: RoleItem): string {
|
||||
if (isBuiltin(row)) return '内置管理员角色不可删除'
|
||||
if (row.user_count > 0) return `仍有 ${row.user_count} 个用户绑定该角色,请先解除绑定`
|
||||
return ''
|
||||
}
|
||||
|
||||
// ==================== 新增 / 编辑 ====================
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
const isEdit = computed(() => editingId.value !== null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
role_code: '',
|
||||
role_name: '',
|
||||
description: '',
|
||||
sort_order: 0,
|
||||
})
|
||||
|
||||
const rules = computed<FormRules>(() => ({
|
||||
role_code: [
|
||||
{ required: true, message: '请输入角色编码', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^[a-z][a-z0-9_]{1,31}$/,
|
||||
message: '以小写字母开头,仅可含小写字母、数字与下划线(2–32 位)',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
role_name: [
|
||||
{ required: true, message: '请输入角色名称', trigger: 'blur' },
|
||||
{ max: 64, message: '最长 64 个字符', trigger: 'blur' },
|
||||
],
|
||||
description: [{ max: 255, message: '最长 255 个字符', trigger: 'blur' }],
|
||||
}))
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null
|
||||
Object.assign(form, { role_code: '', role_name: '', description: '', sort_order: 0 })
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function openEdit(row: RoleItem): void {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
role_code: row.role_code,
|
||||
role_name: row.role_name,
|
||||
description: row.description ?? '',
|
||||
sort_order: row.sort_order,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editingId.value !== null) {
|
||||
await roleApi.updateRole(editingId.value, {
|
||||
role_name: form.role_name,
|
||||
description: form.description,
|
||||
// el-input-number 清空后为 undefined,被 JSON 省略时后端会取默认 0,
|
||||
// 排序被静默重置 —— 提交前兜底为 0 并明确表达「未填即 0」的意图
|
||||
sort_order: form.sort_order ?? 0,
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
await roleApi.createRole({
|
||||
role_code: form.role_code,
|
||||
role_name: form.role_name,
|
||||
description: form.description || undefined,
|
||||
sort_order: form.sort_order ?? 0,
|
||||
})
|
||||
ElMessage.success('角色已创建')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} catch (error) {
|
||||
handledError(error) // 失败提示由请求拦截器统一完成
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 启停 / 删除 ====================
|
||||
|
||||
async function handleToggleStatus(row: RoleItem): Promise<void> {
|
||||
const next = row.status === 1 ? 0 : 1
|
||||
const action = next === 1 ? '启用' : '禁用'
|
||||
await confirmAndRun({
|
||||
confirmText:
|
||||
next === 0
|
||||
? `确定禁用角色「${row.role_name}」?禁用后该角色不再出现在用户表单的下拉中且不可再分配,但已绑定用户的权限不受影响。`
|
||||
: `确定启用角色「${row.role_name}」?`,
|
||||
title: `${action}确认`,
|
||||
confirmButtonText: action,
|
||||
successText: `已${action}`,
|
||||
run: () => roleApi.setRoleStatus(row.id, next),
|
||||
after: load,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(row: RoleItem): Promise<void> {
|
||||
await confirmAndRun({
|
||||
confirmText: `确定删除角色「${row.role_name}」(${row.role_code})?删除后不可恢复。`,
|
||||
title: '删除确认',
|
||||
confirmButtonText: '删除',
|
||||
successText: '角色已删除',
|
||||
run: () => roleApi.deleteRole(row.id),
|
||||
after: reloadAfterDelete,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="编码或名称,模糊匹配"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
|
||||
<el-option
|
||||
v-for="item in STATUS_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增角色</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="records" stripe>
|
||||
<el-table-column label="编码" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="role-code">{{ row.role_code }}</span>
|
||||
<el-tag v-if="isBuiltin(row)" class="builtin-tag" size="small" type="warning" disable-transitions>
|
||||
内置
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role_name" label="名称" min-width="120" />
|
||||
<el-table-column prop="description" label="说明" min-width="180">
|
||||
<template #default="{ row }">{{ row.description || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<StatusTag :value="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户数" width="90" align="center">
|
||||
<template #default="{ row }">{{ row.user_count }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<span :title="isBuiltin(row) ? '内置管理员角色不可禁用' : ''">
|
||||
<el-button
|
||||
link
|
||||
type="warning"
|
||||
size="small"
|
||||
:disabled="isBuiltin(row)"
|
||||
@click="handleToggleStatus(row)"
|
||||
>
|
||||
{{ row.status === 1 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
</span>
|
||||
<span :title="deleteHint(row)">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isBuiltin(row) || row.user_count > 0"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑角色' : '新增角色'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="角色编码" prop="role_code">
|
||||
<el-input
|
||||
v-model="form.role_code"
|
||||
:disabled="isEdit"
|
||||
placeholder="如 sales_manager,创建后不可修改"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色名称" prop="role_name">
|
||||
<el-input v-model="form.role_name" placeholder="如 销售主管" />
|
||||
</el-form-item>
|
||||
<el-form-item label="说明" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort_order">
|
||||
<el-input-number v-model="form.sort_order" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.role-code {
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.builtin-tag {
|
||||
margin-left: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,467 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
import type { RoleOption } from '@/api/types'
|
||||
import * as userApi from '@/api/users'
|
||||
import type { UserItem, UserQueryParams } from '@/api/users'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import StatusTag from '@/components/table/StatusTag.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { STATUS_OPTIONS } from '@/utils/constants'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { passwordRule } from '@/utils/validate'
|
||||
|
||||
/**
|
||||
* 用户管理(需求 2.5.1)。
|
||||
*
|
||||
* 与后端约定对齐:
|
||||
* - 不能删除/禁用自己、不能移除自己的管理员角色 —— 前端对当前登录行
|
||||
* 禁用相应按钮(后端仍会拦截,界面禁用只是体验)
|
||||
* - 密码按字节校验(8–72),中文一个字符占 3 字节
|
||||
* - 重置密码会使用户已登录的会话立即失效
|
||||
* - 编辑表单把未修改字段以空串提交,后端将 '' 归一为 NULL(清空即写回
|
||||
* NULL),前端无需特殊处理
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
|
||||
// ==================== 列表 ====================
|
||||
|
||||
const filters = reactive({
|
||||
username: '',
|
||||
real_name: '',
|
||||
status: undefined as number | undefined,
|
||||
role_code: undefined as string | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
usePagedList<UserItem, UserQueryParams>(userApi.listUsers, () => ({
|
||||
username: filters.username,
|
||||
real_name: filters.real_name,
|
||||
status: filters.status,
|
||||
role_code: filters.role_code,
|
||||
}))
|
||||
|
||||
const roleOptions = ref<RoleOption[]>([])
|
||||
|
||||
async function loadRoleOptions(): Promise<void> {
|
||||
try {
|
||||
roleOptions.value = await userApi.listRoleOptions()
|
||||
} catch (error) {
|
||||
handledError(error) // 拦截器已提示;下拉为空不阻断列表使用
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.username = ''
|
||||
filters.real_name = ''
|
||||
filters.status = undefined
|
||||
filters.role_code = undefined
|
||||
search()
|
||||
}
|
||||
|
||||
/** 是否当前登录用户本人(其危险操作按钮需禁用) */
|
||||
function isSelf(row: UserItem): boolean {
|
||||
return row.id === userStore.userInfo?.id
|
||||
}
|
||||
|
||||
/** 自我保护提示:按钮外层 span 的 title(disabled 按钮自身不触发悬浮事件) */
|
||||
function selfHint(row: UserItem): string {
|
||||
return isSelf(row) ? '不能对自己执行此操作' : ''
|
||||
}
|
||||
|
||||
// ==================== 新增 / 编辑 ====================
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
const isEdit = computed(() => editingId.value !== null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
real_name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
roles: [] as string[],
|
||||
remark: '',
|
||||
})
|
||||
|
||||
/** 编辑时不校验密码(密码走专门的重置入口) */
|
||||
const rules = computed<FormRules>(() => ({
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 2, max: 64, message: '用户名长度 2–64 个字符', trigger: 'blur' },
|
||||
],
|
||||
...(isEdit.value ? {} : { password: [{ required: true, validator: passwordRule, trigger: 'blur' }] }),
|
||||
roles: [
|
||||
{ required: true, type: 'array', min: 1, message: '至少选择一个角色', trigger: 'change' },
|
||||
],
|
||||
phone: [{ max: 20, message: '手机号最长 20 个字符', trigger: 'blur' }],
|
||||
email: [{ max: 128, message: '邮箱最长 128 个字符', trigger: 'blur' }],
|
||||
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
|
||||
}))
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null
|
||||
Object.assign(form, {
|
||||
username: '',
|
||||
password: '',
|
||||
real_name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
roles: [],
|
||||
remark: '',
|
||||
})
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function openEdit(row: UserItem): void {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
username: row.username,
|
||||
password: '',
|
||||
real_name: row.real_name ?? '',
|
||||
phone: row.phone ?? '',
|
||||
email: row.email ?? '',
|
||||
roles: [...row.roles],
|
||||
remark: row.remark ?? '',
|
||||
})
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editingId.value !== null) {
|
||||
await userApi.updateUser(editingId.value, {
|
||||
real_name: form.real_name,
|
||||
phone: form.phone,
|
||||
email: form.email,
|
||||
roles: form.roles,
|
||||
remark: form.remark,
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
await userApi.createUser({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
real_name: form.real_name || undefined,
|
||||
phone: form.phone || undefined,
|
||||
email: form.email || undefined,
|
||||
roles: form.roles,
|
||||
remark: form.remark || undefined,
|
||||
})
|
||||
ElMessage.success('用户已创建')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} catch (error) {
|
||||
handledError(error) // 失败提示由请求拦截器统一完成
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 重置密码 ====================
|
||||
|
||||
const resetVisible = ref(false)
|
||||
const resetFormRef = ref<FormInstance>()
|
||||
const resetSubmitting = ref(false)
|
||||
const resetTarget = reactive({ id: 0, username: '', isSelf: false })
|
||||
const resetForm = reactive({ newPassword: '' })
|
||||
|
||||
const resetRules: FormRules = {
|
||||
newPassword: [{ required: true, validator: passwordRule, trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openReset(row: UserItem): void {
|
||||
resetTarget.id = row.id
|
||||
resetTarget.username = row.username
|
||||
resetTarget.isSelf = isSelf(row)
|
||||
resetForm.newPassword = ''
|
||||
resetVisible.value = true
|
||||
resetFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function handleResetSubmit(): Promise<void> {
|
||||
const valid = await resetFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
resetSubmitting.value = true
|
||||
try {
|
||||
await userApi.resetPassword(resetTarget.id, resetForm.newPassword)
|
||||
ElMessage.success('密码已重置,该用户需重新登录')
|
||||
resetVisible.value = false
|
||||
} catch (error) {
|
||||
handledError(error) // 拦截器已提示
|
||||
} finally {
|
||||
resetSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 启停 / 删除 ====================
|
||||
|
||||
async function handleToggleStatus(row: UserItem): Promise<void> {
|
||||
const next = row.status === 1 ? 0 : 1
|
||||
const action = next === 1 ? '启用' : '禁用'
|
||||
await confirmAndRun({
|
||||
confirmText:
|
||||
next === 0
|
||||
? `确定禁用用户「${row.username}」?其已登录的会话将立即失效。`
|
||||
: `确定启用用户「${row.username}」?`,
|
||||
title: `${action}确认`,
|
||||
confirmButtonText: action,
|
||||
successText: `已${action}`,
|
||||
run: () => userApi.setUserStatus(row.id, next),
|
||||
after: load,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(row: UserItem): Promise<void> {
|
||||
await confirmAndRun({
|
||||
confirmText: `确定删除用户「${row.username}」?删除为物理删除、不可恢复;如仅为停用账号,建议使用「禁用」以保留审计线索。`,
|
||||
title: '删除确认',
|
||||
confirmButtonText: '删除',
|
||||
successText: '用户已删除',
|
||||
run: () => userApi.deleteUser(row.id),
|
||||
after: reloadAfterDelete,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
onMounted(() => {
|
||||
// 两个请求各自容错(load 内部已兜底),互不拖累
|
||||
void load()
|
||||
void loadRoleOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
v-model="filters.username"
|
||||
placeholder="模糊匹配"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名">
|
||||
<el-input
|
||||
v-model="filters.real_name"
|
||||
placeholder="模糊匹配"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
|
||||
<el-option
|
||||
v-for="item in STATUS_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="filters.role_code" placeholder="全部" clearable style="width: 150px">
|
||||
<el-option
|
||||
v-for="role in roleOptions"
|
||||
:key="role.role_code"
|
||||
:label="role.role_name"
|
||||
:value="role.role_code"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增用户</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="records" stripe>
|
||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column prop="real_name" label="姓名" min-width="100">
|
||||
<template #default="{ row }">{{ row.real_name || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phone" label="手机号" min-width="120">
|
||||
<template #default="{ row }">{{ row.phone || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-for="role in row.roles"
|
||||
:key="role"
|
||||
class="role-tag"
|
||||
size="small"
|
||||
type="info"
|
||||
disable-transitions
|
||||
>
|
||||
{{ role }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<StatusTag :value="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后登录" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.last_login_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="250" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openReset(row)">
|
||||
重置密码
|
||||
</el-button>
|
||||
<span :title="selfHint(row)">
|
||||
<el-button
|
||||
link
|
||||
type="warning"
|
||||
size="small"
|
||||
:disabled="isSelf(row)"
|
||||
@click="handleToggleStatus(row)"
|
||||
>
|
||||
{{ row.status === 1 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
</span>
|
||||
<span :title="selfHint(row)">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isSelf(row)"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增 / 编辑 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑用户' : '新增用户'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
:disabled="isEdit"
|
||||
placeholder="登录用户名,创建后不可修改"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isEdit" label="初始密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="8–72 字节(中文一个字符约 3 字节)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名" prop="real_name">
|
||||
<el-input v-model="form.real_name" placeholder="真实姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" prop="roles">
|
||||
<el-select v-model="form.roles" multiple placeholder="至少选择一个角色" style="width: 100%">
|
||||
<el-option
|
||||
v-for="role in roleOptions"
|
||||
:key="role.role_code"
|
||||
:label="`${role.role_name}(${role.role_code})`"
|
||||
:value="role.role_code"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重置密码 -->
|
||||
<el-dialog v-model="resetVisible" title="重置密码" width="440px" destroy-on-close>
|
||||
<p class="reset-tip">
|
||||
将重置用户「{{ resetTarget.username }}」的密码。
|
||||
重置后该用户<strong>已登录的所有会话立即失效</strong>,需用新密码重新登录。
|
||||
<template v-if="resetTarget.isSelf">
|
||||
<br />
|
||||
注意:<strong>你正在重置自己的密码</strong>,保存后当前会话也会失效。
|
||||
</template>
|
||||
</p>
|
||||
<el-form ref="resetFormRef" :model="resetForm" :rules="resetRules" label-width="80px">
|
||||
<el-form-item label="新密码" prop="newPassword">
|
||||
<el-input
|
||||
v-model="resetForm.newPassword"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="8–72 字节(中文一个字符约 3 字节)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="resetVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="resetSubmitting" @click="handleResetSubmit">
|
||||
确认重置
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.role-tag {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.reset-tip {
|
||||
margin: 0 0 16px;
|
||||
color: var(--ys-text);
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user