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

- 新增 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
+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>