You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

761 lines
19 KiB

<script setup lang="ts">
definePageMeta({
title: '项目',
})
const { loggedIn, initialized } = useAuthSession()
const { $toast } = useNuxtApp()
// ── State ──
const projects = ref<any[]>([])
const loading = ref(true)
const showForm = ref(false)
const editingProject = ref<any>(null)
const formData = ref({ name: '', tags: '', description: '', path: '' })
const submitting = ref(false)
// ── Intersection-observer entrance ──
const gridEl = ref<HTMLElement | null>(null)
// After cards render, add visible class with stagger
function revealCards() {
if (!gridEl.value) return
for (let i = 0; i < gridEl.value.children.length; i++) {
const child = gridEl.value.children[i] as HTMLElement
if (child) {
child.style.setProperty('--idx', String(i))
child.classList.add('visible')
}
}
}
// ── Fetch ──
async function fetchProjects() {
loading.value = true
try {
const res = await $fetch('/api/projects')
projects.value = (res as any)?.data?.items ?? []
await nextTick()
revealCards()
} catch (e: any) {
$toast?.error('加载项目失败')
} finally {
loading.value = false
}
}
// ── CRUD ──
function openCreate() {
editingProject.value = null
formData.value = { name: '', tags: '', description: '', path: '' }
showForm.value = true
}
function openEdit(project: any) {
editingProject.value = project
formData.value = {
name: project.name,
tags: project.tags || '',
description: project.description || '',
path: project.path || '',
}
showForm.value = true
}
async function submitForm() {
submitting.value = true
try {
const payload = {
name: formData.value.name,
tags: formData.value.tags || null,
description: formData.value.description || null,
path: formData.value.path || null,
}
if (editingProject.value) {
await $fetch(`/api/projects/${editingProject.value.id}`, { method: 'PUT', body: payload })
$toast?.success('项目已更新')
} else {
await $fetch('/api/projects', { method: 'POST', body: payload })
$toast?.success('项目已创建')
}
showForm.value = false
await fetchProjects()
} catch (e: any) {
$toast?.error(e?.data?.statusMessage || '操作失败')
} finally {
submitting.value = false
}
}
async function deleteProject(project: any) {
if (!confirm(`确定删除「${project.name}」?`)) return
try {
await $fetch(`/api/projects/${project.id}`, { method: 'DELETE' })
$toast?.success('项目已删除')
await fetchProjects()
} catch (e: any) {
$toast?.error('删除失败')
}
}
function parseTags(tags: string | null): string[] {
if (!tags) return []
return tags.split(',').map(t => t.trim()).filter(Boolean)
}
const cardBgPool = [
'var(--color-surface-card, #efe9de)',
'var(--color-surface-soft, #f5f0e8)',
'var(--color-canvas, #faf9f5)',
]
function cardBg(index: number): string {
return cardBgPool[index % cardBgPool.length]
}
function padNum(i: number): string {
return String(i + 1).padStart(2, '0')
}
onMounted(() => { fetchProjects() })
</script>
<template>
<div class="projects-page">
<!-- Header -->
<header class="page-header">
<div class="header-content">
<p class="page-eyebrow">Portfolio</p>
<h1 class="page-title">项目</h1>
<p class="page-subtitle">一些有趣的项目与探索</p>
</div>
<button v-if="loggedIn && initialized" class="add-btn" @click="openCreate">
<span class="add-icon">+</span>
新建项目
</button>
</header>
<!-- Loading -->
<div v-if="loading" class="loading-state">
<span class="loader-dot" />
<span class="loader-dot" />
<span class="loader-dot" />
</div>
<!-- Empty State -->
<div v-else-if="projects.length === 0" class="empty-state">
<div class="empty-mark">✦</div>
<p class="empty-text">还没有项目</p>
<p class="empty-hint">项目展示从这里开始</p>
<button v-if="loggedIn && initialized" class="add-btn" @click="openCreate">
创建第一个项目
</button>
</div>
<!-- Card Grid -->
<div v-else ref="gridEl" class="project-grid">
<article
v-for="(project, index) in projects"
:key="project.id"
:data-index="index"
class="project-card"
:class="{ 'card-featured': index === 0 && projects.length < 4 }"
:style="{ '--card-bg': cardBg(index), '--idx': index }"
>
<!-- Decorative index number -->
<span class="card-index">{{ padNum(index) }}</span>
<div class="card-body">
<h3 class="card-title">{{ project.name }}</h3>
<div v-if="project.tags" class="card-tags">
<span
v-for="tag in parseTags(project.tags)"
:key="tag"
class="tag-pill"
>{{ tag }}</span>
</div>
<p v-if="project.description" class="card-desc">{{ project.description }}</p>
<a
v-if="project.path"
:href="project.path"
target="_blank"
rel="noopener noreferrer"
class="card-link"
@click.stop
>
<svg class="link-svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
<span class="link-label">查看项目</span>
</a>
</div>
<div v-if="loggedIn && initialized" class="card-actions">
<button class="action-btn" title="编辑" @click="openEdit(project)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
</svg>
</button>
<button class="action-btn action-delete" title="删除" @click="deleteProject(project)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
</svg>
</button>
</div>
</article>
</div>
<!-- Form Modal -->
<Teleport to="body">
<div v-if="showForm" class="modal-overlay" @click.self="showForm = false">
<div class="modal-card">
<div class="modal-header">
<h2 class="modal-title">{{ editingProject ? '编辑项目' : '新建项目' }}</h2>
<button class="modal-close" @click="showForm = false">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
</svg>
</button>
</div>
<form class="modal-body" @submit.prevent="submitForm">
<div class="form-group">
<label class="form-label">项目名 <span class="required">*</span></label>
<input v-model="formData.name" class="form-input" placeholder="输入项目名称" required maxlength="100" />
</div>
<div class="form-group">
<label class="form-label">标签</label>
<input v-model="formData.tags" class="form-input" placeholder="用逗号分隔,如: Vue, Nuxt, 项目" maxlength="500" />
</div>
<div class="form-group">
<label class="form-label">描述</label>
<textarea v-model="formData.description" class="form-textarea" placeholder="简短的描述" rows="3" maxlength="1000" />
</div>
<div class="form-group">
<label class="form-label">链接</label>
<input v-model="formData.path" class="form-input" placeholder="https://github.com/..." maxlength="500" />
</div>
<div class="modal-footer">
<button type="button" class="btn-cancel" @click="showForm = false">取消</button>
<button type="submit" class="btn-submit" :disabled="submitting">
{{ submitting ? '保存中...' : (editingProject ? '更新' : '创建') }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
/* ═══════════════════════
Projects Page — Brand
═══════════════════════ */
.projects-page {
max-width: 1280px;
margin: 0 auto;
padding: 32px 32px 80px;
}
/* ─── Header ─── */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 36px;
gap: 24px;
flex-wrap: wrap;
}
.header-content {
flex: 1;
}
.page-eyebrow {
font-family: var(--font-body, 'Inter', sans-serif);
font-size: 11px;
font-weight: 600;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--color-primary, #cc785c);
margin: 0 0 8px;
}
.page-title {
font-family: 'Copernicus', 'Times New Roman', serif;
font-size: 36px;
font-weight: 400;
color: var(--color-ink, #141413);
letter-spacing: -0.8px;
margin: 0 0 6px;
line-height: 1.1;
}
.page-subtitle {
font-family: var(--font-body, 'Inter', sans-serif);
font-size: 16px;
color: var(--color-muted, #6c6a64);
margin: 0;
line-height: 1.55;
}
/* ─── Add Button ─── */
.add-btn {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--font-body, 'Inter', sans-serif);
font-size: 14px;
font-weight: 500;
color: var(--color-on-primary, #ffffff);
background: var(--color-primary, #cc785c);
border: none;
padding: 11px 22px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease;
flex-shrink: 0;
}
.add-btn:hover {
background: var(--color-primary-active, #a9583e);
}
.add-btn:active {
transform: scale(0.97);
}
.add-icon {
font-size: 18px;
line-height: 1;
}
/* ─── Loading ─── */
.loading-state {
display: flex;
justify-content: center;
gap: 8px;
padding: 120px 0;
}
.loader-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary, #cc785c);
animation: dotPulse 1.2s ease-in-out infinite;
}
.loader-dot:nth-child(2) { animation-delay: 0.15s; }
.loader-dot:nth-child(3) { animation-delay: 0.3s; }
@keyframes dotPulse {
0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
/* ─── Empty State ─── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120px 0;
gap: 12px;
}
.empty-mark {
font-size: 36px;
color: var(--color-primary, #cc785c);
margin-bottom: 8px;
animation: gentleSpin 4s ease-in-out infinite;
}
@keyframes gentleSpin {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(180deg); }
}
.empty-text {
font-family: 'Copernicus', 'Times New Roman', serif;
font-size: 24px;
font-weight: 400;
color: var(--color-ink, #141413);
margin: 0;
}
.empty-hint {
font-family: var(--font-body, 'Inter', sans-serif);
font-size: 14px;
color: var(--color-muted, #6c6a64);
margin: 0 0 8px;
}
/* ═══════════════════════
Project Grid
═══════════════════════ */
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
}
/* Featured card: first card spans wider when few projects */
.project-grid:has(:only-child) .card-featured,
.project-grid:has(:nth-child(2):last-child) .card-featured {
grid-column: 1 / -1;
}
/* ─── Card ─── */
.project-card {
position: relative;
background: var(--card-bg, var(--color-surface-card, #efe9de));
border-radius: 14px;
padding: 24px 24px 20px;
display: flex;
flex-direction: column;
gap: 10px;
opacity: 1;
transform: translateY(32px) rotateX(4deg);
transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1), transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.35s ease;
transform-origin: center top;
will-change: transform, opacity;
}
.project-card.visible {
opacity: 1;
transform: translateY(0) rotateX(0deg);
transition-delay: calc(var(--idx, 0) * 0.08s);
}
.project-card:hover {
transform: translateY(-4px) scale(1.005);
box-shadow: 0 12px 36px rgba(20, 20, 19, 0.07);
}
/* ─── Card Index ─── */
.card-index {
font-family: 'Copernicus', 'Times New Roman', serif;
font-size: 12px;
font-weight: 400;
color: var(--color-primary, #cc785c);
letter-spacing: 1px;
line-height: 1;
opacity: 0.7;
}
/* ─── Card Body ─── */
.card-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.card-title {
font-family: 'Copernicus', 'Times New Roman', serif;
font-size: 22px;
font-weight: 400;
color: var(--color-ink, #141413);
margin: 0;
letter-spacing: -0.3px;
line-height: 1.25;
}
/* ─── Tags ─── */
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.tag-pill {
display: inline-block;
font-family: var(--font-body, 'Inter', sans-serif);
font-size: 11px;
font-weight: 500;
color: var(--color-primary, #cc785c);
background: rgba(204, 120, 92, 0.1);
padding: 3px 9px;
border-radius: 9999px;
line-height: 1.4;
letter-spacing: 0.2px;
}
/* ─── Description ─── */
.card-desc {
font-family: var(--font-body, 'Inter', sans-serif);
font-size: 14px;
color: var(--color-body, #3d3d3a);
line-height: 1.6;
margin: 0;
}
/* ─── Link ─── */
.card-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-body, 'Inter', sans-serif);
font-size: 13px;
font-weight: 500;
color: var(--color-primary, #cc785c);
text-decoration: none;
transition: gap 0.2s ease;
margin-top: 2px;
}
.card-link:hover {
gap: 10px;
}
.link-svg {
flex-shrink: 0;
transition: transform 0.2s ease;
}
.card-link:hover .link-svg {
transform: translate(2px, -2px);
}
/* ─── Card Actions ─── */
.card-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
border-top: 1px solid rgba(230, 223, 216, 0.4);
padding-top: 14px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
border-radius: 8px;
background: transparent;
cursor: pointer;
transition: all 0.2s ease;
color: var(--color-muted, #6c6a64);
}
.action-btn:hover {
background: var(--color-canvas, #faf9f5);
color: var(--color-ink, #141413);
}
.action-delete:hover {
background: rgba(198, 69, 69, 0.08);
color: var(--color-error, #c64545);
}
/* ═══════════════════════
Modal
═══════════════════════ */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(20, 20, 19, 0.45);
backdrop-filter: blur(5px);
padding: 24px;
}
.modal-card {
background: var(--color-canvas, #faf9f5);
border-radius: 16px;
width: 100%;
max-width: 480px;
box-shadow: 0 20px 60px rgba(20, 20, 19, 0.18);
animation: modalIn 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.93) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 24px 0;
}
.modal-title {
font-family: 'Copernicus', 'Times New Roman', serif;
font-size: 22px;
font-weight: 400;
color: var(--color-ink, #141413);
margin: 0;
letter-spacing: -0.3px;
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: transparent;
cursor: pointer;
color: var(--color-muted, #6c6a64);
transition: all 0.2s ease;
}
.modal-close:hover {
background: var(--color-surface-card, #efe9de);
color: var(--color-ink, #141413);
}
.modal-body {
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-family: var(--font-body, 'Inter', sans-serif);
font-size: 13px;
font-weight: 500;
color: var(--color-ink, #141413);
}
.required { color: var(--color-error, #c64545); }
.form-input,
.form-textarea {
font-family: var(--font-body, 'Inter', sans-serif);
font-size: 14px;
color: var(--color-ink, #141413);
background: var(--color-canvas, #faf9f5);
border: 1px solid var(--color-hairline, #e6dfd8);
border-radius: 8px;
padding: 10px 14px;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.form-input:focus,
.form-textarea:focus {
border-color: var(--color-primary, #cc785c);
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.12);
}
.form-textarea {
resize: vertical;
min-height: 72px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 8px;
}
.btn-cancel {
font-family: var(--font-body, 'Inter', sans-serif);
font-size: 14px;
font-weight: 500;
color: var(--color-muted, #6c6a64);
background: transparent;
border: 1px solid var(--color-hairline, #e6dfd8);
padding: 9px 18px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-cancel:hover {
background: var(--color-surface-card, #efe9de);
color: var(--color-ink, #141413);
}
.btn-submit {
font-family: var(--font-body, 'Inter', sans-serif);
font-size: 14px;
font-weight: 500;
color: var(--color-on-primary, #ffffff);
background: var(--color-primary, #cc785c);
border: none;
padding: 9px 22px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s ease;
}
.btn-submit:hover { background: var(--color-primary-active, #a9583e); }
.btn-submit:disabled { opacity: 0.6; cursor: not-allowed; }
/* ═══════════════════════
Responsive
═══════════════════════ */
@media (max-width: 768px) {
.projects-page {
padding: 40px 20px 80px;
}
.page-header {
flex-direction: column;
align-items: flex-start;
margin-bottom: 40px;
}
.page-title {
font-size: 32px;
}
.project-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.project-card {
padding: 24px 24px 20px;
}
.card-title {
font-size: 20px;
}
}
</style>