first commit

This commit is contained in:
zhangtao 2025-08-01 17:58:00 +08:00
commit 5fd3dccb3a
24 changed files with 3848 additions and 0 deletions

17
env.example Normal file
View File

@ -0,0 +1,17 @@
# API 基础地址
VITE_API_BASE_URL=http://localhost:8080
# 应用标题
VITE_APP_TITLE=DeployHelper 管理系统
# 应用版本
VITE_APP_VERSION=1.0.0
# 是否启用调试模式
VITE_DEBUG=false
# 文件上传大小限制 (MB)
VITE_UPLOAD_SIZE_LIMIT=50
# 分页默认大小
VITE_PAGE_SIZE=10

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DeployHelper 前端管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1718
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "deploy-helper-front",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.12",
"vue-router": "^4.5.0",
"tdesign-vue-next": "^1.10.4",
"tdesign-icons-vue-next": "^0.2.6",
"axios": "^1.7.9"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"vite": "^7.0.6",
"@types/node": "^22.10.5"
}
}

67
src/App.vue Normal file
View File

@ -0,0 +1,67 @@
<template>
<div id="app">
<t-layout style="height: 100vh;">
<t-header class="header">
<t-head-menu
:value="activeMenu"
theme="light"
@change="handleMenuChange"
>
<div class="logo" slot="logo">
<span class="logo-text">DeployHelper</span>
</div>
<t-menu-item value="home">首页</t-menu-item>
<t-menu-item value="deploy">部署管理</t-menu-item>
<t-menu-item value="files">文件管理</t-menu-item>
<t-menu-item value="logs">日志管理</t-menu-item>
<t-menu-item value="users">用户管理</t-menu-item>
</t-head-menu>
</t-header>
<t-content class="content">
<router-view />
</t-content>
</t-layout>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const activeMenu = ref('home')
const handleMenuChange = (value) => {
activeMenu.value = value
router.push(`/${value}`)
}
</script>
<style scoped>
.header {
background-color: #fff;
border-bottom: 1px solid #e7e7e7;
}
.logo {
display: flex;
align-items: center;
padding: 0 24px;
}
.logo-text {
font-size: 20px;
font-weight: bold;
color: #0052d9;
}
.content {
padding: 24px;
background-color: #f5f5f5;
}
#app {
font-family: 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
}
</style>

39
src/api/deploy.js Normal file
View File

@ -0,0 +1,39 @@
import api from './index'
// 部署相关 API
export const deployApi = {
// 获取部署列表
getDeployList(params) {
return api.get('/sys_deploy_file/list', { params })
},
// 创建部署
createDeploy(data) {
return api.post('/sys_deploy_file/create', data)
},
// 获取部署详情
getDeployDetail(id) {
return api.get(`/sys_deploy_file/detail/${id}`)
},
// 更新部署
updateDeploy(id, data) {
return api.put(`/sys_deploy_file/update/${id}`, data)
},
// 删除部署
deleteDeploy(id) {
return api.delete(`/sys_deploy_file/delete/${id}`)
},
// 执行部署
executeDeploy(id) {
return api.post(`/sys_deploy_file/execute/${id}`)
},
// 获取部署状态
getDeployStatus(id) {
return api.get(`/sys_deploy_file/status/${id}`)
}
}

74
src/api/index.js Normal file
View File

@ -0,0 +1,74 @@
import axios from 'axios'
import { MessagePlugin } from 'tdesign-vue-next'
// 创建 axios 实例
const api = axios.create({
baseURL: '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
api.interceptors.request.use(
(config) => {
// 添加 token
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
const { data } = response
// 统一处理响应
if (data.code === 200) {
return data.data
} else {
MessagePlugin.error(data.message || '请求失败')
return Promise.reject(new Error(data.message || '请求失败'))
}
},
(error) => {
// 处理错误响应
if (error.response) {
const { status, data } = error.response
switch (status) {
case 401:
MessagePlugin.error('登录已过期,请重新登录')
localStorage.removeItem('token')
window.location.href = '/login'
break
case 403:
MessagePlugin.error('没有权限访问该资源')
break
case 404:
MessagePlugin.error('请求的资源不存在')
break
case 500:
MessagePlugin.error('服务器内部错误')
break
default:
MessagePlugin.error(data.message || '网络错误')
}
} else if (error.request) {
MessagePlugin.error('网络连接失败,请检查网络')
} else {
MessagePlugin.error(error.message || '请求失败')
}
return Promise.reject(error)
}
)
export default api

42
src/api/log.js Normal file
View File

@ -0,0 +1,42 @@
import api from './index'
// 日志相关 API
export const logApi = {
// 获取登录日志列表
getLoginLogList(params) {
return api.get('/sys_login_log/list', { params })
},
// 获取系统日志
getSystemLogs(params) {
return api.get('/logs/system', { params })
},
// 获取应用日志
getAppLogs(params) {
return api.get('/logs/app', { params })
},
// 获取错误日志
getErrorLogs(params) {
return api.get('/logs/error', { params })
},
// 搜索日志
searchLogs(params) {
return api.get('/logs/search', { params })
},
// 下载日志文件
downloadLogs(params) {
return api.get('/logs/download', {
params,
responseType: 'blob'
})
},
// 清理日志
clearLogs(params) {
return api.post('/logs/clear', params)
}
}

46
src/api/upload.js Normal file
View File

@ -0,0 +1,46 @@
import api from './index'
// 文件上传相关 API
export const uploadApi = {
// 上传文件
uploadFile(file, onProgress) {
const formData = new FormData()
formData.append('file', file)
return api.post('/sys_upload/file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
if (onProgress) {
const percent = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
)
onProgress(percent)
}
}
})
},
// 获取文件列表
getFileList(params) {
return api.get('/sys_upload/list', { params })
},
// 删除文件
deleteFile(id) {
return api.delete(`/sys_upload/delete/${id}`)
},
// 下载文件
downloadFile(id) {
return api.get(`/sys_upload/download/${id}`, {
responseType: 'blob'
})
},
// 获取文件详情
getFileDetail(id) {
return api.get(`/sys_upload/detail/${id}`)
}
}

49
src/api/user.js Normal file
View File

@ -0,0 +1,49 @@
import api from './index'
// 用户相关 API
export const userApi = {
// 用户登录
login(data) {
return api.post('/sys_user/login', data)
},
// 用户登出
logout() {
return api.post('/sys_user/logout')
},
// 获取用户信息
getUserInfo() {
return api.get('/sys_user/info')
},
// 获取用户列表
getUserList(params) {
return api.get('/sys_user/list', { params })
},
// 创建用户
createUser(data) {
return api.post('/sys_user/create', data)
},
// 更新用户
updateUser(id, data) {
return api.put(`/sys_user/update/${id}`, data)
},
// 删除用户
deleteUser(id) {
return api.delete(`/sys_user/delete/${id}`)
},
// 重置密码
resetPassword(id, data) {
return api.post(`/sys_user/reset-password/${id}`, data)
},
// 修改密码
changePassword(data) {
return api.post('/sys_user/change-password', data)
}
}

View File

@ -0,0 +1,66 @@
<template>
<div class="empty-state">
<div class="empty-icon">
<t-icon :name="icon" size="64px" />
</div>
<div class="empty-content">
<h3 class="empty-title">{{ title }}</h3>
<p v-if="description" class="empty-description">{{ description }}</p>
<div v-if="$slots.actions" class="empty-actions">
<slot name="actions"></slot>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
icon: {
type: String,
default: 'inbox'
},
title: {
type: String,
default: '暂无数据'
},
description: {
type: String,
default: ''
}
})
</script>
<style scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
}
.empty-icon {
margin-bottom: 24px;
color: #d9d9d9;
}
.empty-title {
margin: 0 0 8px 0;
font-size: 16px;
font-weight: 500;
color: #8c8c8c;
}
.empty-description {
margin: 0 0 24px 0;
font-size: 14px;
color: #bfbfbf;
max-width: 400px;
line-height: 1.5;
}
.empty-actions {
margin-top: 16px;
}
</style>

View File

@ -0,0 +1,57 @@
<template>
<div class="loading-spinner" :class="{ 'full-screen': fullScreen }">
<div class="spinner-content">
<t-loading :size="size" />
<p v-if="text" class="loading-text">{{ text }}</p>
</div>
</div>
</template>
<script setup>
defineProps({
fullScreen: {
type: Boolean,
default: false
},
size: {
type: String,
default: 'medium'
},
text: {
type: String,
default: ''
}
})
</script>
<style scoped>
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.loading-spinner.full-screen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
z-index: 9999;
}
.spinner-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.loading-text {
margin: 0;
color: #8c8c8c;
font-size: 14px;
}
</style>

View File

@ -0,0 +1,106 @@
<template>
<div class="page-header">
<div class="page-header-content">
<div class="page-title">
<h1>{{ title }}</h1>
<p v-if="description" class="page-description">{{ description }}</p>
</div>
<div v-if="$slots.extra" class="page-extra">
<slot name="extra"></slot>
</div>
</div>
<div v-if="showBreadcrumb" class="page-breadcrumb">
<t-breadcrumb>
<t-breadcrumb-item v-for="item in breadcrumbItems" :key="item.path">
<router-link v-if="item.path" :to="item.path">{{ item.title }}</router-link>
<span v-else>{{ item.title }}</span>
</t-breadcrumb-item>
</t-breadcrumb>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const props = defineProps({
title: {
type: String,
required: true
},
description: {
type: String,
default: ''
},
showBreadcrumb: {
type: Boolean,
default: true
},
breadcrumb: {
type: Array,
default: () => []
}
})
const route = useRoute()
const breadcrumbItems = computed(() => {
if (props.breadcrumb.length > 0) {
return props.breadcrumb
}
//
const routeMap = {
'/home': '首页',
'/deploy': '部署管理',
'/files': '文件管理',
'/logs': '日志管理',
'/users': '用户管理'
}
return [
{ title: '首页', path: '/home' },
{ title: routeMap[route.path] || '页面', path: null }
]
})
</script>
<style scoped>
.page-header {
background: #fff;
padding: 24px;
margin-bottom: 24px;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.page-header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.page-title h1 {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
color: #262626;
}
.page-description {
margin: 0;
color: #8c8c8c;
font-size: 14px;
}
.page-extra {
flex-shrink: 0;
}
.page-breadcrumb {
border-top: 1px solid #e7e7e7;
padding-top: 16px;
}
</style>

14
src/main.js Normal file
View File

@ -0,0 +1,14 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
// 引入 TDesign 样式和组件
import TDesign from 'tdesign-vue-next'
import 'tdesign-vue-next/es/style/index.css'
const app = createApp(App)
app.use(TDesign)
app.use(router)
app.mount('#app')

45
src/router/index.js Normal file
View File

@ -0,0 +1,45 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import Deploy from '@/views/Deploy.vue'
import Files from '@/views/Files.vue'
import Logs from '@/views/Logs.vue'
import Users from '@/views/Users.vue'
const routes = [
{
path: '/',
redirect: '/home'
},
{
path: '/home',
name: 'Home',
component: Home
},
{
path: '/deploy',
name: 'Deploy',
component: Deploy
},
{
path: '/files',
name: 'Files',
component: Files
},
{
path: '/logs',
name: 'Logs',
component: Logs
},
{
path: '/users',
name: 'Users',
component: Users
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

163
src/utils/format.js Normal file
View File

@ -0,0 +1,163 @@
// 格式化工具函数
/**
* 格式化文件大小
* @param {number} size 文件大小字节
* @returns {string} 格式化后的文件大小
*/
export function formatFileSize(size) {
if (!size || size === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const index = Math.floor(Math.log(size) / Math.log(1024))
const formattedSize = (size / Math.pow(1024, index)).toFixed(2)
return `${formattedSize} ${units[index]}`
}
/**
* 格式化时间
* @param {Date|string|number} date 时间
* @param {string} format 格式模板
* @returns {string} 格式化后的时间
*/
export function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
if (!date) return '-'
const d = new Date(date)
if (isNaN(d.getTime())) return '-'
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
const seconds = String(d.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
}
/**
* 格式化相对时间
* @param {Date|string|number} date 时间
* @returns {string} 相对时间
*/
export function formatRelativeTime(date) {
if (!date) return '-'
const now = new Date()
const targetDate = new Date(date)
const diff = now - targetDate
const minute = 60 * 1000
const hour = minute * 60
const day = hour * 24
const month = day * 30
const year = month * 12
if (diff < minute) {
return '刚刚'
} else if (diff < hour) {
return `${Math.floor(diff / minute)}分钟前`
} else if (diff < day) {
return `${Math.floor(diff / hour)}小时前`
} else if (diff < month) {
return `${Math.floor(diff / day)}天前`
} else if (diff < year) {
return `${Math.floor(diff / month)}个月前`
} else {
return `${Math.floor(diff / year)}年前`
}
}
/**
* 格式化数字
* @param {number} num 数字
* @param {number} decimals 小数位数
* @returns {string} 格式化后的数字
*/
export function formatNumber(num, decimals = 0) {
if (num === null || num === undefined || isNaN(num)) return '0'
return Number(num).toLocaleString('zh-CN', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals
})
}
/**
* 格式化百分比
* @param {number} value 数值
* @param {number} total 总数
* @param {number} decimals 小数位数
* @returns {string} 百分比
*/
export function formatPercent(value, total, decimals = 1) {
if (!total || total === 0) return '0%'
const percent = (value / total) * 100
return `${percent.toFixed(decimals)}%`
}
/**
* 格式化状态文本
* @param {string} status 状态值
* @returns {object} 状态配置
*/
export function formatStatus(status) {
const statusMap = {
'active': { text: '启用', theme: 'success' },
'inactive': { text: '禁用', theme: 'default' },
'pending': { text: '待处理', theme: 'warning' },
'processing': { text: '处理中', theme: 'primary' },
'success': { text: '成功', theme: 'success' },
'failed': { text: '失败', theme: 'danger' },
'error': { text: '错误', theme: 'danger' },
'cancelled': { text: '已取消', theme: 'default' }
}
return statusMap[status] || { text: status, theme: 'default' }
}
/**
* 格式化手机号
* @param {string} phone 手机号
* @returns {string} 格式化后的手机号
*/
export function formatPhone(phone) {
if (!phone) return '-'
const phoneStr = String(phone)
if (phoneStr.length === 11) {
return phoneStr.replace(/(\d{3})(\d{4})(\d{4})/, '$1****$3')
}
return phone
}
/**
* 格式化邮箱
* @param {string} email 邮箱
* @returns {string} 格式化后的邮箱
*/
export function formatEmail(email) {
if (!email) return '-'
const atIndex = email.indexOf('@')
if (atIndex > 0) {
const username = email.substring(0, atIndex)
const domain = email.substring(atIndex)
if (username.length > 3) {
return `${username.substring(0, 3)}***${domain}`
}
}
return email
}

62
src/utils/storage.js Normal file
View File

@ -0,0 +1,62 @@
// 本地存储工具类
class Storage {
constructor(storage = localStorage) {
this.storage = storage
}
// 设置存储项
set(key, value, expire = null) {
const item = {
value,
expire: expire ? Date.now() + expire * 1000 : null
}
this.storage.setItem(key, JSON.stringify(item))
}
// 获取存储项
get(key, defaultValue = null) {
try {
const item = this.storage.getItem(key)
if (!item) return defaultValue
const data = JSON.parse(item)
// 检查是否过期
if (data.expire && Date.now() > data.expire) {
this.remove(key)
return defaultValue
}
return data.value
} catch (error) {
console.error('Storage get error:', error)
return defaultValue
}
}
// 删除存储项
remove(key) {
this.storage.removeItem(key)
}
// 清空存储
clear() {
this.storage.clear()
}
// 获取所有键
keys() {
return Object.keys(this.storage)
}
// 检查键是否存在
has(key) {
return this.storage.getItem(key) !== null
}
}
// 创建实例
export const localStorage = new Storage(window.localStorage)
export const sessionStorage = new Storage(window.sessionStorage)
export default localStorage

190
src/views/Deploy.vue Normal file
View File

@ -0,0 +1,190 @@
<template>
<div class="deploy">
<t-card title="部署管理">
<template #actions>
<t-button theme="primary" @click="handleCreate">
<t-icon name="add" />
创建部署
</t-button>
</template>
<t-table
:data="deployList"
:columns="columns"
:loading="loading"
row-key="id"
:pagination="pagination"
@page-change="handlePageChange"
/>
</t-card>
<!-- 创建部署对话框 -->
<t-dialog
v-model:visible="createVisible"
title="创建部署"
width="600px"
@confirm="handleSubmit"
>
<t-form ref="formRef" :data="formData" :rules="rules" label-width="100px">
<t-form-item label="部署名称" name="name">
<t-input v-model="formData.name" placeholder="请输入部署名称" />
</t-form-item>
<t-form-item label="部署环境" name="environment">
<t-select v-model="formData.environment" placeholder="请选择部署环境">
<t-option value="dev" label="开发环境" />
<t-option value="test" label="测试环境" />
<t-option value="prod" label="生产环境" />
</t-select>
</t-form-item>
<t-form-item label="部署文件" name="file">
<t-upload
v-model="formData.file"
theme="file-input"
placeholder="请选择部署文件"
/>
</t-form-item>
<t-form-item label="描述" name="description">
<t-textarea
v-model="formData.description"
placeholder="请输入部署描述"
:maxlength="200"
/>
</t-form-item>
</t-form>
</t-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
const loading = ref(false)
const createVisible = ref(false)
const formRef = ref()
const deployList = ref([
{
id: 1,
name: '前端应用部署',
environment: '生产环境',
status: '成功',
createTime: '2024-01-15 10:30:00',
user: 'admin'
},
{
id: 2,
name: '后端API部署',
environment: '测试环境',
status: '进行中',
createTime: '2024-01-15 09:20:00',
user: 'developer'
}
])
const columns = [
{
colKey: 'id',
title: 'ID',
width: 80
},
{
colKey: 'name',
title: '部署名称'
},
{
colKey: 'environment',
title: '环境'
},
{
colKey: 'status',
title: '状态',
cell: ({ row }) => {
if (!row || !row.status) return '-'
const statusMap = {
'成功': 'success',
'失败': 'danger',
'进行中': 'warning'
}
return `<t-tag theme="${statusMap[row.status] || 'default'}">${row.status}</t-tag>`
}
},
{
colKey: 'user',
title: '操作用户'
},
{
colKey: 'createTime',
title: '创建时间'
},
{
colKey: 'action',
title: '操作',
cell: ({ row }) => {
if (!row || !row.id) return '-'
return `
<t-button theme="primary" variant="text" size="small">查看</t-button>
<t-button theme="danger" variant="text" size="small">删除</t-button>
`
}
}
]
const pagination = reactive({
current: 1,
pageSize: 10,
total: 2,
showJumper: true,
showSizer: true
})
const formData = reactive({
name: '',
environment: '',
file: [],
description: ''
})
const rules = {
name: [{ required: true, message: '请输入部署名称' }],
environment: [{ required: true, message: '请选择部署环境' }],
file: [{ required: true, message: '请选择部署文件' }]
}
const handleCreate = () => {
createVisible.value = true
}
const handleSubmit = async () => {
const result = await formRef.value.validate()
if (result === true) {
MessagePlugin.success('部署创建成功')
createVisible.value = false
//
Object.keys(formData).forEach(key => {
if (Array.isArray(formData[key])) {
formData[key] = []
} else {
formData[key] = ''
}
})
}
}
const handlePageChange = (pageInfo) => {
pagination.current = pageInfo.current
pagination.pageSize = pageInfo.pageSize
}
onMounted(() => {
//
})
</script>
<style scoped>
.deploy {
max-width: 1200px;
margin: 0 auto;
}
</style>

231
src/views/Files.vue Normal file
View File

@ -0,0 +1,231 @@
<template>
<div class="files">
<t-card title="文件管理">
<template #actions>
<t-space>
<t-button theme="primary" @click="handleUpload">
<t-icon name="upload" />
上传文件
</t-button>
<t-button variant="outline" @click="handleRefresh">
<t-icon name="refresh" />
刷新
</t-button>
</t-space>
</template>
<t-table
:data="fileList"
:columns="columns"
:loading="loading"
row-key="id"
:pagination="pagination"
@page-change="handlePageChange"
/>
</t-card>
<!-- 上传文件对话框 -->
<t-dialog
v-model:visible="uploadVisible"
title="上传文件"
width="600px"
@confirm="handleUploadSubmit"
>
<t-upload
v-model="uploadFiles"
theme="file-flow"
:auto-upload="false"
multiple
:max="5"
accept=".zip,.tar,.gz,.jar,.war"
>
<t-upload-dragger>
<div class="upload-dragger-content">
<t-icon name="cloud-upload" size="48px" />
<div class="upload-text">
<div class="upload-tip">点击上传或将文件拖拽到此区域</div>
<div class="upload-sub-tip">支持 .zip.tar.gz.jar.war 格式</div>
</div>
</div>
</t-upload-dragger>
</t-upload>
</t-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
const loading = ref(false)
const uploadVisible = ref(false)
const uploadFiles = ref([])
const fileList = ref([
{
id: 1,
filename: 'app-v1.2.0.zip',
size: '15.2 MB',
type: 'ZIP',
uploadTime: '2024-01-15 10:30:00',
uploader: 'admin',
status: '已上传'
},
{
id: 2,
filename: 'backend-api.jar',
size: '32.8 MB',
type: 'JAR',
uploadTime: '2024-01-15 09:20:00',
uploader: 'developer',
status: '已上传'
},
{
id: 3,
filename: 'config.tar.gz',
size: '2.1 MB',
type: 'TAR.GZ',
uploadTime: '2024-01-14 16:45:00',
uploader: 'admin',
status: '已上传'
}
])
const columns = [
{
colKey: 'id',
title: 'ID',
width: 80
},
{
colKey: 'filename',
title: '文件名',
cell: ({ row }) => {
if (!row || !row.filename) return '-'
return `<div class="file-cell">
<t-icon name="file" style="margin-right: 8px;" />
${row.filename}
</div>`
}
},
{
colKey: 'size',
title: '文件大小'
},
{
colKey: 'type',
title: '文件类型',
cell: ({ row }) => {
if (!row || !row.type) return '-'
const typeMap = {
'ZIP': 'primary',
'JAR': 'success',
'TAR.GZ': 'warning',
'WAR': 'danger'
}
return `<t-tag theme="${typeMap[row.type] || 'default'}">${row.type}</t-tag>`
}
},
{
colKey: 'uploader',
title: '上传者'
},
{
colKey: 'uploadTime',
title: '上传时间'
},
{
colKey: 'status',
title: '状态',
cell: ({ row }) => {
if (!row || !row.status) return '-'
return `<t-tag theme="success">${row.status}</t-tag>`
}
},
{
colKey: 'action',
title: '操作',
cell: ({ row }) => {
if (!row || !row.id) return '-'
return `
<t-button theme="primary" variant="text" size="small">下载</t-button>
<t-button theme="danger" variant="text" size="small">删除</t-button>
`
}
}
]
const pagination = reactive({
current: 1,
pageSize: 10,
total: 3,
showJumper: true,
showSizer: true
})
const handleUpload = () => {
uploadVisible.value = true
}
const handleRefresh = () => {
loading.value = true
setTimeout(() => {
loading.value = false
MessagePlugin.success('刷新成功')
}, 1000)
}
const handleUploadSubmit = () => {
if (uploadFiles.value.length === 0) {
MessagePlugin.warning('请选择要上传的文件')
return
}
MessagePlugin.success('文件上传成功')
uploadVisible.value = false
uploadFiles.value = []
}
const handlePageChange = (pageInfo) => {
pagination.current = pageInfo.current
pagination.pageSize = pageInfo.pageSize
}
onMounted(() => {
//
})
</script>
<style scoped>
.files {
max-width: 1200px;
margin: 0 auto;
}
.file-cell {
display: flex;
align-items: center;
}
.upload-dragger-content {
text-align: center;
padding: 40px 20px;
}
.upload-text {
margin-top: 16px;
}
.upload-tip {
font-size: 16px;
color: #262626;
margin-bottom: 8px;
}
.upload-sub-tip {
font-size: 14px;
color: #8c8c8c;
}
</style>

137
src/views/Home.vue Normal file
View File

@ -0,0 +1,137 @@
<template>
<div class="home">
<t-card class="welcome-card" title="欢迎使用 DeployHelper">
<template #subtitle>
一个简单高效的部署管理系统
</template>
<div class="stats-grid">
<t-card class="stat-card" v-for="stat in stats" :key="stat.title">
<div class="stat-content">
<div class="stat-icon">
<t-icon :name="stat.icon" size="24px" :style="{ color: stat.color }" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-title">{{ stat.title }}</div>
</div>
</div>
</t-card>
</div>
<t-divider />
<t-space direction="vertical" size="large">
<div>
<h3>快速开始</h3>
<t-space>
<t-button theme="primary" @click="goToDeploy">开始部署</t-button>
<t-button variant="outline" @click="goToFiles">管理文件</t-button>
<t-button variant="outline" @click="goToLogs">查看日志</t-button>
</t-space>
</div>
</t-space>
</t-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const stats = ref([
{
title: '部署次数',
value: '128',
icon: 'rocket',
color: '#0052d9'
},
{
title: '文件数量',
value: '45',
icon: 'folder',
color: '#00a870'
},
{
title: '活跃用户',
value: '12',
icon: 'user',
color: '#ed7b2f'
},
{
title: '系统状态',
value: '正常',
icon: 'check-circle',
color: '#00a870'
}
])
const goToDeploy = () => {
router.push('/deploy')
}
const goToFiles = () => {
router.push('/files')
}
const goToLogs = () => {
router.push('/logs')
}
</script>
<style scoped>
.home {
max-width: 1200px;
margin: 0 auto;
}
.welcome-card {
margin-bottom: 24px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
margin: 24px 0;
}
.stat-card {
border: 1px solid #e7e7e7;
}
.stat-content {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 8px;
background-color: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #262626;
}
.stat-title {
font-size: 14px;
color: #8c8c8c;
margin-top: 4px;
}
h3 {
margin: 0 0 16px 0;
color: #262626;
}
</style>

252
src/views/Logs.vue Normal file
View File

@ -0,0 +1,252 @@
<template>
<div class="logs">
<t-card title="日志管理">
<template #actions>
<t-space>
<t-input
v-model="searchKeyword"
placeholder="搜索日志内容"
style="width: 200px;"
@enter="handleSearch"
>
<template #suffix-icon>
<t-icon name="search" />
</template>
</t-input>
<t-select
v-model="logLevel"
placeholder="日志级别"
style="width: 120px;"
@change="handleFilter"
>
<t-option value="" label="全部" />
<t-option value="INFO" label="INFO" />
<t-option value="WARN" label="WARN" />
<t-option value="ERROR" label="ERROR" />
<t-option value="DEBUG" label="DEBUG" />
</t-select>
<t-button variant="outline" @click="handleRefresh">
<t-icon name="refresh" />
刷新
</t-button>
</t-space>
</template>
<t-table
:data="logList"
:columns="columns"
:loading="loading"
row-key="id"
:pagination="pagination"
@page-change="handlePageChange"
/>
</t-card>
<!-- 日志详情对话框 -->
<t-dialog
v-model:visible="detailVisible"
title="日志详情"
width="800px"
:footer="false"
>
<div class="log-detail" v-if="currentLog">
<t-descriptions :data="logDetailData" />
<t-divider />
<div class="log-content">
<h4>日志内容</h4>
<pre class="log-text">{{ currentLog.content }}</pre>
</div>
</div>
</t-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
const loading = ref(false)
const detailVisible = ref(false)
const searchKeyword = ref('')
const logLevel = ref('')
const currentLog = ref(null)
const logList = ref([
{
id: 1,
level: 'INFO',
module: '部署模块',
message: '部署任务开始执行',
content: '[2024-01-15 10:30:00] INFO: Deploy task started for project: frontend-app\nTarget environment: production\nExecuting deployment steps...',
timestamp: '2024-01-15 10:30:00',
user: 'admin'
},
{
id: 2,
level: 'ERROR',
module: '文件上传',
message: '文件上传失败:文件大小超过限制',
content: '[2024-01-15 10:25:00] ERROR: File upload failed\nReason: File size exceeds maximum limit (50MB)\nFile: large-package.zip (85MB)\nUser: developer',
timestamp: '2024-01-15 10:25:00',
user: 'developer'
},
{
id: 3,
level: 'WARN',
module: '系统监控',
message: '内存使用率较高',
content: '[2024-01-15 10:20:00] WARN: Memory usage is high\nCurrent usage: 85%\nRecommend cleanup or scaling\nSystem performance may be affected',
timestamp: '2024-01-15 10:20:00',
user: 'system'
},
{
id: 4,
level: 'INFO',
module: '用户登录',
message: '用户登录成功',
content: '[2024-01-15 10:15:00] INFO: User login successful\nUsername: admin\nIP Address: 192.168.1.100\nUser Agent: Mozilla/5.0...',
timestamp: '2024-01-15 10:15:00',
user: 'admin'
}
])
const columns = [
{
colKey: 'id',
title: 'ID',
width: 80
},
{
colKey: 'level',
title: '级别',
width: 100,
cell: ({ row }) => {
if (!row || !row.level) return '-'
const levelMap = {
'INFO': { theme: 'primary', color: '#0052d9' },
'WARN': { theme: 'warning', color: '#ed7b2f' },
'ERROR': { theme: 'danger', color: '#e34d59' },
'DEBUG': { theme: 'default', color: '#8c8c8c' }
}
const config = levelMap[row.level] || levelMap['DEBUG']
return `<t-tag theme="${config.theme}">${row.level}</t-tag>`
}
},
{
colKey: 'module',
title: '模块'
},
{
colKey: 'message',
title: '消息',
ellipsis: true
},
{
colKey: 'user',
title: '用户'
},
{
colKey: 'timestamp',
title: '时间'
},
{
colKey: 'action',
title: '操作',
cell: ({ row }) => {
if (!row || !row.id) return '-'
return `<t-button theme="primary" variant="text" size="small" onclick="showDetail(${row.id})">查看详情</t-button>`
}
}
]
const pagination = reactive({
current: 1,
pageSize: 10,
total: 4,
showJumper: true,
showSizer: true
})
const logDetailData = computed(() => {
if (!currentLog.value) return []
return [
{ label: '日志ID', value: currentLog.value.id },
{ label: '级别', value: currentLog.value.level },
{ label: '模块', value: currentLog.value.module },
{ label: '用户', value: currentLog.value.user },
{ label: '时间', value: currentLog.value.timestamp },
{ label: '消息', value: currentLog.value.message }
]
})
//
window.showDetail = (id) => {
currentLog.value = logList.value.find(log => log.id === id)
detailVisible.value = true
}
const handleSearch = () => {
MessagePlugin.info(`搜索关键词:${searchKeyword.value}`)
// API
}
const handleFilter = () => {
MessagePlugin.info(`筛选日志级别:${logLevel.value || '全部'}`)
// API
}
const handleRefresh = () => {
loading.value = true
setTimeout(() => {
loading.value = false
MessagePlugin.success('刷新成功')
}, 1000)
}
const handlePageChange = (pageInfo) => {
pagination.current = pageInfo.current
pagination.pageSize = pageInfo.pageSize
}
onMounted(() => {
//
})
</script>
<style scoped>
.logs {
max-width: 1200px;
margin: 0 auto;
}
.log-detail {
max-height: 600px;
overflow-y: auto;
}
.log-content {
margin-top: 16px;
}
.log-content h4 {
margin: 0 0 12px 0;
color: #262626;
}
.log-text {
background-color: #1e1e1e;
color: #d4d4d4;
padding: 16px;
border-radius: 6px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
max-height: 300px;
overflow-y: auto;
}
</style>

384
src/views/Users.vue Normal file
View File

@ -0,0 +1,384 @@
<template>
<div class="users">
<t-card title="用户管理">
<template #actions>
<t-space>
<t-button theme="primary" @click="handleCreate">
<t-icon name="user-add" />
添加用户
</t-button>
<t-button variant="outline" @click="handleRefresh">
<t-icon name="refresh" />
刷新
</t-button>
</t-space>
</template>
<t-table
:data="userList"
:columns="columns"
:loading="loading"
row-key="id"
:pagination="pagination"
@page-change="handlePageChange"
/>
</t-card>
<!-- 添加/编辑用户对话框 -->
<t-dialog
v-model:visible="formVisible"
:title="isEdit ? '编辑用户' : '添加用户'"
width="600px"
@confirm="handleSubmit"
@cancel="handleCancel"
>
<t-form ref="formRef" :data="formData" :rules="rules" label-width="100px">
<t-form-item label="用户名" name="username">
<t-input
v-model="formData.username"
placeholder="请输入用户名"
:disabled="isEdit"
/>
</t-form-item>
<t-form-item label="真实姓名" name="realName">
<t-input v-model="formData.realName" placeholder="请输入真实姓名" />
</t-form-item>
<t-form-item label="邮箱" name="email">
<t-input v-model="formData.email" placeholder="请输入邮箱地址" />
</t-form-item>
<t-form-item label="手机号" name="phone">
<t-input v-model="formData.phone" placeholder="请输入手机号" />
</t-form-item>
<t-form-item label="角色" name="role">
<t-select v-model="formData.role" placeholder="请选择用户角色">
<t-option value="admin" label="管理员" />
<t-option value="user" label="普通用户" />
<t-option value="viewer" label="只读用户" />
</t-select>
</t-form-item>
<t-form-item label="状态" name="status">
<t-radio-group v-model="formData.status">
<t-radio value="active">启用</t-radio>
<t-radio value="inactive">禁用</t-radio>
</t-radio-group>
</t-form-item>
<t-form-item v-if="!isEdit" label="密码" name="password">
<t-input
v-model="formData.password"
type="password"
placeholder="请输入初始密码"
/>
</t-form-item>
</t-form>
</t-dialog>
<!-- 重置密码对话框 -->
<t-dialog
v-model:visible="resetPasswordVisible"
title="重置密码"
width="400px"
@confirm="handleResetPassword"
>
<t-form ref="passwordFormRef" :data="passwordData" :rules="passwordRules" label-width="100px">
<t-form-item label="新密码" name="newPassword">
<t-input
v-model="passwordData.newPassword"
type="password"
placeholder="请输入新密码"
/>
</t-form-item>
<t-form-item label="确认密码" name="confirmPassword">
<t-input
v-model="passwordData.confirmPassword"
type="password"
placeholder="请再次输入新密码"
/>
</t-form-item>
</t-form>
</t-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
const loading = ref(false)
const formVisible = ref(false)
const resetPasswordVisible = ref(false)
const isEdit = ref(false)
const currentUser = ref(null)
const formRef = ref()
const passwordFormRef = ref()
const userList = ref([
{
id: 1,
username: 'admin',
realName: '系统管理员',
email: 'admin@example.com',
phone: '138****8888',
role: 'admin',
status: 'active',
lastLoginTime: '2024-01-15 10:30:00',
createTime: '2024-01-01 00:00:00'
},
{
id: 2,
username: 'developer',
realName: '开发者',
email: 'dev@example.com',
phone: '139****9999',
role: 'user',
status: 'active',
lastLoginTime: '2024-01-15 09:20:00',
createTime: '2024-01-10 10:00:00'
},
{
id: 3,
username: 'tester',
realName: '测试员',
email: 'test@example.com',
phone: '136****6666',
role: 'viewer',
status: 'inactive',
lastLoginTime: '2024-01-14 16:45:00',
createTime: '2024-01-12 14:30:00'
}
])
const columns = [
{
colKey: 'id',
title: 'ID',
width: 80
},
{
colKey: 'username',
title: '用户名'
},
{
colKey: 'realName',
title: '真实姓名'
},
{
colKey: 'email',
title: '邮箱'
},
{
colKey: 'phone',
title: '手机号'
},
{
colKey: 'role',
title: '角色',
cell: ({ row }) => {
if (!row || !row.role) return '-'
const roleMap = {
'admin': { label: '管理员', theme: 'danger' },
'user': { label: '普通用户', theme: 'primary' },
'viewer': { label: '只读用户', theme: 'default' }
}
const config = roleMap[row.role] || roleMap['viewer']
return `<t-tag theme="${config.theme}">${config.label}</t-tag>`
}
},
{
colKey: 'status',
title: '状态',
cell: ({ row }) => {
if (!row || !row.status) return '-'
return row.status === 'active'
? '<t-tag theme="success">启用</t-tag>'
: '<t-tag theme="default">禁用</t-tag>'
}
},
{
colKey: 'lastLoginTime',
title: '最后登录'
},
{
colKey: 'action',
title: '操作',
cell: ({ row }) => {
if (!row || !row.id) return '-'
return `
<t-button theme="primary" variant="text" size="small" onclick="editUser(${row.id})">编辑</t-button>
<t-button theme="warning" variant="text" size="small" onclick="resetUserPassword(${row.id})">重置密码</t-button>
<t-button theme="danger" variant="text" size="small" onclick="deleteUser(${row.id})">删除</t-button>
`
}
}
]
const pagination = reactive({
current: 1,
pageSize: 10,
total: 3,
showJumper: true,
showSizer: true
})
const formData = reactive({
username: '',
realName: '',
email: '',
phone: '',
role: '',
status: 'active',
password: ''
})
const passwordData = reactive({
newPassword: '',
confirmPassword: ''
})
const rules = {
username: [{ required: true, message: '请输入用户名' }],
realName: [{ required: true, message: '请输入真实姓名' }],
email: [
{ required: true, message: '请输入邮箱地址' },
{ email: true, message: '请输入正确的邮箱格式' }
],
phone: [
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式' }
],
role: [{ required: true, message: '请选择用户角色' }],
password: [{ required: true, message: '请输入初始密码' }]
}
const passwordRules = {
newPassword: [
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度不能少于6位' }
],
confirmPassword: [
{ required: true, message: '请再次输入新密码' },
{
validator: (val) => val === passwordData.newPassword,
message: '两次输入的密码不一致'
}
]
}
//
window.editUser = (id) => {
currentUser.value = userList.value.find(user => user.id === id)
if (currentUser.value) {
Object.keys(formData).forEach(key => {
if (key !== 'password' && currentUser.value[key] !== undefined) {
formData[key] = currentUser.value[key]
}
})
isEdit.value = true
formVisible.value = true
}
}
window.resetUserPassword = (id) => {
currentUser.value = userList.value.find(user => user.id === id)
if (currentUser.value) {
passwordData.newPassword = ''
passwordData.confirmPassword = ''
resetPasswordVisible.value = true
}
}
window.deleteUser = (id) => {
const user = userList.value.find(u => u.id === id)
if (!user) return
if (user.username === 'admin') {
MessagePlugin.warning('不能删除管理员账户')
return
}
DialogPlugin.confirm({
header: '确认删除',
body: `确定要删除用户 "${user.realName}" 吗?此操作不可恢复。`,
onConfirm: () => {
const index = userList.value.findIndex(u => u.id === id)
if (index > -1) {
userList.value.splice(index, 1)
pagination.total--
MessagePlugin.success('用户删除成功')
}
}
})
}
const handleCreate = () => {
Object.keys(formData).forEach(key => {
formData[key] = key === 'status' ? 'active' : ''
})
isEdit.value = false
formVisible.value = true
}
const handleRefresh = () => {
loading.value = true
setTimeout(() => {
loading.value = false
MessagePlugin.success('刷新成功')
}, 1000)
}
const handleSubmit = async () => {
const result = await formRef.value.validate()
if (result === true) {
if (isEdit.value) {
//
const index = userList.value.findIndex(u => u.id === currentUser.value.id)
if (index > -1) {
Object.assign(userList.value[index], formData)
MessagePlugin.success('用户更新成功')
}
} else {
//
const newUser = {
id: Date.now(),
...formData,
lastLoginTime: '-',
createTime: new Date().toLocaleString('zh-CN')
}
userList.value.push(newUser)
pagination.total++
MessagePlugin.success('用户添加成功')
}
formVisible.value = false
}
}
const handleCancel = () => {
formVisible.value = false
}
const handleResetPassword = async () => {
const result = await passwordFormRef.value.validate()
if (result === true) {
MessagePlugin.success(`用户 "${currentUser.value.realName}" 密码重置成功`)
resetPasswordVisible.value = false
}
}
const handlePageChange = (pageInfo) => {
pagination.current = pageInfo.current
pagination.pageSize = pageInfo.pageSize
}
onMounted(() => {
//
})
</script>
<style scoped>
.users {
max-width: 1200px;
margin: 0 auto;
}
</style>

26
test-build.js Normal file
View File

@ -0,0 +1,26 @@
const { execSync } = require('child_process');
console.log('🧪 开始测试 DeployHelper 前端项目...\n');
try {
// 检查依赖是否安装
console.log('📦 检查依赖...');
execSync('npm list --depth=0', { stdio: 'inherit' });
console.log('✅ 依赖检查通过\n');
// 检查构建
console.log('🔨 测试构建...');
execSync('npm run build', { stdio: 'inherit' });
console.log('✅ 构建测试通过\n');
console.log('🎉 所有测试通过!项目可以正常运行。');
console.log('\n📝 使用说明:');
console.log('1. 开发模式npm run dev');
console.log('2. 构建生产版本npm run build');
console.log('3. 预览构建结果npm run preview');
console.log('\n🌐 访问地址http://localhost:3000');
} catch (error) {
console.error('❌ 测试失败:', error.message);
process.exit(1);
}

27
vite.config.js Normal file
View File

@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3000,
open: true,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
build: {
outDir: 'dist'
}
})