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