refactor(frontend): 重命名前端项目为 gym-manage-web

This commit is contained in:
张翔
2026-04-17 18:37:45 +08:00
parent deb961c427
commit 45bb89fc7f
140 changed files with 2 additions and 2 deletions
@@ -0,0 +1,235 @@
<template>
<div class="exception-log">
<el-card>
<template #header>
<div class="card-header">
<div class="search-section">
<el-input
v-model="searchKeyword"
placeholder="搜索操作人或异常信息"
clearable
style="width: 300px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button
type="primary"
@click="handleSearch"
>
搜索
</el-button>
</div>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column
prop="id"
label="ID"
sortable="custom"
width="80"
/>
<el-table-column
prop="username"
label="操作人"
sortable="custom"
width="120"
/>
<el-table-column
prop="operation"
label="操作模块"
sortable="custom"
width="150"
/>
<el-table-column
prop="method"
label="请求方法"
sortable="custom"
width="200"
:show-overflow-tooltip="true"
/>
<el-table-column
prop="errorMsg"
label="异常信息"
:show-overflow-tooltip="true"
width="250"
/>
<el-table-column
prop="ip"
label="IP地址"
sortable="custom"
width="120"
/>
<el-table-column
prop="createTime"
label="异常时间"
sortable="custom"
width="180"
/>
<el-table-column
label="操作"
width="120"
fixed="right"
>
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="handleViewDetail(row)"
>
查看详情
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 16px; justify-content: flex-end"
@current-change="handleTableChange"
@size-change="handleSizeChange"
/>
</el-card>
<el-dialog
v-model="detailVisible"
title="异常详情"
width="800px"
>
<el-descriptions
:column="1"
border
>
<el-descriptions-item label="ID">
{{ currentDetail.id }}
</el-descriptions-item>
<el-descriptions-item label="操作人">
{{ currentDetail.username }}
</el-descriptions-item>
<el-descriptions-item label="操作模块">
{{ currentDetail.operation }}
</el-descriptions-item>
<el-descriptions-item label="请求方法">
{{ currentDetail.method }}
</el-descriptions-item>
<el-descriptions-item label="请求参数">
<pre style="max-height: 200px; overflow: auto;">{{ currentDetail.params }}</pre>
</el-descriptions-item>
<el-descriptions-item label="异常信息">
<div style="color: #f56c6c; word-break: break-all;">
{{ currentDetail.errorMsg }}
</div>
</el-descriptions-item>
<el-descriptions-item label="异常堆栈">
<pre style="max-height: 300px; overflow: auto; font-size: 12px;">{{ currentDetail.exceptionStack }}</pre>
</el-descriptions-item>
<el-descriptions-item label="IP地址">
{{ currentDetail.ip }}
</el-descriptions-item>
<el-descriptions-item label="异常时间">
{{ currentDetail.createTime }}
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="detailVisible = false">
关闭
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { exceptionLogApi, ExceptionLog } from '@/api/exceptionLog'
const loading = ref(false)
const dataSource = ref<ExceptionLog[]>([])
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0
})
const sortInfo = reactive({
sort: 'id',
order: 'asc'
})
const detailVisible = ref(false)
const currentDetail = ref<ExceptionLog>({})
const fetchData = async () => {
loading.value = true
try {
const res = await exceptionLogApi.getPage({
page: pagination.current - 1,
size: pagination.pageSize,
sort: sortInfo.sort,
order: sortInfo.order,
keyword: searchKeyword.value || undefined
})
dataSource.value = res.content
pagination.total = res.totalElements
} finally {
loading.value = false
}
}
const handleTableChange = () => {
fetchData()
}
const handleSizeChange = () => {
pagination.current = 1
fetchData()
}
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleSortChange = ({ prop, order }: any) => {
sortInfo.sort = prop
sortInfo.order = order === 'ascending' ? 'asc' : 'desc'
fetchData()
}
const handleViewDetail = (row: ExceptionLog) => {
currentDetail.value = { ...row }
detailVisible.value = true
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.exception-log {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.search-section {
display: flex;
gap: 8px;
align-items: center;
}
}
}
</style>
+176
View File
@@ -0,0 +1,176 @@
<template>
<div class="login-log">
<el-card>
<template #header>
<div class="card-header">
<div class="search-section">
<el-input
v-model="searchKeyword"
placeholder="搜索用户名或IP地址"
clearable
style="width: 300px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button
type="primary"
@click="handleSearch"
>
搜索
</el-button>
</div>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column
prop="id"
label="ID"
sortable="custom"
/>
<el-table-column
prop="username"
label="用户名"
sortable="custom"
/>
<el-table-column
prop="ip"
label="IP地址"
sortable="custom"
/>
<el-table-column
prop="location"
label="登录地点"
sortable="custom"
/>
<el-table-column
prop="browser"
label="浏览器"
sortable="custom"
/>
<el-table-column
prop="os"
label="操作系统"
sortable="custom"
/>
<el-table-column label="状态">
<template #default="{ row }">
<el-tag
:type="row.status === '0' ? 'success' : 'danger'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.status === '0' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="loginTime"
label="登录时间"
sortable="custom"
>
<template #default="{ row }">
{{ formatDateTime(row.loginTime) }}
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 16px; justify-content: flex-end"
@current-change="handleTableChange"
@size-change="handleSizeChange"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Search } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const dataSource = ref([])
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0
})
const sortInfo = reactive({
sort: 'id',
order: 'asc'
})
const fetchData = async () => {
loading.value = true
try {
const res: any = await request.get('/logs/login/page', {
params: {
page: pagination.current - 1,
size: pagination.pageSize,
sort: sortInfo.sort,
order: sortInfo.order,
keyword: searchKeyword.value || undefined
}
})
dataSource.value = res.content
pagination.total = res.totalElements
} finally {
loading.value = false
}
}
const handleTableChange = () => {
fetchData()
}
const handleSizeChange = () => {
pagination.current = 1
fetchData()
}
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleSortChange = ({ prop, order }: any) => {
sortInfo.sort = prop
sortInfo.order = order === 'ascending' ? 'asc' : 'desc'
fetchData()
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.login-log {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.search-section {
display: flex;
gap: 8px;
align-items: center;
}
}
}
</style>
@@ -0,0 +1,311 @@
<template>
<div class="operation-log">
<el-card>
<template #header>
<div class="card-header">
<div class="search-section">
<el-input
v-model="searchKeyword"
placeholder="搜索操作人或操作模块"
clearable
style="width: 300px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button
type="primary"
@click="handleSearch"
>
搜索
</el-button>
<el-button
type="success"
@click="handleExport"
>
<el-icon><Download /></el-icon>
导出
</el-button>
</div>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column
prop="id"
label="ID"
sortable="custom"
/>
<el-table-column
prop="username"
label="操作人"
sortable="custom"
/>
<el-table-column
prop="operation"
label="操作模块"
sortable="custom"
>
<template #default="{ row }">
<div class="operation-cell">
<el-icon class="operation-icon">
<component :is="getOperationIcon(row.operation)" />
</el-icon>
<span>{{ row.operation }}</span>
</div>
</template>
</el-table-column>
<el-table-column
prop="method"
label="请求方法"
sortable="custom"
/>
<el-table-column
prop="params"
label="请求参数"
:show-overflow-tooltip="true"
>
<template #default="{ row }">
<div
v-if="row.params"
class="params-content"
>
<el-popover
placement="top"
:width="500"
trigger="hover"
>
<template #reference>
<div class="params-preview">
{{ formatParams(row.params) }}
</div>
</template>
<pre class="params-detail">{{ formatParams(row.params) }}</pre>
</el-popover>
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态">
<template #default="{ row }">
<el-tag
:type="row.status === '0' ? 'success' : 'danger'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.status === '0' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="duration"
label="耗时(ms)"
sortable="custom"
/>
<el-table-column
prop="createdAt"
label="操作时间"
sortable="custom"
>
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 16px; justify-content: flex-end"
@current-change="handleTableChange"
@size-change="handleSizeChange"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Search, User, Document, Setting, Lock, View, Edit, Delete, Plus, Download } from '@element-plus/icons-vue'
import { operationLogApi, OperationLog } from '@/api/operationLog'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const dataSource = ref<OperationLog[]>([])
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0
})
const sortInfo = reactive({
sort: 'id',
order: 'asc'
})
const fetchData = async () => {
loading.value = true
try {
const res = await operationLogApi.getPage({
page: pagination.current - 1,
size: pagination.pageSize,
sort: sortInfo.sort,
order: sortInfo.order,
keyword: searchKeyword.value || undefined
})
dataSource.value = res.content
pagination.total = res.totalElements
} finally {
loading.value = false
}
}
const handleTableChange = () => {
fetchData()
}
const handleSizeChange = () => {
pagination.current = 1
fetchData()
}
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleExport = async () => {
try {
loading.value = true
const params = new URLSearchParams()
if (searchKeyword.value) {
params.append('keyword', searchKeyword.value)
}
const response = await fetch(`/api/logs/operation/export?${params.toString()}`, {
method: 'GET',
headers: {
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}
})
if (!response.ok) {
throw new Error('导出失败')
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `operation_logs_${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.xlsx`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (error) {
console.error('导出失败:', error)
} finally {
loading.value = false
}
}
const handleSortChange = ({ prop, order }: any) => {
sortInfo.sort = prop
sortInfo.order = order === 'ascending' ? 'asc' : 'desc'
fetchData()
}
const formatParams = (params: string) => {
if (!params) return ''
try {
const parsed = JSON.parse(params)
return JSON.stringify(parsed, null, 2)
} catch {
return params
}
}
const getOperationIcon = (operation: string) => {
if (!operation) return Document
const op = operation.toLowerCase()
if (op.includes('登录') || op.includes('login')) return User
if (op.includes('删除') || op.includes('delete')) return Delete
if (op.includes('编辑') || op.includes('修改') || op.includes('update') || op.includes('edit')) return Edit
if (op.includes('查看') || op.includes('查询') || op.includes('view') || op.includes('get')) return View
if (op.includes('新增') || op.includes('创建') || op.includes('add') || op.includes('create')) return Plus
if (op.includes('下载') || op.includes('download')) return Download
if (op.includes('设置') || op.includes('配置') || op.includes('setting') || op.includes('config')) return Setting
if (op.includes('密码') || op.includes('password')) return Lock
return Document
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.operation-log {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.search-section {
display: flex;
gap: 8px;
align-items: center;
}
}
.operation-cell {
display: flex;
align-items: center;
gap: 8px;
.operation-icon {
font-size: 16px;
color: #409eff;
}
}
.params-content {
.params-preview {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #606266;
font-size: 13px;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #409eff;
}
}
.params-detail {
max-height: 300px;
overflow-y: auto;
background: #f5f7fa;
padding: 12px;
border-radius: 4px;
font-size: 12px;
color: #303133;
margin: 0;
line-height: 1.5;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
}
}
</style>
@@ -0,0 +1,179 @@
<template>
<div class="config-management">
<el-card>
<template #header>
<div class="card-title">
<span>参数配置</span>
<el-button
type="primary"
@click="handleAdd"
>
新增配置
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
>
<el-table-column
prop="id"
label="ID"
/>
<el-table-column
prop="configName"
label="参数名称"
/>
<el-table-column
prop="configKey"
label="参数键名"
/>
<el-table-column
prop="configValue"
label="参数值"
/>
<el-table-column label="类型">
<template #default="{ row }">
<el-tag
:type="row.configType === 'Y' ? 'info' : 'success'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.configType === 'Y' ? '内置' : '自定义' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="操作"
width="150"
>
<template #default="{ row }">
<el-button
type="primary"
link
size="small"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
>
<el-form
:model="formState"
label-width="80px"
>
<el-form-item label="参数名称">
<el-input v-model="formState.configName" />
</el-form-item>
<el-form-item label="参数键名">
<el-input v-model="formState.configKey" />
</el-form-item>
<el-form-item label="参数值">
<el-input v-model="formState.configValue" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modalVisible = false">
取消
</el-button>
<el-button
type="primary"
@click="handleModalOk"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
const loading = ref(false)
const dataSource = ref([])
const modalVisible = ref(false)
const modalTitle = ref('')
const formState = reactive({ id: null, configName: '', configKey: '', configValue: '' })
const fetchData = async () => {
loading.value = true
try {
const res: any = await request.get('/config')
dataSource.value = res
} finally {
loading.value = false
}
}
const handleAdd = () => {
modalTitle.value = '新增配置'
Object.assign(formState, { id: null, configName: '', configKey: '', configValue: '' })
modalVisible.value = true
}
const handleEdit = (row: any) => {
modalTitle.value = '编辑配置'
Object.assign(formState, row)
modalVisible.value = true
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该配置吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await request.delete(`/config/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
console.error('删除配置失败:', error)
}
}
const handleModalOk = async () => {
try {
if (formState.id) {
await request.put(`/config/${formState.id}`, formState)
} else {
await request.post('/config', formState)
}
ElMessage.success('操作成功')
modalVisible.value = false
fetchData()
} catch {
ElMessage.error('操作失败')
}
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.config-management .card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -0,0 +1,194 @@
<template>
<div class="dict-management">
<el-card>
<template #header>
<div class="card-title">
<span>字典管理</span>
<el-button
type="primary"
@click="handleAdd"
>
新增字典
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
>
<el-table-column
prop="id"
label="ID"
/>
<el-table-column
prop="dictName"
label="字典名称"
/>
<el-table-column
prop="dictType"
label="字典类型"
/>
<el-table-column label="状态">
<template #default="{ row }">
<el-tag
:type="row.status === '0' ? 'success' : 'danger'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.status === '0' ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
/>
<el-table-column
label="操作"
width="200"
>
<template #default="{ row }">
<el-button
type="primary"
link
size="small"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
>
<el-form
:model="formState"
label-width="80px"
>
<el-form-item label="字典名称">
<el-input v-model="formState.dictName" />
</el-form-item>
<el-form-item label="字典类型">
<el-input v-model="formState.dictType" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formState.status">
<el-option
value="0"
label="正常"
/>
<el-option
value="1"
label="停用"
/>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="formState.remark"
type="textarea"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modalVisible = false">
取消
</el-button>
<el-button
type="primary"
@click="handleModalOk"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
const loading = ref(false)
const dataSource = ref([])
const modalVisible = ref(false)
const modalTitle = ref('')
const formState = reactive({ id: null, dictName: '', dictType: '', status: '0', remark: '' })
const fetchData = async () => {
loading.value = true
try {
const res: any = await request.get('/dict/types')
dataSource.value = res
} finally {
loading.value = false
}
}
const handleAdd = () => {
modalTitle.value = '新增字典'
Object.assign(formState, { id: null, dictName: '', dictType: '', status: '0', remark: '' })
modalVisible.value = true
}
const handleEdit = (row: any) => {
modalTitle.value = '编辑字典'
Object.assign(formState, row)
modalVisible.value = true
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该字典吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await request.delete(`/dict/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
console.error('删除字典失败:', error)
}
}
const handleModalOk = async () => {
try {
if (formState.id) {
await request.put(`/dict/types/${formState.id}`, formState)
} else {
await request.post('/dict/types', formState)
}
ElMessage.success('操作成功')
modalVisible.value = false
fetchData()
} catch {
ElMessage.error('操作失败')
}
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.dict-management .card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -0,0 +1,200 @@
<template>
<div class="file-management">
<el-card>
<template #header>
<div class="card-title">
<span>文件管理</span>
<el-upload
:before-upload="handleUpload"
:show-file-list="false"
>
<el-button type="primary">
<el-icon><Upload /></el-icon> 上传文件
</el-button>
</el-upload>
</div>
</template>
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索文件名"
clearable
style="width: 300px"
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<el-table
v-loading="loading"
:data="filteredDataSource"
style="width: 100%"
>
<el-table-column
prop="id"
label="ID"
/>
<el-table-column
prop="fileName"
label="文件名"
/>
<el-table-column
prop="fileSize"
label="文件大小"
/>
<el-table-column label="文件类型">
<template #default="{ row }">
<el-tag :type="getFileTypeTag(row.fileType)">
{{ getFileTypeName(row.fileType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="storageType"
label="存储方式"
/>
<el-table-column
prop="createdAt"
label="上传时间"
>
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column
prop="createBy"
label="上传人"
/>
<el-table-column
label="操作"
width="150"
>
<template #default="{ row }">
<el-button
type="primary"
link
size="small"
@click="handleDownload(row)"
>
下载
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload, Search } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const dataSource = ref([])
const searchKeyword = ref('')
const filteredDataSource = computed(() => {
if (!searchKeyword.value) {
return dataSource.value
}
return dataSource.value.filter((item: any) =>
item.fileName.toLowerCase().includes(searchKeyword.value.toLowerCase())
)
})
const handleSearch = () => {
}
const fetchData = async () => {
loading.value = true
try {
const res: any = await request.get('/files')
dataSource.value = res
} finally {
loading.value = false
}
}
const handleUpload = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
try {
await request.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
ElMessage.success('上传成功')
fetchData()
} catch {
ElMessage.error('上传失败')
}
return false
}
const handleDownload = (row: any) => {
const downloadUrl = `/api/files/${row.id}/download`
const link = document.createElement('a')
link.href = downloadUrl
link.download = row.fileName
link.click()
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该文件吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await request.delete(`/files/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
console.error('删除文件失败:', error)
}
}
const getFileTypeName = (fileType: string) => {
if (!fileType) return '未知'
if (fileType.startsWith('image/')) return '图片'
if (fileType.startsWith('video/')) return '视频'
if (fileType.startsWith('audio/')) return '音频'
if (fileType.includes('pdf')) return 'PDF'
if (fileType.includes('word') || fileType.includes('document')) return 'Word'
if (fileType.includes('excel') || fileType.includes('spreadsheet')) return 'Excel'
return '其他'
}
const getFileTypeTag = (fileType: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
if (!fileType) return 'info'
if (fileType.startsWith('image/')) return 'success'
if (fileType.startsWith('video/')) return 'danger'
if (fileType.startsWith('audio/')) return 'warning'
if (fileType.includes('pdf')) return 'danger'
if (fileType.includes('word')) return ''
if (fileType.includes('excel')) return 'success'
return 'info'
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.file-management .card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -0,0 +1,222 @@
<template>
<div class="notice-management">
<el-card>
<template #header>
<div class="card-title">
<span>通知公告</span>
<el-button
type="primary"
@click="handleAdd"
>
新增公告
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
>
<el-table-column
prop="id"
label="ID"
/>
<el-table-column
prop="noticeTitle"
label="公告标题"
/>
<el-table-column
label="公告类型"
width="100"
>
<template #default="{ row }">
<el-tag
:type="row.noticeType === '1' ? 'info' : 'success'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.noticeType === '1' ? '通知' : '公告' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="状态"
width="80"
>
<template #default="{ row }">
<el-tag
:type="row.status === '0' ? 'success' : 'danger'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.status === '0' ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createdAt"
label="发布时间"
>
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column
label="操作"
width="150"
>
<template #default="{ row }">
<el-button
type="primary"
link
size="small"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
>
<el-form
:model="formState"
label-width="80px"
>
<el-form-item label="公告标题">
<el-input v-model="formState.noticeTitle" />
</el-form-item>
<el-form-item label="公告类型">
<el-select v-model="formState.noticeType">
<el-option
value="1"
label="通知"
/>
<el-option
value="2"
label="公告"
/>
</el-select>
</el-form-item>
<el-form-item label="公告内容">
<el-input
v-model="formState.noticeContent"
type="textarea"
:rows="4"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formState.status">
<el-option
value="0"
label="正常"
/>
<el-option
value="1"
label="停用"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modalVisible = false">
取消
</el-button>
<el-button
type="primary"
@click="handleModalOk"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const dataSource = ref([])
const modalVisible = ref(false)
const modalTitle = ref('')
const formState = reactive({ id: null, noticeTitle: '', noticeType: '1', noticeContent: '', status: '0' })
const fetchData = async () => {
loading.value = true
try {
const res: any = await request.get('/notices')
dataSource.value = res
} finally {
loading.value = false
}
}
const handleAdd = () => {
modalTitle.value = '新增公告'
Object.assign(formState, { id: null, noticeTitle: '', noticeType: '1', noticeContent: '', status: '0' })
modalVisible.value = true
}
const handleEdit = (row: any) => {
modalTitle.value = '编辑公告'
Object.assign(formState, row)
modalVisible.value = true
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该公告吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await request.delete(`/notice/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
console.error('删除通知失败:', error)
}
}
const handleModalOk = async () => {
try {
if (formState.id) {
await request.put(`/notices/${formState.id}`, formState)
} else {
await request.post('/notices', formState)
}
ElMessage.success('操作成功')
modalVisible.value = false
fetchData()
} catch {
ElMessage.error('操作失败')
}
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.notice-management .card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -0,0 +1,373 @@
<template>
<div class="dashboard">
<el-row :gutter="16">
<el-col :span="6">
<el-card
v-loading="loading"
class="stat-card user-card"
>
<el-statistic
title="用户总数"
:value="stats.userCount"
>
<template #prefix>
<el-icon class="stat-icon user-icon">
<User />
</el-icon>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card
v-loading="loading"
class="stat-card role-card"
>
<el-statistic
title="角色总数"
:value="stats.roleCount"
>
<template #prefix>
<el-icon class="stat-icon role-icon">
<UserFilled />
</el-icon>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card
v-loading="loading"
class="stat-card login-card"
>
<el-statistic
title="今日登录"
:value="stats.todayLogin"
>
<template #prefix>
<el-icon class="stat-icon login-icon">
<ArrowRight />
</el-icon>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card
v-loading="loading"
class="stat-card log-card"
>
<el-statistic
title="操作日志"
:value="stats.operationLog"
>
<template #prefix>
<el-icon class="stat-icon log-icon">
<Document />
</el-icon>
</template>
</el-statistic>
</el-card>
</el-col>
</el-row>
<el-row
:gutter="16"
style="margin-top: 16px"
>
<el-col :span="12">
<el-card
v-loading="loading"
title="最近登录"
class="recent-login-card"
>
<template #header>
<div class="card-header">
<span class="card-title">最近登录</span>
<el-icon class="header-icon">
<Clock />
</el-icon>
</div>
</template>
<el-timeline>
<el-timeline-item
v-for="item in recentLogins"
:key="item.id"
:type="item.status === '0' ? 'success' : 'danger'"
:timestamp="formatDateTime(item.loginTime)"
placement="top"
>
<div class="login-item">
<div class="login-user">
<el-icon><User /></el-icon>
<span>{{ item.username }}</span>
</div>
<div class="login-ip">
<el-icon><Location /></el-icon>
<span>{{ item.ip }}</span>
</div>
</div>
</el-timeline-item>
<el-timeline-item
v-if="recentLogins.length === 0"
placement="top"
>
<div class="empty-tip">
暂无登录记录
</div>
</el-timeline-item>
</el-timeline>
</el-card>
</el-col>
<el-col :span="12">
<el-card
v-loading="loading"
title="系统信息"
class="system-info-card"
>
<template #header>
<div class="card-header">
<span class="card-title">系统信息</span>
<el-icon class="header-icon">
<Setting />
</el-icon>
</div>
</template>
<el-descriptions
:column="1"
border
>
<el-descriptions-item label="系统版本">
<div class="info-item">
<el-icon><Star /></el-icon>
<span>{{ systemInfo.version }}</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="Java版本">
<div class="info-item">
<el-icon><Cpu /></el-icon>
<span>{{ systemInfo.javaVersion }}</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="前端框架">
<div class="info-item">
<el-icon><Monitor /></el-icon>
<span>{{ systemInfo.frontendFramework }}</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="数据库">
<div class="info-item">
<el-icon><Coin /></el-icon>
<span>{{ systemInfo.database }}</span>
</div>
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { User, UserFilled, ArrowRight, Document, Clock, Location, Setting, Star, Cpu, Monitor, Coin } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const stats = reactive({
userCount: 0,
roleCount: 0,
todayLogin: 0,
operationLog: 0
})
const recentLogins = ref<any[]>([])
const systemInfo = reactive({
version: '1.0.0',
javaVersion: '21',
frontendFramework: 'Vue 3 + Element Plus',
database: 'PostgreSQL'
})
const fetchStats = async () => {
loading.value = true
try {
const [userCountRes, roleCountRes, todayLoginRes, operationLogRes] = await Promise.allSettled([
request.get('/users/count'),
request.get('/roles/count'),
request.get('/logs/login/today/count'),
request.get('/logs/operation/count')
])
stats.userCount = userCountRes.status === 'fulfilled' ? (userCountRes.value || 0) : 0
stats.roleCount = roleCountRes.status === 'fulfilled' ? (roleCountRes.value || 0) : 0
stats.todayLogin = todayLoginRes.status === 'fulfilled' ? (todayLoginRes.value || 0) : 0
stats.operationLog = operationLogRes.status === 'fulfilled' ? (operationLogRes.value || 0) : 0
} catch (error) {
console.error('Failed to fetch stats:', error)
} finally {
loading.value = false
}
}
const fetchRecentLogins = async () => {
try {
const res: any = await request.get('/logs/login/recent?limit=10')
recentLogins.value = res || []
} catch (error) {
console.error('Failed to fetch recent logins:', error)
recentLogins.value = []
}
}
onMounted(() => {
fetchStats()
fetchRecentLogins()
})
</script>
<style scoped lang="css">
.dashboard {
padding: 16px;
.stat-card {
transition: all 0.3s ease;
border-radius: 8px;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.stat-icon {
font-size: 24px;
margin-right: 8px;
}
&.user-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
:deep(.el-statistic__head) {
color: rgba(255, 255, 255, 0.9);
}
:deep(.el-statistic__content) {
color: white;
}
}
&.role-card {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
:deep(.el-statistic__head) {
color: rgba(255, 255, 255, 0.9);
}
:deep(.el-statistic__content) {
color: white;
}
}
&.login-card {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
:deep(.el-statistic__head) {
color: rgba(255, 255, 255, 0.9);
}
:deep(.el-statistic__content) {
color: white;
}
}
&.log-card {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
color: white;
:deep(.el-statistic__head) {
color: rgba(255, 255, 255, 0.9);
}
:deep(.el-statistic__content) {
color: white;
}
}
}
.recent-login-card,
.system-info-card {
border-radius: 8px;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
.card-title {
font-size: 16px;
color: #303133;
}
.header-icon {
color: #409eff;
font-size: 18px;
}
}
.login-item {
.login-user,
.login-ip {
display: flex;
align-items: center;
gap: 6px;
margin: 4px 0;
font-size: 14px;
.el-icon {
color: #409eff;
}
}
.login-user {
font-weight: 500;
color: #303133;
}
.login-ip {
color: #909399;
font-size: 13px;
}
}
.empty-tip {
color: #909399;
font-size: 14px;
padding: 8px 0;
}
.info-item {
display: flex;
align-items: center;
gap: 8px;
.el-icon {
color: #409eff;
font-size: 16px;
}
span {
color: #303133;
}
}
}
}
</style>
@@ -0,0 +1,45 @@
<template>
<div class="forbidden-container">
<el-result
icon="warning"
title="403"
sub-title="抱歉您没有权限访问此页面"
>
<template #extra>
<el-button
type="primary"
@click="goBack"
>
返回上一页
</el-button>
<el-button @click="goHome">
返回首页
</el-button>
</template>
</el-result>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const goBack = () => {
router.go(-1)
}
const goHome = () => {
router.push('/dashboard')
}
</script>
<style scoped lang="css">
.forbidden-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f5f7fa;
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<template>
<div class="login-container">
<el-card class="login-card">
<template #header>
<h2>登录 - Novalon 管理系统</h2>
</template>
<el-form
:model="formState"
label-position="top"
@submit.prevent="onFinish"
>
<el-form-item
label="用户名"
prop="username"
:rules="[{ required: true, message: '请输入用户名', trigger: 'blur' }]"
>
<el-input
v-model="formState.username"
placeholder="请输入用户名"
/>
</el-form-item>
<el-form-item
label="密码"
prop="password"
:rules="[{ required: true, message: '请输入密码', trigger: 'blur' }]"
>
<el-input
v-model="formState.password"
type="password"
placeholder="请输入密码"
show-password
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="loading"
style="width: 100%"
>
登录
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import { onMounted } from 'vue'
import { jwtDecode } from 'jwt-decode'
import { usePermissionStore } from '@/stores/permission'
const router = useRouter()
const loading = ref(false)
const permissionStore = usePermissionStore()
const formState = reactive({
username: '',
password: ''
})
onMounted(() => {
document.title = '登录 - Novalon 管理系统'
})
interface JwtPayload {
userId: number
username: string
roles: string[]
exp: number
iat: number
}
const onFinish = async () => {
loading.value = true
try {
const res: any = await request.post('/auth/login', formState)
if (!res || !res.token) {
ElMessage.error('登录失败:未收到有效响应')
return
}
localStorage.setItem('token', res.token)
if (res.userId) {
localStorage.setItem('userId', String(res.userId))
}
if (res.username) {
localStorage.setItem('username', res.username)
}
try {
const decoded = jwtDecode<JwtPayload>(res.token)
if (decoded.roles && Array.isArray(decoded.roles)) {
localStorage.setItem('roles', JSON.stringify(decoded.roles))
}
} catch (decodeError) {
console.warn('解析Token中的角色信息失败:', decodeError)
}
try {
await permissionStore.fetchUserMenus()
} catch (menuError) {
console.error('获取用户菜单失败:', menuError)
}
ElMessage.success('登录成功')
await router.push('/')
} catch (error: any) {
console.error('登录错误:', error)
ElMessage.error(error.response?.data?.message || error.message || '登录失败')
} finally {
loading.value = false
}
}
</script>
<style scoped lang="css">
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: var(--el-color-primary-light-9);
.login-card {
width: 400px;
}
}
</style>
@@ -0,0 +1,291 @@
<template>
<div class="menu-management">
<el-card>
<template #header>
<div class="card-title">
<span>菜单管理</span>
<el-button
type="primary"
@click="handleAdd"
>
新增菜单
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
:pagination="false"
row-key="id"
style="width: 100%"
>
<el-table-column
prop="menuName"
label="菜单名称"
/>
<el-table-column
label="菜单类型"
width="100"
>
<template #default="{ row }">
<el-tag
:type="row.menuType === 'M' ? 'info' : row.menuType === 'C' ? 'success' : 'warning'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.menuType === 'M' ? '目录' : row.menuType === 'C' ? '菜单' : '按钮' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="perms"
label="权限标识"
/>
<el-table-column
prop="component"
label="组件"
/>
<el-table-column
prop="orderNum"
label="排序"
width="80"
/>
<el-table-column
label="状态"
width="80"
>
<template #default="{ row }">
<el-tag :type="row.status === '0' ? 'success' : 'danger'">
{{ row.status === '0' ? '显示' : '隐藏' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="操作"
width="150"
>
<template #default="{ row }">
<el-button
type="primary"
link
size="small"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
>
<el-form
ref="formRef"
:model="formState"
:rules="formRules"
label-width="100px"
>
<el-form-item
label="菜单名称"
prop="menuName"
>
<el-input v-model="formState.menuName" />
</el-form-item>
<el-form-item label="父级菜单">
<el-tree-select
v-model="formState.parentId"
:data="menuTree"
placeholder="请选择父级菜单"
clearable
check-strictly
/>
</el-form-item>
<el-form-item
label="菜单类型"
prop="menuType"
>
<el-select v-model="formState.menuType">
<el-option
value="M"
label="目录"
/>
<el-option
value="C"
label="菜单"
/>
<el-option
value="F"
label="按钮"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="formState.menuType !== 'F'"
label="路由地址"
prop="perms"
>
<el-input v-model="formState.perms" />
</el-form-item>
<el-form-item
v-if="formState.menuType === 'C'"
label="组件路径"
prop="component"
>
<el-input v-model="formState.component" />
</el-form-item>
<el-form-item
label="排序"
prop="orderNum"
>
<el-input-number v-model="formState.orderNum" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formState.status">
<el-option
value="0"
label="显示"
/>
<el-option
value="1"
label="隐藏"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modalVisible = false">
取消
</el-button>
<el-button
type="primary"
@click="handleModalOk"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
const loading = ref(false)
const dataSource = ref([])
const menuTree = ref<any[]>([])
const modalVisible = ref(false)
const modalTitle = ref('')
const formRef = ref()
const formState = reactive({
id: null, menuName: '', parentId: 0, menuType: 'C', perms: '', component: '', orderNum: 0, status: '0'
})
const formRules = {
menuName: [
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
{ min: 2, max: 50, message: '菜单名称长度在 2 到 50 个字符', trigger: 'blur' }
],
menuType: [
{ required: true, message: '请选择菜单类型', trigger: 'change' }
],
perms: [
{ required: true, message: '请输入路由地址', trigger: 'blur' },
{ pattern: /^\/[a-zA-Z0-9/_-]*$/, message: '路由地址格式不正确,应以/开头', trigger: 'blur' }
],
component: [
{ required: true, message: '请输入组件路径', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9/-]+$/, message: '组件路径格式不正确', trigger: 'blur' }
],
orderNum: [
{ required: true, message: '请输入排序', trigger: 'blur' },
{ type: 'number', min: 0, message: '排序必须大于等于0', trigger: 'blur' }
]
}
const fetchData = async () => {
loading.value = true
try {
const res: any = await request.get('/menus')
dataSource.value = res
menuTree.value = buildTreeSelect(res)
} finally {
loading.value = false
}
}
const buildTreeSelect = (menus: any[]): any[] => {
return menus.map(m => ({ value: m.id, label: m.menuName, children: m.children ? buildTreeSelect(m.children) : undefined }))
}
const handleAdd = () => {
modalTitle.value = '新增菜单'
Object.assign(formState, { id: null, menuName: '', parentId: 0, menuType: 'C', perms: '', component: '', orderNum: 0, status: '0' })
modalVisible.value = true
}
const handleEdit = (row: any) => {
modalTitle.value = '编辑菜单'
Object.assign(formState, row)
modalVisible.value = true
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该菜单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await request.delete(`/menus/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
console.error('删除菜单失败:', error)
}
}
const handleModalOk = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (formState.id) {
await request.put(`/menus/${formState.id}`, formState)
} else {
await request.post('/menus', formState)
}
ElMessage.success('操作成功')
modalVisible.value = false
fetchData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('操作失败')
}
}
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.menu-management .card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -0,0 +1,469 @@
<template>
<div class="role-management">
<el-card>
<template #header>
<div class="card-header">
<div class="search-section">
<el-input
v-model="searchKeyword"
placeholder="搜索角色名称或标识"
clearable
style="width: 300px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button
type="primary"
@click="handleSearch"
>
搜索
</el-button>
</div>
<el-button
type="primary"
@click="handleAdd"
>
新增角色
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column
prop="id"
label="ID"
width="80"
sortable="custom"
/>
<el-table-column
prop="roleName"
label="角色名称"
sortable="custom"
/>
<el-table-column
prop="roleKey"
label="角色标识"
sortable="custom"
/>
<el-table-column
prop="roleSort"
label="显示顺序"
sortable="custom"
/>
<el-table-column
label="状态"
width="100"
>
<template #default="{ row }">
<el-tag
:type="row.status === RoleStatus.ACTIVE ? 'success' : 'danger'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.status === RoleStatus.ACTIVE ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createdAt"
label="创建时间"
sortable="custom"
>
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column
label="操作"
width="250"
>
<template #default="{ row }">
<el-button
type="primary"
link
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="warning"
link
@click="handleAssignPermissions(row)"
>
分配权限
</el-button>
<el-button
type="danger"
link
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 16px; justify-content: flex-end"
@current-change="handleTableChange"
@size-change="handleSizeChange"
/>
</el-card>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
>
<el-form
ref="formRef"
:model="formState"
:rules="formRules"
label-width="80px"
>
<el-form-item
label="角色名称"
prop="roleName"
required
>
<el-input v-model="formState.roleName" />
</el-form-item>
<el-form-item
label="角色标识"
prop="roleKey"
required
>
<el-input
v-model="formState.roleKey"
:disabled="!!formState.id"
/>
</el-form-item>
<el-form-item
label="显示顺序"
prop="roleSort"
required
>
<el-input-number
v-model="formState.roleSort"
:min="1"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formState.status">
<el-option
value="ACTIVE"
label="正常"
/>
<el-option
value="INACTIVE"
label="禁用"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modalVisible = false">
取消
</el-button>
<el-button
type="primary"
@click="handleModalOk"
>
确定
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="permissionDialogVisible"
title="分配权限"
width="600px"
>
<el-tree
ref="permissionTreeRef"
:data="permissionTree"
:props="{ label: 'name', children: 'children' }"
show-checkbox
node-key="id"
:default-checked-keys="selectedPermissions"
check-strictly
/>
<template #footer>
<el-button @click="permissionDialogVisible = false">
取消
</el-button>
<el-button
type="primary"
@click="handleAssignPermissionsOk"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { roleApi, type Role, type CreateRoleRequest, type UpdateRoleRequest, type Permission } from '@/api/role.api'
import { handleApiError } from '@/utils/errorHandler'
import { RoleStatus } from '@/constants/status'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const dataSource = ref<Role[]>([])
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0
})
const sortInfo = reactive({
sortBy: 'id',
sortOrder: 'asc' as 'asc' | 'desc'
})
const modalVisible = ref(false)
const modalTitle = ref('')
const formRef = ref()
const formState = reactive<CreateRoleRequest & { id?: number; status?: RoleStatus }>({
roleName: '',
roleKey: '',
roleSort: 1,
permissions: [],
status: RoleStatus.ACTIVE
})
const formRules = {
roleName: [
{ required: true, message: '请输入角色名称', trigger: 'blur' },
{ min: 2, max: 50, message: '角色名称长度在 2 到 50 个字符', trigger: 'blur' }
],
roleKey: [
{ required: true, message: '请输入角色标识', trigger: 'blur' },
{ min: 2, max: 50, message: '角色标识长度在 2 到 50 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '角色标识只能包含字母、数字、下划线和横线', trigger: 'blur' }
],
roleSort: [
{ required: true, message: '请输入显示顺序', trigger: 'blur' },
{ type: 'number', min: 1, message: '显示顺序必须大于0', trigger: 'blur' }
]
}
const permissionDialogVisible = ref(false)
const permissionTreeRef = ref()
const permissionTree = ref<any[]>([])
const selectedPermissions = ref<number[]>([])
const currentRoleId = ref<number | null>(null)
const fetchData = async () => {
loading.value = true
try {
const res = await roleApi.getPage({
page: pagination.current - 1,
size: pagination.pageSize,
sortBy: sortInfo.sortBy,
sortOrder: sortInfo.sortOrder,
name: searchKeyword.value || undefined
})
dataSource.value = res.content
pagination.total = res.totalElements
} catch (error) {
handleApiError(error)
} finally {
loading.value = false
}
}
const handleTableChange = () => {
fetchData()
}
const handleSizeChange = () => {
pagination.current = 1
fetchData()
}
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleSortChange = ({ prop, order }: any) => {
sortInfo.sortBy = prop
sortInfo.sortOrder = order === 'ascending' ? 'asc' : 'desc'
fetchData()
}
const handleAdd = () => {
modalTitle.value = '新增角色'
Object.assign(formState, {
id: undefined,
roleName: '',
roleKey: '',
roleSort: 1,
permissions: [],
status: RoleStatus.ACTIVE
})
modalVisible.value = true
}
const handleEdit = (row: Role) => {
modalTitle.value = '编辑角色'
Object.assign(formState, {
id: row.id,
roleName: row.roleName,
roleKey: row.roleKey,
roleSort: row.roleSort,
status: row.status,
permissions: []
})
modalVisible.value = true
}
const handleDelete = async (row: Role) => {
try {
await ElMessageBox.confirm('确定要删除该角色吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await roleApi.delete(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
if (error !== 'cancel') {
handleApiError(error)
}
}
}
const handleModalOk = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (formState.id) {
const updateData: UpdateRoleRequest = {
roleName: formState.roleName,
roleKey: formState.roleKey,
roleSort: formState.roleSort,
status: formState.status
}
await roleApi.update(formState.id, updateData)
ElMessage.success('更新成功')
} else {
const createData: CreateRoleRequest = {
roleName: formState.roleName,
roleKey: formState.roleKey,
roleSort: formState.roleSort,
permissions: formState.permissions
}
await roleApi.create(createData)
ElMessage.success('创建成功')
}
modalVisible.value = false
fetchData()
} catch (error) {
modalVisible.value = false
if (error !== 'cancel') {
handleApiError(error)
}
}
}
const handleAssignPermissions = async (row: Role) => {
currentRoleId.value = row.id
try {
const allPermissions = await roleApi.getAllPermissions()
permissionTree.value = buildPermissionTree(allPermissions)
const rolePermissions = await roleApi.getPermissions(row.id)
selectedPermissions.value = rolePermissions.map((p: Permission) => p.id)
permissionDialogVisible.value = true
} catch (error) {
handleApiError(error)
}
}
const buildPermissionTree = (permissions: Permission[]): any[] => {
const tree: any[] = []
const resourceMap = new Map<string, Permission[]>()
permissions.forEach(p => {
if (!resourceMap.has(p.resource)) {
resourceMap.set(p.resource, [])
}
resourceMap.get(p.resource)!.push(p)
})
resourceMap.forEach((perms, resource) => {
tree.push({
id: `resource-${resource}`,
name: resource,
children: perms.map(p => ({
id: p.id,
name: p.name
}))
})
})
return tree
}
const handleAssignPermissionsOk = async () => {
if (!currentRoleId.value) return
try {
const checkedNodes = permissionTreeRef.value.getCheckedNodes()
const permissionIds = checkedNodes
.filter((node: any) => typeof node.id === 'number')
.map((node: any) => node.id)
await roleApi.assignPermissions(currentRoleId.value, permissionIds)
ElMessage.success('权限分配成功')
permissionDialogVisible.value = false
fetchData()
} catch (error) {
handleApiError(error)
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="css">
.role-management {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.search-section {
display: flex;
gap: 8px;
align-items: center;
}
}
}
</style>
@@ -0,0 +1,460 @@
<template>
<div class="user-management">
<el-card>
<template #header>
<div class="card-header">
<div class="search-section">
<el-input
v-model="searchKeyword"
placeholder="搜索用户名或邮箱"
clearable
style="width: 300px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button
type="primary"
@click="handleSearch"
>
搜索
</el-button>
</div>
<el-button
type="primary"
@click="handleAdd"
>
新增用户
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column
prop="id"
label="ID"
width="80"
sortable="custom"
/>
<el-table-column
prop="username"
label="用户名"
sortable="custom"
/>
<el-table-column
prop="nickname"
label="昵称"
sortable="custom"
/>
<el-table-column
prop="email"
label="邮箱"
sortable="custom"
/>
<el-table-column
prop="phone"
label="手机号"
sortable="custom"
/>
<el-table-column
label="状态"
width="100"
>
<template #default="{ row }">
<el-tag
:type="row.status === 1 ? 'success' : 'danger'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createdAt"
label="创建时间"
sortable="custom"
>
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column
label="操作"
width="250"
>
<template #default="{ row }">
<el-button
type="primary"
link
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="warning"
link
@click="handleAssignRoles(row)"
>
分配角色
</el-button>
<el-button
type="danger"
link
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 16px; justify-content: flex-end"
@current-change="handleTableChange"
@size-change="handleSizeChange"
/>
</el-card>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
>
<el-form
ref="formRef"
:model="formState"
:rules="formRules"
label-width="80px"
>
<el-form-item
label="用户名"
prop="username"
required
>
<el-input
v-model="formState.username"
:disabled="!!formState.id"
/>
</el-form-item>
<el-form-item
v-if="!formState.id"
label="密码"
prop="password"
required
>
<el-input
v-model="formState.password"
type="password"
/>
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="formState.nickname" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="formState.email" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="formState.phone" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formState.status">
<el-option
:value="UserStatus.ACTIVE"
label="正常"
/>
<el-option
:value="UserStatus.INACTIVE"
label="禁用"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleModalCancel">
取消
</el-button>
<el-button
type="primary"
@click="handleModalOk"
>
确定
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="roleDialogVisible"
title="分配角色"
width="500px"
>
<el-transfer
v-model="selectedRoles"
:data="allRoles"
:titles="['可选角色', '已分配角色']"
/>
<template #footer>
<el-button @click="roleDialogVisible = false">
取消
</el-button>
<el-button
type="primary"
@click="handleAssignRolesOk"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { userApi, type User, type CreateUserRequest, type UpdateUserRequest } from '@/api/user.api'
import { roleApi, type Role } from '@/api/role.api'
import { handleApiError } from '@/utils/errorHandler'
import { UserStatus, StatusHelper } from '@/constants/status'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const dataSource = ref<User[]>([])
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0
})
const sortInfo = reactive({
sortBy: 'id',
sortOrder: 'asc' as 'asc' | 'desc'
})
const modalVisible = ref(false)
const modalTitle = ref('')
const formRef = ref()
const formState = reactive<CreateUserRequest & { id?: number; status?: UserStatus }>({
username: '',
password: '',
nickname: '',
email: '',
phone: '',
roles: [],
status: UserStatus.ACTIVE
})
const formRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 50, message: '用户名长度在 3 到 50 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '用户名只能包含字母、数字、下划线和横线', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 20, message: '密码长度在 8 到 20 个字符', trigger: 'blur' },
{ pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, message: '密码必须包含大小写字母和数字', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
{ max: 100, message: '邮箱长度不能超过100个字符', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
]
}
const roleDialogVisible = ref(false)
const selectedRoles = ref<number[]>([])
const allRoles = ref<{ key: number; label: string }[]>([])
const currentUserId = ref<number | null>(null)
const fetchData = async () => {
loading.value = true
try {
const res = await userApi.getPage({
page: pagination.current - 1,
size: pagination.pageSize,
sortBy: sortInfo.sortBy,
sortOrder: sortInfo.sortOrder,
keyword: searchKeyword.value || undefined
})
dataSource.value = res.content
pagination.total = res.totalElements
} catch (error) {
handleApiError(error)
} finally {
loading.value = false
}
}
const handleTableChange = () => {
fetchData()
}
const handleSizeChange = () => {
pagination.current = 1
fetchData()
}
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleSortChange = ({ prop, order }: any) => {
sortInfo.sortBy = prop
sortInfo.sortOrder = order === 'ascending' ? 'asc' : 'desc'
fetchData()
}
const handleAdd = () => {
modalTitle.value = '新增用户'
Object.assign(formState, {
id: undefined,
username: '',
password: '',
nickname: '',
email: '',
phone: '',
roles: [],
status: 'ACTIVE'
})
modalVisible.value = true
}
const handleEdit = (row: User) => {
modalTitle.value = '编辑用户'
Object.assign(formState, {
id: row.id,
username: row.username,
nickname: row.nickname,
email: row.email,
phone: row.phone,
status: row.status,
roles: []
})
modalVisible.value = true
}
const handleDelete = async (row: User) => {
try {
await ElMessageBox.confirm('确定要删除该用户吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await userApi.delete(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
if (error !== 'cancel') {
handleApiError(error)
}
}
}
const handleModalOk = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (formState.id) {
const updateData: UpdateUserRequest = {
nickname: formState.nickname,
email: formState.email,
phone: formState.phone,
status: formState.status
}
await userApi.update(formState.id, updateData)
ElMessage.success('更新成功')
} else {
const createData: CreateUserRequest = {
username: formState.username,
password: formState.password,
nickname: formState.nickname,
email: formState.email,
phone: formState.phone,
roles: formState.roles
}
await userApi.create(createData)
ElMessage.success('创建成功')
}
modalVisible.value = false
fetchData()
} catch (error) {
modalVisible.value = false
if (error !== 'cancel') {
handleApiError(error)
}
}
}
const handleModalCancel = () => {
modalVisible.value = false
}
const handleAssignRoles = async (row: User) => {
currentUserId.value = row.id
try {
const roles = await roleApi.getAll()
allRoles.value = roles.map((role: Role) => ({
key: role.id,
label: role.roleName
}))
selectedRoles.value = row.roles || []
roleDialogVisible.value = true
} catch (error) {
handleApiError(error)
}
}
const handleAssignRolesOk = async () => {
if (!currentUserId.value) return
try {
await userApi.assignRoles(currentUserId.value, selectedRoles.value)
ElMessage.success('角色分配成功')
roleDialogVisible.value = false
fetchData()
} catch (error) {
handleApiError(error)
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="css">
.user-management {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.search-section {
display: flex;
gap: 8px;
align-items: center;
}
}
}
</style>