Browse Source

Refactor project structure and enhance UI components. Added health check endpoint, improved styles, and updated form elements. Removed index.html and added variables.css for better styling management. Updated various components for a cohesive design and user experience.

main
dash 2 weeks ago
parent
commit
969a46a81b
  1. 2
      .gitignore
  2. BIN
      backend/database/dev.sqlite3
  3. 5
      backend/src/routes/index.ts
  4. 44
      frontend/src/App.vue
  5. 8
      frontend/src/api/auth.ts
  6. 80
      frontend/src/components/Drawer.vue
  7. 163
      frontend/src/components/NavBar.vue
  8. 247
      frontend/src/components/ProjectCard.vue
  9. 1
      frontend/src/main.ts
  10. 157
      frontend/src/styles/variables.css
  11. 228
      frontend/src/views/Home.vue
  12. 215
      frontend/src/views/Login.vue
  13. 364
      frontend/src/views/Project.vue
  14. 238
      frontend/src/views/Register.vue
  15. 230
      frontend/src/views/Settings.vue
  16. 564
      frontend/src/views/Upload.vue
  17. 11
      index.html

2
.gitignore

@ -7,4 +7,6 @@ database/*.sqlite3
uploads/
.vscode/
.idea/
backup/
*.zip

BIN
backend/database/dev.sqlite3

Binary file not shown.

5
backend/src/routes/index.ts

@ -6,6 +6,11 @@ import settingRoutes from './settings';
const router = new Router();
// 健康检查端点(用于 Docker 健康检查)
router.get('/api/health', (ctx) => {
ctx.body = { status: 'ok', timestamp: new Date().toISOString() };
});
router.use('/api/auth', authRoutes.routes());
router.use('/api/projects', projectRoutes.routes());
router.use('/api/files', fileRoutes.routes());

44
frontend/src/App.vue

@ -24,20 +24,48 @@ onMounted(async () => {
</script>
<style>
* {
html, body {
margin: 0;
padding: 0;
box-sizing: border-box;
width: 100%;
height: 100%;
}
/* Global styles are managed in variables.css */
#app {
min-height: 100vh;
width: 100%;
display: flex;
flex-direction: column;
background-color: var(--bg-body);
color: var(--color-text-main);
font-family: var(--font-family-base);
/* Slight noise texture for that analog feel */
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.03'/%3E%3C/svg%3E");
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
a {
text-decoration: none;
color: inherit;
transition: var(--transition-fast);
}
#app {
min-height: 100vh;
a:hover {
color: var(--primary);
}
button, input, select, textarea {
font-family: inherit;
}
/* Global Transitions */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

8
frontend/src/api/auth.ts

@ -12,9 +12,9 @@ export interface RegisterData {
}
export const authApi = {
login: (data: LoginData) => api.post('/auth/login', data),
register: (data: RegisterData) => api.post('/auth/register', data),
logout: () => api.post('/auth/logout'),
getMe: () => api.get('/auth/me')
login: (data: LoginData) => api.post('/auth/login', data) as Promise<any>,
register: (data: RegisterData) => api.post('/auth/register', data) as Promise<any>,
logout: () => api.post('/auth/logout') as Promise<any>,
getMe: () => api.get('/auth/me') as Promise<any>
};

80
frontend/src/components/Drawer.vue

@ -3,10 +3,10 @@
<div v-if="visible" class="drawer-overlay" @click="handleOverlayClick">
<div class="drawer-content" @click.stop>
<div class="drawer-header">
<h2>{{ title }}</h2>
<h2 class="glitch-text" :data-text="title">{{ title }}</h2>
<button @click="handleClose" class="close-btn">×</button>
</div>
<div class="drawer-body">
<div class="drawer-body custom-scrollbar">
<slot></slot>
</div>
</div>
@ -42,69 +42,110 @@ const handleOverlayClick = () => {
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
z-index: 1000;
display: flex;
justify-content: flex-end;
}
.drawer-content {
background: white;
width: 400px;
background: rgba(18, 18, 20, 0.95);
width: 500px;
max-width: 90vw;
height: 100%;
display: flex;
flex-direction: column;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1);
box-shadow: -10px 0 40px rgba(0, 0, 0, 0.5);
border-left: 1px solid var(--primary);
position: relative;
}
.drawer-content::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 1px;
background: var(--primary);
box-shadow: 0 0 15px var(--primary);
}
.drawer-header {
padding: 1.5rem;
border-bottom: 1px solid #eee;
padding: 2rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255,255,255,0.02);
}
.drawer-header h2 {
margin: 0;
font-size: 1.5rem;
color: #333;
font-size: 1.8rem;
font-weight: 800;
color: white;
letter-spacing: -1px;
text-transform: uppercase;
}
.close-btn {
background: none;
border: none;
background: transparent;
border: 1px solid transparent;
font-size: 2rem;
color: #999;
color: var(--color-text-muted);
cursor: pointer;
line-height: 1;
padding: 0;
width: 32px;
height: 32px;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.close-btn:hover {
color: #333;
color: var(--primary);
border-color: var(--primary);
box-shadow: 0 0 10px rgba(var(--color-primary-rgb), 0.3);
transform: rotate(90deg);
}
.drawer-body {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
padding: 2rem;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(0,0,0,0.2);
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #333;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: var(--primary);
}
/* Drawer Transition */
.drawer-enter-active,
.drawer-leave-active {
transition: opacity 0.3s;
transition: opacity 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.drawer-enter-active .drawer-content,
.drawer-leave-active .drawer-content {
transition: transform 0.3s;
transition: transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.drawer-enter-from {
@ -123,4 +164,3 @@ const handleOverlayClick = () => {
transform: translateX(100%);
}
</style>

163
frontend/src/components/NavBar.vue

@ -2,33 +2,38 @@
<nav class="navbar">
<div class="navbar-container">
<div class="navbar-brand" @click="$router.push('/')">
<h1>前端部署平台</h1>
<span class="brand-icon">🚀</span>
<h1>DEPLOY<span class="highlight">.SYS</span></h1>
</div>
<div class="navbar-search">
<span class="search-icon">🔍</span>
<input
v-model="searchQuery"
type="text"
placeholder="搜索项目..."
placeholder="SEARCH_MODULES..."
@input="handleSearch"
class="search-input"
/>
</div>
<div class="navbar-actions">
<template v-if="userStore.isAuthenticated">
<button v-if="userStore.isSuperAdmin" @click="$router.push('/settings')" class="btn-icon">
设置
<button v-if="userStore.isSuperAdmin" @click="$router.push('/settings')" class="btn-icon" title="SETTINGS">
</button>
<button @click="$router.push('/upload')" class="btn-primary">
上传
<button @click="$router.push('/upload')" class="btn btn-primary">
<span>📤</span> UPLOAD
</button>
<div class="user-menu">
<div class="avatar">
{{ userStore.user?.username.charAt(0).toUpperCase() }}
</div>
<span class="username">{{ userStore.user?.username }}</span>
<button @click="handleLogout" class="btn-text">登出</button>
<button @click="handleLogout" class="btn-logout">[ LOGOUT ]</button>
</div>
</template>
<template v-else>
<button @click="$router.push('/login')" class="btn-text">登录</button>
<button @click="$router.push('/register')" class="btn-primary">注册</button>
<button @click="$router.push('/login')" class="btn btn-ghost">LOGIN</button>
<button @click="$router.push('/register')" class="btn btn-primary">JOIN</button>
</template>
</div>
</div>
@ -39,14 +44,12 @@
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/store';
import { projectApi } from '@/api/project';
const router = useRouter();
const userStore = useUserStore();
const searchQuery = ref('');
const handleSearch = () => {
//
router.push({ path: '/', query: { search: searchQuery.value } });
};
@ -58,9 +61,13 @@ const handleLogout = () => {
<style scoped>
.navbar {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: rgba(5, 5, 5, 0.7);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding: 1rem 2rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 100;
}
.navbar-container {
@ -73,26 +80,67 @@ const handleLogout = () => {
.navbar-brand {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
transition: transform var(--transition-fast);
}
.navbar-brand:hover {
transform: scale(1.02);
text-shadow: var(--glow-primary);
}
.brand-icon {
font-size: 1.5rem;
filter: drop-shadow(0 0 5px var(--primary));
}
.navbar-brand h1 {
color: white;
font-size: 1.5rem;
font-weight: 700;
font-size: 1.2rem;
font-weight: 900;
margin: 0;
letter-spacing: 1px;
font-family: var(--font-family-mono);
}
.highlight {
color: var(--primary);
}
.navbar-search {
flex: 1;
max-width: 400px;
max-width: 480px;
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 1rem;
color: var(--color-text-muted);
font-size: 0.9rem;
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.5rem 1rem;
border: none;
border-radius: 8px;
padding: 0.8rem 1rem 0.8rem 2.5rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 0.9rem;
background: rgba(255,255,255,0.03);
transition: all var(--transition-fast);
color: white;
font-family: var(--font-family-mono);
}
.search-input:focus {
background: rgba(0,0,0,0.5);
border-color: var(--primary);
box-shadow: 0 0 15px rgba(var(--color-primary-rgb), 0.2);
outline: none;
}
@ -105,50 +153,67 @@ const handleLogout = () => {
.user-menu {
display: flex;
align-items: center;
gap: 0.5rem;
color: white;
gap: 1rem;
padding-left: 1.5rem;
border-left: 1px solid var(--border-color);
height: 30px;
}
.username {
font-weight: 500;
.avatar {
width: 32px;
height: 32px;
background: var(--primary);
color: black;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 1rem;
box-shadow: 0 0 10px rgba(var(--color-primary-rgb), 0.4);
}
.btn-primary {
padding: 0.5rem 1.5rem;
background: white;
color: #667eea;
border: none;
border-radius: 8px;
.username {
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
}
.btn-primary:hover {
transform: translateY(-2px);
color: white;
font-family: var(--font-family-mono);
letter-spacing: 0.5px;
font-size: 0.9rem;
}
.btn-text {
padding: 0.5rem 1rem;
.btn-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: white;
border: 1px solid white;
border-radius: 8px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.2s;
font-size: 1.2rem;
transition: all var(--transition-fast);
}
.btn-text:hover {
background: rgba(255, 255, 255, 0.2);
.btn-icon:hover {
border-color: var(--primary);
background: rgba(var(--color-primary-rgb), 0.1);
transform: rotate(90deg);
}
.btn-icon {
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.2);
color: white;
.btn-logout {
font-size: 0.75rem;
color: var(--color-text-muted);
border: none;
border-radius: 8px;
background: transparent;
cursor: pointer;
transition: all var(--transition-fast);
font-family: var(--font-family-mono);
letter-spacing: 1px;
}
</style>
.btn-logout:hover {
color: var(--accent);
text-shadow: var(--glow-accent);
}
</style>

247
frontend/src/components/ProjectCard.vue

@ -1,21 +1,35 @@
<template>
<div class="project-card">
<div class="project-card acid-card">
<div class="card-content" @click="handleClick">
<div class="card-header">
<h3 class="card-title">{{ project.title }}</h3>
<span v-if="project.category" class="card-category">{{ project.category }}</span>
</div>
<p v-if="project.description" class="card-description">{{ project.description }}</p>
<div v-if="project.tags && project.tags.length > 0" class="card-tags">
<span v-for="tag in project.tags" :key="tag" class="tag">{{ tag }}</span>
<div class="title-row">
<h3 class="card-title">{{ project.title }}</h3>
<span v-if="project.category" class="card-category">{{ project.category }}</span>
</div>
<div class="card-meta">
<span class="date">Deployed: {{ formatDate(project.created_at) }}</span>
</div>
</div>
<p class="card-description">{{ project.description || 'NO_DESCRIPTION_AVAILABLE' }}</p>
<div class="card-footer">
<span class="card-date">{{ formatDate(project.created_at) }}</span>
<div v-if="project.tags && project.tags.length > 0" class="card-tags">
<span v-for="tag in project.tags" :key="tag" class="tag">#{{ tag }}</span>
</div>
<div class="card-status" v-else>
<span class="empty-tag">// NO_TAGS</span>
</div>
</div>
</div>
<div v-if="canEdit" class="card-actions" @click.stop>
<button @click="handleEdit" class="action-btn edit-btn">编辑</button>
<button @click="handleDelete" class="action-btn delete-btn">删除</button>
<button @click="handleEdit" class="action-btn edit-btn" title="EDIT">
</button>
<button @click="handleDelete" class="action-btn delete-btn" title="DELETE">
🗑
</button>
</div>
</div>
</template>
@ -51,142 +65,201 @@ const handleEdit = () => {
};
const handleDelete = async () => {
if (!confirm('确定要删除这个项目吗?')) {
if (!confirm('CONFIRM DELETION PROTOCOL?')) {
return;
}
try {
await projectApi.delete(props.project.id);
//
emit('deleted', props.project.id);
} catch (error: any) {
alert(error.response?.data?.error?.message || '删除失败');
alert(error.response?.data?.error?.message || 'DELETION FAILED');
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN');
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).replace(/\//g, '.');
};
</script>
<style scoped>
.project-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
height: 100%;
display: flex;
flex-direction: column;
cursor: pointer;
border-width: 1px;
}
.project-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
border-color: var(--primary);
box-shadow: var(--glow-primary);
transform: translateY(-4px) scale(1.01);
}
.card-content {
cursor: pointer;
}
.card-actions {
padding: 0;
flex: 1;
display: flex;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.action-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.2s;
flex-direction: column;
}
.edit-btn {
background: #667eea;
color: white;
.card-header {
margin-bottom: 1rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
padding-bottom: 1rem;
}
.edit-btn:hover {
background: #5568d3;
.title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.delete-btn {
background: #e74c3c;
.card-title {
font-size: 1.2rem;
font-weight: 800;
color: white;
margin: 0;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-transform: uppercase;
}
.delete-btn:hover {
background: #c0392b;
.card-category {
background: var(--primary);
color: black;
padding: 0.1rem 0.4rem;
border-radius: 2px;
font-size: 0.6rem;
font-weight: 900;
text-transform: uppercase;
white-space: nowrap;
flex-shrink: 0;
box-shadow: 0 0 5px var(--primary);
}
.card-header {
.card-meta {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 0.75rem;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: #333;
margin: 0;
flex: 1;
align-items: center;
gap: 0.5rem;
}
.card-category {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
.date {
font-size: 0.7rem;
color: var(--color-text-muted);
font-family: var(--font-family-mono);
letter-spacing: 0.5px;
}
.card-description {
color: #666;
color: #aaa;
font-size: 0.9rem;
margin: 0.75rem 0;
line-height: 1.5;
margin-bottom: 1.5rem;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
.card-footer {
margin-top: auto;
padding-top: 1rem;
border-top: 1px solid rgba(255,255,255,0.05);
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.75rem 0;
}
.tag {
background: #f0f0f0;
color: #666;
padding: 0.25rem 0.5rem;
border-radius: 6px;
font-size: 0.75rem;
border: 1px solid var(--secondary);
color: var(--secondary);
padding: 0.1rem 0.4rem;
border-radius: 2px;
font-size: 0.7rem;
font-family: var(--font-family-mono);
transition: all var(--transition-fast);
}
.card-footer {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eee;
.project-card:hover .tag {
background: var(--secondary);
color: white;
box-shadow: 0 0 8px var(--secondary);
}
.empty-tag {
color: var(--color-text-muted);
font-size: 0.7rem;
font-family: var(--font-family-mono);
opacity: 0.5;
}
/* Actions Overlay */
.card-actions {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
gap: 0.5rem;
opacity: 0;
transform: translateX(10px);
transition: all 0.2s ease;
z-index: 10;
}
.project-card:hover .card-actions {
opacity: 1;
transform: translateX(0);
}
.action-btn {
width: 30px;
height: 30px;
border: 1px solid rgba(255,255,255,0.2);
border-radius: 4px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
justify-content: center;
font-size: 0.9rem;
transition: all 0.2s;
background: black;
}
.card-date {
color: #999;
font-size: 0.8rem;
.edit-btn {
color: var(--primary);
border-color: var(--primary);
}
</style>
.edit-btn:hover {
background: var(--primary);
color: black;
box-shadow: 0 0 10px var(--primary);
}
.delete-btn {
color: var(--accent);
border-color: var(--accent);
}
.delete-btn:hover {
background: var(--accent);
color: white;
box-shadow: 0 0 10px var(--accent);
}
</style>

1
frontend/src/main.ts

@ -2,6 +2,7 @@ import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import './styles/variables.css';
const app = createApp(App);

157
frontend/src/styles/variables.css

@ -0,0 +1,157 @@
:root {
/* --- ACID / NEON PALETTE --- */
/* Base Colors - RGB for opacity manipulation */
--color-primary-rgb: 204, 255, 0; /* Acid Lime: #CCFF00 */
--color-secondary-rgb: 138, 43, 226; /* Electric Violet: #8A2BE2 */
--color-accent-rgb: 255, 0, 85; /* Cyber Pink: #FF0055 */
--color-info-rgb: 0, 240, 255; /* Cyan: #00F0FF */
--color-bg-rgb: 5, 5, 5; /* Void Black */
--color-surface-rgb: 18, 18, 20; /* Dark Gunmetal */
--color-surface-light-rgb: 30, 30, 35;
--color-text-main: #ffffff;
--color-text-muted: #888888;
/* Actual Color Variables */
--primary: rgb(var(--color-primary-rgb));
--secondary: rgb(var(--color-secondary-rgb));
--accent: rgb(var(--color-accent-rgb));
--info: rgb(var(--color-info-rgb));
--bg-body: rgb(var(--color-bg-rgb));
--bg-surface: rgb(var(--color-surface-rgb));
--bg-surface-light: rgb(var(--color-surface-light-rgb));
/* --- BORDERS & GLOWS --- */
--border-color: rgba(255, 255, 255, 0.1);
--border-color-hover: var(--primary);
--glow-primary: 0 0 20px rgba(var(--color-primary-rgb), 0.4);
--glow-accent: 0 0 20px rgba(var(--color-accent-rgb), 0.4);
--glow-text: 0 0 10px rgba(var(--color-primary-rgb), 0.6);
/* --- TYPOGRAPHY --- */
--font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--font-family-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* --- ANIMATIONS & MICRO-INTERACTIONS --- */
--transition-fast: 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94);
--transition-normal: 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
--transition-bounce: 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
--radius-sm: 4px;
--radius-md: 12px;
--radius-lg: 24px;
}
/* --- GLOBAL RESET & UTILITIES --- */
body {
background-color: var(--bg-body);
color: var(--color-text-main);
font-family: var(--font-family-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-body);
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--primary);
}
/* Utility Classes */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 24px;
font-weight: 700;
font-size: 1rem;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-normal);
border: none;
text-transform: uppercase;
letter-spacing: 0.5px;
position: relative;
overflow: hidden;
}
.btn:active {
transform: scale(0.96);
}
/* Primary Button - Acid Style */
.btn-primary {
background-color: var(--primary);
color: #000;
box-shadow: 0 4px 0 rgba(255, 255, 255, 0.2) inset, 0 -4px 0 rgba(0,0,0,0.1) inset;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 0 25px rgba(var(--color-primary-rgb), 0.6);
}
/* Secondary/Ghost Button */
.btn-ghost {
background: transparent;
border: 1px solid var(--border-color);
color: var(--color-text-main);
}
.btn-ghost:hover {
border-color: var(--color-text-main);
background: rgba(255,255,255,0.05);
}
/* Input Styles */
.input-field {
width: 100%;
padding: 12px 16px;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: white;
font-size: 1rem;
transition: var(--transition-normal);
outline: none;
}
.input-field:focus {
border-color: var(--primary);
box-shadow: 0 0 0 4px rgba(var(--color-primary-rgb), 0.1);
background: var(--bg-surface-light);
}
/* Card Style */
.acid-card {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 20px;
transition: var(--transition-normal);
position: relative;
}
.acid-card:hover {
border-color: var(--primary);
transform: translateY(-4px);
box-shadow: var(--glow-primary);
z-index: 1;
}

228
frontend/src/views/Home.vue

@ -2,33 +2,42 @@
<div class="home">
<NavBar />
<div class="container">
<div class="filters">
<!-- Hero Banner -->
<div class="hero-section">
<h2 class="glitch-text" data-text="EXPLORE PROJECTS">EXPLORE PROJECTS</h2>
<p>DEPLOY // SHARE // DOMINATE</p>
</div>
<div class="filters-bar acid-card">
<div class="filter-group">
<label>分类</label>
<label>CATEGORY</label>
<select v-model="selectedCategory" @change="loadProjects" class="filter-select">
<option value="">全部</option>
<option value="">ALL CATEGORIES</option>
<option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</option>
</select>
</div>
<div class="filter-group">
<label>标签</label>
<label>TAGS</label>
<select v-model="selectedTag" @change="loadProjects" class="filter-select">
<option value="">全部</option>
<option value="">ALL TAGS</option>
<option v-for="tag in tags" :key="tag" :value="tag">{{ tag }}</option>
</select>
</div>
<div class="filter-group">
<button @click="clearFilters" class="btn-clear">清除筛选</button>
</div>
<button v-if="selectedCategory || selectedTag" @click="clearFilters" class="btn-clear">
RESET FILTER [X]
</button>
</div>
<div v-if="loading" class="loading">
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<p>加载中...</p>
<p>LOADING_DATA...</p>
</div>
<div v-else-if="projects.length === 0" class="empty">
<p>暂无项目</p>
<div v-else-if="projects.length === 0" class="empty-state">
<div class="empty-icon">📦</div>
<h3>NO PROJECTS FOUND</h3>
<p>INITIATE FIRST DEPLOYMENT</p>
<button @click="$router.push('/upload')" class="btn btn-primary">UPLOAD NOW</button>
</div>
<div v-else class="projects-grid">
@ -46,17 +55,17 @@
:disabled="currentPage === 1"
class="page-btn"
>
上一页
&lt; PREV
</button>
<span class="page-info">
{{ currentPage }} / {{ Math.ceil(total / limit) }}
PAGE {{ currentPage }} / {{ Math.ceil(total / limit) }}
</span>
<button
@click="loadPage(currentPage + 1)"
:disabled="currentPage >= Math.ceil(total / limit)"
class="page-btn"
>
下一页
NEXT &gt;
</button>
</div>
</div>
@ -169,7 +178,10 @@ onMounted(() => {
<style scoped>
.home {
min-height: 100vh;
background: #f5f5f5;
background-color: var(--bg-body);
background-image:
radial-gradient(circle at 20% 20%, rgba(var(--color-primary-rgb), 0.05) 0%, transparent 40%),
radial-gradient(circle at 80% 80%, rgba(var(--color-secondary-rgb), 0.05) 0%, transparent 40%);
}
.container {
@ -178,67 +190,129 @@ onMounted(() => {
padding: 2rem;
}
.filters {
background: white;
padding: 1.5rem;
border-radius: 12px;
margin-bottom: 2rem;
.hero-section {
text-align: center;
margin-bottom: 4rem;
padding: 4rem 0;
position: relative;
}
.hero-section::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100px;
height: 2px;
background: var(--primary);
box-shadow: var(--glow-primary);
}
.hero-section h2 {
font-size: 3.5rem;
font-weight: 900;
margin-bottom: 1rem;
color: white;
letter-spacing: -2px;
text-transform: uppercase;
text-shadow: 0 0 20px rgba(var(--color-primary-rgb), 0.3);
}
.hero-section p {
color: var(--color-text-muted);
font-size: 1rem;
letter-spacing: 4px;
font-family: var(--font-family-mono);
}
.filters-bar {
background: rgba(var(--color-surface-rgb), 0.8);
padding: 1.5rem 2rem;
margin-bottom: 3rem;
display: flex;
gap: 1.5rem;
align-items: center;
gap: 2rem;
align-items: flex-end;
flex-wrap: wrap;
backdrop-filter: blur(10px);
}
.filter-group {
display: flex;
align-items: center;
flex-direction: column;
gap: 0.5rem;
}
.filter-group label {
font-weight: 500;
color: #666;
font-weight: 600;
color: var(--primary);
font-size: 0.7rem;
font-family: var(--font-family-mono);
letter-spacing: 1px;
}
.filter-select {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 8px;
padding: 0.8rem 2.5rem 0.8rem 1rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
font-size: 0.9rem;
outline: none;
cursor: pointer;
background-color: rgba(0,0,0,0.3);
color: white;
transition: all var(--transition-fast);
appearance: none;
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23CCFF00%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E");
background-repeat: no-repeat;
background-position: right 1rem top 50%;
background-size: 0.8rem auto;
min-width: 200px;
font-family: var(--font-family-mono);
}
.filter-select:hover,
.filter-select:focus {
border-color: #667eea;
border-color: var(--primary);
box-shadow: 0 0 15px rgba(var(--color-primary-rgb), 0.1);
background-color: black;
}
.btn-clear {
padding: 0.5rem 1rem;
background: #f0f0f0;
border: none;
border-radius: 8px;
padding: 0.8rem 1.5rem;
background: transparent;
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
cursor: pointer;
color: #666;
color: var(--accent);
font-size: 0.8rem;
transition: all var(--transition-fast);
margin-left: auto;
font-family: var(--font-family-mono);
letter-spacing: 1px;
}
.btn-clear:hover {
background: #e0e0e0;
background: var(--accent);
color: white;
box-shadow: var(--glow-accent);
}
.loading {
.loading-state {
text-align: center;
padding: 4rem;
padding: 6rem 0;
color: var(--color-text-muted);
font-family: var(--font-family-mono);
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
width: 48px;
height: 48px;
border: 4px solid rgba(255,255,255,0.1);
border-top: 4px solid var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
animation: spin 0.8s linear infinite;
margin: 0 auto 1.5rem;
box-shadow: var(--glow-primary);
}
@keyframes spin {
@ -246,48 +320,80 @@ onMounted(() => {
100% { transform: rotate(360deg); }
}
.empty {
.empty-state {
text-align: center;
padding: 4rem;
color: #999;
padding: 6rem 2rem;
background: rgba(var(--color-surface-rgb), 0.5);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
backdrop-filter: blur(10px);
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.8;
filter: drop-shadow(0 0 10px var(--secondary));
}
.empty-state h3 {
font-size: 1.5rem;
color: white;
margin-bottom: 0.5rem;
font-weight: 800;
letter-spacing: -1px;
}
.empty-state p {
color: var(--color-text-muted);
margin-bottom: 2rem;
font-family: var(--font-family-mono);
font-size: 0.8rem;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
margin-bottom: 4rem;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
gap: 1.5rem;
padding: 2rem;
}
.page-btn {
padding: 0.5rem 1.5rem;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 0.8rem 1.5rem;
background: black;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s;
transition: all var(--transition-fast);
font-weight: 600;
color: white;
font-family: var(--font-family-mono);
font-size: 0.8rem;
}
.page-btn:hover:not(:disabled) {
background: #667eea;
color: white;
border-color: #667eea;
border-color: var(--primary);
color: var(--primary);
box-shadow: var(--glow-primary);
}
.page-btn:disabled {
opacity: 0.5;
opacity: 0.3;
cursor: not-allowed;
border-color: transparent;
}
.page-info {
color: #666;
color: var(--color-text-muted);
font-family: var(--font-family-mono);
font-size: 0.9rem;
}
</style>

215
frontend/src/views/Login.vue

@ -1,35 +1,51 @@
<template>
<div class="login-page">
<div class="login-container">
<h1>登录</h1>
<div class="glow-bg"></div>
<div class="login-container acid-card">
<div class="brand-header">
<div class="logo-icon">🚀</div>
<h1 class="glitch-text" data-text="欢迎回来">欢迎回来</h1>
<p class="subtitle">SYSTEM ACCESS // GRANTED</p>
</div>
<form @submit.prevent="handleLogin" class="login-form">
<div class="form-group">
<label>用户名</label>
<input
v-model="form.username"
type="text"
required
placeholder="请输入用户名"
class="form-input"
/>
<label>USERNAME</label>
<div class="input-wrapper">
<input
v-model="form.username"
type="text"
required
placeholder="IDENTIFIER"
class="input-field"
/>
</div>
</div>
<div class="form-group">
<label>密码</label>
<input
v-model="form.password"
type="password"
required
placeholder="请输入密码"
class="form-input"
/>
<label>PASSWORD</label>
<div class="input-wrapper">
<input
v-model="form.password"
type="password"
required
placeholder="ACCESS KEY"
class="input-field"
/>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<button type="submit" :disabled="loading" class="submit-btn">
{{ loading ? '登录中...' : '登录' }}
<div v-if="error" class="error-message">
<span class="error-icon"></span> {{ error }}
</div>
<button type="submit" :disabled="loading" class="btn btn-primary submit-btn">
{{ loading ? 'INITIALIZING...' : 'CONNECT' }}
</button>
<p class="form-footer">
还没有账号
<router-link to="/register">立即注册</router-link>
NO ACCESS?
<router-link to="/register" class="link">CREATE ID</router-link>
</p>
</form>
</div>
@ -62,7 +78,7 @@ const handleLogin = async () => {
userStore.setUser(response.user);
router.push('/');
} catch (err: any) {
error.value = err.response?.data?.error?.message || '登录失败,请重试';
error.value = err.response?.data?.error?.message || 'Access Denied';
} finally {
loading.value = false;
}
@ -75,96 +91,131 @@ const handleLogin = async () => {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 2rem;
background-color: var(--bg-body);
position: relative;
overflow: hidden;
}
.glow-bg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 60vw;
height: 60vw;
background: radial-gradient(circle, rgba(var(--color-primary-rgb), 0.15) 0%, rgba(0,0,0,0) 70%);
filter: blur(80px);
z-index: 0;
animation: breathe 8s infinite ease-in-out;
}
@keyframes breathe {
0%, 100% { opacity: 0.5; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 0.8; transform: translate(-50%, -50%) scale(1.1); }
}
.login-container {
background: white;
border-radius: 16px;
padding: 3rem;
width: 100%;
max-width: 400px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
z-index: 1;
backdrop-filter: blur(20px);
background: rgba(var(--color-surface-rgb), 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 0 40px rgba(0,0,0,0.5);
}
.login-container h1 {
.brand-header {
text-align: center;
margin-bottom: 2rem;
color: #333;
font-size: 2rem;
margin-bottom: 2.5rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
.logo-icon {
font-size: 3rem;
margin-bottom: 1rem;
filter: drop-shadow(0 0 10px var(--primary));
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 500;
color: #666;
h1 {
color: var(--color-text-main);
font-size: 2rem;
font-weight: 800;
letter-spacing: -1px;
text-transform: uppercase;
margin-bottom: 0.5rem;
}
.form-input {
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
.subtitle {
color: var(--color-text-muted);
font-family: var(--font-family-mono);
font-size: 0.75rem;
letter-spacing: 2px;
}
.form-input:focus {
border-color: #667eea;
.form-group {
margin-bottom: 1.5rem;
}
.error-message {
color: #e74c3c;
font-size: 0.9rem;
text-align: center;
label {
display: block;
color: var(--primary);
font-family: var(--font-family-mono);
font-size: 0.75rem;
margin-bottom: 0.5rem;
letter-spacing: 1px;
}
.submit-btn {
padding: 0.75rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
.input-field {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
transition: all 0.3s ease;
}
.submit-btn:hover:not(:disabled) {
transform: translateY(-2px);
.input-field:focus {
background: rgba(0, 0, 0, 0.5);
border-color: var(--primary);
box-shadow: 0 0 15px rgba(var(--color-primary-rgb), 0.2);
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
.submit-btn {
width: 100%;
margin-top: 1rem;
font-family: var(--font-family-mono);
letter-spacing: 2px;
}
.form-footer {
margin-top: 2rem;
text-align: center;
color: #666;
margin-top: 1rem;
font-family: var(--font-family-mono);
font-size: 0.75rem;
color: var(--color-text-muted);
}
.form-footer a {
color: #667eea;
text-decoration: none;
font-weight: 500;
.link {
color: var(--color-text-main);
margin-left: 0.5rem;
text-decoration: underline;
text-decoration-color: var(--primary);
text-underline-offset: 4px;
}
.form-footer a:hover {
text-decoration: underline;
.link:hover {
color: var(--primary);
text-decoration-color: transparent;
text-shadow: 0 0 8px var(--primary);
}
.error-message {
color: var(--accent);
background: rgba(var(--color-accent-rgb), 0.1);
border: 1px solid rgba(var(--color-accent-rgb), 0.3);
padding: 0.75rem;
border-radius: var(--radius-sm);
margin-bottom: 1rem;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>

364
frontend/src/views/Project.vue

@ -7,53 +7,61 @@
sandbox="allow-scripts allow-same-origin allow-forms"
></iframe>
<div class="control-buttons">
<button @click="goHome" class="control-btn back-btn">
<span></span>
<button @click="goHome" class="control-btn back-btn" title="BACK HOME">
<span>🏠</span>
</button>
<button @click="drawerVisible = true" class="control-btn info-btn">
<button @click="drawerVisible = true" class="control-btn info-btn" title="PROJECT INFO">
<span>ℹ️</span>
</button>
</div>
<Drawer v-model:visible="drawerVisible" title="项目信息" @close="handleDrawerClose">
<Drawer v-model:visible="drawerVisible" title="PROJECT_MANIFEST" @close="handleDrawerClose">
<div v-if="project" class="project-info">
<div class="info-section">
<h3>{{ project.title }}</h3>
<div class="info-section header-section">
<h3 class="glitch-text" :data-text="project.title">{{ project.title }}</h3>
<p v-if="project.description" class="description">{{ project.description }}</p>
</div>
<div v-if="project.category" class="info-item">
<strong>分类</strong>{{ project.category }}
</div>
<div v-if="project.tags && project.tags.length > 0" class="info-item">
<strong>标签</strong>
<span v-for="tag in project.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<div class="info-item">
<strong>创建时间</strong>{{ formatDate(project.created_at) }}
<div class="info-section meta-section acid-card">
<div v-if="project.category" class="info-item">
<span class="label">CATEGORY</span>
<span class="value category-badge">{{ project.category }}</span>
</div>
<div v-if="project.tags && project.tags.length > 0" class="info-item">
<span class="label">TAGS</span>
<div class="tags-wrapper">
<span v-for="tag in project.tags" :key="tag" class="tag">#{{ tag }}</span>
</div>
</div>
<div class="info-item">
<span class="label">DEPLOYED</span>
<span class="value">{{ formatDate(project.created_at) }}</span>
</div>
</div>
<div v-if="canEdit" class="info-section actions-section">
<button @click="handleEdit" class="action-btn edit-btn">编辑</button>
<button @click="handleDelete" class="action-btn delete-btn">删除</button>
<button @click="handleEdit" class="action-btn edit-btn">EDIT_SOURCE</button>
<button @click="handleDelete" class="action-btn delete-btn">TERMINATE</button>
</div>
<div v-if="project.attachments && project.attachments.length > 0" class="info-section">
<h4>附件</h4>
<h4>ATTACHMENTS</h4>
<ul class="attachment-list">
<li v-for="attachment in project.attachments" :key="attachment.id" class="attachment-item">
<span>{{ attachment.file_name }}</span>
<span class="file-icon">📎</span>
<span class="file-name">{{ attachment.file_name }}</span>
<a
:href="`/api/files/attachment/${attachment.id}`"
:download="attachment.file_name"
class="download-link"
>
下载
DOWNLOAD
</a>
</li>
</ul>
</div>
<div v-if="project.documents && project.documents.length > 0" class="info-section">
<h4>文档</h4>
<h4>DOCUMENTATION</h4>
<ul class="document-list">
<li
v-for="document in project.documents"
@ -61,16 +69,21 @@
class="document-item"
@click="viewDocument(document)"
>
{{ document.title }}
<span class="doc-icon">📄</span>
<span class="doc-title">{{ document.title }}</span>
<span class="doc-arrow">&gt;</span>
</li>
</ul>
</div>
</div>
<div v-else-if="loading" class="loading">加载中...</div>
<div v-else class="error">加载失败</div>
<div v-else-if="loading" class="loading">
<div class="spinner"></div>
<p>INITIALIZING...</p>
</div>
<div v-else class="error">SYSTEM ERROR: LOAD FAILED</div>
</Drawer>
<Drawer v-model:visible="documentVisible" title="文档" @close="documentVisible = false">
<Drawer v-model:visible="documentVisible" title="DOC_VIEWER" @close="documentVisible = false">
<div v-if="currentDocument" class="document-viewer">
<div v-html="renderedMarkdown" class="markdown-content"></div>
</div>
@ -84,9 +97,11 @@ import { useRoute, useRouter } from 'vue-router';
import { marked } from 'marked';
import Drawer from '@/components/Drawer.vue';
import { projectApi, Project, Document } from '@/api/project';
import { useUserStore } from '@/store';
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const projectId = parseInt(route.params.id as string);
const project = ref<Project | null>(null);
@ -96,6 +111,12 @@ const documentVisible = ref(false);
const currentDocument = ref<Document | null>(null);
const previewUrl = ref('');
const canEdit = computed(() => {
if (!project.value) return false;
return userStore.isAuthenticated &&
(userStore.user?.id === project.value.user_id || userStore.isSuperAdmin);
});
const renderedMarkdown = computed(() => {
if (!currentDocument.value?.content) return '';
return marked(currentDocument.value.content);
@ -133,7 +154,7 @@ const goHome = () => {
};
const handleDelete = async () => {
if (!confirm('确定要删除这个项目吗?')) {
if (!confirm('WARNING: PERMANENT DELETION PROTOCOL INITIATED. PROCEED?')) {
return;
}
@ -141,7 +162,7 @@ const handleDelete = async () => {
await projectApi.delete(projectId);
router.push('/');
} catch (error: any) {
alert(error.response?.data?.error?.message || '删除失败');
alert(error.response?.data?.error?.message || 'DELETION FAILED');
}
};
@ -161,136 +182,194 @@ onMounted(() => {
width: 100vw;
height: 100vh;
overflow: hidden;
background-color: var(--bg-body);
}
.preview-iframe {
width: 100%;
height: 100%;
border: none;
background: white;
}
.control-buttons {
position: fixed;
right: 0;
top: 50%;
transform: translateY(-50%) translateX(calc(100% - 20px));
transform: translateY(-50%) translateX(calc(100% - 12px));
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 1rem;
z-index: 999;
transition: transform 0.3s ease;
padding-right: 0.5rem;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
padding: 1rem 0.5rem 1rem 1rem;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(10px);
border-radius: 20px 0 0 20px;
border: 1px solid var(--primary);
border-right: none;
}
.control-buttons:hover {
transform: translateY(-50%) translateX(0);
background: rgba(0, 0, 0, 0.9);
box-shadow: var(--glow-primary);
}
.control-btn {
width: 44px;
height: 44px;
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(102, 126, 234, 0.7);
backdrop-filter: blur(8px);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
background: black;
color: var(--primary);
border: 1px solid var(--primary);
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
font-size: 1.2rem;
font-size: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
opacity: 0.8;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
}
.control-btn:hover {
opacity: 1;
background: rgba(102, 126, 234, 0.9);
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
transform: scale(1.15);
background: var(--primary);
box-shadow: 0 0 20px rgba(var(--color-primary-rgb), 0.5);
color: black;
z-index: 2;
}
.project-info {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.info-section {
margin-bottom: 1rem;
gap: 2rem;
padding-bottom: 2rem;
}
.info-section h3 {
margin: 0 0 0.5rem 0;
color: #333;
font-size: 1.5rem;
color: white;
font-size: 1.8rem;
font-weight: 900;
letter-spacing: -1px;
text-transform: uppercase;
}
.info-section h4 {
margin: 0 0 0.75rem 0;
color: #666;
font-size: 1.1rem;
margin: 0 0 1rem 0;
color: var(--primary);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 2px;
font-weight: 600;
font-family: var(--font-family-mono);
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
}
.description {
color: #666;
line-height: 1.6;
color: var(--color-text-muted);
line-height: 1.7;
margin: 0.5rem 0;
font-size: 1rem;
}
.meta-section {
background: rgba(255,255,255,0.03);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.info-item {
margin-bottom: 0.75rem;
color: #666;
display: flex;
align-items: center;
gap: 1rem;
}
.info-item strong {
color: #333;
.info-item .label {
width: 80px;
font-weight: 600;
color: var(--secondary);
font-size: 0.8rem;
font-family: var(--font-family-mono);
letter-spacing: 1px;
}
.info-item .value {
color: white;
font-weight: 500;
font-family: var(--font-family-mono);
}
.category-badge {
background: var(--primary);
color: black !important;
padding: 0.2rem 0.8rem;
font-weight: 800 !important;
text-transform: uppercase;
box-shadow: 0 0 10px rgba(var(--color-primary-rgb), 0.3);
}
.tags-wrapper {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
display: inline-block;
background: #f0f0f0;
color: #666;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
margin-right: 0.5rem;
background: transparent;
border: 1px solid var(--secondary);
color: var(--secondary);
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
font-family: var(--font-family-mono);
}
.actions-section {
display: flex;
gap: 0.75rem;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #eee;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding-top: 1rem;
border-top: 1px dashed var(--border-color);
}
.action-btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
padding: 1rem;
border: 1px solid transparent;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
font-weight: 800;
transition: all var(--transition-fast);
text-align: center;
text-transform: uppercase;
font-family: var(--font-family-mono);
letter-spacing: 1px;
}
.edit-btn {
background: #667eea;
color: white;
background: black;
border-color: var(--primary);
color: var(--primary);
}
.edit-btn:hover {
background: #5568d3;
background: var(--primary);
color: black;
box-shadow: var(--glow-primary);
}
.delete-btn {
background: #e74c3c;
color: white;
background: black;
border-color: var(--accent);
color: var(--accent);
}
.delete-btn:hover {
background: #c0392b;
background: var(--accent);
color: white;
box-shadow: var(--glow-accent);
}
.attachment-list,
@ -298,83 +377,140 @@ onMounted(() => {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.attachment-item,
.document-item {
padding: 0.75rem;
background: #f9f9f9;
border-radius: 6px;
margin-bottom: 0.5rem;
padding: 1rem;
background: rgba(255,255,255,0.03);
border: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
transition: all var(--transition-fast);
}
.document-item {
cursor: pointer;
transition: background 0.2s;
}
.attachment-item:hover,
.document-item:hover {
background: #f0f0f0;
transform: translateX(4px);
border-color: var(--primary);
background: rgba(0,0,0,0.5);
box-shadow: var(--glow-text);
}
.file-icon, .doc-icon {
font-size: 1.2rem;
filter: grayscale(100%);
}
.file-name, .doc-title {
flex: 1;
font-weight: 500;
color: white;
font-family: var(--font-family-mono);
font-size: 0.9rem;
}
.download-link {
color: #667eea;
color: var(--primary);
font-weight: 800;
font-size: 0.8rem;
padding: 0.4rem 0.8rem;
border: 1px solid var(--primary);
transition: all var(--transition-fast);
text-transform: uppercase;
text-decoration: none;
font-weight: 500;
}
.download-link:hover {
text-decoration: underline;
background: var(--primary);
color: black;
box-shadow: var(--glow-primary);
}
.doc-arrow {
color: var(--secondary);
font-weight: bold;
font-family: monospace;
}
.loading,
.error {
text-align: center;
padding: 2rem;
color: #999;
padding: 4rem 2rem;
color: var(--color-text-muted);
font-family: var(--font-family-mono);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255,255,255,0.1);
border-top: 3px solid var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 1rem;
}
.document-viewer {
padding: 1rem 0;
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.markdown-content {
line-height: 1.8;
color: #333;
color: #ccc;
padding: 0.5rem;
font-family: sans-serif;
}
.markdown-content :deep(h1),
.markdown-content :deep(h2),
.markdown-content :deep(h3) {
margin-top: 1.5rem;
margin-bottom: 1rem;
color: #333;
color: white;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
margin-top: 2rem;
text-transform: uppercase;
}
.markdown-content :deep(p) {
margin-bottom: 1rem;
.markdown-content :deep(a) {
color: var(--primary);
}
.markdown-content :deep(code) {
background: #f4f4f4;
background: #222;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: 'Courier New', monospace;
border: 1px solid #333;
font-family: var(--font-family-mono);
color: var(--secondary);
}
.markdown-content :deep(pre) {
background: #f4f4f4;
padding: 1rem;
border-radius: 6px;
background: #000;
color: #0f0;
padding: 1.5rem;
border: 1px solid #333;
overflow-x: auto;
margin: 1.5rem 0;
}
.markdown-content :deep(pre code) {
background: transparent;
color: inherit;
padding: 0;
border: none;
}
.markdown-content :deep(ul),
.markdown-content :deep(ol) {
margin-left: 1.5rem;
margin-bottom: 1rem;
.markdown-content :deep(img) {
max-width: 100%;
border: 1px solid var(--border-color);
}
</style>

238
frontend/src/views/Register.vue

@ -1,44 +1,63 @@
<template>
<div class="register-page">
<div class="register-container">
<h1>注册</h1>
<div class="glow-bg"></div>
<div class="register-container acid-card">
<div class="brand-header">
<div class="logo-icon"></div>
<h1 class="glitch-text" data-text="创建账号">创建账号</h1>
<p class="subtitle">INITIATE NEW USER PROTOCOL</p>
</div>
<form @submit.prevent="handleRegister" class="register-form">
<div class="form-group">
<label>用户名</label>
<input
v-model="form.username"
type="text"
required
placeholder="请输入用户名"
class="form-input"
/>
<label>USERNAME</label>
<div class="input-wrapper">
<input
v-model="form.username"
type="text"
required
placeholder="IDENTIFIER"
class="input-field"
/>
</div>
</div>
<div class="form-group">
<label>邮箱可选</label>
<input
v-model="form.email"
type="email"
placeholder="请输入邮箱"
class="form-input"
/>
<label>EMAIL (OPTIONAL)</label>
<div class="input-wrapper">
<input
v-model="form.email"
type="email"
placeholder="CONTACT_ADDR"
class="input-field"
/>
</div>
</div>
<div class="form-group">
<label>密码</label>
<input
v-model="form.password"
type="password"
required
placeholder="请输入密码"
class="form-input"
/>
<label>PASSWORD</label>
<div class="input-wrapper">
<input
v-model="form.password"
type="password"
required
placeholder="ACCESS KEY"
class="input-field"
/>
</div>
</div>
<div v-if="error" class="error-message">
<span class="error-icon"></span> {{ error }}
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<button type="submit" :disabled="loading" class="submit-btn">
{{ loading ? '注册中...' : '注册' }}
<button type="submit" :disabled="loading" class="btn btn-primary submit-btn">
{{ loading ? 'PROCESSING...' : 'REGISTER ID' }}
</button>
<p class="form-footer">
已有账号
<router-link to="/login">立即登录</router-link>
ALREADY REGISTERED?
<router-link to="/login" class="link">LOGIN</router-link>
</p>
</form>
</div>
@ -72,7 +91,7 @@ const handleRegister = async () => {
userStore.setUser(response.user);
router.push('/');
} catch (err: any) {
error.value = err.response?.data?.error?.message || '注册失败,请重试';
error.value = err.response?.data?.error?.message || 'Registration Failed';
} finally {
loading.value = false;
}
@ -85,96 +104,139 @@ const handleRegister = async () => {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 2rem;
background-color: var(--bg-body);
position: relative;
overflow: hidden;
}
.glow-bg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 70vw;
height: 70vw;
background: radial-gradient(circle, rgba(var(--color-secondary-rgb), 0.15) 0%, rgba(0,0,0,0) 70%);
filter: blur(80px);
z-index: 0;
animation: pulse 10s infinite ease-in-out;
}
@keyframes pulse {
0%, 100% { opacity: 0.5; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 0.7; transform: translate(-50%, -50%) scale(1.05); }
}
.register-container {
background: white;
border-radius: 16px;
padding: 3rem;
width: 100%;
max-width: 400px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
max-width: 440px;
z-index: 1;
backdrop-filter: blur(20px);
background: rgba(var(--color-surface-rgb), 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 0 40px rgba(0,0,0,0.5);
}
.register-container h1 {
.brand-header {
text-align: center;
margin-bottom: 2rem;
color: #333;
font-size: 2rem;
margin-bottom: 2.5rem;
}
.register-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
.logo-icon {
font-size: 3rem;
margin-bottom: 1rem;
filter: drop-shadow(0 0 10px var(--secondary));
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
h1 {
color: var(--color-text-main);
font-size: 2rem;
font-weight: 800;
letter-spacing: -1px;
text-transform: uppercase;
margin-bottom: 0.5rem;
}
.form-group label {
font-weight: 500;
color: #666;
.subtitle {
color: var(--color-text-muted);
font-family: var(--font-family-mono);
font-size: 0.75rem;
letter-spacing: 2px;
}
.form-input {
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
.form-group {
margin-bottom: 1.5rem;
}
.form-input:focus {
border-color: #667eea;
label {
display: block;
color: var(--secondary);
font-family: var(--font-family-mono);
font-size: 0.75rem;
margin-bottom: 0.5rem;
letter-spacing: 1px;
}
.error-message {
color: #e74c3c;
font-size: 0.9rem;
text-align: center;
.input-field {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
transition: all 0.3s ease;
}
.input-field:focus {
background: rgba(0, 0, 0, 0.5);
border-color: var(--secondary);
box-shadow: 0 0 15px rgba(var(--color-secondary-rgb), 0.2);
}
.submit-btn {
padding: 0.75rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
width: 100%;
margin-top: 1rem;
font-family: var(--font-family-mono);
letter-spacing: 2px;
background-color: var(--secondary);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
}
.submit-btn:hover:not(:disabled) {
transform: translateY(-2px);
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: var(--secondary);
box-shadow: 0 0 25px rgba(var(--color-secondary-rgb), 0.6);
color: white;
}
.form-footer {
margin-top: 2rem;
text-align: center;
color: #666;
margin-top: 1rem;
font-family: var(--font-family-mono);
font-size: 0.75rem;
color: var(--color-text-muted);
}
.form-footer a {
color: #667eea;
text-decoration: none;
font-weight: 500;
.link {
color: var(--color-text-main);
margin-left: 0.5rem;
text-decoration: underline;
text-decoration-color: var(--secondary);
text-underline-offset: 4px;
}
.form-footer a:hover {
text-decoration: underline;
.link:hover {
color: var(--secondary);
text-decoration-color: transparent;
text-shadow: 0 0 8px var(--secondary);
}
.error-message {
color: var(--accent);
background: rgba(var(--color-accent-rgb), 0.1);
border: 1px solid rgba(var(--color-accent-rgb), 0.3);
padding: 0.75rem;
border-radius: var(--radius-sm);
margin-bottom: 1rem;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>

230
frontend/src/views/Settings.vue

@ -2,37 +2,57 @@
<div class="settings-page">
<NavBar />
<div class="container">
<h1>系统设置</h1>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="settings-content">
<div class="setting-item">
<div class="setting-info">
<h3>允许注册</h3>
<p>控制是否允许新用户注册</p>
<div class="page-header">
<h1 class="glitch-text" data-text="SYSTEM_CONFIG">SYSTEM_CONFIG</h1>
<p class="subtitle">GLOBAL PARAMETERS & ACCESS CONTROL</p>
</div>
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<p>LOADING CONFIGURATION...</p>
</div>
<div v-else-if="error" class="error-state">
<span class="error-icon"></span>
<p>{{ error }}</p>
</div>
<div v-else class="settings-card acid-card">
<div class="setting-group">
<div class="group-header">
<span class="icon">🛡</span>
<h2>ACCESS_PROTOCOLS</h2>
</div>
<label class="switch">
<input
type="checkbox"
:checked="settings.allow_register === 'true'"
@change="updateSetting('allow_register', $event)"
/>
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<h3>允许上传</h3>
<p>控制是否允许用户上传网页</p>
<div class="setting-item">
<div class="setting-info">
<h3>OPEN REGISTRATION</h3>
<p>ALLOW NEW USERS TO GENERATE IDENTITIES</p>
</div>
<label class="toggle-switch">
<input
type="checkbox"
:checked="settings.allow_register === 'true'"
@change="updateSetting('allow_register', $event)"
/>
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<h3>UPLOAD PERMISSION</h3>
<p>ENABLE DEPLOYMENT FOR STANDARD USERS</p>
</div>
<label class="toggle-switch">
<input
type="checkbox"
:checked="settings.allow_upload === 'true'"
@change="updateSetting('allow_upload', $event)"
/>
<span class="slider"></span>
</label>
</div>
<label class="switch">
<input
type="checkbox"
:checked="settings.allow_upload === 'true'"
@change="updateSetting('allow_upload', $event)"
/>
<span class="slider"></span>
</label>
</div>
</div>
</div>
@ -60,13 +80,12 @@ const loadSettings = async () => {
settings[setting.key] = setting.value;
});
} catch (err: any) {
const errorMessage = err.response?.data?.error?.message || '加载设置失败';
const errorMessage = err.response?.data?.error?.message || 'LOAD FAILED';
error.value = errorMessage;
//
if (err.response?.status === 403) {
error.value = '您没有权限访问设置页面';
error.value = 'ACCESS DENIED';
} else if (err.response?.status === 401) {
error.value = '请先登录';
error.value = 'AUTHENTICATION REQUIRED';
}
} finally {
loading.value = false;
@ -81,8 +100,7 @@ const updateSetting = async (key: string, event: Event) => {
await settingApi.update(key, value);
settings[key] = value;
} catch (err: any) {
error.value = err.response?.data?.error?.message || '更新设置失败';
//
error.value = err.response?.data?.error?.message || 'UPDATE FAILED';
target.checked = settings[key] === 'true';
}
};
@ -95,7 +113,7 @@ onMounted(() => {
<style scoped>
.settings-page {
min-height: 100vh;
background: #f5f5f5;
background: var(--bg-body);
}
.container {
@ -104,16 +122,54 @@ onMounted(() => {
padding: 2rem;
}
.container h1 {
.page-header {
margin-bottom: 3rem;
text-align: center;
}
.page-header h1 {
margin-bottom: 0.5rem;
color: white;
font-size: 2.5rem;
font-weight: 900;
letter-spacing: -1px;
}
.subtitle {
color: var(--color-text-muted);
font-size: 1rem;
font-family: var(--font-family-mono);
letter-spacing: 2px;
}
.settings-card {
background: rgba(var(--color-surface-rgb), 0.8);
backdrop-filter: blur(10px);
border-radius: var(--radius-md);
padding: 2.5rem;
}
.group-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
color: #333;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.settings-content {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.group-header .icon {
font-size: 1.5rem;
filter: drop-shadow(0 0 5px var(--primary));
}
.group-header h2 {
font-size: 1.2rem;
color: var(--primary);
margin: 0;
font-weight: 700;
font-family: var(--font-family-mono);
letter-spacing: 1px;
}
.setting-item {
@ -121,7 +177,7 @@ onMounted(() => {
justify-content: space-between;
align-items: center;
padding: 1.5rem 0;
border-bottom: 1px solid #eee;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.setting-item:last-child {
@ -129,25 +185,30 @@ onMounted(() => {
}
.setting-info h3 {
margin: 0 0 0.5rem 0;
color: #333;
font-size: 1.25rem;
margin: 0 0 0.4rem 0;
color: white;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.5px;
}
.setting-info p {
margin: 0;
color: #666;
font-size: 0.9rem;
color: var(--color-text-muted);
font-size: 0.8rem;
font-family: var(--font-family-mono);
}
.switch {
/* Acid Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
height: 30px;
flex-shrink: 0;
}
.switch input {
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
@ -160,39 +221,72 @@ onMounted(() => {
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.4s;
border-radius: 34px;
background-color: #333;
transition: .3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 0; /* Hard edges for cyber feel */
border: 1px solid var(--border-color);
}
.slider:before {
position: absolute;
content: '';
height: 26px;
width: 26px;
content: "";
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
background-color: var(--color-text-muted);
transition: .3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 0; /* Hard edges */
}
input:checked + .slider {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-color: rgba(var(--color-primary-rgb), 0.2);
border-color: var(--primary);
box-shadow: 0 0 10px rgba(var(--color-primary-rgb), 0.2);
}
input:checked + .slider:before {
transform: translateX(26px);
transform: translateX(30px);
background-color: var(--primary);
box-shadow: 0 0 8px var(--primary);
}
/* States */
.loading-state {
text-align: center;
padding: 4rem;
color: var(--color-text-muted);
font-family: var(--font-family-mono);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255,255,255,0.1);
border-top: 3px solid var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading,
.error {
.error-state {
text-align: center;
padding: 2rem;
color: #999;
color: var(--accent);
background: rgba(var(--color-accent-rgb), 0.1);
border-radius: var(--radius-md);
border: 1px solid var(--accent);
font-family: var(--font-family-mono);
}
.error {
color: #e74c3c;
.error-icon {
font-size: 2rem;
display: block;
margin-bottom: 0.5rem;
}
</style>

564
frontend/src/views/Upload.vue

@ -2,124 +2,162 @@
<div class="upload-page">
<NavBar />
<div class="container">
<h1>{{ isEditMode ? '编辑项目' : '上传项目' }}</h1>
<form @submit.prevent="handleSubmit" class="upload-form">
<div class="page-header">
<h1 class="glitch-text" :data-text="isEditMode ? 'UPDATE PROTOCOL' : 'INITIATE UPLOAD'">
{{ isEditMode ? 'UPDATE PROTOCOL' : 'INITIATE UPLOAD' }}
</h1>
<p class="subtitle">{{ isEditMode ? 'MODIFY EXISTING PARAMETERS' : 'DEPLOY NEW MODULE TO NETWORK' }}</p>
</div>
<form @submit.prevent="handleSubmit" class="upload-form acid-card">
<div class="form-section">
<h2>基本信息</h2>
<div class="form-group">
<label>标题 *</label>
<input
v-model="form.title"
type="text"
required
placeholder="请输入项目标题"
class="form-input"
/>
</div>
<div class="form-group">
<label>描述</label>
<textarea
v-model="form.description"
placeholder="请输入项目描述"
rows="4"
class="form-textarea"
></textarea>
</div>
<div class="form-group">
<label>分类</label>
<input
v-model="form.category"
type="text"
placeholder="请输入分类"
class="form-input"
/>
<div class="section-header">
<span class="section-icon">📝</span>
<h2>CORE_DATA</h2>
</div>
<div class="form-group">
<label>标签用逗号分隔</label>
<input
v-model="form.tags"
type="text"
placeholder="例如:vue, react, demo"
class="form-input"
/>
<div class="form-grid">
<div class="form-group full-width">
<label>PROJECT TITLE <span class="required">*</span></label>
<input
v-model="form.title"
type="text"
required
placeholder="ENTER IDENTIFIER"
class="form-input"
/>
</div>
<div class="form-group full-width">
<label>DESCRIPTION</label>
<textarea
v-model="form.description"
placeholder="SYSTEM FUNCTIONS AND SPECS..."
rows="4"
class="form-textarea"
></textarea>
</div>
<div class="form-group">
<label>CATEGORY</label>
<div class="input-wrapper">
<input
v-model="form.category"
type="text"
placeholder="CLASSIFICATION"
class="form-input"
/>
</div>
</div>
<div class="form-group">
<label>TAGS</label>
<div class="input-wrapper">
<input
v-model="form.tags"
type="text"
placeholder="KEYWORDS (COMMA SEPARATED)"
class="form-input"
/>
</div>
</div>
</div>
</div>
<div v-if="!isEditMode" class="form-section">
<h2>网页文件 *</h2>
<div class="file-upload">
<div class="section-header">
<span class="section-icon">📦</span>
<h2>SOURCE_FILE <span class="required">*</span></h2>
</div>
<div class="file-upload-area" :class="{ 'has-file': form.file }">
<input
ref="fileInput"
type="file"
accept=".html,.zip"
@change="handleFileChange"
class="file-input"
class="file-input-hidden"
/>
<div class="file-display">
<p v-if="form.file">{{ form.file.name }}</p>
<p v-else class="placeholder">请选择HTML文件或ZIP压缩包</p>
<div class="upload-content" @click="$refs.fileInput.click()">
<div class="upload-icon">{{ form.file ? '📄' : '☁️' }}</div>
<div class="upload-text">
<p class="main-text">{{ form.file ? form.file.name : 'DRAG & DROP OR CLICK' }}</p>
<p class="sub-text">{{ form.file ? (form.file.size / 1024).toFixed(2) + ' KB' : 'ACCEPTS .HTML OR .ZIP ARCHIVE' }}</p>
</div>
<button type="button" class="btn-select">
{{ form.file ? 'REPLACE FILE' : 'SELECT FILE' }}
</button>
</div>
<button type="button" @click="$refs.fileInput.click()" class="btn-select">
选择文件
</button>
</div>
</div>
<div v-if="!isEditMode" class="form-section">
<h2>附件可选</h2>
<div class="file-upload">
<div class="section-header">
<span class="section-icon">📎</span>
<h2>ATTACHMENTS <span class="optional">(OPTIONAL)</span></h2>
</div>
<div class="file-list-upload">
<input
ref="attachmentInput"
type="file"
multiple
@change="handleAttachmentChange"
class="file-input"
class="file-input-hidden"
/>
<div class="file-list">
<div class="file-list" v-if="form.attachments.length > 0">
<div v-for="(file, index) in form.attachments" :key="index" class="file-item">
<span>{{ file.name }}</span>
<span class="file-icon">📎</span>
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ (file.size / 1024).toFixed(2) }} KB</span>
<button type="button" @click="removeAttachment(index)" class="btn-remove">×</button>
</div>
</div>
<button type="button" @click="$refs.attachmentInput.click()" class="btn-select">
添加附件
<button type="button" @click="$refs.attachmentInput.click()" class="btn-add-file">
[ + ADD ATTACHMENT ]
</button>
</div>
</div>
<div v-if="!isEditMode" class="form-section">
<h2>Markdown文档可选</h2>
<div class="file-upload">
<div class="section-header">
<span class="section-icon">📘</span>
<h2>DOCUMENTATION <span class="optional">(MARKDOWN)</span></h2>
</div>
<div class="file-list-upload">
<input
ref="documentInput"
type="file"
accept=".md,.markdown"
multiple
@change="handleDocumentChange"
class="file-input"
class="file-input-hidden"
/>
<div class="file-list">
<div v-for="(file, index) in form.documents" :key="index" class="file-item">
<span>{{ file.name }}</span>
<div class="file-list" v-if="form.documents.length > 0">
<div v-for="(file, index) in form.documents" :key="index" class="file-item document-type">
<span class="file-icon">📝</span>
<span class="file-name">{{ file.name }}</span>
<button type="button" @click="removeDocument(index)" class="btn-remove">×</button>
</div>
</div>
<button type="button" @click="$refs.documentInput.click()" class="btn-select">
添加文档
<button type="button" @click="$refs.documentInput.click()" class="btn-add-file">
[ + ADD DOC ]
</button>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="uploadProgress > 0 && uploadProgress < 100" class="progress">
<div class="progress-bar" :style="{ width: uploadProgress + '%' }"></div>
<span class="progress-text">{{ uploadProgress }}%</span>
<div v-if="error" class="error-message">
<span class="error-icon"></span> {{ error }}
</div>
<div v-if="uploadProgress > 0 && uploadProgress < 100" class="progress-container">
<div class="progress-info">
<span>UPLOADING...</span>
<span>{{ uploadProgress }}%</span>
</div>
<div class="progress-track">
<div class="progress-bar" :style="{ width: uploadProgress + '%' }"></div>
</div>
</div>
<div class="form-actions">
<button type="button" @click="$router.back()" class="btn-cancel">取消</button>
<button type="submit" :disabled="loading || (!isEditMode && !form.file)" class="btn-submit">
{{ loading ? (isEditMode ? '保存中...' : '上传中...') : (isEditMode ? '保存' : '上传') }}
<button type="button" @click="$router.back()" class="btn-cancel">CANCEL</button>
<button type="submit" :disabled="loading || (!isEditMode && !form.file)" class="btn btn-primary btn-submit">
{{ loading ? (isEditMode ? 'SAVING...' : 'UPLOADING...') : (isEditMode ? 'SAVE CHANGES' : 'DEPLOY NOW') }}
</button>
</div>
</form>
@ -152,6 +190,9 @@ const form = ref({
const loading = ref(false);
const error = ref('');
const uploadProgress = ref(0);
const fileInput = ref<HTMLInputElement | null>(null);
const attachmentInput = ref<HTMLInputElement | null>(null);
const documentInput = ref<HTMLInputElement | null>(null);
const handleFileChange = (e: Event) => {
const target = e.target as HTMLInputElement;
@ -163,14 +204,14 @@ const handleFileChange = (e: Event) => {
const handleAttachmentChange = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.files) {
form.value.attachments = Array.from(target.files);
form.value.attachments = [...form.value.attachments, ...Array.from(target.files)];
}
};
const handleDocumentChange = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.files) {
form.value.documents = Array.from(target.files);
form.value.documents = [...form.value.documents, ...Array.from(target.files)];
}
};
@ -189,15 +230,14 @@ const loadProjectForEdit = async (id: number) => {
form.value.description = project.description || '';
form.value.category = project.category || '';
form.value.tags = project.tags ? project.tags.join(', ') : '';
//
} catch (err: any) {
error.value = '加载项目失败';
error.value = 'LOAD FAILED';
}
};
const handleSubmit = async () => {
if (!isEditMode.value && !form.value.file) {
error.value = '请选择网页文件';
error.value = 'SOURCE FILE REQUIRED';
return;
}
@ -207,7 +247,6 @@ const handleSubmit = async () => {
try {
if (isEditMode.value && editProjectId.value) {
//
const updateData: any = {
title: form.value.title,
description: form.value.description,
@ -215,19 +254,17 @@ const handleSubmit = async () => {
tags: form.value.tags
};
const response = await projectApi.update(editProjectId.value, updateData);
await projectApi.update(editProjectId.value, updateData);
router.push(`/project/${editProjectId.value}`);
return;
}
//
const formData = new FormData();
formData.append('title', form.value.title);
if (form.value.description) formData.append('description', form.value.description);
if (form.value.category) formData.append('category', form.value.category);
if (form.value.tags) formData.append('tags', form.value.tags);
// HTMLZIP
const isZip = form.value.file!.name.endsWith('.zip');
if (isZip) {
formData.append('file', form.value.file!);
@ -243,7 +280,6 @@ const handleSubmit = async () => {
formData.append('documents', file);
});
//
const progressInterval = setInterval(() => {
if (uploadProgress.value < 90) {
uploadProgress.value += 10;
@ -258,11 +294,10 @@ const handleSubmit = async () => {
router.replace(`/project/${response.project.id}`);
}, 500);
} catch (err: any) {
const errorMessage = err.response?.data?.error?.message || '操作失败,请重试';
const errorMessage = err.response?.data?.error?.message || 'OPERATION FAILED';
error.value = errorMessage;
uploadProgress.value = 0;
// 401
if (err.response?.status === 401) {
return;
}
@ -287,95 +322,217 @@ onMounted(() => {
<style scoped>
.upload-page {
min-height: 100vh;
background: #f5f5f5;
background: var(--bg-body);
}
.container {
max-width: 800px;
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
.container h1 {
margin-bottom: 2rem;
color: #333;
.page-header {
text-align: center;
margin-bottom: 3rem;
}
.page-header h1 {
font-size: 2.5rem;
color: white;
margin-bottom: 0.5rem;
font-weight: 900;
letter-spacing: -1px;
}
.subtitle {
color: var(--color-text-muted);
font-size: 1rem;
font-family: var(--font-family-mono);
letter-spacing: 2px;
}
.upload-form {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 2.5rem;
background: rgba(var(--color-surface-rgb), 0.8);
backdrop-filter: blur(10px);
}
.form-section {
margin-bottom: 2rem;
margin-bottom: 2.5rem;
padding-bottom: 2rem;
border-bottom: 1px solid #eee;
border-bottom: 1px dashed var(--border-color);
}
.form-section:last-child {
.form-section:last-of-type {
border-bottom: none;
}
.form-section h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: #333;
.section-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.section-icon {
font-size: 1.5rem;
filter: drop-shadow(0 0 5px var(--secondary));
}
.section-header h2 {
font-size: 1.2rem;
color: var(--secondary);
margin: 0;
font-weight: 700;
font-family: var(--font-family-mono);
letter-spacing: 1px;
}
.optional {
font-size: 0.8rem;
color: var(--color-text-muted);
font-weight: normal;
margin-left: 0.5rem;
}
.required {
color: var(--accent);
margin-left: 4px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.full-width {
grid-column: span 2;
}
.form-group {
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #666;
font-weight: 600;
color: var(--primary);
font-size: 0.7rem;
font-family: var(--font-family-mono);
letter-spacing: 1px;
}
.input-wrapper {
position: relative;
}
.form-input,
.form-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
padding: 0.8rem 1rem;
border: 1px solid var(--border-color);
background: rgba(0,0,0,0.3);
color: white;
font-family: var(--font-family-mono);
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
font-family: inherit;
transition: all var(--transition-fast);
}
.form-input:focus,
.form-textarea:focus {
border-color: #667eea;
border-color: var(--primary);
box-shadow: 0 0 10px rgba(var(--color-primary-rgb), 0.2);
background: black;
}
.file-upload {
display: flex;
flex-direction: column;
gap: 1rem;
/* File Upload Styling */
.file-upload-area {
border: 1px dashed var(--border-color);
padding: 3rem;
text-align: center;
cursor: pointer;
transition: all var(--transition-fast);
background: rgba(0,0,0,0.2);
position: relative;
overflow: hidden;
}
.file-input {
.file-upload-area:hover {
border-color: var(--primary);
background: rgba(var(--color-primary-rgb), 0.05);
box-shadow: inset 0 0 20px rgba(var(--color-primary-rgb), 0.1);
}
.file-upload-area.has-file {
border-style: solid;
border-color: var(--primary);
background: rgba(var(--color-primary-rgb), 0.05);
}
.file-input-hidden {
display: none;
}
.file-display {
padding: 1rem;
background: #f9f9f9;
border-radius: 8px;
min-height: 3rem;
.upload-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
position: relative;
z-index: 1;
}
.upload-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
filter: drop-shadow(0 0 10px var(--primary));
}
.upload-text .main-text {
font-weight: 700;
color: white;
margin-bottom: 0.2rem;
font-family: var(--font-family-mono);
letter-spacing: 1px;
}
.upload-text .sub-text {
color: var(--color-text-muted);
font-size: 0.8rem;
font-family: var(--font-family-mono);
}
.placeholder {
color: #999;
.btn-select {
padding: 0.6rem 1.5rem;
background: transparent;
border: 1px solid var(--primary);
font-weight: 600;
color: var(--primary);
cursor: pointer;
transition: all var(--transition-fast);
font-family: var(--font-family-mono);
margin-top: 1rem;
}
.file-upload-area:hover .btn-select {
background: var(--primary);
color: black;
box-shadow: 0 0 10px var(--primary);
}
/* File List */
.file-list-upload {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.file-list {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.5rem;
@ -383,109 +540,140 @@ onMounted(() => {
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: #f9f9f9;
border-radius: 6px;
gap: 1rem;
padding: 0.8rem;
background: rgba(255,255,255,0.05);
border: 1px solid var(--border-color);
transition: all var(--transition-fast);
}
.btn-remove {
background: #e74c3c;
color: white;
border: none;
border-radius: 4px;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
.file-item:hover {
border-color: var(--secondary);
background: rgba(var(--color-secondary-rgb), 0.1);
}
.btn-select {
padding: 0.75rem 1.5rem;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
.file-name {
flex: 1;
font-weight: 500;
align-self: flex-start;
color: white;
font-family: var(--font-family-mono);
font-size: 0.9rem;
}
.btn-select:hover {
background: #5568d3;
.file-size {
color: var(--color-text-muted);
font-size: 0.8rem;
font-family: var(--font-family-mono);
}
.progress {
margin: 1rem 0;
background: #f0f0f0;
border-radius: 8px;
height: 32px;
position: relative;
overflow: hidden;
.btn-remove {
width: 24px;
height: 24px;
border-radius: 0;
border: 1px solid var(--accent);
background: transparent;
color: var(--accent);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
line-height: 1;
transition: all var(--transition-fast);
}
.progress-bar {
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s;
.btn-remove:hover {
background: var(--accent);
color: white;
box-shadow: 0 0 8px var(--accent);
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
.btn-add-file {
padding: 0.6rem 1.2rem;
background: transparent;
border: 1px dashed var(--border-color);
color: var(--color-text-muted);
font-weight: 600;
color: #333;
cursor: pointer;
transition: all var(--transition-fast);
font-family: var(--font-family-mono);
font-size: 0.8rem;
}
.error-message {
color: #e74c3c;
padding: 1rem;
background: #fee;
border-radius: 8px;
margin-bottom: 1rem;
.btn-add-file:hover {
border-color: var(--secondary);
color: var(--secondary);
background: rgba(var(--color-secondary-rgb), 0.1);
box-shadow: 0 0 10px rgba(var(--color-secondary-rgb), 0.2);
}
/* Actions */
.form-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid var(--border-color);
}
.btn-cancel {
padding: 0.75rem 2rem;
background: #f0f0f0;
border: none;
border-radius: 8px;
padding: 0.8rem 2rem;
background: transparent;
border: 1px solid var(--border-color);
color: var(--color-text-muted);
font-weight: 600;
cursor: pointer;
font-weight: 500;
transition: all var(--transition-fast);
font-family: var(--font-family-mono);
}
.btn-cancel:hover {
background: #e0e0e0;
border-color: white;
color: white;
}
.btn-submit {
padding: 0.75rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: transform 0.2s;
min-width: 160px;
}
.error-message {
margin-top: 1.5rem;
background: rgba(var(--color-accent-rgb), 0.1);
color: var(--accent);
padding: 1rem;
border: 1px solid var(--accent);
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-family-mono);
font-size: 0.9rem;
}
.progress-container {
margin-top: 1.5rem;
}
.btn-submit:hover:not(:disabled) {
transform: translateY(-2px);
.progress-info {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--primary);
margin-bottom: 0.5rem;
font-family: var(--font-family-mono);
}
.btn-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
.progress-track {
height: 4px;
background: #333;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--primary);
transition: width 0.2s ease;
box-shadow: 0 0 10px var(--primary);
}
</style>

11
index.html

@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
dsads
</body>
</html>
Loading…
Cancel
Save