feat: 初始化智能物联网设备管理系统前后端基础项目

完成项目整体结构搭建,包含:
1. 前端Vue3+TS+Vite+Antd项目基础配置与核心依赖
2. 后端MidwayJS+TypeORM+JWT权限认证框架
3. 完整的用户、角色、权限、日志数据库表结构
4. 核心业务模块API与基础服务实现
5. 全局样式、路由、状态管理配置
This commit is contained in:
andyesfly committed 2026-05-28 16:23:15 +08:00
commit 3d7eb12103
89 files changed
+16085

No files matched your search

+606
View File
@@ -0,0 +1,606 @@
<template>
<div class="dashboard">
<div class="dashboard-header">
<h2 class="dashboard-title">系统运营概况</h2>
<div class="header-actions">
<span class="update-time">最后更新: {{ lastUpdateTime }}</span>
<a-button type="primary" :loading="loading" @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
刷新数据
</a-button>
</div>
</div>
<a-spin :spinning="loading" tip="加载中...">
<div class="dashboard-content">
<a-row :gutter="[16, 16]" class="stat-cards">
<a-col :xs="24" :sm="12" :md="6" v-for="card in statCards" :key="card.key">
<a-card :hoverable="true" class="stat-card" :class="`stat-card-${card.key}`">
<div class="stat-card-content">
<div class="stat-icon-wrapper" :style="{ background: card.iconBg }">
<component :is="card.icon" class="stat-icon" />
</div>
<div class="stat-info">
<div class="stat-value" :style="{ color: card.color }">
{{ stats?.[card.key] ?? '--' }}
</div>
<div class="stat-title">{{ card.title }}</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="[16, 16]" class="chart-section">
<a-col :xs="24" :lg="14">
<a-card title="设备趋势" class="chart-card">
<div ref="lineChartRef" class="chart-container"></div>
</a-card>
</a-col>
<a-col :xs="24" :lg="10">
<a-card title="设备类型分布" class="chart-card">
<div ref="pieChartRef" class="chart-container"></div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="[16, 16]" class="list-section">
<a-col :xs="24" :lg="12">
<a-card title="最近告警" class="list-card">
<template #extra>
<a-badge :count="stats?.alertToday || 0" :number-style="{ backgroundColor: '#ff4d4f' }" />
</template>
<div class="alert-list">
<div
v-for="alert in stats?.recentAlerts"
:key="alert.id"
class="alert-item"
>
<div class="alert-header">
<a-tag :color="getAlertLevelColor(alert.level)">
{{ getAlertLevelText(alert.level) }}
</a-tag>
<span class="alert-device">{{ alert.device }}</span>
<a-tag :color="alert.status === 'pending' ? 'orange' : 'green'" class="alert-status">
{{ alert.status === 'pending' ? '待处理' : '已解决' }}
</a-tag>
</div>
<div class="alert-body">
<span class="alert-type">{{ alert.type }}</span>
<span class="alert-time">{{ alert.time }}</span>
</div>
</div>
<a-empty v-if="!stats?.recentAlerts?.length" description="暂无告警信息" />
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card title="最近活动" class="list-card">
<a-timeline class="activity-timeline">
<a-timeline-item
v-for="activity in stats?.recentActivities"
:key="activity.id"
:color="getActivityColor(activity.module)"
>
<div class="activity-content">
<div class="activity-header">
<span class="activity-user">{{ activity.user }}</span>
<a-tag color="blue" class="activity-module">{{ activity.module }}</a-tag>
</div>
<div class="activity-action">{{ activity.action }}</div>
<div class="activity-meta">
<span class="activity-time">{{ activity.time }}</span>
<span class="activity-ip">IP: {{ activity.ip }}</span>
</div>
</div>
</a-timeline-item>
</a-timeline>
<a-empty v-if="!stats?.recentActivities?.length" description="暂无活动记录" />
</a-card>
</a-col>
</a-row>
</div>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, markRaw } from 'vue';
import {
Card,
Col,
Row,
Statistic,
Timeline,
TimelineItem,
Tag,
Button,
Badge,
Spin,
Empty,
message,
} from 'ant-design-vue';
import {
DeviceOutlined,
CheckCircleOutlined,
AlertOutlined,
TeamOutlined,
ReloadOutlined,
} from '@ant-design/icons-vue';
import * as echarts from 'echarts';
import { dashboardApi, type DashboardStats } from '@/api/dashboard';
const ACard = Card;
const ACol = Col;
const ARow = Row;
const ATimeline = Timeline;
const ATimelineItem = TimelineItem;
const stats = ref<DashboardStats | null>(null);
const loading = ref(false);
const lineChartRef = ref<HTMLDivElement>();
const pieChartRef = ref<HTMLDivElement>();
let lineChartInstance: echarts.ECharts | null = null;
let pieChartInstance: echarts.ECharts | null = null;
let refreshTimer: ReturnType<typeof setInterval> | null = null;
const lastUpdateTime = ref('--');
const statCards = [
{
key: 'deviceTotal',
title: '设备总数',
icon: markRaw(DeviceOutlined),
color: '#1890ff',
iconBg: '#e6f7ff',
},
{
key: 'deviceOnline',
title: '在线设备',
icon: markRaw(CheckCircleOutlined),
color: '#52c41a',
iconBg: '#f6ffed',
},
{
key: 'alertToday',
title: '今日告警',
icon: markRaw(AlertOutlined),
color: '#ff4d4f',
iconBg: '#fff2f0',
},
{
key: 'activeUsers',
title: '活跃用户',
icon: markRaw(TeamOutlined),
color: '#faad14',
iconBg: '#fffbe6',
},
];
onMounted(async () => {
await loadStats();
await nextTick();
initCharts();
startAutoRefresh();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
stopAutoRefresh();
disposeCharts();
window.removeEventListener('resize', handleResize);
});
async function loadStats() {
loading.value = true;
try {
const res = await dashboardApi.getStats();
if (res.code === 200) {
stats.value = res.data;
lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN');
updateCharts();
}
} catch (error) {
console.error('加载仪表盘数据失败:', error);
message.error('加载数据失败,请稍后重试');
} finally {
loading.value = false;
}
}
function handleRefresh() {
loadStats();
}
function initCharts() {
if (lineChartRef.value) {
lineChartInstance = echarts.init(lineChartRef.value);
}
if (pieChartRef.value) {
pieChartInstance = echarts.init(pieChartRef.value);
}
}
function updateCharts() {
if (!stats.value) return;
if (lineChartInstance && stats.value.deviceTrend) {
const option = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
},
legend: {
data: ['在线设备', '离线设备'],
top: 0,
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: stats.value.deviceTrend.map(d => d.date.slice(5)),
axisLine: { lineStyle: { color: '#d9d9d9' } },
axisLabel: { color: '#666' },
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } },
axisLabel: { color: '#666' },
},
series: [
{
name: '在线设备',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
data: stats.value.deviceTrend.map(d => d.online),
itemStyle: { color: '#52c41a' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(82, 196, 26, 0.3)' },
{ offset: 1, color: 'rgba(82, 196, 26, 0.05)' },
]),
},
},
{
name: '离线设备',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
data: stats.value.deviceTrend.map(d => d.offline),
itemStyle: { color: '#ff4d4f' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(255, 77, 79, 0.3)' },
{ offset: 1, color: 'rgba(255, 77, 79, 0.05)' },
]),
},
},
],
};
lineChartInstance.setOption(option, true);
}
if (pieChartInstance && stats.value.deviceTypeDistribution) {
const option = {
tooltip: {
trigger: 'item',
formatter: '{b}: {c}台 ({d}%)',
},
legend: {
orient: 'vertical',
left: 'left',
top: 'center',
textStyle: { color: '#666' },
},
series: [
{
type: 'pie',
radius: ['45%', '70%'],
center: ['60%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 6,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: true,
formatter: '{b}\n{d}%',
fontSize: 12,
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold',
},
},
labelLine: {
length: 15,
length2: 10,
},
data: stats.value.deviceTypeDistribution.map((item, index) => ({
...item,
itemStyle: {
color: ['#1890ff', '#52c41a', '#faad14', '#722ed1', '#13c2c2'][index],
},
})),
},
],
};
pieChartInstance.setOption(option, true);
}
}
function handleResize() {
lineChartInstance?.resize();
pieChartInstance?.resize();
}
function startAutoRefresh() {
refreshTimer = setInterval(loadStats, 30000);
}
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
function disposeCharts() {
lineChartInstance?.dispose();
pieChartInstance?.dispose();
lineChartInstance = null;
pieChartInstance = null;
}
function getAlertLevelColor(level: string): string {
const colors: Record<string, string> = {
high: 'red',
medium: 'orange',
low: 'blue',
};
return colors[level] || 'default';
}
function getAlertLevelText(level: string): string {
const texts: Record<string, string> = {
high: '严重',
medium: '警告',
low: '提示',
};
return texts[level] || level;
}
function getActivityColor(module: string): string {
const colors: Record<string, string> = {
认证: 'blue',
设备管理: 'green',
用户管理: 'purple',
监控中心: 'red',
数据分析: 'orange',
系统: 'cyan',
系统设置: 'geekblue',
};
return colors[module] || 'gray';
}
</script>
<style scoped lang="less">
.dashboard {
padding: 24px;
min-height: calc(100vh - 120px);
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.dashboard-title {
font-size: 20px;
font-weight: 600;
color: #1f1f1f;
margin: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
.update-time {
color: #999;
font-size: 13px;
}
}
}
.dashboard-content {
.stat-cards {
margin-bottom: 24px;
.stat-card {
border-radius: 8px;
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stat-card-content {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 0;
.stat-icon-wrapper {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.stat-icon {
font-size: 28px;
}
}
.stat-info {
flex: 1;
text-align: left;
.stat-value {
font-size: 28px;
font-weight: 700;
line-height: 1.2;
margin-bottom: 4px;
}
.stat-title {
font-size: 14px;
color: #666;
}
}
}
}
}
.chart-section {
margin-bottom: 24px;
.chart-card {
border-radius: 8px;
.chart-container {
height: 350px;
width: 100%;
}
}
}
.list-section {
.list-card {
border-radius: 8px;
min-height: 400px;
.alert-list {
max-height: 360px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: #d9d9d9;
border-radius: 3px;
}
.alert-item {
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
&:last-child {
border-bottom: none;
}
.alert-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.alert-device {
flex: 1;
font-weight: 500;
color: #333;
}
.alert-status {
font-size: 12px;
}
}
.alert-body {
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 4px;
.alert-type {
color: #666;
font-size: 13px;
}
.alert-time {
color: #999;
font-size: 12px;
}
}
}
}
.activity-timeline {
max-height: 360px;
overflow-y: auto;
padding-top: 8px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: #d9d9d9;
border-radius: 3px;
}
.activity-content {
.activity-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
.activity-user {
font-weight: 500;
color: #333;
}
.activity-module {
font-size: 11px;
}
}
.activity-action {
color: #666;
font-size: 13px;
margin-bottom: 4px;
}
.activity-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: #999;
}
}
}
}
}
}
}
</style>
+309
View File
@@ -0,0 +1,309 @@
<template>
<div class="operation-log">
<a-card :bordered="false" class="search-card">
<a-form layout="inline" :model="queryParams">
<a-form-item label="操作人">
<a-input
v-model:value="queryParams.operator"
placeholder="请输入操作人"
allow-clear
style="width: 160px"
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="操作类型">
<a-select
v-model:value="queryParams.actionType"
placeholder="请选择操作类型"
allow-clear
style="width: 160px"
>
<a-select-option value="登录">登录</a-select-option>
<a-select-option value="创建">创建</a-select-option>
<a-select-option value="更新">更新</a-select-option>
<a-select-option value="删除">删除</a-select-option>
<a-select-option value="登出">登出</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="时间范围">
<a-range-picker
v-model:value="timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
:placeholder="['开始时间', '结束时间']"
style="width: 340px"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
<a-button type="primary" ghost :loading="exportLoading" @click="handleExport">
<template #icon><DownloadOutlined /></template>
导出
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<a-card :bordered="false" class="table-card">
<a-table
:columns="columns"
:data-source="logList"
:loading="tableLoading"
:pagination="paginationConfig"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'statusCode'">
<a-tag :color="getStatusCodeColor(record.statusCode)">
{{ record.statusCode }}
</a-tag>
</template>
<template v-else-if="column.key === 'duration'">
<span :class="{ 'duration-warning': record.duration > 1000 }">
{{ record.duration }}ms
</span>
</template>
<template v-else-if="column.key === 'createdAt'">
{{ formatTime(record.createdAt) }}
</template>
<template v-else-if="column.key === 'action'">
<a-tooltip :title="record.action">
<span class="action-text">{{ record.action }}</span>
</a-tooltip>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { SearchOutlined, ReloadOutlined, DownloadOutlined } from '@ant-design/icons-vue'
import dayjs from 'dayjs'
import { logApi, type LogItem } from '@/api/log'
const tableLoading = ref(false)
const exportLoading = ref(false)
const logList = ref<LogItem[]>([])
const total = ref(0)
const timeRange = ref<any[]>([])
const queryParams = reactive({
page: 1,
pageSize: 10,
operator: '',
actionType: undefined as string | undefined,
startDate: '',
endDate: '',
})
const columns = [
{
title: '序号',
key: 'index',
width: 70,
customRender: ({ index }: { index: number }) => (queryParams.page - 1) * queryParams.pageSize + index + 1,
},
{
title: '操作人',
dataIndex: 'username',
key: 'username',
width: 120,
ellipsis: true,
},
{
title: '操作类型',
dataIndex: 'action',
key: 'action',
width: 140,
ellipsis: true,
},
{
title: '操作模块',
dataIndex: 'module',
key: 'module',
width: 110,
ellipsis: true,
},
{
title: '请求方法',
dataIndex: 'method',
key: 'method',
width: 100,
},
{
title: 'IP地址',
dataIndex: 'ipAddress',
key: 'ipAddress',
width: 140,
ellipsis: true,
},
{
title: '状态码',
dataIndex: 'statusCode',
key: 'statusCode',
width: 90,
align: 'center' as const,
},
{
title: '耗时(ms)',
dataIndex: 'duration',
key: 'duration',
width: 100,
align: 'center' as const,
},
{
title: '操作时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
},
]
const paginationConfig = reactive({
current: queryParams.page,
pageSize: queryParams.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (t: number) => `共 ${t} 条记录`,
})
function getStatusCodeColor(code: number): string {
if (code >= 200 && code < 300) return 'success'
if (code >= 400 && code < 500) return 'warning'
if (code >= 500) return 'error'
return 'default'
}
function formatTime(time: string): string {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
}
async function fetchLogList() {
tableLoading.value = true
try {
if (timeRange.value && timeRange.value.length === 2) {
queryParams.startDate = timeRange.value[0].format('YYYY-MM-DD HH:mm:ss')
queryParams.endDate = timeRange.value[1].format('YYYY-MM-DD HH:mm:ss')
} else {
queryParams.startDate = ''
queryParams.endDate = ''
}
const res: any = await logApi.getLogList(queryParams)
if (res.code === 200) {
logList.value = res.data.list
total.value = res.data.total
paginationConfig.total = res.data.total
}
} catch (error) {
message.error('获取日志列表失败')
} finally {
tableLoading.value = false
}
}
function handleSearch() {
queryParams.page = 1
paginationConfig.current = 1
fetchLogList()
}
function handleReset() {
queryParams.operator = ''
queryParams.actionType = undefined
queryParams.startDate = ''
queryParams.endDate = ''
timeRange.value = []
handleSearch()
}
function handleTableChange(pag: any) {
queryParams.page = pag.current
queryParams.pageSize = pag.pageSize
paginationConfig.current = pag.current
paginationConfig.pageSize = pag.pageSize
fetchLogList()
}
async function handleExport() {
exportLoading.value = true
try {
const params: any = {}
if (queryParams.operator) params.operator = queryParams.operator
if (queryParams.actionType) params.actionType = queryParams.actionType
if (timeRange.value && timeRange.value.length === 2) {
params.startDate = timeRange.value[0].format('YYYY-MM-DD HH:mm:ss')
params.endDate = timeRange.value[1].format('YYYY-MM-DD HH:mm:ss')
}
const response: any = await logApi.exportLogs(params)
const blob = new Blob([response], { type: 'text/csv;charset=utf-8' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `操作日志_${dayjs().format('YYYYMMDDHHmmss')}.csv`
link.click()
window.URL.revokeObjectURL(url)
message.success('导出成功')
} catch (error) {
message.error('导出失败')
} finally {
exportLoading.value = false
}
}
onMounted(() => {
fetchLogList()
})
</script>
<style scoped lang="less">
.operation-log {
padding: 20px;
.search-card {
margin-bottom: 16px;
:deep(.ant-card-body) {
padding-bottom: 4px;
}
}
.table-card {
:deep(.ant-table) {
font-size: 13px;
}
}
.duration-warning {
color: #cf1322;
font-weight: 500;
}
.action-text {
max-width: 150px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
}
</style>
+347
View File
@@ -0,0 +1,347 @@
<template>
<div class="login-container">
<div class="bg-decoration">
<div class="circle circle-1"></div>
<div class="circle circle-2"></div>
<div class="circle circle-3"></div>
</div>
<div class="login-card" :class="{ 'fade-in': true }">
<div class="brand-section">
<div class="logo-icon">
<ThunderboltOutlined />
</div>
<h1 class="title">智能电网设备管理系统</h1>
<p class="subtitle">Smart Grid IoT Management System</p>
</div>
<a-divider />
<a-form ref="formRef" :model="formState" :rules="rules" size="large" @finish="handleLogin">
<a-form-item name="username">
<a-input ref="usernameInputRef" v-model:value="formState.username" placeholder="请输入用户名" allow-clear
:disabled="loading" @pressEnter="focusPassword">
<template #prefix>
<UserOutlined class="input-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item name="password">
<a-input-password ref="passwordInputRef" v-model:value="formState.password" placeholder="请输入密码" allow-clear
:disabled="loading" @pressEnter="submitForm">
<template #prefix>
<LockOutlined class="input-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item class="options-row">
<a-checkbox v-model:checked="formState.rememberMe" :disabled="loading">
记住我
</a-checkbox>
<a class="forgot-link" href="javascript:void(0)">忘记密码?</a>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit" :loading="loading" block class="login-btn">
<template #icon v-if="!loading">
<span></span>
</template>
{{ loading ? '登录中...' : '登 录' }}
</a-button>
</a-form-item>
</a-form>
<div class="footer-info">
<p>默认账户:admin / admin123456</p>
</div>
</div>
<div class="copyright">
<p>&copy; 2026 智能电网设备管理系统 All Rights Reserved</p>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import {
UserOutlined,
LockOutlined,
ThunderboltOutlined,
} from '@ant-design/icons-vue'
import { usePermissionStore } from '@/stores/permission'
const router = useRouter()
const route = useRoute()
const permissionStore = usePermissionStore()
const formRef = ref()
const usernameInputRef = ref()
const passwordInputRef = ref()
const loading = ref(false)
const formState = reactive({
username: '',
password: '',
rememberMe: false,
})
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度为3-20个字符', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度为6-20个字符', trigger: 'blur' },
],
}
onMounted(() => {
nextTick(() => {
const savedUsername = localStorage.getItem('remembered_username')
if (savedUsername) {
formState.username = savedUsername
formState.rememberMe = true
}
usernameInputRef.value?.focus()
})
})
function focusPassword() {
passwordInputRef.value?.focus()
}
function submitForm() {
formRef.value?.submit()
}
async function handleLogin() {
try {
loading.value = true
await permissionStore.login(formState.username, formState.password)
if (formState.rememberMe) {
localStorage.setItem('remembered_username', formState.username)
} else {
localStorage.removeItem('remembered_username')
}
message.success('登录成功!')
setTimeout(() => {
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
}, 500)
} catch (error: any) {
message.error(error.message || '登录失败,请检查用户名和密码')
formState.password = ''
nextTick(() => {
passwordInputRef.value?.focus()
})
} finally {
loading.value = false
}
}
</script>
<style scoped lang="less">
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 50%, #7e22ce 100%);
position: relative;
overflow: hidden;
padding: 20px;
}
.bg-decoration {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
.circle {
position: absolute;
border-radius: 50%;
opacity: 0.08;
}
.circle-1 {
width: 600px;
height: 600px;
top: -200px;
right: -100px;
background: #fff;
animation: float 20s ease-in-out infinite;
}
.circle-2 {
width: 400px;
height: 400px;
bottom: -150px;
left: -100px;
background: #fff;
animation: float 15s ease-in-out infinite reverse;
}
.circle-3 {
width: 200px;
height: 200px;
top: 50%;
left: 10%;
background: #fff;
animation: float 12s ease-in-out infinite;
}
}
@keyframes float {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-30px) rotate(5deg);
}
}
.login-card {
width: 420px;
max-width: 100%;
background: #fff;
border-radius: 12px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
position: relative;
z-index: 1;
animation: slideUp 0.6s ease-out;
&.fade-in {
animation: slideUp 0.6s ease-out;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.brand-section {
text-align: center;
margin-bottom: 10px;
.logo-icon {
margin-bottom: 16px;
:deep(.anticon) {
font-size: 48px;
color: #1890ff;
filter: drop-shadow(0 2px 4px rgba(24, 144, 255, 0.3));
}
}
.title {
font-size: 24px;
font-weight: 600;
color: #1a1a2e;
margin: 0 0 8px 0;
letter-spacing: 1px;
}
.subtitle {
font-size: 14px;
color: #999;
margin: 0;
font-weight: 400;
letter-spacing: 0.5px;
}
}
.options-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px !important;
}
.forgot-link {
color: #1890ff;
font-size: 14px;
transition: color 0.3s;
&:hover {
color: #40a9ff;
}
}
.login-btn {
height: 44px;
font-size: 16px;
font-weight: 500;
border-radius: 6px;
letter-spacing: 4px;
}
.footer-info {
text-align: center;
margin-top: 20px;
color: #bbb;
font-size: 12px;
}
.input-icon {
color: rgba(0, 0, 0, 0.25);
}
.copyright {
position: absolute;
bottom: 20px;
left: 0;
right: 0;
text-align: center;
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
z-index: 1;
p {
margin: 0;
}
}
@media (max-width: 480px) {
.login-card {
width: 100%;
padding: 30px 20px;
}
.brand-section .title {
font-size: 20px;
}
.copyright {
position: relative;
bottom: auto;
margin-top: 20px;
color: rgba(255, 255, 255, 0.5);
}
}
</style>
+175
View File
@@ -0,0 +1,175 @@
<template>
<div class="basic-settings">
<a-card title="基本设置" :loading="loading">
<a-form
:model="formState"
:rules="rules"
ref="formRef"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 14 }"
>
<a-form-item label="站点名称" name="siteName">
<a-input
v-model:value="formState.siteName"
placeholder="请输入站点名称"
:maxlength="50"
show-count
/>
</a-form-item>
<a-form-item label="Logo" name="logo">
<a-upload
name="file"
list-type="picture-card"
class="logo-uploader"
:show-upload-list="false"
:before-upload="beforeUpload"
:custom-request="handleUpload"
>
<img v-if="formState.logo" :src="formState.logo" alt="logo" style="width: 100%" />
<div v-else>
<plus-outlined />
<div class="ant-upload-text">上传</div>
</div>
</a-upload>
<div class="form-tip">支持 JPG、PNG 格式,建议尺寸 200x200px</div>
</a-form-item>
<a-form-item label="会话超时时间" name="sessionTimeout">
<a-input-number
v-model:value="formState.sessionTimeout"
:min="5"
:max="1440"
style="width: 200px"
/>
<span class="form-tip">单位:分钟,范围 5-1440</span>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 6, span: 14 }">
<a-button type="primary" @click="handleSave" :loading="saving">
保存设置
</a-button>
</a-form-item>
</a-form>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { systemApi, type BasicSettings } from '@/api/system';
const formRef = ref();
const loading = ref(false);
const saving = ref(false);
const formState = reactive<BasicSettings>({
siteName: '',
logo: '',
sessionTimeout: 120,
});
const rules = {
siteName: [
{ required: true, message: '请输入站点名称', trigger: 'blur' },
{ max: 50, message: '站点名称不能超过50个字符', trigger: 'blur' },
],
sessionTimeout: [
{ required: true, message: '请输入会话超时时间', trigger: 'blur' },
{ type: 'number', min: 5, max: 1440, message: '超时时间范围为5-1440分钟', trigger: 'blur' },
],
};
onMounted(async () => {
await loadSettings();
});
async function loadSettings() {
loading.value = true;
try {
const res: any = await systemApi.getSettings();
if (res.code === 200 && res.data?.basic) {
Object.assign(formState, res.data.basic);
}
} catch (error) {
console.error('加载设置失败:', error);
message.error('加载设置失败');
} finally {
loading.value = false;
}
}
function beforeUpload(file: File) {
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
if (!isJpgOrPng) {
message.error('只能上传 JPG/PNG 格式的图片!');
return false;
}
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isLt2M) {
message.error('图片大小不能超过 2MB!');
return false;
}
return true;
}
function handleUpload(options: any) {
const reader = new FileReader();
reader.onload = () => {
formState.logo = reader.result as string;
};
reader.readAsDataURL(options.file);
options.onSuccess();
}
async function handleSave() {
try {
await formRef.value.validateFields();
} catch {
return;
}
saving.value = true;
try {
const res: any = await systemApi.updateBasicSettings({
siteName: formState.siteName,
logo: formState.logo,
sessionTimeout: formState.sessionTimeout,
});
if (res.code === 200) {
message.success('基本设置保存成功');
}
} catch (error) {
console.error('保存失败:', error);
message.error('保存失败,请重试');
} finally {
saving.value = false;
}
}
</script>
<style scoped lang="less">
.basic-settings {
padding: 20px;
.form-tip {
margin-left: 10px;
color: #999;
font-size: 12px;
}
.logo-uploader {
:deep(.ant-upload) {
width: 128px;
height: 128px;
}
}
.ant-upload-text {
margin-top: 8px;
color: #999;
}
}
</style>
+151
View File
@@ -0,0 +1,151 @@
<template>
<div class="security-settings">
<a-card title="安全设置" :loading="loading">
<a-form
:model="formState"
:rules="rules"
ref="formRef"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 14 }"
>
<a-form-item label="密码最小长度" name="passwordMinLength">
<a-input-number
v-model:value="formState.passwordMinLength"
:min="6"
:max="20"
style="width: 200px"
/>
<span class="form-tip">范围 6-20 位</span>
</a-form-item>
<a-form-item label="最大登录尝试次数" name="maxLoginAttempts">
<a-input-number
v-model:value="formState.maxLoginAttempts"
:min="3"
:max="10"
style="width: 200px"
/>
<span class="form-tip">超过此次数将锁定账户</span>
</a-form-item>
<a-form-item label="账户锁定时长" name="lockoutDuration">
<a-input-number
v-model:value="formState.lockoutDuration"
:min="5"
:max="60"
style="width: 200px"
/>
<span class="form-tip">单位:分钟,范围 5-60</span>
</a-form-item>
<a-form-item label="IP白名单" name="ipWhitelist">
<a-select
v-model:value="formState.ipWhitelist"
mode="tags"
placeholder="输入IP地址后按回车添加"
style="width: 100%"
:token-separators="[',']"
/>
<div class="form-tip">
留空表示不限制IP访问,多个IP用回车或逗号分隔(如:192.168.1.1)
</div>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 6, span: 14 }">
<a-button type="primary" @click="handleSave" :saving="saving">
保存设置
</a-button>
</a-form-item>
</a-form>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { systemApi, type SecuritySettings } from '@/api/system';
const formRef = ref();
const loading = ref(false);
const saving = ref(false);
const formState = reactive<SecuritySettings>({
passwordMinLength: 6,
maxLoginAttempts: 5,
lockoutDuration: 30,
ipWhitelist: [],
});
const rules = {
passwordMinLength: [
{ required: true, message: '请输入密码最小长度', trigger: 'blur' },
{ type: 'number', min: 6, max: 20, message: '长度范围为6-20', trigger: 'blur' },
],
maxLoginAttempts: [
{ required: true, message: '请输入最大登录尝试次数', trigger: 'blur' },
{ type: 'number', min: 3, max: 10, message: '范围为3-10次', trigger: 'blur' },
],
lockoutDuration: [
{ required: true, message: '请输入账户锁定时长', trigger: 'blur' },
{ type: 'number', min: 5, max: 60, message: '范围为5-60分钟', trigger: 'blur' },
],
};
onMounted(async () => {
await loadSettings();
});
async function loadSettings() {
loading.value = true;
try {
const res: any = await systemApi.getSettings();
if (res.code === 200 && res.data?.security) {
Object.assign(formState, res.data.security);
}
} catch (error) {
console.error('加载设置失败:', error);
message.error('加载设置失败');
} finally {
loading.value = false;
}
}
async function handleSave() {
try {
await formRef.value.validateFields();
} catch {
return;
}
saving.value = true;
try {
const res: any = await systemApi.updateSecuritySettings({
passwordMinLength: formState.passwordMinLength,
maxLoginAttempts: formState.maxLoginAttempts,
lockoutDuration: formState.lockoutDuration,
ipWhitelist: formState.ipWhitelist,
});
if (res.code === 200) {
message.success('安全设置保存成功');
}
} catch (error) {
console.error('保存失败:', error);
message.error('保存失败,请重试');
} finally {
saving.value = false;
}
}
</script>
<style scoped lang="less">
.security-settings {
padding: 20px;
.form-tip {
margin-left: 10px;
color: #999;
font-size: 12px;
}
}
</style>
+454
View File
@@ -0,0 +1,454 @@
<template>
<div class="role-management">
<a-card title="角色管理">
<template #extra>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增角色
</a-button>
</template>
<div class="search-bar">
<a-input-search
v-model:value="keyword"
placeholder="搜索角色名称/编码"
allow-clear
style="width: 300px"
@search="handleSearch"
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input-search>
</div>
<a-table
:columns="columns"
:data-source="roleList"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'name'">
<span>{{ record.name }}</span>
</template>
<template v-if="column.dataIndex === 'code'">
<a-tag color="blue">{{ record.code }}</a-tag>
</template>
<template v-if="column.dataIndex === 'status'">
<a-badge :status="record.status === 1 ? 'success' : 'error'" :text="record.status === 1 ? '启用' : '禁用'" />
</template>
<template v-if="column.dataIndex === 'userCount'">
<a @click="handleViewUsers(record)">{{ record.userCount }}</a>
</template>
<template v-if="column.dataIndex === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-if="column.dataIndex === 'action'">
<a-space>
<a-button type="link" size="small" @click="handlePermission(record)">
<KeyOutlined />权限配置
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
<EditOutlined />编辑
</a-button>
<a-popconfirm
title="确定删除该角色?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>
<DeleteOutlined />删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<a-modal
v-model:open="formModalVisible"
:title="formModalTitle"
:confirm-loading="formLoading"
@ok="handleSubmitForm"
@cancel="resetForm"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 17 }"
>
<a-form-item label="角色名称" name="name">
<a-input v-model:value="formData.name" placeholder="请输入角色名称" />
</a-form-item>
<a-form-item label="角色编码" name="code">
<a-input
v-model:value="formData.code"
placeholder="请输入角色编码"
:disabled="isEdit"
/>
</a-form-item>
<a-form-item label="描述" name="description">
<a-textarea v-model:value="formData.description" placeholder="请输入角色描述" :rows="3" />
</a-form-item>
<a-form-item label="排序" name="sortOrder">
<a-input-number v-model:value="formData.sortOrder" :min="0" :max="999" style="width: 100%" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">禁用</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="permissionModalVisible"
title="权限配置"
width="600px"
:confirm-loading="permissionLoading"
@ok="handleSavePermissions"
>
<a-spin :spinning="permissionTreeLoading">
<a-tree
v-if="!permissionTreeLoading"
v-model:checkedKeys="checkedKeys"
:tree-data="permissionTree"
checkable
:field-names="{ title: 'name', key: 'id', children: 'children' }"
:check-strictly="false"
:default-expand-all="true"
style="max-height: 500px; overflow-y: auto;"
>
<template #title="{ name, icon }">
<span>
<component v-if="icon" :is="icon" style="margin-right: 8px" />
{{ name }}
</span>
</template>
</a-tree>
</a-spin>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import {
PlusOutlined,
SearchOutlined,
KeyOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue';
import { roleApi, type RoleInfo, type PermissionTreeNode } from '@/api/role';
import dayjs from 'dayjs';
const loading = ref(false);
const roleList = ref<RoleInfo[]>([]);
const keyword = ref('');
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showTotal: (total: number) => `共 ${total} 条`,
});
const columns = [
{
title: '角色名称',
dataIndex: 'name',
key: 'name',
},
{
title: '角色编码',
dataIndex: 'code',
key: 'code',
width: 150,
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
align: 'center' as const,
},
{
title: '关联用户数',
dataIndex: 'userCount',
key: 'userCount',
width: 120,
align: 'center' as const,
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
},
{
title: '操作',
dataIndex: 'action',
key: 'action',
width: 280,
fixed: 'right' as const,
},
];
async function fetchRoleList() {
loading.value = true;
try {
const res: any = await roleApi.getRoleList({
page: pagination.current,
pageSize: pagination.pageSize,
keyword: keyword.value || undefined,
});
if (res.code === 200) {
roleList.value = res.data.list;
pagination.total = res.data.total;
}
} catch (error) {
message.error('获取角色列表失败');
} finally {
loading.value = false;
}
}
function handleSearch() {
pagination.current = 1;
fetchRoleList();
}
function handleTableChange(pag: any) {
pagination.current = pag.current;
pagination.pageSize = pag.pageSize;
fetchRoleList();
}
function formatDateTime(dateStr: string) {
if (!dateStr) return '-';
return dayjs(dateStr).format('YYYY-MM-DD HH:mm:ss');
}
const formModalVisible = ref(false);
const formLoading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const currentRoleId = ref<number | null>(null);
const formModalTitle = computed(() => (isEdit.value ? '编辑角色' : '新增角色'));
const formData = reactive({
name: '',
code: '',
description: '',
sortOrder: 0,
status: 1,
});
const formRules = {
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
};
function handleAdd() {
isEdit.value = false;
currentRoleId.value = null;
resetForm();
formModalVisible.value = true;
}
function handleEdit(record: RoleInfo) {
isEdit.value = true;
currentRoleId.value = record.id;
formData.name = record.name;
formData.code = record.code;
formData.description = record.description || '';
formData.sortOrder = record.sortOrder;
formData.status = record.status;
formModalVisible.value = true;
}
function resetForm() {
formData.name = '';
formData.code = '';
formData.description = '';
formData.sortOrder = 0;
formData.status = 1;
formRef.value?.resetFields();
}
async function handleSubmitForm() {
try {
await formRef.value?.validate();
} catch {
return;
}
formLoading.value = true;
try {
let res: any;
if (isEdit.value && currentRoleId.value) {
res = await roleApi.updateRole(currentRoleId.value, {
name: formData.name,
description: formData.description,
sortOrder: formData.sortOrder,
status: formData.status,
});
} else {
res = await roleApi.createRole({
name: formData.name,
code: formData.code,
description: formData.description,
sortOrder: formData.sortOrder,
status: formData.status,
});
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
formModalVisible.value = false;
fetchRoleList();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
formLoading.value = false;
}
}
async function handleDelete(record: RoleInfo) {
try {
const res: any = await roleApi.deleteRole(record.id);
if (res.code === 200) {
message.success('删除成功');
fetchRoleList();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
function handleViewUsers(_record: RoleInfo) {
message.info('查看用户功能开发中');
}
const permissionModalVisible = ref(false);
const permissionLoading = ref(false);
const permissionTreeLoading = ref(false);
const permissionTree = ref<PermissionTreeNode[]>([]);
const checkedKeys = ref<number[]>([]);
async function handlePermission(record: RoleInfo) {
currentRoleId.value = record.id;
permissionModalVisible.value = true;
checkedKeys.value = [];
await loadPermissionTree();
}
async function loadPermissionTree() {
permissionTreeLoading.value = true;
try {
if (!currentRoleId.value) return;
const res: any = await roleApi.getRolePermissions(currentRoleId.value);
if (res.code === 200) {
permissionTree.value = res.data;
extractCheckedKeys(res.data);
}
} catch (error) {
message.error('获取权限数据失败');
} finally {
permissionTreeLoading.value = false;
}
}
function extractCheckedKeys(tree: PermissionTreeNode[]) {
const keys: number[] = [];
function traverse(nodes: PermissionTreeNode[]) {
for (const node of nodes) {
if (node.checked) {
keys.push(node.id);
}
if (node.children?.length) {
traverse(node.children);
}
}
}
traverse(tree);
checkedKeys.value = keys;
}
async function handleSavePermissions() {
if (!currentRoleId.value) return;
permissionLoading.value = true;
try {
const res: any = await roleApi.updateRolePermissions(
currentRoleId.value,
checkedKeys.value as unknown as number[]
);
if (res.code === 200) {
message.success('权限保存成功');
permissionModalVisible.value = false;
} else {
message.error(res.message || '保存失败');
}
} catch (error) {
message.error('保存失败');
} finally {
permissionLoading.value = false;
}
}
onMounted(() => {
fetchRoleList();
});
</script>
<style scoped>
.role-management {
padding: 20px;
}
.search-bar {
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
}
:deep(.ant-table) {
font-size: 14px;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
</style>
+499
View File
@@ -0,0 +1,499 @@
<template>
<div class="user-management">
<a-card :bordered="false">
<a-form layout="inline" :model="searchForm" class="search-form">
<a-form-item label="关键词">
<a-input v-model:value="searchForm.keyword" placeholder="用户名/昵称/手机号" allow-clear style="width: 220px" @pressEnter="handleSearch" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="searchForm.status" placeholder="请选择状态" allow-clear style="width: 120px">
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<div class="table-operations">
<a-space>
<a-button type="primary" v-permission="'user:create'" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增用户
</a-button>
<a-button danger v-permission="'user:delete'" :disabled="!selectedRowKeys.length" @click="handleBatchDelete">
<template #icon><DeleteOutlined /></template>
批量删除 ({{ selectedRowKeys.length }})
</a-button>
</a-space>
</div>
<a-card :bordered="false">
<a-table
:columns="columns"
:data-source="dataSource"
:row-selection="rowSelection"
:loading="loading"
:pagination="pagination"
:scroll="{ x: 1200 }"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'avatar'">
<a-avatar :src="record.avatar" :size="40">{{ record.nickname?.charAt(0) }}</a-avatar>
</template>
<template v-if="column.key === 'roles'">
<a-space>
<a-tag v-for="role in record.userRoles" :key="role.roleId" :color="getRoleColor(role.role?.code)">
{{ role.role?.name }}
</a-tag>
</a-space>
</template>
<template v-if="column.key === 'status'">
<a-switch
:checked="record.status === 1"
checked-children="启用"
un-checked-children="禁用"
@change="(checked: boolean) => handleStatusChange(record.id, checked ? 1 : 0)"
v-permission="'user:update'"
/>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" v-permission="'user:update'" @click="handleEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-popconfirm title="确定要删除该用户吗?" ok-text="确定" cancel-text="取消" @confirm="handleDelete(record.id)">
<a-button type="link" size="small" danger v-permission="'user:delete'">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
<template #emptyText>
<a-empty description="暂无用户数据" />
</template>
</a-table>
</a-card>
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="600px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="用户名" name="username">
<a-input v-model:value="formData.username" placeholder="请输入用户名" :disabled="isEdit" />
</a-form-item>
<a-form-item label="密码" name="password" v-if="!isEdit || showPassword">
<a-input-password v-model:value="formData.password" placeholder="请输入密码" />
</a-form-item>
<a-form-item label="确认密码" name="confirmPassword" v-if="!isEdit || showPassword">
<a-input-password v-model:value="formData.confirmPassword" placeholder="请再次输入密码" />
</a-form-item>
<a-form-item v-if="isEdit && !showPassword">
<a-button type="link" size="small" @click="showPassword = true">修改密码</a-button>
</a-form-item>
<a-form-item label="昵称" name="nickname">
<a-input v-model:value="formData.nickname" placeholder="请输入昵称" />
</a-form-item>
<a-form-item label="手机号" name="phone">
<a-input v-model:value="formData.phone" placeholder="请输入手机号" />
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="formData.email" placeholder="请输入邮箱" />
</a-form-item>
<a-form-item label="角色" name="roleIds">
<a-select
v-model:value="formData.roleIds"
mode="multiple"
placeholder="请选择角色"
:options="roleOptions"
/>
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">禁用</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message, Modal } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
DeleteOutlined,
EditOutlined,
} from '@ant-design/icons-vue';
import { userApi, type UserInfo, type UserQuery, type RoleInfo } from '@/api/user';
const loading = ref(false);
const dataSource = ref<UserInfo[]>([]);
const total = ref(0);
const searchForm = reactive<UserQuery>({
page: 1,
pageSize: 10,
keyword: '',
status: undefined as any,
});
const selectedRowKeys = ref<number[]>([]);
const modalVisible = ref(false);
const modalTitle = ref('');
const modalLoading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const showPassword = ref(false);
const roleOptions = ref<{ label: string; value: number }[]>([]);
const formData = reactive({
username: '',
password: '',
confirmPassword: '',
nickname: '',
phone: '',
email: '',
roleIds: [] as number[],
status: 1,
});
const validateConfirmPassword = async (_rule: any, value: string) => {
if (value && value !== formData.password) {
return Promise.reject(new Error('两次输入的密码不一致'));
}
return Promise.resolve();
};
const formRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能小于6位', trigger: 'blur' },
],
confirmPassword: [{ required: true, message: '请确认密码', trigger: 'blur' }, { validator: validateConfirmPassword, trigger: 'blur' }],
nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
phone: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
email: [{ type: 'email' as const, message: '邮箱格式不正确', trigger: 'blur' }],
roleIds: [{ required: true, message: '请选择角色', trigger: 'change', type: 'array' as const }],
};
const columns = [
{ title: '用户名', dataIndex: 'username', key: 'username', width: 120 },
{ title: '昵称', dataIndex: 'nickname', key: 'nickname', width: 120 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 180 },
{ title: '角色', key: 'roles', width: 200 },
{
title: '状态',
key: 'status',
width: 100,
align: 'center' as const,
},
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: number[]) => {
selectedRowKeys.value = keys;
},
}));
onMounted(() => {
fetchUserList();
fetchRoleList();
});
async function fetchUserList() {
loading.value = true;
try {
const res: any = await userApi.getUserList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
message.error('获取用户列表失败');
} finally {
loading.value = false;
}
}
async function fetchRoleList() {
try {
const res: any = await userApi.getRoleList();
if (res.code === 200) {
roleOptions.value = res.data.map((item: RoleInfo) => ({
label: item.name,
value: item.id,
}));
}
} catch (error) {
console.error('获取角色列表失败:', error);
}
}
function handleSearch() {
searchForm.page = 1;
fetchUserList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.status = undefined as any;
searchForm.page = 1;
fetchUserList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchUserList();
}
function handleAdd() {
isEdit.value = false;
editId.value = null;
showPassword.value = false;
modalTitle.value = '新增用户';
resetForm();
modalVisible.value = true;
}
function handleEdit(record: UserInfo) {
isEdit.value = true;
editId.value = record.id;
showPassword.value = false;
modalTitle.value = '编辑用户';
resetForm();
formData.username = record.username;
formData.nickname = record.nickname;
formData.phone = record.phone || '';
formData.email = record.email || '';
formData.status = record.status;
formData.roleIds = record.userRoles?.map((ur) => ur.roleId) || [];
modalVisible.value = true;
}
function resetForm() {
formData.username = '';
formData.password = '';
formData.confirmPassword = '';
formData.nickname = '';
formData.phone = '';
formData.email = '';
formData.roleIds = [];
formData.status = 1;
if (formRef.value) {
formRef.value.resetFields();
}
}
async function handleSubmit() {
try {
await formRef.value.validate();
} catch {
return;
}
modalLoading.value = true;
try {
let res: any;
if (isEdit.value && editId.value) {
const updateData: any = {
nickname: formData.nickname,
phone: formData.phone,
email: formData.email,
roleIds: formData.roleIds,
status: formData.status,
};
if (showPassword.value && formData.password) {
updateData.password = formData.password;
}
res = await userApi.updateUser(editId.value, updateData);
} else {
res = await userApi.createUser({
username: formData.username,
password: formData.password,
nickname: formData.nickname,
phone: formData.phone,
email: formData.email,
roleIds: formData.roleIds,
status: formData.status,
});
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
fetchUserList();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
modalLoading.value = false;
}
}
function handleCancel() {
modalVisible.value = false;
resetForm();
}
async function handleDelete(id: number) {
try {
const res: any = await userApi.deleteUser(id);
if (res.code === 200) {
message.success('删除成功');
fetchUserList();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
function handleBatchDelete() {
if (!selectedRowKeys.value.length) return;
Modal.confirm({
title: '批量删除确认',
content: `确定要删除选中的 ${selectedRowKeys.value.length} 个用户吗?此操作不可恢复!`,
okText: '确定',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
const res: any = await userApi.batchDeleteUsers(selectedRowKeys.value);
if (res.code === 200) {
message.success(res.message);
selectedRowKeys.value = [];
fetchUserList();
} else {
message.error(res.message || '批量删除失败');
}
} catch (error) {
message.error('批量删除失败');
}
},
});
}
async function handleStatusChange(id: number, status: number) {
try {
const res: any = await userApi.updateStatus(id, status);
if (res.code === 200) {
message.success(status === 1 ? '已启用' : '已禁用');
fetchUserList();
} else {
message.error(res.message || '状态更新失败');
}
} catch (error) {
message.error('状态更新失败');
}
}
function getRoleColor(code?: string): string {
const colorMap: Record<string, string> = {
admin: 'red',
manager: 'orange',
operator: 'blue',
user: 'green',
};
return colorMap[code || ''] || 'default';
}
</script>
<style scoped lang="less">
.user-management {
padding: 24px;
.search-form {
margin-bottom: 16px;
}
.table-operations {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
:deep(.ant-table) {
.ant-table-thead > tr > th {
background-color: #fafafa;
font-weight: 600;
}
.ant-table-tbody > tr:hover > td {
background-color: #e6f7ff;
}
}
:deep(.ant-avatar) {
background-color: #1890ff;
color: #fff;
}
}
</style>