AI-News/frontend/app/pages/my-articles.vue
2025-12-04 10:04:21 +08:00

436 lines
9.7 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.

<template>
<div class="mine-page">
<div class="mine-shell">
<header class="mine-header">
<div>
<h1 class="title">我上架的文章</h1>
<p class="desc">查看编辑或删除你发布的文章</p>
</div>
<div class="actions">
<button class="btn ghost" type="button" @click="goNew">新建文章</button>
<button class="btn" type="button" @click="() => loadArticles(true)" :disabled="loading">
{{ loading ? '加载中...' : '刷新' }}
</button>
</div>
</header>
<div class="toolbar">
<input
v-model.trim="keyword"
class="input"
type="search"
placeholder="搜索标题/描述"
@keyup.enter="() => loadArticles(true)"
/>
<button class="btn" type="button" @click="() => loadArticles(true)" :disabled="loading">
搜索
</button>
</div>
<div v-if="loading" class="state">加载中...</div>
<div v-else-if="error" class="state error">{{ error }}</div>
<div v-else-if="articles.length === 0" class="state">暂无文章</div>
<div v-else class="cards-grid">
<div v-for="it in articles" :key="it.slug" class="card">
<div class="card-cover" :style="{ backgroundImage: `url(${it.cover || defaultCover})` }" />
<div class="card-main">
<h3 class="card-title">{{ it.title }}</h3>
<p class="card-desc">{{ it.description || '暂无描述' }}</p>
<div class="card-meta">
<span>{{ it.createdAt ? new Date(it.createdAt).toLocaleDateString() : '--' }}</span>
<span>·</span>
<span>{{ it.views ?? 0 }} 浏览</span>
</div>
</div>
<div class="card-actions">
<button class="btn ghost" type="button" @click="() => viewArticle(it)">
查看
</button>
<button class="btn" type="button" @click="() => goEdit(it)" :disabled="savingMap[it.slug]">
编辑
</button>
<button class="btn danger" type="button" @click="() => deleteArticle(it)" :disabled="savingMap[it.slug]">
删除
</button>
</div>
</div>
</div>
<div class="footer-actions" v-if="hasMore && !loading && !error">
<button class="btn ghost" type="button" @click="loadArticles(false)" :disabled="loadingMore">
{{ loadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
<div class="state" v-else-if="!hasMore && articles.length">已到底部</div>
</div>
<!-- 编辑弹窗 -->
<div v-if="showEdit" class="modal-mask">
<div class="modal">
<header class="modal-head">
<h3>编辑文章</h3>
<button class="close" type="button" @click="closeEdit">×</button>
</header>
<label class="field">
<span>标题</span>
<input v-model.trim="editForm.title" class="input" type="text" />
</label>
<label class="field">
<span>描述</span>
<textarea v-model.trim="editForm.description" class="input" rows="3" />
</label>
<label class="field">
<span>封面 URL</span>
<input v-model.trim="editForm.cover" class="input" type="text" placeholder="可留空" />
</label>
<div class="modal-actions">
<button class="btn ghost" type="button" @click="closeEdit">取消</button>
<button class="btn" type="button" :disabled="saving" @click="saveEdit">{{ saving ? '保存中...' : '保存' }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watch, computed } from 'vue'
import { navigateTo } from '#app'
import { useApi, useAuthToken } from '@/composables/useApi'
import { useAuth } from '@/composables/useAuth'
definePageMeta({
layout: 'default',
title: '我上架的文章',
})
interface ArticleItem {
slug: string
title: string
description?: string | null
cover?: string | null
tagList?: string[]
createdAt?: string
views?: number
}
interface ArticlesResponse {
articles?: ArticleItem[]
}
const api = useApi()
const { token } = useAuthToken()
const { user: authUser, fetchMe } = useAuth() as any
const articles = ref<ArticleItem[]>([])
const loading = ref(false)
const loadingMore = ref(false)
const hasMore = ref(true)
const error = ref('')
const keyword = ref('')
const defaultCover = '/cover.jpg'
const offset = ref(0)
const PAGE_SIZE = 20
const savingMap = ref<Record<string, boolean>>({})
const username = computed(() => authUser.value?.username || '')
async function ensureLogin(): Promise<boolean> {
if (!token.value) {
await navigateTo('/login')
return false
}
if (!authUser.value) {
try {
await fetchMe()
} catch (err) {
console.warn('[MyArticles] fetch me failed', err)
}
}
if (!authUser.value?.username) {
await navigateTo('/login')
return false
}
return true
}
async function loadArticles(reset = false): Promise<void> {
if (!(await ensureLogin())) return
if (loading.value || loadingMore.value) return
if (reset) {
loading.value = true
error.value = ''
offset.value = 0
hasMore.value = true
articles.value = []
} else {
if (!hasMore.value) return
loadingMore.value = true
}
try {
const res = (await api.get('/articles', {
author: username.value,
search: keyword.value || undefined,
limit: PAGE_SIZE,
offset: offset.value,
})) as ArticlesResponse
const list = Array.isArray(res.articles) ? res.articles : []
if (reset) {
articles.value = list
} else {
articles.value = [...articles.value, ...list]
}
offset.value += list.length
hasMore.value = list.length === PAGE_SIZE
} catch (err: any) {
console.error('[MyArticles] load failed', err)
error.value = err?.statusMessage || err?.message || '加载失败'
} finally {
loading.value = false
loadingMore.value = false
}
}
function viewArticle(it: ArticleItem): void {
navigateTo(`/articles/${it.slug}`)
}
function goEdit(it: ArticleItem): void {
navigateTo(`/articles/${it.slug}?edit=1`)
}
async function deleteArticle(it: ArticleItem): Promise<void> {
if (!it?.slug) return
if (!confirm(`确定删除「${it.title}」吗?`)) return
savingMap.value[it.slug] = true
try {
await api.del(`/articles/${it.slug}`)
articles.value = articles.value.filter((a) => a.slug !== it.slug)
} catch (err: any) {
console.error('[MyArticles] delete failed', err)
alert(err?.statusMessage || err?.message || '删除失败')
} finally {
delete savingMap.value[it.slug]
}
}
function goNew(): void {
navigateTo('/articles/new')
}
onMounted(() => {
loadArticles(true)
})
watch(
() => token.value,
async (val) => {
if (!val) {
articles.value = []
return
}
await loadArticles(true)
},
)
</script>
<style scoped>
.mine-page {
min-height: 100vh;
background: #f7f9fc;
padding: 80px 16px 60px;
}
.mine-shell {
max-width: 1200px;
margin: 0 auto;
background: #fff;
border-radius: 20px;
padding: 20px;
border: 1px solid #e5e7eb;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
.mine-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 800;
}
.desc {
margin: 6px 0 0;
color: #6b7280;
font-size: 14px;
}
.actions {
display: flex;
gap: 10px;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 14px;
}
.input {
flex: 1;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid #d1d5db;
background: #fff;
font-size: 14px;
}
.btn {
padding: 10px 14px;
border-radius: 12px;
border: 1px solid #d1d5db;
background: #111827;
color: #fff;
cursor: pointer;
font-weight: 700;
transition: all 0.18s ease;
}
.btn.ghost {
background: #fff;
color: #111827;
}
.btn.danger {
background: #fff5f5;
color: #b91c1c;
border-color: #fecdd3;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.card {
border: 1px solid #e5e7eb;
border-radius: 14px;
background: #fff;
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.06);
display: flex;
flex-direction: column;
overflow: hidden;
}
.card-cover {
height: 160px;
background-size: cover;
background-position: center;
}
.card-main {
padding: 12px 12px 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.card-title {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.card-desc {
margin: 0;
color: #6b7280;
font-size: 13px;
min-height: 34px;
}
.card-meta {
display: flex;
gap: 6px;
color: #9ca3af;
font-size: 12px;
}
.card-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding: 10px 12px 12px;
}
.state {
padding: 16px 10px;
text-align: center;
color: #6b7280;
}
.state.error {
color: #b91c1c;
}
.footer-actions {
display: flex;
justify-content: center;
margin-top: 12px;
}
.modal-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.modal {
width: min(520px, 92vw);
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.4);
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>