This commit is contained in:
2025-10-22 17:26:10 +08:00
parent 476944d22e
commit 3bc3fb7d02
10 changed files with 864 additions and 687 deletions

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { onMounted } from "vue"
import ProjectManager from "./components/ProjectManager.vue"
import ProjectUpload from "./components/ProjectUpload.vue"
import ProjectList from "./components/ProjectList.vue"
import { useProjects } from "./composables/useProjects"
const { projects, fetchProjects } = useProjects()
const { projects, fetchProjects, searchProjects, clearSearch, searchQuery } = useProjects()
function getApiBase() {
if (window.location.hostname !== "localhost") {
@@ -12,6 +13,10 @@ function getApiBase() {
return "http://localhost:3000/api"
}
const handleProjectUpdated = () => {
fetchProjects()
}
onMounted(() => {
fetchProjects()
})
@@ -19,11 +24,18 @@ onMounted(() => {
<template>
<div class="app">
<ProjectManager
:projects="projects"
:api-base="getApiBase()"
@project-uploaded="fetchProjects"
/>
<div class="project-manager">
<ProjectUpload @project-uploaded="handleProjectUpdated" />
<ProjectList
:projects="projects"
:api-base="getApiBase()"
:search-query="searchQuery"
@search="searchProjects"
@clear-search="clearSearch"
@project-updated="handleProjectUpdated"
/>
</div>
</div>
</template>
@@ -32,6 +44,11 @@ onMounted(() => {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
.project-manager {
display: flex;
flex-direction: column;
gap: 30px;
}
</style>

View File

@@ -0,0 +1,323 @@
<script setup lang="ts">
import { api } from "../services/api"
import { formatDate, getProjectUrl, copyToClipboard } from "../utils"
import { useMessage } from "../composables/useMessage"
import type { Project } from "../types"
interface Props {
project: Project
apiBase: string
}
interface Emits {
(e: "project-updated"): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
const { showMessage } = useMessage()
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-updated")
} 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-updated")
} catch (error) {
showMessage(
error instanceof Error ? error.message : "删除失败,请检查网络连接",
"error"
)
console.error("删除失败:", error)
}
}
</script>
<template>
<div 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>
</template>
<style scoped>
.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: 8px 12px;
border-radius: 6px;
text-align: center;
font-weight: 500;
transition: all 0.3s ease;
font-size: 13px;
}
.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: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
font-size: 13px;
}
.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: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
font-size: 13px;
}
.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);
}
/* 手机端响应式设计 */
@media (max-width: 768px) {
.project-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.visit-button,
.copy-button,
.delete-button,
.activate-button,
.deactivate-button {
font-size: 12px;
padding: 6px 8px;
}
}
</style>

View File

@@ -0,0 +1,233 @@
<script setup lang="ts">
import { ref } from "vue"
import ProjectCard from "./ProjectCard.vue"
import type { Project } from "../types"
interface Props {
projects: Project[]
apiBase: string
searchQuery: string
}
interface Emits {
(e: "search", query: string): void
(e: "clear-search"): void
(e: "project-updated"): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
const searchInput = ref("")
const handleSearch = () => {
emit("search", searchInput.value.trim())
}
const handleClearSearch = () => {
searchInput.value = ""
emit("clear-search")
}
const handleSearchInput = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.value.trim() === "") {
emit("clear-search")
}
}
const handleProjectUpdated = () => {
emit("project-updated")
}
</script>
<template>
<div class="projects-section">
<div class="projects-header">
<h2>📋 我的托管项目</h2>
<div class="search-container">
<div class="search-box">
<input
v-model="searchInput"
type="text"
placeholder="搜索项目名称..."
class="search-input"
@input="handleSearchInput"
@keyup.enter="handleSearch"
/>
<button
@click="handleSearch"
class="search-button"
:disabled="!searchInput.trim()"
>
🔍 搜索
</button>
<button
v-if="searchQuery"
@click="handleClearSearch"
class="clear-search-button"
>
清除
</button>
</div>
</div>
</div>
<div v-if="searchQuery" class="search-results-info">
<p>搜索 "{{ searchQuery }}" 的结果 ({{ projects.length }} 个项目)</p>
</div>
<div v-if="projects.length === 0" class="empty-state">
<p v-if="searchQuery">没有找到匹配的项目请尝试其他搜索词</p>
<p v-else>还没有托管任何项目上传一个HTML文件开始吧</p>
</div>
<div v-else class="projects-grid">
<ProjectCard
v-for="project in projects"
:key="project.id"
:project="project"
:api-base="apiBase"
@project-updated="handleProjectUpdated"
/>
</div>
</div>
</template>
<style scoped>
.projects-section {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.projects-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 20px;
}
.projects-header h2 {
margin: 0;
color: #333;
font-size: 1.5em;
}
.search-container {
flex: 1;
max-width: 400px;
}
.search-box {
display: flex;
gap: 8px;
align-items: center;
}
.search-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.search-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.search-button,
.clear-search-button {
padding: 8px 12px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.3s ease;
}
.search-button {
background: #667eea;
color: white;
}
.search-button:hover:not(:disabled) {
background: #5a6fd8;
transform: translateY(-1px);
}
.search-button:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
}
.clear-search-button {
background: #dc3545;
color: white;
}
.clear-search-button:hover {
background: #c82333;
transform: translateY(-1px);
}
.search-results-info {
margin-bottom: 15px;
padding: 10px;
background: #f8f9fa;
border-radius: 6px;
border-left: 4px solid #667eea;
}
.search-results-info p {
margin: 0;
color: #666;
font-size: 14px;
}
.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;
}
.projects-header {
flex-direction: column;
align-items: stretch;
gap: 15px;
}
.search-container {
max-width: none;
}
.search-box {
flex-wrap: wrap;
}
.search-input {
min-width: 200px;
}
}
</style>

View File

@@ -1,584 +0,0 @@
<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) return
// 文件类型验证
if (!file.name.endsWith(".html")) {
showMessage("请选择HTML文件", "error")
return
}
// 文件大小验证 (5MB)
const maxSize = 5 * 1024 * 1024
if (file.size > maxSize) {
showMessage("文件大小不能超过5MB", "error")
return
}
if (file.size === 0) {
showMessage("文件不能为空", "error")
return
}
uploadProject(file)
}
const uploadProject = async (file: File) => {
if (!projectName.value.trim()) {
showMessage("请输入项目名称", "error")
return
}
if (projectName.value.length > 50) {
showMessage("项目名称不能超过50个字符", "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"
maxlength="50"
:disabled="isUploading"
/>
<small class="input-hint">最多50个字符</small>
</div>
<div class="file-upload">
<input
ref="fileInput"
type="file"
accept=".html"
@change="handleFileSelect"
style="display: none"
:disabled="isUploading"
/>
<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);
}
.form-input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.input-hint {
color: #666;
font-size: 12px;
margin-top: 4px;
display: block;
}
.upload-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 10px 20px;
font-size: 14px;
border-radius: 20px;
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: 8px 12px;
border-radius: 6px;
text-align: center;
font-weight: 500;
transition: all 0.3s ease;
font-size: 13px;
}
.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: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
font-size: 13px;
}
.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: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
font-size: 13px;
}
.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,247 @@
<script setup lang="ts">
import { ref } from "vue"
import { api } from "../services/api"
import { useMessage } from "../composables/useMessage"
interface Emits {
(e: "project-uploaded"): void
}
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) return
// 文件类型验证
if (!file.name.endsWith(".html")) {
showMessage("请选择HTML文件", "error")
return
}
// 文件大小验证 (5MB)
const maxSize = 5 * 1024 * 1024
if (file.size > maxSize) {
showMessage("文件大小不能超过5MB", "error")
return
}
if (file.size === 0) {
showMessage("文件不能为空", "error")
return
}
uploadProject(file)
}
const uploadProject = async (file: File) => {
if (!projectName.value.trim()) {
showMessage("请输入项目名称", "error")
return
}
if (projectName.value.length > 50) {
showMessage("项目名称不能超过50个字符", "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 = ""
}
}
</script>
<template>
<div class="upload-section">
<h2>🚀 在港口即将出发</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"
maxlength="50"
:disabled="isUploading"
/>
<small class="input-hint">最多50个字符</small>
</div>
<div class="file-upload">
<input
ref="fileInput"
type="file"
accept=".html"
@change="handleFileSelect"
style="display: none"
:disabled="isUploading"
/>
<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>
</template>
<style scoped>
.upload-section {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
text-align: center;
}
.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);
}
.form-input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.input-hint {
color: #666;
font-size: 12px;
margin-top: 4px;
display: block;
}
.upload-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 10px 20px;
font-size: 14px;
border-radius: 20px;
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;
}
</style>

View File

@@ -15,14 +15,9 @@ export function useMessage() {
}
}
const clearMessage = () => {
message.value = ''
}
return {
message,
messageType,
showMessage,
clearMessage
showMessage
}
}

View File

@@ -1,35 +1,49 @@
import { ref } from 'vue'
import { api } from '../services/api'
import type { Project } from '../types'
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 searchQuery = ref("")
const fetchProjects = async () => {
const fetchProjects = async (search?: string) => {
loading.value = true
error.value = null
try {
projects.value = await api.getProjects()
projects.value = await api.getProjects(search)
} catch (err) {
error.value = err instanceof Error ? err.message : '获取项目列表失败'
console.error('获取项目列表失败:', err)
error.value = err instanceof Error ? err.message : "获取项目列表失败"
console.error("获取项目列表失败:", err)
} finally {
loading.value = false
}
}
const searchProjects = async (query: string) => {
searchQuery.value = query
await fetchProjects(query)
}
const clearSearch = async () => {
searchQuery.value = ""
await fetchProjects()
}
const refreshProjects = () => {
return fetchProjects()
return fetchProjects(searchQuery.value)
}
return {
projects,
loading,
error,
searchQuery,
fetchProjects,
refreshProjects
searchProjects,
clearSearch,
refreshProjects,
}
}

View File

@@ -31,7 +31,10 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
// API方法
export const api = {
// 获取项目列表
getProjects: (): Promise<Project[]> => request<Project[]>("/projects"),
getProjects: (searchQuery?: string): Promise<Project[]> => {
const url = searchQuery ? `/projects?search=${encodeURIComponent(searchQuery)}` : "/projects"
return request<Project[]>(url)
},
// 上传项目
uploadProject: (formData: FormData): Promise<UploadResponse> => {

View File

@@ -1,79 +1,15 @@
: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;
/* 全局样式重置 */
* {
box-sizing: border-box;
}
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;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f5f5;
color: #333;
}
#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;
}
min-height: 100vh;
}

View File

@@ -8,13 +8,6 @@ export interface Project {
uploadedAt: string
}
// API响应类型
export interface ApiResponse<T = any> {
data?: T
error?: string
message?: string
}
// 上传响应类型
export interface UploadResponse {
id: number