40
web/.gitignore
vendored
Normal file
40
web/.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist/*
|
||||
!dist/.gitkeep
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
38
web/README.md
Normal file
38
web/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# web
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
0
web/dist/.gitkeep
vendored
Normal file
0
web/dist/.gitkeep
vendored
Normal file
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CI Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
web/jsconfig.json
Normal file
8
web/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
2836
web/package-lock.json
generated
Normal file
2836
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
web/package.json
Normal file
25
web/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && touch dist/.gitkeep",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.17.0",
|
||||
"element-plus": "^2.14.2",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-vue-devtools": "^8.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
BIN
web/public/favicon.ico
Normal file
BIN
web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
179
web/src/App.vue
Normal file
179
web/src/App.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<script setup>
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand" @click="router.push('/')">
|
||||
<div class="brand-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="brand-text">CI Hub</span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<router-link to="/" class="nav-item" :class="{ active: route.path === '/' }">
|
||||
<span class="nav-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||
</span>
|
||||
<span class="nav-label">Projects</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button class="btn-new-project" @click="router.push('/projects/new')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="page" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
/* Page transition */
|
||||
.page-enter-active,
|
||||
.page-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
||||
.page-enter-from { opacity: 0; transform: translateY(8px); }
|
||||
.page-leave-to { opacity: 0; transform: translateY(-8px); }
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(180deg, #1a1d2e 0%, #16192a 100%);
|
||||
color: #c8ccd8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
border-right: 1px solid #ffffff08;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 22px 20px 18px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #ffffff0a;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 36px; height: 36px;
|
||||
background: linear-gradient(135deg, #409eff, #337ecc);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
.brand-icon svg { width: 20px; height: 20px; }
|
||||
|
||||
.brand-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #e8eaef;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
/* ── Nav ── */
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
color: #9ca0b0;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: #ffffff08;
|
||||
color: #d0d4e0;
|
||||
}
|
||||
.nav-item.active {
|
||||
background: #ffffff0f;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-icon svg { width: 18px; height: 18px; display: block; }
|
||||
|
||||
/* ── Footer button ── */
|
||||
.sidebar-footer {
|
||||
padding: 16px 14px 20px;
|
||||
border-top: 1px solid #ffffff0a;
|
||||
}
|
||||
|
||||
.btn-new-project {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
background: linear-gradient(135deg, #409eff, #337ecc);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-new-project:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 14px #409eff40;
|
||||
}
|
||||
.btn-new-project:active { transform: translateY(0); }
|
||||
.btn-new-project svg { width: 16px; height: 16px; }
|
||||
|
||||
/* ── Main ── */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 28px 32px;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
</style>
|
||||
90
web/src/api/index.js
Normal file
90
web/src/api/index.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// --- Projects ---
|
||||
export function listProjects() {
|
||||
return api.get('/projects').then(r => r.data)
|
||||
}
|
||||
|
||||
export function getProject(id) {
|
||||
return api.get(`/projects/${id}`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function createProject(data) {
|
||||
return api.post('/projects', data).then(r => r.data)
|
||||
}
|
||||
|
||||
export function updateProject(id, data) {
|
||||
return api.put(`/projects/${id}`, data).then(r => r.data)
|
||||
}
|
||||
|
||||
export function deleteProject(id) {
|
||||
return api.delete(`/projects/${id}`).then(r => r.data)
|
||||
}
|
||||
|
||||
// --- Build & Run ---
|
||||
export function triggerBuild(id) {
|
||||
return api.post(`/projects/${id}/build`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function startProject(id) {
|
||||
return api.post(`/projects/${id}/start`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function stopProject(id) {
|
||||
return api.post(`/projects/${id}/stop`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function restartProject(id) {
|
||||
return api.post(`/projects/${id}/restart`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function getBuilds(id) {
|
||||
return api.get(`/projects/${id}/builds`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function getBuildLog(id, buildId) {
|
||||
return api.get(`/projects/${id}/logs/${buildId}`).then(r => r.data)
|
||||
}
|
||||
|
||||
// --- Upload ---
|
||||
export function uploadFile(id, formData) {
|
||||
return api.post(`/projects/${id}/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}).then(r => r.data)
|
||||
}
|
||||
|
||||
// --- Deploy Config ---
|
||||
export function getDeployConfig(id) {
|
||||
return api.get(`/projects/${id}/deploy-config`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function updateDeployConfig(id, data) {
|
||||
return api.put(`/projects/${id}/deploy-config`, data).then(r => r.data)
|
||||
}
|
||||
|
||||
export function deleteDeployConfig(id) {
|
||||
return api.delete(`/projects/${id}/deploy-config`).then(r => r.data)
|
||||
}
|
||||
|
||||
// --- Deploy Execution ---
|
||||
export function triggerDeploy(id) {
|
||||
return api.post(`/projects/${id}/deploy`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function getDeployRecords(id) {
|
||||
return api.get(`/projects/${id}/deploy-records`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function getDeployLog(id, deployId) {
|
||||
return api.get(`/projects/${id}/deploy-records/${deployId}/log`).then(r => r.data)
|
||||
}
|
||||
|
||||
// --- SSE ---
|
||||
export function createLogStream(id) {
|
||||
return new EventSource(`/api/projects/${id}/logs/stream`)
|
||||
}
|
||||
86
web/src/assets/base.css
Normal file
86
web/src/assets/base.css
Normal file
@@ -0,0 +1,86 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
1
web/src/assets/logo.svg
Normal file
1
web/src/assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
After Width: | Height: | Size: 276 B |
35
web/src/assets/main.css
Normal file
35
web/src/assets/main.css
Normal file
@@ -0,0 +1,35 @@
|
||||
@import './base.css';
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
}
|
||||
44
web/src/components/HelloWorld.vue
Normal file
44
web/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
msg: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve successfully created a project with
|
||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-weight: 500;
|
||||
font-size: 2.6rem;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
95
web/src/components/TheWelcome.vue
Normal file
95
web/src/components/TheWelcome.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
|
||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
|
||||
>Vue - Official</a
|
||||
>. If you need to test your components and web pages, check out
|
||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||
and
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
/
|
||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in
|
||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||
>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||
(our official Discord server), or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also follow the official
|
||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||
Bluesky account or the
|
||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
X account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
||||
87
web/src/components/WelcomeItem.vue
Normal file
87
web/src/components/WelcomeItem.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
7
web/src/components/icons/IconCommunity.vue
Normal file
7
web/src/components/icons/IconCommunity.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
7
web/src/components/icons/IconDocumentation.vue
Normal file
7
web/src/components/icons/IconDocumentation.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
7
web/src/components/icons/IconEcosystem.vue
Normal file
7
web/src/components/icons/IconEcosystem.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
7
web/src/components/icons/IconSupport.vue
Normal file
7
web/src/components/icons/IconSupport.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
19
web/src/components/icons/IconTooling.vue
Normal file
19
web/src/components/icons/IconTooling.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
10
web/src/main.js
Normal file
10
web/src/main.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(ElementPlus)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
17
web/src/router/index.js
Normal file
17
web/src/router/index.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import Dashboard from '../views/Dashboard.vue'
|
||||
import ProjectCreate from '../views/ProjectCreate.vue'
|
||||
import ProjectDetail from '../views/ProjectDetail.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'Dashboard', component: Dashboard },
|
||||
{ path: '/projects/new', name: 'ProjectCreate', component: ProjectCreate },
|
||||
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetail, props: true },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
250
web/src/views/Dashboard.vue
Normal file
250
web/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listProjects, deleteProject } from '../api'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const projects = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
projects.value = await listProjects()
|
||||
} catch (e) {
|
||||
ElMessage.error('Failed to load projects')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (project) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`Delete project "${project.name}"? This cannot be undone.`,
|
||||
'Confirm Delete',
|
||||
{ type: 'warning', confirmButtonText: 'Delete', cancelButtonText: 'Cancel' }
|
||||
)
|
||||
await deleteProject(project.id)
|
||||
ElMessage.success('Project deleted')
|
||||
fetchProjects()
|
||||
} catch (e) { /* cancelled */ }
|
||||
}
|
||||
|
||||
const statusTag = (status) => {
|
||||
const map = { running: 'success', stopped: 'info', building: 'warning', error: 'danger' }
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
const statusLabel = (status) => {
|
||||
const map = { running: 'Running', stopped: 'Stopped', building: 'Building...', error: 'Error' }
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
onMounted(fetchProjects)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Projects</h1>
|
||||
<p class="page-subtitle">{{ projects.length }} project{{ projects.length !== 1 ? 's' : '' }} managed</p>
|
||||
</div>
|
||||
<el-button type="primary" size="large" @click="router.push('/projects/new')" round>
|
||||
<span style="font-size:18px;line-height:1">+</span> New Project
|
||||
</el-button>
|
||||
</header>
|
||||
|
||||
<el-skeleton :loading="loading" :count="3" animated />
|
||||
|
||||
<div v-if="!loading && projects.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
</div>
|
||||
<h3>No projects yet</h3>
|
||||
<p>Create your first project to start building and deploying.</p>
|
||||
<el-button type="primary" @click="router.push('/projects/new')">Create Project</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && projects.length > 0" class="project-grid">
|
||||
<article
|
||||
v-for="p in projects"
|
||||
:key="p.id"
|
||||
class="project-card"
|
||||
@click="router.push(`/projects/${p.id}`)"
|
||||
>
|
||||
<div class="card-top">
|
||||
<div class="card-status-row">
|
||||
<span class="status-dot" :class="'dot-' + p.status"></span>
|
||||
<span class="status-text">{{ statusLabel(p.status) }}</span>
|
||||
</div>
|
||||
<el-tag size="small" :type="p.mode === 'git' ? '' : 'warning'" effect="plain" round>
|
||||
{{ p.mode === 'git' ? 'Git' : 'Upload' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<h3 class="card-title">{{ p.name }}</h3>
|
||||
<p class="card-desc">{{ p.description || 'No description' }}</p>
|
||||
|
||||
<div class="card-meta">
|
||||
<span v-if="p.git_url" class="meta-item" :title="p.git_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
{{ p.git_url }}
|
||||
</span>
|
||||
<span v-if="p.port" class="meta-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||
:{{ p.port }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-actions" @click.stop>
|
||||
<el-button size="small" round @click="router.push(`/projects/${p.id}`)">Detail</el-button>
|
||||
<el-button size="small" type="danger" round plain @click="handleDelete(p)">Delete</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard { max-width: 1200px; }
|
||||
|
||||
/* ── Header ── */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #1a1d2e;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: #8c8f9a;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Empty ── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
.empty-icon {
|
||||
width: 72px; height: 72px;
|
||||
margin: 0 auto 20px;
|
||||
background: #e8ecf1;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #a0a4b0;
|
||||
}
|
||||
.empty-icon svg { width: 32px; height: 32px; }
|
||||
.empty-state h3 { font-size: 18px; color: #333; margin-bottom: 6px; }
|
||||
.empty-state p { font-size: 14px; color: #999; margin-bottom: 20px; }
|
||||
|
||||
/* ── Grid ── */
|
||||
.project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.project-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 22px 24px 18px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #ebeef2;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px #00000005;
|
||||
}
|
||||
.project-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 25px #0000000d;
|
||||
border-color: #d0d7e2;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot-running { background: #67c23a; box-shadow: 0 0 6px #67c23a60; }
|
||||
.dot-stopped { background: #909399; }
|
||||
.dot-building { background: #e6a23c; animation: pulse 1.2s ease-in-out infinite; }
|
||||
.dot-error { background: #f56c6c; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #1a1d2e;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
color: #8c8f9a;
|
||||
min-height: 36px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #a0a4b0;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.meta-item svg { width: 13px; height: 13px; flex-shrink: 0; }
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
</style>
|
||||
219
web/src/views/ProjectCreate.vue
Normal file
219
web/src/views/ProjectCreate.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { createProject } from '../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
mode: 'git',
|
||||
git_url: '',
|
||||
git_branch: 'master',
|
||||
git_username: '',
|
||||
git_password: '',
|
||||
build_script: '',
|
||||
run_command: '',
|
||||
output_binary: '',
|
||||
env_vars: '',
|
||||
port: null,
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.value.name) {
|
||||
ElMessage.warning('Project name is required')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await createProject(form.value)
|
||||
ElMessage.success('Project created')
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'Failed to create project')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="create-page">
|
||||
<el-page-header @back="router.push('/')" class="page-back">
|
||||
<template #content>
|
||||
<span class="back-title">New Project</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-card class="form-card" shadow="never">
|
||||
<el-form :model="form" label-position="top" class="project-form">
|
||||
<!-- Basic info -->
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||||
Basic Information
|
||||
</h3>
|
||||
<div class="form-row two-col">
|
||||
<el-form-item label="Project Name" required>
|
||||
<el-input v-model="form.name" placeholder="my-project" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Port">
|
||||
<el-input-number v-model="form.port" :min="1" :max="65535" placeholder="8080" size="large" style="width:100%" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="Description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="A short description of this project..." />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- Source -->
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
Source Code
|
||||
</h3>
|
||||
<el-form-item label="Source Mode">
|
||||
<el-radio-group v-model="form.mode" class="mode-switch">
|
||||
<el-radio-button value="git">
|
||||
<span class="radio-inner">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V9"/><path d="M12 12v3"/></svg>
|
||||
Git Repository
|
||||
</span>
|
||||
</el-radio-button>
|
||||
<el-radio-button value="upload">
|
||||
<span class="radio-inner">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
File Upload
|
||||
</span>
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="form.mode === 'git'">
|
||||
<el-form-item label="Git URL">
|
||||
<el-input v-model="form.git_url" placeholder="https://gitee.com/user/repo.git" size="large" />
|
||||
</el-form-item>
|
||||
<div class="form-row two-col">
|
||||
<el-form-item label="Branch">
|
||||
<el-input v-model="form.git_branch" placeholder="master" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Username (private repos)">
|
||||
<el-input v-model="form.git_username" placeholder="optional" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="Password / Token">
|
||||
<el-input v-model="form.git_password" type="password" placeholder="optional" show-password />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="form.mode === 'upload'">
|
||||
<el-alert type="info" :closable="false" show-icon>
|
||||
After creating this project, go to its detail page to upload your source files as .zip or .tar.gz.
|
||||
</el-alert>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Build & Run -->
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5"/><line x1="13" y1="19" x2="19" y2="13"/><line x1="16" y1="16" x2="20" y2="20"/><line x1="19" y1="21" x2="21" y2="19"/></svg>
|
||||
Build & Run Configuration
|
||||
</h3>
|
||||
<el-form-item label="Build Script">
|
||||
<el-input v-model="form.build_script" type="textarea" :rows="3"
|
||||
placeholder="go build -o app ." />
|
||||
</el-form-item>
|
||||
<div class="form-row two-col">
|
||||
<el-form-item label="Output Binary">
|
||||
<el-input v-model="form.output_binary" placeholder="app" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Run Command">
|
||||
<el-input v-model="form.run_command" placeholder="./app" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="Environment Variables">
|
||||
<el-input v-model="form.env_vars" type="textarea" :rows="2"
|
||||
placeholder='{"DB_HOST": "192.168.1.100", "DB_PORT": "3306"}' />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button size="large" @click="router.push('/')">Cancel</el-button>
|
||||
<el-button type="primary" size="large" :loading="submitting" @click="handleSubmit">
|
||||
Create Project
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-page { max-width: 760px; margin: 0 auto; }
|
||||
|
||||
.page-back { margin-bottom: 20px; }
|
||||
.back-title { font-size: 20px; font-weight: 700; color: #1a1d2e; }
|
||||
|
||||
.form-card {
|
||||
border-radius: 14px;
|
||||
border: 1px solid #ebeef2;
|
||||
box-shadow: 0 1px 3px #00000005;
|
||||
}
|
||||
.form-card :deep(.el-card__body) { padding: 28px 32px; }
|
||||
|
||||
/* ── Sections ── */
|
||||
.form-section {
|
||||
margin-bottom: 28px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.form-section:last-of-type { border-bottom: none; margin-bottom: 24px; padding-bottom: 0; }
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1a1d2e;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.section-title svg { width: 18px; height: 18px; color: #409eff; }
|
||||
|
||||
.form-row { display: flex; gap: 16px; }
|
||||
.form-row.two-col > * { flex: 1; }
|
||||
|
||||
/* ── Mode switch ── */
|
||||
.mode-switch { width: 100%; }
|
||||
.mode-switch :deep(.el-radio-button) { flex: 1; }
|
||||
.mode-switch :deep(.el-radio-button__inner) {
|
||||
width: 100%;
|
||||
padding: 14px 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.radio-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.radio-inner svg { width: 16px; height: 16px; }
|
||||
|
||||
/* ── Form items ── */
|
||||
.project-form :deep(.el-form-item__label) {
|
||||
font-weight: 600;
|
||||
color: #444;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
</style>
|
||||
687
web/src/views/ProjectDetail.vue
Normal file
687
web/src/views/ProjectDetail.vue
Normal file
@@ -0,0 +1,687 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
getProject, updateProject, triggerBuild, startProject, stopProject, restartProject,
|
||||
getBuilds, uploadFile, createLogStream,
|
||||
getDeployConfig, updateDeployConfig, deleteDeployConfig, triggerDeploy, getDeployRecords, getDeployLog
|
||||
} from '../api'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const props = defineProps({ id: String })
|
||||
const router = useRouter()
|
||||
|
||||
const project = ref(null)
|
||||
const builds = ref([])
|
||||
const logs = ref([])
|
||||
const logContainer = ref(null)
|
||||
const activeTab = ref('overview')
|
||||
const loading = ref(true)
|
||||
const actionLoading = ref(false)
|
||||
const editingSettings = ref(false)
|
||||
const settingsForm = ref({})
|
||||
const uploadRef = ref(null)
|
||||
|
||||
const deployConfig = ref(null)
|
||||
const deployRecords = ref([])
|
||||
const editingDeploy = ref(false)
|
||||
const deployForm = ref({
|
||||
host: '', port: 22, username: '', auth_method: 'key',
|
||||
ssh_key: '', ssh_password: '',
|
||||
deploy_dir: '', file_mappings: '',
|
||||
pre_deploy_command: '', post_deploy_command: '',
|
||||
health_check_url: '', health_check_timeout: 30,
|
||||
auto_deploy: false,
|
||||
})
|
||||
|
||||
let sse = null
|
||||
|
||||
const projectId = () => parseInt(props.id)
|
||||
|
||||
const fetchProject = async () => {
|
||||
try {
|
||||
project.value = await getProject(projectId())
|
||||
} catch (e) {
|
||||
ElMessage.error('Project not found')
|
||||
router.push('/')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchBuilds = async () => {
|
||||
try { builds.value = await getBuilds(projectId()) } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
const connectLogStream = () => {
|
||||
if (sse) sse.close()
|
||||
sse = createLogStream(projectId())
|
||||
sse.onmessage = (event) => {
|
||||
logs.value.push(event.data)
|
||||
if (logs.value.length > 1000) logs.value.shift()
|
||||
setTimeout(() => {
|
||||
if (logContainer.value) logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||
}, 50)
|
||||
}
|
||||
sse.onerror = () => {}
|
||||
}
|
||||
|
||||
const doBuild = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await triggerBuild(projectId())
|
||||
ElMessage.success('Build started')
|
||||
logs.value = []
|
||||
activeTab.value = 'logs'
|
||||
connectLogStream()
|
||||
fetchDeployRecords()
|
||||
pollStatus(30000)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'Build failed')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const doStart = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await startProject(projectId())
|
||||
ElMessage.success('Project started')
|
||||
fetchProject()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'Failed to start')
|
||||
} finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
const doStop = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await stopProject(projectId())
|
||||
ElMessage.success('Project stopped')
|
||||
fetchProject()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'Failed to stop')
|
||||
} finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
const doRestart = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await restartProject(projectId())
|
||||
ElMessage.success('Project restarted')
|
||||
fetchProject()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'Failed to restart')
|
||||
} finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
const doUpload = async (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.file)
|
||||
try {
|
||||
await uploadFile(projectId(), formData)
|
||||
ElMessage.success('File uploaded and extracted')
|
||||
fetchProject()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'Upload failed')
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
await updateProject(projectId(), settingsForm.value)
|
||||
ElMessage.success('Settings saved')
|
||||
editingSettings.value = false
|
||||
fetchProject()
|
||||
} catch (e) {
|
||||
ElMessage.error('Failed to save settings')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDeployConfig = async () => {
|
||||
try {
|
||||
const cfg = await getDeployConfig(projectId())
|
||||
if (cfg && cfg.id) deployConfig.value = cfg
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const fetchDeployRecords = async () => {
|
||||
try { deployRecords.value = await getDeployRecords(projectId()) } catch (e) {}
|
||||
}
|
||||
|
||||
const saveDeployConfig = async () => {
|
||||
try {
|
||||
await updateDeployConfig(projectId(), deployForm.value)
|
||||
ElMessage.success('Deploy config saved')
|
||||
editingDeploy.value = false
|
||||
fetchDeployConfig()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'Failed to save deploy config')
|
||||
}
|
||||
}
|
||||
|
||||
const removeDeployConfig = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('Remove deploy configuration?', 'Confirm', { type: 'warning' })
|
||||
await deleteDeployConfig(projectId())
|
||||
ElMessage.success('Deploy config removed')
|
||||
deployConfig.value = null
|
||||
deployRecords.value = []
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const doDeploy = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await triggerDeploy(projectId())
|
||||
ElMessage.success('Deployment started')
|
||||
logs.value = []
|
||||
activeTab.value = 'logs'
|
||||
connectLogStream()
|
||||
setTimeout(() => { fetchProject(); fetchDeployRecords() }, 3000)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'Failed to start deploy')
|
||||
} finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
const viewDeployLog = async (deployId) => {
|
||||
try {
|
||||
const log = await getDeployLog(projectId(), deployId)
|
||||
logs.value = log.split('\n')
|
||||
activeTab.value = 'logs'
|
||||
setTimeout(() => {
|
||||
if (logContainer.value) logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||
}, 100)
|
||||
} catch (e) {
|
||||
ElMessage.error('Failed to load deploy log')
|
||||
}
|
||||
}
|
||||
|
||||
const deployStatusTag = (s) => {
|
||||
const map = { running: 'warning', success: 'success', failed: 'danger' }
|
||||
return map[s] || 'info'
|
||||
}
|
||||
|
||||
const pollStatus = (duration) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const p = await getProject(projectId())
|
||||
project.value = p
|
||||
if (p.status !== 'building') {
|
||||
clearInterval(interval)
|
||||
fetchBuilds()
|
||||
}
|
||||
} catch (e) { clearInterval(interval) }
|
||||
}, 2000)
|
||||
setTimeout(() => clearInterval(interval), duration)
|
||||
}
|
||||
|
||||
const statusTag = (s) => {
|
||||
const map = { running: 'success', stopped: 'info', building: 'warning', error: 'danger' }
|
||||
return map[s] || 'info'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchProject()
|
||||
fetchBuilds()
|
||||
fetchDeployConfig()
|
||||
fetchDeployRecords()
|
||||
connectLogStream()
|
||||
})
|
||||
|
||||
onUnmounted(() => { if (sse) sse.close() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="detail-page">
|
||||
<el-page-header @back="router.push('/')" class="page-back">
|
||||
<template #content>
|
||||
<span v-if="project" class="back-title">{{ project.name }}</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<div v-if="project">
|
||||
<!-- ==== Action Bar ==== -->
|
||||
<div class="action-bar">
|
||||
<div class="action-bar-left">
|
||||
<span class="status-badge" :class="'badge-' + project.status">
|
||||
<span class="badge-dot"></span>
|
||||
{{ project.status }}
|
||||
</span>
|
||||
<span class="mode-badge">{{ project.mode === 'git' ? 'Git' : 'Upload' }}</span>
|
||||
</div>
|
||||
<div class="action-bar-right">
|
||||
<el-button-group class="btn-group">
|
||||
<el-button type="primary" :loading="actionLoading" @click="doBuild" :disabled="project.status === 'building'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="btn-icon"><polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5"/><line x1="13" y1="19" x2="19" y2="13"/><line x1="16" y1="16" x2="20" y2="20"/><line x1="19" y1="21" x2="21" y2="19"/></svg>
|
||||
Build
|
||||
</el-button>
|
||||
<el-button type="success" :loading="actionLoading" @click="doStart" :disabled="project.status === 'running'">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none" class="btn-icon"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Start
|
||||
</el-button>
|
||||
<el-button type="warning" :loading="actionLoading" @click="doStop" :disabled="project.status !== 'running'">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none" class="btn-icon"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
|
||||
Stop
|
||||
</el-button>
|
||||
<el-button :loading="actionLoading" @click="doRestart" :disabled="project.status !== 'running'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="btn-icon"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
|
||||
Restart
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
<el-button
|
||||
v-if="deployConfig"
|
||||
type="primary"
|
||||
:loading="actionLoading"
|
||||
@click="doDeploy"
|
||||
class="btn-deploy"
|
||||
plain
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="btn-icon"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
Deploy
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==== Tabs ==== -->
|
||||
<el-tabs v-model="activeTab" class="main-tabs">
|
||||
<!-- Overview -->
|
||||
<el-tab-pane label="Overview" name="overview">
|
||||
<div class="overview-grid">
|
||||
<el-card class="ov-card" shadow="never">
|
||||
<template #header><span class="card-header-title">Project Info</span></template>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="Status">
|
||||
<el-tag :type="statusTag(project.status)" effect="dark" size="small">{{ project.status }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Mode">{{ project.mode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="PID">{{ project.pid || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Port">{{ project.port || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Created">{{ new Date(project.created_at).toLocaleString() }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
<el-card class="ov-card" shadow="never" v-if="project.mode === 'git'">
|
||||
<template #header><span class="card-header-title">Git Repository</span></template>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="URL">{{ project.git_url || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Branch">{{ project.git_branch || 'master' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
<el-card class="ov-card" shadow="never">
|
||||
<template #header><span class="card-header-title">Build Config</span></template>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="Build Script">{{ project.build_script || 'go build -o app .' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Output Binary">{{ project.output_binary || 'app' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Run Command">{{ project.run_command || './app' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-card v-if="project.mode === 'upload'" shadow="never" class="upload-card">
|
||||
<template #header><span class="card-header-title">Upload Source Files</span></template>
|
||||
<el-upload ref="uploadRef" drag :auto-upload="false" :on-change="doUpload" :limit="1" accept=".zip,.tar.gz,.tgz">
|
||||
<div class="upload-area">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="upload-icon"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
<p>Drop .zip or .tar.gz here or click to upload</p>
|
||||
</div>
|
||||
</el-upload>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Logs -->
|
||||
<el-tab-pane label="Logs" name="logs">
|
||||
<div class="log-console" ref="logContainer">
|
||||
<div v-if="logs.length === 0" class="log-empty">
|
||||
<span class="log-prompt">Waiting for output...</span>
|
||||
</div>
|
||||
<div v-for="(line, i) in logs" :key="i" class="log-line" :class="{ 'log-deploy': line.startsWith('[Deploy]'), 'log-error': line.includes('ERROR') || line.includes('FAILED') || line.includes('failed'), 'log-success': line.includes('SUCCESS') || line.includes('completed successfully') }">{{ line }}</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Build History -->
|
||||
<el-tab-pane label="Build History" name="builds">
|
||||
<el-card shadow="never">
|
||||
<el-table :data="builds" stripe class="history-table">
|
||||
<el-table-column prop="id" label="#" width="60" align="center" />
|
||||
<el-table-column prop="status" label="Status" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'success' ? 'success' : row.status === 'running' ? 'warning' : 'danger'" size="small" effect="dark" round>
|
||||
{{ row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Commit" width="120">
|
||||
<template #default="{ row }">
|
||||
<code v-if="row.commit_hash" class="commit-hash">{{ (row.commit_hash || '').slice(0, 7) }}</code>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Started" width="180">
|
||||
<template #default="{ row }">{{ new Date(row.started_at).toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Duration">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.finished_at">{{ Math.round((new Date(row.finished_at) - new Date(row.started_at)) / 1000) }}s</span>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Log" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text type="primary" @click="activeTab = 'logs'">View</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="builds.length === 0" description="No builds yet" :image-size="80" />
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Settings -->
|
||||
<el-tab-pane label="Settings" name="settings">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<span class="card-header-title">Project Settings</span>
|
||||
<el-button v-if="!editingSettings" type="primary" plain size="small" round @click="editingSettings = true; settingsForm = { ...project }">Edit</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-form v-if="editingSettings" :model="settingsForm" label-position="top">
|
||||
<el-form-item label="Name"><el-input v-model="settingsForm.name" /></el-form-item>
|
||||
<el-form-item label="Description"><el-input v-model="settingsForm.description" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item v-if="project.mode === 'git'" label="Git URL"><el-input v-model="settingsForm.git_url" /></el-form-item>
|
||||
<el-form-item v-if="project.mode === 'git'" label="Branch"><el-input v-model="settingsForm.git_branch" /></el-form-item>
|
||||
<el-form-item label="Build Script"><el-input v-model="settingsForm.build_script" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item label="Run Command"><el-input v-model="settingsForm.run_command" /></el-form-item>
|
||||
<el-form-item label="Port"><el-input-number v-model="settingsForm.port" /></el-form-item>
|
||||
<el-form-item label="Env Vars"><el-input v-model="settingsForm.env_vars" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveSettings">Save</el-button>
|
||||
<el-button @click="editingSettings = false">Cancel</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-descriptions v-else :column="1" border>
|
||||
<el-descriptions-item label="Name">{{ project.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Description">{{ project.description || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Mode">{{ project.mode }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="project.mode === 'git'" label="Git URL">{{ project.git_url || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Build Script">{{ project.build_script || 'go build -o app .' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Run Command">{{ project.run_command || './app' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Port">{{ project.port || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Deploy -->
|
||||
<el-tab-pane label="Deploy" name="deploy">
|
||||
<el-card class="deploy-config-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<span class="card-header-title">Deploy Configuration</span>
|
||||
<div class="header-actions">
|
||||
<el-button v-if="!editingDeploy && deployConfig" type="primary" plain size="small" round @click="editingDeploy = true; deployForm = { ...deployConfig, ssh_key: '', ssh_password: '' }">Edit</el-button>
|
||||
<el-button v-if="!editingDeploy && !deployConfig" type="primary" size="small" round @click="editingDeploy = true">Add Config</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form v-if="editingDeploy" :model="deployForm" label-position="top" class="deploy-form">
|
||||
<div class="form-section">
|
||||
<h4 class="section-subtitle">SSH Connection</h4>
|
||||
<div class="form-row three-col">
|
||||
<el-form-item label="Host" required><el-input v-model="deployForm.host" placeholder="192.168.1.100" /></el-form-item>
|
||||
<el-form-item label="Port"><el-input-number v-model="deployForm.port" :min="1" :max="65535" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="Username" required><el-input v-model="deployForm.username" placeholder="root" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="Auth Method">
|
||||
<el-radio-group v-model="deployForm.auth_method">
|
||||
<el-radio value="key">SSH Key</el-radio>
|
||||
<el-radio value="password">Password</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="deployForm.auth_method === 'key'" label="SSH Private Key">
|
||||
<el-input v-model="deployForm.ssh_key" type="textarea" :rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="deployForm.auth_method === 'password'" label="SSH Password">
|
||||
<el-input v-model="deployForm.ssh_password" type="password" placeholder="SSH password" show-password />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h4 class="section-subtitle">Deployment</h4>
|
||||
<el-form-item label="Deploy Directory" required><el-input v-model="deployForm.deploy_dir" placeholder="/opt/myapp" /></el-form-item>
|
||||
<el-form-item label="File Mappings">
|
||||
<el-input v-model="deployForm.file_mappings" type="textarea" :rows="3" placeholder='[{"local":"app","remote":"/opt/myapp/app"}]' />
|
||||
</el-form-item>
|
||||
<div class="form-row two-col">
|
||||
<el-form-item label="Pre-deploy Command"><el-input v-model="deployForm.pre_deploy_command" placeholder="systemctl stop myapp" /></el-form-item>
|
||||
<el-form-item label="Post-deploy Command"><el-input v-model="deployForm.post_deploy_command" placeholder="systemctl start myapp" /></el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h4 class="section-subtitle">Health Check</h4>
|
||||
<div class="form-row two-col">
|
||||
<el-form-item label="Health Check URL"><el-input v-model="deployForm.health_check_url" placeholder="http://192.168.1.100:8080/health" /></el-form-item>
|
||||
<el-form-item label="Timeout (seconds)"><el-input-number v-model="deployForm.health_check_timeout" :min="5" :max="120" style="width:100%" /></el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h4 class="section-subtitle">Automation</h4>
|
||||
<el-form-item label="Auto-deploy after successful build">
|
||||
<el-switch v-model="deployForm.auto_deploy" active-text="On" inactive-text="Off" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button @click="editingDeploy = false">Cancel</el-button>
|
||||
<el-button v-if="deployConfig" type="danger" plain @click="removeDeployConfig">Remove Config</el-button>
|
||||
<el-button type="primary" @click="saveDeployConfig">Save Configuration</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<el-descriptions v-else-if="deployConfig" :column="1" border>
|
||||
<el-descriptions-item label="SSH">{{ deployConfig.username }}@{{ deployConfig.host }}:{{ deployConfig.port }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Auth">{{ deployConfig.auth_method === 'key' ? 'SSH Key' : 'Password' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Deploy Directory">{{ deployConfig.deploy_dir }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Pre-deploy">{{ deployConfig.pre_deploy_command || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Post-deploy">{{ deployConfig.post_deploy_command || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Health Check">{{ deployConfig.health_check_url || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Auto-deploy">{{ deployConfig.auto_deploy ? 'Enabled' : 'Disabled' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-empty v-else description="No deploy configuration yet. Click 'Add Config' to set up SSH deployment." :image-size="80" />
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="deploy-history-card">
|
||||
<template #header><span class="card-header-title">Deploy History</span></template>
|
||||
<el-table :data="deployRecords" stripe class="history-table">
|
||||
<el-table-column prop="id" label="#" width="60" align="center" />
|
||||
<el-table-column label="Status" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="deployStatusTag(row.status)" size="small" effect="dark" round>{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Started" width="180">
|
||||
<template #default="{ row }">{{ new Date(row.started_at).toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Duration">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.finished_at">{{ Math.round((new Date(row.finished_at) - new Date(row.started_at)) / 1000) }}s</span>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Build #" width="90" align="center">
|
||||
<template #default="{ row }">{{ row.build_record_id || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Log" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text type="primary" @click="viewDeployLog(row.id)">View</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="deployRecords.length === 0" description="No deployments yet" :image-size="80" />
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-page { max-width: 1100px; }
|
||||
.page-back { margin-bottom: 16px; }
|
||||
.back-title { font-size: 20px; font-weight: 700; color: #1a1d2e; }
|
||||
|
||||
/* ════ Action Bar ════ */
|
||||
.action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 18px 22px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #ebeef2;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px #00000004;
|
||||
}
|
||||
.action-bar-left { display: flex; align-items: center; gap: 12px; }
|
||||
.action-bar-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.badge-dot { width: 7px; height: 7px; border-radius: 50%; }
|
||||
.badge-running { background: #eaf7e2; color: #3d8c1e; }
|
||||
.badge-running .badge-dot { background: #67c23a; }
|
||||
.badge-stopped { background: #f0f1f3; color: #6a6e78; }
|
||||
.badge-stopped .badge-dot { background: #909399; }
|
||||
.badge-building { background: #fef5e7; color: #b07816; }
|
||||
.badge-building .badge-dot { background: #e6a23c; animation: pulse 1.2s ease-in-out infinite; }
|
||||
.badge-error { background: #fef0f0; color: #c03639; }
|
||||
.badge-error .badge-dot { background: #f56c6c; }
|
||||
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
|
||||
|
||||
.mode-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: #e8ecf1;
|
||||
color: #606470;
|
||||
}
|
||||
|
||||
.btn-group .el-button { padding: 8px 16px; }
|
||||
.btn-icon { width: 15px; height: 15px; margin-right: 4px; vertical-align: -2px; }
|
||||
.btn-deploy {
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-deploy:hover { background: #409eff10; }
|
||||
|
||||
/* ════ Tabs ════ */
|
||||
.main-tabs :deep(.el-tabs__header) { margin-bottom: 18px; }
|
||||
.main-tabs :deep(.el-tabs__item) {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* ════ Overview ════ */
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ov-card { border-radius: 12px; border: 1px solid #ebeef2; }
|
||||
.upload-card { margin-top: 0; border-radius: 12px; border: 1px solid #ebeef2; }
|
||||
.upload-area { padding: 10px 0; text-align: center; color: #909399; }
|
||||
.upload-icon { width: 52px; height: 52px; color: #c0c4cc; margin-bottom: 8px; }
|
||||
|
||||
/* ════ Cards ════ */
|
||||
.card-header-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.card-header-title { font-size: 15px; font-weight: 700; color: #1a1d2e; }
|
||||
.header-actions { display: flex; gap: 8px; }
|
||||
|
||||
/* ════ Log Console ════ */
|
||||
.log-console {
|
||||
background: #161821;
|
||||
color: #c8d1e0;
|
||||
font-family: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 12px;
|
||||
min-height: 480px;
|
||||
max-height: 560px;
|
||||
overflow-y: auto;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-console::-webkit-scrollbar { width: 6px; }
|
||||
.log-console::-webkit-scrollbar-track { background: transparent; }
|
||||
.log-console::-webkit-scrollbar-thumb { background: #3a3d4a; border-radius: 3px; }
|
||||
|
||||
.log-empty { color: #5a5e6e; padding: 20px 0; text-align: center; }
|
||||
.log-prompt::before { content: '▎'; animation: blink 1s step-end infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
.log-deploy { color: #7dcfff; }
|
||||
.log-error { color: #f7768e; font-weight: 500; }
|
||||
.log-success { color: #9ece6a; }
|
||||
|
||||
/* ════ Tables ════ */
|
||||
.history-table { border-radius: 8px; overflow: hidden; }
|
||||
.commit-hash {
|
||||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
background: #f0f1f3;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
color: #409eff;
|
||||
}
|
||||
.text-muted { color: #c0c4cc; }
|
||||
|
||||
/* ════ Deploy config ════ */
|
||||
.deploy-config-card { border-radius: 12px; border: 1px solid #ebeef2; margin-bottom: 16px; }
|
||||
.deploy-history-card { border-radius: 12px; border: 1px solid #ebeef2; }
|
||||
.deploy-form .form-section {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
.deploy-form .form-section:last-of-type { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #409eff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #409eff15;
|
||||
}
|
||||
|
||||
.form-row { display: flex; gap: 16px; }
|
||||
.form-row > * { flex: 1; }
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
</style>
|
||||
21
web/vite.config.js
Normal file
21
web/vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
})
|
||||
8
web/web.iml
Normal file
8
web/web.iml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Reference in New Issue
Block a user