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

- 新增 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
+4
View File
@@ -115,6 +115,10 @@ watch(() => route.path, () => loadCounts())
<span>标签</span>
<span class="badge">{{ counts.tags }}</span>
</RouterLink>
<RouterLink to="/admin/projects" class="item">
<span class="ic">◈</span>
<span>作品</span>
</RouterLink>
<div class="group">操作</div>
<RouterLink to="/admin/new" class="item">
<span class="ic">✎</span>
+325
View File
@@ -0,0 +1,325 @@
<script setup>
import { onMounted, ref } from 'vue'
import { adminApi } from '../api'
const projects = ref([])
const loading = ref(true)
const error = ref('')
function blank() {
return {
id: null,
title: '',
summary: '',
cover_url: '',
url: '',
repo_url: '',
status: 'published',
position: 0
}
}
// 顶部新建表单
const form = ref(blank())
// 当前展开编辑的那一行 id(null 表示没有)
const editing = ref(null)
const draft = ref(blank())
async function load() {
loading.value = true
try {
const data = await adminApi.projects()
projects.value = data.projects || data || []
} catch (e) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
async function create() {
if (!form.value.title.trim()) {
alert('请填写作品名称')
return
}
try {
await adminApi.createProject({
title: form.value.title,
summary: form.value.summary,
cover_url: form.value.cover_url,
url: form.value.url,
repo_url: form.value.repo_url,
status: form.value.status,
position: Number(form.value.position) || 0
})
form.value = blank()
await load()
} catch (e) {
alert(e.message || '创建失败')
}
}
function startEdit(p) {
editing.value = p.id
draft.value = {
id: p.id,
title: p.title,
summary: p.summary,
cover_url: p.cover_url,
url: p.url,
repo_url: p.repo_url,
status: p.status,
position: p.position
}
}
function cancelEdit() {
editing.value = null
draft.value = blank()
}
async function saveEdit() {
if (!draft.value.title.trim()) {
alert('请填写作品名称')
return
}
try {
await adminApi.updateProject(editing.value, {
title: draft.value.title,
summary: draft.value.summary,
cover_url: draft.value.cover_url,
url: draft.value.url,
repo_url: draft.value.repo_url,
status: draft.value.status,
position: Number(draft.value.position) || 0
})
editing.value = null
await load()
} catch (e) {
alert(e.message || '保存失败')
}
}
async function toggleStatus(p) {
const next = p.status === 'published' ? 'draft' : 'published'
try {
await adminApi.updateProject(p.id, { status: next })
await load()
} catch (e) {
alert(e.message || '更新失败')
}
}
async function remove(p) {
if (!window.confirm(`删除作品「${p.title}」?此操作不可撤销。`)) return
try {
await adminApi.deleteProject(p.id)
await load()
} catch (e) {
alert(e.message || '删除失败')
}
}
</script>
<template>
<section>
<header style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px;">
<h1 style="font-family: var(--serif); font-size: 22px;">
作品 <span style="font-family: var(--sans); font-size: 13px; color: var(--admin-muted); font-weight: 400;">{{ projects.length }}</span>
</h1>
</header>
<div class="panel" style="margin-bottom: 16px;">
<div class="panel-title"><h2>新建作品</h2></div>
<div class="form">
<label class="field">
<span class="label">名称</span>
<input v-model="form.title" class="input" placeholder="Folo" spellcheck="false" />
</label>
<label class="field">
<span class="label">一句话描述</span>
<input v-model="form.summary" class="input" placeholder="This AI RSS reader reads the internet for you" spellcheck="false" />
</label>
<label class="field">
<span class="label">封面图 URL(16:9)</span>
<input v-model="form.cover_url" class="input" placeholder="https://…/cover.webp" spellcheck="false" />
</label>
<label class="field">
<span class="label">链接</span>
<input v-model="form.url" class="input" placeholder="https://folo.is" spellcheck="false" />
</label>
<label class="field">
<span class="label">仓库链接</span>
<input v-model="form.repo_url" class="input" placeholder="https://github.com/…" spellcheck="false" />
</label>
<div class="row2">
<label class="field">
<span class="label">排序权重</span>
<input v-model.number="form.position" type="number" class="input" />
</label>
<label class="field">
<span class="label">状态</span>
<select v-model="form.status" class="select">
<option value="published">已发布</option>
<option value="draft">草稿</option>
</select>
</label>
</div>
<button class="btn btn-primary" @click="create">添加</button>
</div>
</div>
<div v-if="loading" class="loading">载入中…</div>
<div v-else-if="error" class="empty">{{ error }}</div>
<div v-else-if="!projects.length" class="empty">还没有作品。</div>
<div v-else>
<div v-for="p in projects" :key="p.id" class="proj-row">
<div class="line">
<span class="name">{{ p.title }}</span>
<span class="state" :class="p.status">{{ p.status === 'published' ? '已发布' : '草稿' }}</span>
<span class="acts">
<button @click="toggleStatus(p)">{{ p.status === 'published' ? '转草稿' : '发布' }}</button>
<button @click="startEdit(p)">编辑</button>
<button class="danger" @click="remove(p)">删除</button>
</span>
</div>
<div v-if="editing === p.id" class="form edit">
<label class="field">
<span class="label">名称</span>
<input v-model="draft.title" class="input" spellcheck="false" />
</label>
<label class="field">
<span class="label">一句话描述</span>
<input v-model="draft.summary" class="input" spellcheck="false" />
</label>
<label class="field">
<span class="label">封面图 URL(16:9)</span>
<input v-model="draft.cover_url" class="input" spellcheck="false" />
</label>
<label class="field">
<span class="label">链接</span>
<input v-model="draft.url" class="input" spellcheck="false" />
</label>
<label class="field">
<span class="label">仓库链接</span>
<input v-model="draft.repo_url" class="input" spellcheck="false" />
</label>
<div class="row2">
<label class="field">
<span class="label">排序权重</span>
<input v-model.number="draft.position" type="number" class="input" />
</label>
<label class="field">
<span class="label">状态</span>
<select v-model="draft.status" class="select">
<option value="published">已发布</option>
<option value="draft">草稿</option>
</select>
</label>
</div>
<div class="edit-acts">
<button class="btn btn-primary" @click="saveEdit">保存</button>
<button class="btn" @click="cancelEdit">取消</button>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.form {
display: flex;
flex-direction: column;
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 5px;
}
.label {
font-size: 12px;
color: var(--admin-muted);
}
.row2 {
display: flex;
gap: 12px;
}
.row2 .field {
flex: 1;
}
.edit {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--admin-line);
}
.edit-acts {
display: flex;
gap: 10px;
}
.proj-row {
border-top: 1px solid var(--admin-line);
padding: 12px 0;
}
.line {
display: flex;
align-items: center;
gap: 10px;
}
.name {
font-size: 15px;
color: var(--admin-ink);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.state {
font-size: 12px;
padding: 1px 8px;
border-radius: 999px;
border: 1px solid var(--admin-line);
color: var(--admin-muted);
}
.state.published {
color: var(--admin-accent);
border-color: var(--admin-accent);
}
.acts {
display: flex;
gap: 8px;
font-size: 13px;
}
.acts button {
background: none;
border: 0;
color: var(--admin-muted);
cursor: pointer;
padding: 2px 4px;
}
.acts button:hover {
color: var(--admin-ink);
}
.acts button.danger:hover {
color: #c0392b;
}
</style>
+5
View File
@@ -54,6 +54,7 @@ export const publicApi = {
post: (slug) => request('/api/posts/' + encodeURIComponent(slug)),
archive: () => request('/api/archive'),
tags: cached(() => request('/api/tags')),
projects: cached(() => request('/api/projects')),
latest: cached(() => request('/api/posts?size=5'))
}
@@ -76,6 +77,10 @@ export const adminApi = {
deleteTag: (id) => request('/api/admin/tags/' + id, { method: 'DELETE' }),
mergeTag: (id, toId) =>
request('/api/admin/tags/' + id + '/merge', { method: 'POST', body: { to_id: toId } }),
projects: (params = {}) => request('/api/admin/projects?' + new URLSearchParams(params)),
createProject: (body) => request('/api/admin/projects', { method: 'POST', body }),
updateProject: (id, body) => request('/api/admin/projects/' + id, { method: 'PUT', body }),
deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }),
settings: () => request('/api/admin/settings'),
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body })
}
+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>
+2
View File
@@ -7,6 +7,7 @@ const routes = [
{ path: '/tags', name: 'tags', component: () => import('./views/TagsView.vue') },
{ path: '/tag/:slug', name: 'tag', component: () => import('./views/TagView.vue') },
{ path: '/about', name: 'about', component: () => import('./views/AboutView.vue') },
{ path: '/projects', name: 'projects', component: () => import('./views/ProjectsView.vue') },
{ path: '/admin/login', name: 'admin-login', component: () => import('./admin/LoginView.vue') },
{
@@ -18,6 +19,7 @@ const routes = [
{ path: 'new', name: 'admin-new', component: () => import('./admin/EditorView.vue') },
{ path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') },
{ path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') },
{ path: 'projects', name: 'admin-projects', component: () => import('./admin/ProjectsView.vue') },
{ path: 'settings', name: 'admin-settings', component: () => import('./admin/SettingsView.vue') }
]
},
+98
View File
@@ -0,0 +1,98 @@
<script setup>
import { onMounted, ref } from 'vue'
import LeftNav from '../components/LeftNav.vue'
import RightRail from '../components/RightRail.vue'
import ProjectCard from '../components/ProjectCard.vue'
import { publicApi } from '../api'
import { applyDocTitle } from '../site'
const projects = ref([])
const loading = ref(true)
const error = ref('')
onMounted(async () => {
try {
const data = await publicApi.projects()
projects.value = data.projects || []
} catch (e) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
}
})
applyDocTitle('作品')
</script>
<template>
<div class="shell">
<div class="layout">
<LeftNav />
<main class="main">
<header class="head">
<h1 class="page-title">作品</h1>
<p class="sub">做过的一些东西。</p>
</header>
<div v-if="loading" class="loading">载入中…</div>
<div v-else-if="error" class="empty">{{ error }}</div>
<div v-else-if="!projects.length" class="empty">还没有作品。</div>
<div v-else class="grid">
<ProjectCard v-for="p in projects" :key="p.id" :project="p" />
</div>
</main>
<RightRail />
</div>
</div>
</template>
<style scoped>
.main {
min-width: 0;
border-left: 1px solid var(--line);
border-right: 1px solid var(--line);
padding-bottom: 60px;
}
.head {
position: sticky;
top: 0;
z-index: 10;
background-color: var(--paper);
backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line);
padding: 14px 16px;
}
.page-title {
font-size: 20px;
}
.sub {
margin: 2px 0 0;
font-size: 13px;
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 22px 16px;
}
@media (max-width: 620px) {
.grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 780px) {
.main {
border: 0;
}
}
</style>