作品展示页:前端展示 + 后台增删改查

- 新增 projects 表与迁移(唯一 slug 索引)
- 后端:公开 GET /api/projects,后台 GET/POST/PUT/DELETE /api/admin/projects
- 前端:/projects 双虚线边框卡片页(参考 diygod.cc/projects),后台管理页
- 左栏/手机顶栏/后台侧栏均加「作品」入口
- 补充项目 CRUD 单元测试与后台鉴权拦截测试
This commit is contained in:
Sakurasan
2026-09-22 01:47:59 +08:00
parent 2b53eb976b
commit 3238175ef0
14 changed files with 1029 additions and 1 deletions
+3
View File
@@ -35,6 +35,9 @@ onMounted(async () => {
<RouterLink to="/tags" class="item">
<span class="ico">#</span><span>标签</span>
</RouterLink>
<RouterLink to="/projects" class="item">
<span class="ico">◈</span><span>作品</span>
</RouterLink>
<RouterLink to="/about" class="item">
<span class="ico">◍</span><span>关于</span>
</RouterLink>
+198
View File
@@ -0,0 +1,198 @@
<script setup>
defineProps({
project: { type: Object, required: true }
})
</script>
<template>
<component
:is="project.url ? 'a' : 'div'"
:href="project.url ? project.url : null"
:target="project.url ? '_blank' : null"
:rel="project.url ? 'noopener noreferrer' : null"
class="card"
>
<span class="layer layer-back" aria-hidden="true"></span>
<span class="layer layer-front" aria-hidden="true"></span>
<span class="content">
<span class="cover">
<img
v-if="project.cover_url"
:src="project.cover_url"
:alt="project.title"
loading="lazy"
decoding="async"
/>
<span v-else class="cover-fallback">{{ (project.title || '?').slice(0, 1) }}</span>
</span>
<span class="meta">
<span class="title">
{{ project.title }}
<svg
v-if="project.url"
class="arrow"
viewBox="0 0 13 15"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g transform="translate(0.666667, 2.333333)" stroke="currentColor" stroke-width="2.4">
<polyline class="arrow-line" points="5.33333333 0 10.8333333 5.5 5.33333333 11" />
<line class="arrow-stem" x1="10.8333333" y1="5.5" x2="0.833333333" y2="5.16666667" />
</g>
</g>
</svg>
</span>
<span class="summary">{{ project.summary }}</span>
</span>
</span>
</component>
</template>
<style scoped>
.card {
--radius: 10px;
position: relative;
display: flex;
flex-direction: column;
padding: 18px;
border-radius: var(--radius);
color: inherit;
text-decoration: none;
}
.layer {
position: absolute;
inset: 0;
border-radius: var(--radius);
border: 1px dashed var(--line);
transition: transform 0.28s ease, background-color 0.28s ease, border-color 0.28s ease;
pointer-events: none;
}
.layer-back {
z-index: 1;
}
.layer-front {
z-index: 2;
border-color: transparent;
background: transparent;
}
.content {
position: relative;
z-index: 3;
display: flex;
flex-direction: column;
gap: 14px;
}
.cover {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 6px;
overflow: hidden;
background: var(--paper-sunken);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cover-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-family: var(--serif);
font-size: 42px;
color: var(--faint);
}
.meta {
display: flex;
flex-direction: column;
gap: 4px;
}
.title {
display: flex;
align-items: center;
gap: 5px;
font-size: 16px;
font-weight: 600;
color: var(--ink);
letter-spacing: 0.01em;
}
.arrow {
width: 11px;
height: 13px;
color: var(--accent);
transform: translate(-2px, 2px) rotate(-45deg);
transition: transform 0.2s ease, opacity 0.2s ease;
}
.arrow-stem {
transform: translateX(-4px);
opacity: 0;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.summary {
font-size: 13px;
line-height: 1.6;
color: var(--muted);
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ---- hover: split the double dashed border ---- */
.card:hover .layer-back,
.card:hover .layer-front {
transform: translate(4px, 4px);
}
.card:hover .layer-front {
transform: translate(-4px, -4px);
background: var(--card);
border-color: var(--line);
}
.card:hover .content {
transform: translate(-4px, -4px);
}
.card:hover .arrow {
transform: translate(0, 0) rotate(-45deg);
}
.card:hover .arrow-stem {
transform: translateX(0);
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.layer,
.content,
.arrow,
.arrow-stem {
transition: none;
}
.card:hover .layer-back {
transform: none;
}
}
</style>
+1
View File
@@ -10,6 +10,7 @@ import { site } from '../site'
<RouterLink to="/">首页</RouterLink>
<RouterLink to="/archive">归档</RouterLink>
<RouterLink to="/tags">标签</RouterLink>
<RouterLink to="/projects">作品</RouterLink>
<RouterLink to="/about">关于</RouterLink>
</nav>
</header>