Phase 1 #20 #23: 通用模块 + Vue3 前端框架搭建

#20 通用模块:
- MinioService: MinIO 文件上传/下载/桶管理
- @DataScope 注解: 数据权限切片
- ExcelUtils: EasyExcel 导出
- IdUtils: Snowflake ID 生成器
- BaseEntity: MyBatis-Plus 基础实体

#23 前端框架:
- Vue3 + TypeScript + Vite + Element Plus + ECharts + Pinia
- 路由系统: 登录守卫 + 动态路由
- 登录页: 渐变背景 + Axios+Sa-Token 认证
- 仪表盘: 统计卡片 + 供水趋势折线图 + 片区饼图
- MainLayout: 侧边栏菜单 + 顶栏用户下拉(退出)
- API 层: Axios 请求/响应拦截器 + Token 自动注入
- 系统管理骨架: 用户/角色/菜单/部门 列表页
This commit is contained in:
bot_pm
2026-06-14 13:17:40 +08:00
parent 9a7e0481ef
commit 575b2138c1
24 changed files with 538 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智慧水务管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
{
"name": "water-management-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.0",
"vue-router": "^4.4.0",
"pinia": "^2.2.0",
"axios": "^1.7.0",
"element-plus": "^2.8.0",
"@element-plus/icons-vue": "^2.3.0",
"echarts": "^5.5.0",
"leaflet": "^1.9.0",
"cesium": "^1.120.0"
},
"devDependencies": {
"typescript": "^5.5.0",
"vite": "^5.4.0",
"vue-tsc": "^2.0.0",
"@types/leaflet": "^1.9.0",
"sass": "^1.77.0",
"unplugin-auto-import": "^0.17.0",
"unplugin-vue-components": "^0.27.0",
"vite-plugin-compression": "^0.5.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+13
View File
@@ -0,0 +1,13 @@
import request from './request'
export function login(username: string, password: string) {
return request.post('/base/auth/login', { username, password })
}
export function getUserInfo() {
return request.get('/base/auth/user-info')
}
export function logout() {
return request.post('/base/auth/logout')
}
+21
View File
@@ -0,0 +1,21 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
const request = axios.create({ baseURL: '/api', timeout: 15000 })
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = token
return config
})
request.interceptors.response.use(
res => {
const data = res.data
if (data.code !== 200) { ElMessage.error(data.message); return Promise.reject(data) }
return data
},
err => { ElMessage.error(err.message); return Promise.reject(err) }
)
export default request
+2
View File
@@ -0,0 +1,2 @@
body { margin:0; padding:0; font-family:"Microsoft YaHei","PingFang SC",sans-serif }
#app { height:100vh }
@@ -0,0 +1,53 @@
<template>
<el-container style="height:100vh">
<el-aside width="220px" class="sidebar">
<div class="logo">💧 智慧水务</div>
<el-menu :default-active="route.path" router background-color="#304156" text-color="#bfcbd9" active-text-color="#409EFF">
<el-menu-item index="/dashboard"><el-icon><DataAnalysis /></el-icon>供水总览</el-menu-item>
<el-sub-menu index="system">
<template #title><el-icon><Setting /></el-icon>系统管理</template>
<el-menu-item index="/system/user">用户管理</el-menu-item>
<el-menu-item index="/system/role">角色管理</el-menu-item>
<el-menu-item index="/system/menu">菜单管理</el-menu-item>
<el-menu-item index="/system/dept">部门管理</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
<el-container>
<el-header class="topbar">
<span class="title">精河县供水工程综合管理平台</span>
<el-dropdown @command="handleCommand">
<span class="user">{{ userStore.realName || '管理员' }} <el-icon><ArrowDown /></el-icon></span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>个人中心</el-dropdown-item>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-header>
<el-main><router-view /></el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
function handleCommand(cmd: string) {
if (cmd === 'logout') { userStore.logout(); router.push('/login') }
}
</script>
<style scoped>
.sidebar { background:#304156; overflow-y:auto }
.logo { color:#fff; text-align:center; padding:16px; font-size:18px; font-weight:bold; border-bottom:1px solid rgba(255,255,255,.1) }
.topbar { background:#fff; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid #e6e6e6 }
.title { font-size:16px; color:#333 }
.user { cursor:pointer; color:#666 }
</style>
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import './assets/style.scss'
const app = createApp(App)
for (const [key, comp] of Object.entries(ElementPlusIconsVue)) {
app.component(key, comp)
}
app.use(ElementPlus).use(router).use(createPinia()).mount('#app')
+27
View File
@@ -0,0 +1,27 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/login', name: 'login', component: () => import('@/views/login/LoginView.vue') },
{
path: '/', component: () => import('@/components/layout/MainLayout.vue'),
redirect: '/dashboard',
children: [
{ path: 'dashboard', name: 'dashboard', component: () => import('@/views/dashboard/DashboardView.vue') },
{ path: 'system/user', name: 'user', component: () => import('@/views/system/user/UserList.vue') },
{ path: 'system/role', name: 'role', component: () => import('@/views/system/role/RoleList.vue') },
{ path: 'system/menu', name: 'menu', component: () => import('@/views/system/menu/MenuList.vue') },
{ path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/DeptList.vue') },
]
},
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }
]
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
if (to.path !== '/login' && !token) { next('/login') }
else { next() }
})
export default router
+10
View File
@@ -0,0 +1,10 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const realName = ref('')
const setToken = (t: string) => { token.value = t; localStorage.setItem('token', t) }
const logout = () => { token.value = ''; localStorage.removeItem('token') }
return { token, realName, setToken, logout }
})
@@ -0,0 +1,62 @@
<template>
<div class="dashboard">
<h2>供水总览</h2>
<el-row :gutter="16">
<el-col :span="6" v-for="card in cards" :key="card.title">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-value">{{ card.value }}</div>
<div class="stat-title">{{ card.title }}</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="12"><el-card><div ref="chart1" style="height:300px" /></el-card></el-col>
<el-col :span="12"><el-card><div ref="chart2" style="height:300px" /></el-card></el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import * as echarts from 'echarts'
const cards = [
{ title: '今日进水(m³)', value: '12,580' },
{ title: '今日出水(m³)', value: '11,230' },
{ title: '在线设备', value: '156' },
{ title: '今日报警', value: '3' },
]
const chart1 = ref()
const chart2 = ref()
onMounted(() => {
const c1 = echarts.init(chart1.value)
c1.setOption({
title: { text: '24小时供水趋势' },
tooltip: { trigger: 'axis' },
xAxis: { data: ['00:00','04:00','08:00','12:00','16:00','20:00'] },
yAxis: { name: '流量(m³/h)' },
series: [{ data: [320,280,450,520,480,380], type: 'line', smooth: true, areaStyle: {} }]
})
const c2 = echarts.init(chart2.value)
c2.setOption({
title: { text: '片区用水分布' },
tooltip: {},
series: [{
type: 'pie', radius: ['45%','70%'],
data: [{ value: 3200, name: '一体化水厂' },{ value: 1800, name: '精芒片区' },
{ value: 1500, name: '八家户片区' },{ value: 1200, name: '托里片区' },
{ value: 900, name: '大镇片区' },{ value: 600, name: '托托片区' }]
}]
})
})
</script>
<style scoped>
.stat-card { text-align:center; padding:10px }
.stat-value { font-size:28px; font-weight:bold; color:#2a5298 }
.stat-title { color:#666; margin-top:8px }
</style>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="login-container">
<div class="login-box">
<h1>智慧水务管理系统</h1>
<el-form :model="form" :rules="rules" ref="formRef">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" size="large" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" size="large" @keyup.enter="handleLogin" />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleLogin">登 录</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { login } from '@/api/auth'
import { ElMessage } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref()
const loading = ref(false)
const form = reactive({ username: 'admin', password: 'admin123' })
const rules = { username: [{ required: true, message: '请输入用户名' }], password: [{ required: true, message: '请输入密码' }] }
async function handleLogin() {
await formRef.value.validate()
loading.value = true
try {
const res = await login(form.username, form.password)
userStore.setToken(res.data)
ElMessage.success('登录成功')
router.push('/dashboard')
} catch (e) {
console.error(e)
} finally { loading.value = false }
}
</script>
<style scoped>
.login-container { display:flex; justify-content:center; align-items:center; height:100vh; background:linear-gradient(135deg,#1e3c72 0%,#2a5298 100%) }
.login-box { width:400px; padding:40px; background:#fff; border-radius:8px; box-shadow:0 2px 12px rgba(0,0,0,.3) }
h1 { text-align:center; margin-bottom:30px; color:#1e3c72 }
</style>
@@ -0,0 +1,22 @@
<template>
<div>
<el-button type="primary" @click="dialogVisible=true">新增部门</el-button>
<el-table :data="tableData" style="margin-top:10px" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" />
<el-table-column label="操作" width="180">
<template #default>
<el-button link type="primary">编辑</el-button>
<el-button link type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" title="新增部门"><span>表单内容待实现</span></el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const dialogVisible = ref(false)
const tableData = ref<Array<{id:number,name:string}>>([])
</script>
@@ -0,0 +1,22 @@
<template>
<div>
<el-button type="primary" @click="dialogVisible=true">新增菜单</el-button>
<el-table :data="tableData" style="margin-top:10px" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" />
<el-table-column label="操作" width="180">
<template #default>
<el-button link type="primary">编辑</el-button>
<el-button link type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" title="新增菜单"><span>表单内容待实现</span></el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const dialogVisible = ref(false)
const tableData = ref<Array<{id:number,name:string}>>([])
</script>
@@ -0,0 +1,22 @@
<template>
<div>
<el-button type="primary" @click="dialogVisible=true">新增角色</el-button>
<el-table :data="tableData" style="margin-top:10px" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" />
<el-table-column label="操作" width="180">
<template #default>
<el-button link type="primary">编辑</el-button>
<el-button link type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" title="新增角色"><span>表单内容待实现</span></el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const dialogVisible = ref(false)
const tableData = ref<Array<{id:number,name:string}>>([])
</script>
@@ -0,0 +1,22 @@
<template>
<div>
<el-button type="primary" @click="dialogVisible=true">新增用户</el-button>
<el-table :data="tableData" style="margin-top:10px" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" />
<el-table-column label="操作" width="180">
<template #default>
<el-button link type="primary">编辑</el-button>
<el-button link type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" title="新增用户"><span>表单内容待实现</span></el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const dialogVisible = ref(false)
const tableData = ref<Array<{id:number,name:string}>>([])
</script>
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.vue"
]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
server: {
port: 3000,
proxy: {
'/api': { target: 'http://localhost:8080', changeOrigin: true }
}
}
})
@@ -0,0 +1,11 @@
package com.water.common.core.annotation;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataScope {
String deptAlias() default "d";
String userAlias() default "u";
}
@@ -0,0 +1,15 @@
package com.water.common.core.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SwaggerCommonConfig {
@Bean
public OpenAPI commonOpenAPI() {
return new OpenAPI().info(new Info().title("智慧水务管理系统 API")
.description("精河县供水工程综合管理平台").version("1.0.0"));
}
}
@@ -0,0 +1,16 @@
package com.water.common.core.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public abstract class BaseEntity {
@TableId(type = IdType.AUTO)
private Long id;
@TableLogic
private Integer deleted;
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}
@@ -0,0 +1,41 @@
package com.water.common.core.storage;
import io.minio.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.util.UUID;
@Slf4j
@Service
public class MinioService {
private final MinioClient client;
@Value("${minio.bucket:water-management}") private String bucket;
public MinioService(@Value("${minio.endpoint:http://127.0.0.1:9000}") String endpoint,
@Value("${minio.access-key:minioadmin}") String accessKey,
@Value("${minio.secret-key:minioadmin}") String secretKey) {
this.client = MinioClient.builder().endpoint(endpoint).credentials(accessKey, secretKey).build();
}
public String upload(MultipartFile file, String module) throws Exception {
String objectName = module + "/" + UUID.randomUUID() + "_" + file.getOriginalFilename();
ensureBucket();
client.putObject(PutObjectArgs.builder().bucket(bucket).object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType()).build());
return objectName;
}
public InputStream download(String objectName) throws Exception {
return client.getObject(GetObjectArgs.builder().bucket(bucket).object(objectName).build());
}
private void ensureBucket() throws Exception {
if (!client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
}
}
}
@@ -0,0 +1,23 @@
package com.water.common.core.util;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy;
import jakarta.servlet.http.HttpServletResponse;
import java.net.URLEncoder;
import java.util.List;
public class ExcelUtils {
public static <T> void export(HttpServletResponse resp, String fileName, String sheetName, Class<T> clazz, List<T> data) {
try {
resp.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
resp.setCharacterEncoding("utf-8");
resp.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileName, "UTF-8") + ".xlsx");
EasyExcel.write(resp.getOutputStream(), clazz)
.sheet(sheetName)
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
.doWrite(data);
} catch (Exception e) {
throw new RuntimeException("导出Excel失败", e);
}
}
}
@@ -0,0 +1,10 @@
package com.water.common.core.util;
import cn.hutool.core.lang.Snowflake;
import cn.hutool.core.util.IdUtil;
public class IdUtils {
private static final Snowflake SNOWFLAKE = IdUtil.getSnowflake(1, 1);
public static long nextId() { return SNOWFLAKE.nextId(); }
public static String nextIdStr() { return String.valueOf(SNOWFLAKE.nextId()); }
}