first commit

This commit is contained in:
2025-10-21 12:36:13 +08:00
commit 608c6960ac
21 changed files with 2241 additions and 0 deletions

13
.dockerignore Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
{
"semi": false
}

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/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

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View 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
View 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
View 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>

View 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>

View 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
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View 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
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})