feat: 初始化智能物联网设备管理系统前后端基础项目
完成项目整体结构搭建,包含: 1. 前端Vue3+TS+Vite+Antd项目基础配置与核心依赖 2. 后端MidwayJS+TypeORM+JWT权限认证框架 3. 完整的用户、角色、权限、日志数据库表结构 4. 核心业务模块API与基础服务实现 5. 全局样式、路由、状态管理配置
This commit is contained in:
commit
3d7eb12103
89 files changed
+16085
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>© 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user