refactor
This commit is contained in:
33
src/App.vue
33
src/App.vue
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from "vue"
|
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"
|
import { useProjects } from "./composables/useProjects"
|
||||||
|
|
||||||
const { projects, fetchProjects } = useProjects()
|
const { projects, fetchProjects, searchProjects, clearSearch, searchQuery } = useProjects()
|
||||||
|
|
||||||
function getApiBase() {
|
function getApiBase() {
|
||||||
if (window.location.hostname !== "localhost") {
|
if (window.location.hostname !== "localhost") {
|
||||||
@@ -12,6 +13,10 @@ function getApiBase() {
|
|||||||
return "http://localhost:3000/api"
|
return "http://localhost:3000/api"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleProjectUpdated = () => {
|
||||||
|
fetchProjects()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchProjects()
|
fetchProjects()
|
||||||
})
|
})
|
||||||
@@ -19,11 +24,18 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<ProjectManager
|
<div class="project-manager">
|
||||||
:projects="projects"
|
<ProjectUpload @project-uploaded="handleProjectUpdated" />
|
||||||
:api-base="getApiBase()"
|
|
||||||
@project-uploaded="fetchProjects"
|
<ProjectList
|
||||||
/>
|
:projects="projects"
|
||||||
|
:api-base="getApiBase()"
|
||||||
|
:search-query="searchQuery"
|
||||||
|
@search="searchProjects"
|
||||||
|
@clear-search="clearSearch"
|
||||||
|
@project-updated="handleProjectUpdated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -32,6 +44,11 @@ onMounted(() => {
|
|||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
}
|
||||||
|
|
||||||
|
.project-manager {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 30px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
323
src/components/ProjectCard.vue
Normal file
323
src/components/ProjectCard.vue
Normal 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>
|
||||||
233
src/components/ProjectList.vue
Normal file
233
src/components/ProjectList.vue
Normal 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>
|
||||||
@@ -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>
|
|
||||||
247
src/components/ProjectUpload.vue
Normal file
247
src/components/ProjectUpload.vue
Normal 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>
|
||||||
@@ -15,14 +15,9 @@ export function useMessage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearMessage = () => {
|
|
||||||
message.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message,
|
message,
|
||||||
messageType,
|
messageType,
|
||||||
showMessage,
|
showMessage
|
||||||
clearMessage
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,49 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from "vue"
|
||||||
import { api } from '../services/api'
|
import { api } from "../services/api"
|
||||||
import type { Project } from '../types'
|
import type { Project } from "../types"
|
||||||
|
|
||||||
export function useProjects() {
|
export function useProjects() {
|
||||||
const projects = ref<Project[]>([])
|
const projects = ref<Project[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const searchQuery = ref("")
|
||||||
|
|
||||||
const fetchProjects = async () => {
|
const fetchProjects = async (search?: string) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
projects.value = await api.getProjects()
|
projects.value = await api.getProjects(search)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : '获取项目列表失败'
|
error.value = err instanceof Error ? err.message : "获取项目列表失败"
|
||||||
console.error('获取项目列表失败:', err)
|
console.error("获取项目列表失败:", err)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchProjects = async (query: string) => {
|
||||||
|
searchQuery.value = query
|
||||||
|
await fetchProjects(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSearch = async () => {
|
||||||
|
searchQuery.value = ""
|
||||||
|
await fetchProjects()
|
||||||
|
}
|
||||||
|
|
||||||
const refreshProjects = () => {
|
const refreshProjects = () => {
|
||||||
return fetchProjects()
|
return fetchProjects(searchQuery.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projects,
|
projects,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
searchQuery,
|
||||||
fetchProjects,
|
fetchProjects,
|
||||||
refreshProjects
|
searchProjects,
|
||||||
|
clearSearch,
|
||||||
|
refreshProjects,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
|||||||
// API方法
|
// API方法
|
||||||
export const 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> => {
|
uploadProject: (formData: FormData): Promise<UploadResponse> => {
|
||||||
|
|||||||
@@ -1,79 +1,15 @@
|
|||||||
:root {
|
/* 全局样式重置 */
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
* {
|
||||||
line-height: 1.5;
|
box-sizing: border-box;
|
||||||
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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||||
place-items: center;
|
background-color: #f5f5f5;
|
||||||
min-width: 320px;
|
color: #333;
|
||||||
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 {
|
#app {
|
||||||
max-width: 1280px;
|
min-height: 100vh;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,6 @@ export interface Project {
|
|||||||
uploadedAt: string
|
uploadedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// API响应类型
|
|
||||||
export interface ApiResponse<T = any> {
|
|
||||||
data?: T
|
|
||||||
error?: string
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 上传响应类型
|
// 上传响应类型
|
||||||
export interface UploadResponse {
|
export interface UploadResponse {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
Reference in New Issue
Block a user