first commit
This commit is contained in:
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
dist
|
||||
.nyc_output
|
||||
coverage
|
||||
.nyc_output
|
||||
.coverage
|
||||
.coverage/
|
||||
*.log
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
.prettierrc
Normal file
3
.prettierrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"semi": false
|
||||
}
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/harbor.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>港口 - HTML 文件托管</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1253
package-lock.json
generated
Normal file
1253
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "npm:rolldown-vite@7.1.14",
|
||||
"vue-tsc": "^3.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@7.1.14"
|
||||
}
|
||||
}
|
||||
1
public/harbor.svg
Normal file
1
public/harbor.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 15 15"><path fill="currentColor" d="M7.5 0C5.5 0 4 1.567 4 3.5a3.49 3.49 0 0 0 2.5 3.338v6.039c-.93-.165-1.875-.55-2.648-1.27c-1.053-.98-1.85-2.54-1.85-5.109a1 1 0 1 0-2.002 0c0 3.003 1.012 5.196 2.49 6.572S5.838 15 7.5 15c1.666 0 3.535-.56 5.012-1.94s2.486-3.573 2.486-6.562c.065-1.395-2.063-1.395-1.998 0c0 2.553-.8 4.115-1.853 5.1c-.774.722-1.718 1.11-2.647 1.277V6.842A3.49 3.49 0 0 0 11 3.5C11 1.567 9.5 0 7.5 0m0 2a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3"/></svg>
|
||||
|
After Width: | Height: | Size: 541 B |
39
src/App.vue
Normal file
39
src/App.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue"
|
||||
import ProjectManager from "./components/ProjectManager.vue"
|
||||
import { useProjects } from "./composables/useProjects"
|
||||
|
||||
const { projects, fetchProjects } = useProjects()
|
||||
|
||||
const getApiBase = () => {
|
||||
if (window.location.hostname !== "localhost") {
|
||||
return "/api"
|
||||
}
|
||||
return "http://localhost:3000/api"
|
||||
}
|
||||
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
onMounted(() => {
|
||||
fetchProjects()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<ProjectManager
|
||||
:projects="projects"
|
||||
:api-base="API_BASE"
|
||||
@project-uploaded="fetchProjects"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
</style>
|
||||
544
src/components/ProjectManager.vue
Normal file
544
src/components/ProjectManager.vue
Normal file
@@ -0,0 +1,544 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { api } from "../services/api"
|
||||
import { formatDate, getProjectUrl, copyToClipboard } from "../utils"
|
||||
import { useMessage } from "../composables/useMessage"
|
||||
import type { Project } from "../types"
|
||||
|
||||
interface Props {
|
||||
projects: Project[]
|
||||
apiBase: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "project-uploaded"): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { message: uploadMessage, messageType, showMessage } = useMessage()
|
||||
const projectName = ref("")
|
||||
const isUploading = ref(false)
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
|
||||
const handleFileSelect = () => {
|
||||
const file = fileInput.value?.files?.[0]
|
||||
if (file && file.name.endsWith(".html")) {
|
||||
uploadProject(file)
|
||||
} else {
|
||||
showMessage("请选择HTML文件", "error")
|
||||
}
|
||||
}
|
||||
|
||||
const uploadProject = async (file: File) => {
|
||||
if (!projectName.value.trim()) {
|
||||
showMessage("请输入项目名称", "error")
|
||||
return
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("projectName", projectName.value)
|
||||
|
||||
const result = await api.uploadProject(formData)
|
||||
showMessage(
|
||||
`项目 "${result.name}" 上传成功!访问地址: ${result.url}`,
|
||||
"success"
|
||||
)
|
||||
emit("project-uploaded")
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
showMessage(
|
||||
error instanceof Error ? error.message : "上传失败,请检查网络连接",
|
||||
"error"
|
||||
)
|
||||
console.error("上传错误:", error)
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
projectName.value = ""
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const copyUrl = async (url: string) => {
|
||||
try {
|
||||
await copyToClipboard(url)
|
||||
showMessage("链接已复制到剪贴板!", "success")
|
||||
} catch (error) {
|
||||
showMessage(
|
||||
error instanceof Error ? error.message : "复制失败,请手动复制链接",
|
||||
"error"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleProjectStatus = async (projectSlug: string) => {
|
||||
try {
|
||||
const result = await api.toggleProject(projectSlug)
|
||||
showMessage(result.message, "success")
|
||||
emit("project-uploaded")
|
||||
} catch (error) {
|
||||
showMessage(
|
||||
error instanceof Error ? error.message : "状态切换失败,请检查网络连接",
|
||||
"error"
|
||||
)
|
||||
console.error("状态切换失败:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProject = async (projectSlug: string, projectName: string) => {
|
||||
if (!confirm(`确定要删除项目 "${projectName}" 吗?此操作不可撤销。`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.deleteProject(projectSlug)
|
||||
showMessage(result.message, "success")
|
||||
emit("project-uploaded")
|
||||
} catch (error) {
|
||||
showMessage(
|
||||
error instanceof Error ? error.message : "删除失败,请检查网络连接",
|
||||
"error"
|
||||
)
|
||||
console.error("删除失败:", error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="project-manager">
|
||||
<div class="upload-section">
|
||||
<h2>🚀 HTML文件托管</h2>
|
||||
<p class="description">上传HTML文件,立即获得在线访问链接。</p>
|
||||
|
||||
<div class="project-form">
|
||||
<div class="form-group">
|
||||
<label for="projectName">项目名称:</label>
|
||||
<input
|
||||
v-model="projectName"
|
||||
type="text"
|
||||
id="projectName"
|
||||
placeholder="请输入项目名称"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="file-upload">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".html"
|
||||
@change="handleFileSelect"
|
||||
style="display: none"
|
||||
/>
|
||||
<button
|
||||
@click="fileInput?.click()"
|
||||
:disabled="isUploading"
|
||||
class="upload-button"
|
||||
>
|
||||
{{ isUploading ? "上传中..." : "选择HTML文件" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="uploadMessage"
|
||||
class="upload-message"
|
||||
:class="{
|
||||
success: messageType === 'success',
|
||||
error: messageType === 'error',
|
||||
}"
|
||||
>
|
||||
{{ uploadMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="projects-section">
|
||||
<h2>📋 我的托管项目</h2>
|
||||
<div v-if="projects.length === 0" class="empty-state">
|
||||
<p>还没有托管任何项目,上传一个HTML文件开始吧!</p>
|
||||
</div>
|
||||
<div v-else class="projects-grid">
|
||||
<div v-for="project in projects" :key="project.id" class="project-card">
|
||||
<div class="project-info">
|
||||
<div class="project-header">
|
||||
<h3 class="project-name">{{ project.name }}</h3>
|
||||
<div class="project-badges">
|
||||
<span class="project-type-badge"> 📄 HTML文件 </span>
|
||||
<span
|
||||
class="status-badge"
|
||||
:class="{
|
||||
active: project.isActive,
|
||||
inactive: !project.isActive,
|
||||
}"
|
||||
>
|
||||
{{ project.isActive ? "🟢 激活" : "🔴 停用" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-details">
|
||||
<div class="detail-item">
|
||||
<span class="label">入口文件:</span>
|
||||
<span class="value">{{ project.entryPoint }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">创建时间:</span>
|
||||
<span class="value">{{ formatDate(project.uploadedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-actions">
|
||||
<a
|
||||
v-if="project.isActive"
|
||||
:href="getProjectUrl(project, apiBase)"
|
||||
target="_blank"
|
||||
class="visit-button"
|
||||
>
|
||||
🌐 访问项目
|
||||
</a>
|
||||
<button
|
||||
v-if="project.isActive"
|
||||
@click="copyUrl(getProjectUrl(project, apiBase))"
|
||||
class="copy-button"
|
||||
>
|
||||
📋 复制链接
|
||||
</button>
|
||||
<button
|
||||
@click="toggleProjectStatus(project.slug)"
|
||||
:class="
|
||||
project.isActive ? 'deactivate-button' : 'activate-button'
|
||||
"
|
||||
>
|
||||
{{ project.isActive ? "⏸️ 停用项目" : "▶️ 激活项目" }}
|
||||
</button>
|
||||
<button
|
||||
@click="deleteProject(project.slug, project.name)"
|
||||
class="delete-button"
|
||||
>
|
||||
🗑️ 删除项目
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.project-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.upload-section h2 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0 0 20px 0;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.project-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s ease;
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px 30px;
|
||||
font-size: 16px;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.upload-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.upload-message {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.upload-message:not(.success) {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.projects-section {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.projects-section h2 {
|
||||
margin: 0 0 20px 0;
|
||||
color: #333;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 响应式设计:小屏幕时切换为单列 */
|
||||
@media (max-width: 768px) {
|
||||
.projects-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.project-card {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.project-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.project-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.project-badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.project-type-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: #e8f5e8;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status-badge.inactive {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.project-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #333;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.project-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.visit-button {
|
||||
flex: 1;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.visit-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.copy-button,
|
||||
.delete-button {
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background: #5a6268;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.delete-button:hover {
|
||||
background: #c82333;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.activate-button,
|
||||
.deactivate-button {
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.activate-button {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.activate-button:hover {
|
||||
background: #218838;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.deactivate-button {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.deactivate-button:hover {
|
||||
background: #e0a800;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
</style>
|
||||
28
src/composables/useMessage.ts
Normal file
28
src/composables/useMessage.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useMessage() {
|
||||
const message = ref('')
|
||||
const messageType = ref<'success' | 'error' | 'info'>('info')
|
||||
|
||||
const showMessage = (text: string, type: 'success' | 'error' | 'info' = 'info', duration = 3000) => {
|
||||
message.value = text
|
||||
messageType.value = type
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
message.value = ''
|
||||
}, duration)
|
||||
}
|
||||
}
|
||||
|
||||
const clearMessage = () => {
|
||||
message.value = ''
|
||||
}
|
||||
|
||||
return {
|
||||
message,
|
||||
messageType,
|
||||
showMessage,
|
||||
clearMessage
|
||||
}
|
||||
}
|
||||
35
src/composables/useProjects.ts
Normal file
35
src/composables/useProjects.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../services/api'
|
||||
import type { Project } from '../types'
|
||||
|
||||
export function useProjects() {
|
||||
const projects = ref<Project[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const fetchProjects = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
projects.value = await api.getProjects()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '获取项目列表失败'
|
||||
console.error('获取项目列表失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshProjects = () => {
|
||||
return fetchProjects()
|
||||
}
|
||||
|
||||
return {
|
||||
projects,
|
||||
loading,
|
||||
error,
|
||||
fetchProjects,
|
||||
refreshProjects
|
||||
}
|
||||
}
|
||||
5
src/main.ts
Normal file
5
src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
57
src/services/api.ts
Normal file
57
src/services/api.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Project, UploadResponse, ToggleResponse } from '../types'
|
||||
|
||||
// 动态获取API基础URL
|
||||
const getApiBase = () => {
|
||||
// 在Docker环境中,前端通过Caddy代理访问后端
|
||||
if (window.location.hostname !== 'localhost') {
|
||||
return '/api'
|
||||
}
|
||||
// 开发环境直接访问后端
|
||||
return 'http://localhost:3000/api'
|
||||
}
|
||||
|
||||
// 通用请求处理
|
||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const API_BASE = getApiBase()
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '请求失败')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// API方法
|
||||
export const api = {
|
||||
// 获取项目列表
|
||||
getProjects: (): Promise<Project[]> => request<Project[]>('/projects'),
|
||||
|
||||
// 上传项目
|
||||
uploadProject: (formData: FormData): Promise<UploadResponse> =>
|
||||
request<UploadResponse>('/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {}, // 让浏览器自动设置Content-Type
|
||||
}),
|
||||
|
||||
// 切换项目状态
|
||||
toggleProject: (slug: string): Promise<ToggleResponse> =>
|
||||
request<ToggleResponse>(`/projects/${slug}/toggle`, {
|
||||
method: 'PATCH',
|
||||
}),
|
||||
|
||||
// 删除项目
|
||||
deleteProject: (slug: string): Promise<{ message: string }> =>
|
||||
request<{ message: string }>(`/projects/${slug}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
}
|
||||
79
src/style.css
Normal file
79
src/style.css
Normal file
@@ -0,0 +1,79 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
31
src/types/index.ts
Normal file
31
src/types/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// 项目类型定义
|
||||
export interface Project {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
entryPoint: string
|
||||
isActive: boolean
|
||||
uploadedAt: string
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
data?: T
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
// 上传响应类型
|
||||
export interface UploadResponse {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
message: string
|
||||
url: string
|
||||
}
|
||||
|
||||
// 状态切换响应类型
|
||||
export interface ToggleResponse {
|
||||
message: string
|
||||
isActive: boolean
|
||||
}
|
||||
32
src/utils/index.ts
Normal file
32
src/utils/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Project } from "../types"
|
||||
|
||||
// 格式化日期
|
||||
export const formatDate = (dateString: string): string => {
|
||||
if (dateString === "CURRENT_TIMESTAMP") {
|
||||
return "刚刚"
|
||||
}
|
||||
|
||||
const date = new Date(dateString)
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return "未知时间"
|
||||
}
|
||||
|
||||
return date.toLocaleString("zh-CN")
|
||||
}
|
||||
|
||||
// 生成项目URL
|
||||
export const getProjectUrl = (project: Project, apiBase: string): string => {
|
||||
const baseUrl = apiBase.replace("/api", "")
|
||||
return `${baseUrl}/projects/${project.slug}`
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
export const copyToClipboard = async (text: string): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch (error) {
|
||||
console.error("复制失败:", error)
|
||||
throw new Error("复制失败,请手动复制链接")
|
||||
}
|
||||
}
|
||||
16
tsconfig.app.json
Normal file
16
tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
})
|
||||
Reference in New Issue
Block a user