作品展示页:前端展示 + 后台增删改查
- 新增 projects 表与迁移(唯一 slug 索引) - 后端:公开 GET /api/projects,后台 GET/POST/PUT/DELETE /api/admin/projects - 前端:/projects 双虚线边框卡片页(参考 diygod.cc/projects),后台管理页 - 左栏/手机顶栏/后台侧栏均加「作品」入口 - 补充项目 CRUD 单元测试与后台鉴权拦截测试
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user