Files
ONE/frontend/src/admin/ProjectsView.vue
T
Sakurasan 3238175ef0 作品展示页:前端展示 + 后台增删改查
- 新增 projects 表与迁移(唯一 slug 索引)
- 后端:公开 GET /api/projects,后台 GET/POST/PUT/DELETE /api/admin/projects
- 前端:/projects 双虚线边框卡片页(参考 diygod.cc/projects),后台管理页
- 左栏/手机顶栏/后台侧栏均加「作品」入口
- 补充项目 CRUD 单元测试与后台鉴权拦截测试
2026-09-22 01:47:59 +08:00

326 lines
8.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>