master
parent
64b947db1a
commit
c3b48567f0
@ -0,0 +1,37 @@
|
||||
import { http } from '@/utils/http'
|
||||
import type { ApiBody, Paginated } from '@/api/types'
|
||||
|
||||
export interface PastReviewRow {
|
||||
id: number
|
||||
title: string
|
||||
cover_url?: string | null
|
||||
sort: number
|
||||
status: number
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
export async function fetchPastReviews(params: Record<string, unknown>) {
|
||||
const { data } = await http.get<ApiBody<Paginated<PastReviewRow>>>('/admin/v1/past-reviews', { params })
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchPastReview(id: number) {
|
||||
const { data } = await http.get<ApiBody<PastReviewRow>>(`/admin/v1/past-reviews/${id}`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createPastReview(payload: Record<string, unknown>) {
|
||||
const { data } = await http.post<ApiBody<{ id: number }>>('/admin/v1/past-reviews', payload)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updatePastReview(id: number, payload: Record<string, unknown>) {
|
||||
const { data } = await http.put<ApiBody<PastReviewRow>>(`/admin/v1/past-reviews/${id}`, payload)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deletePastReview(id: number) {
|
||||
const { data } = await http.delete<ApiBody<null>>(`/admin/v1/past-reviews/${id}`)
|
||||
return data
|
||||
}
|
||||
@ -0,0 +1,265 @@
|
||||
<script setup lang="ts">
|
||||
import PageTitle from '@/components/PageTitle.vue'
|
||||
import { ref } from 'vue'
|
||||
import { usePageLoad } from '@/composables/usePageLoad'
|
||||
import {
|
||||
createPastReview,
|
||||
deletePastReview,
|
||||
fetchPastReview,
|
||||
fetchPastReviews,
|
||||
updatePastReview,
|
||||
type PastReviewRow,
|
||||
} from '@/api/admin/past-reviews'
|
||||
import { uploadBannerCover } from '@/api/admin/upload'
|
||||
import { enabledStatusClass } from '@/utils/admin-list'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { UploadRequestOptions } from 'element-plus'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const items = ref<PastReviewRow[]>([])
|
||||
const meta = ref({ current_page: 1, per_page: 20, total: 0 })
|
||||
const page = ref(1)
|
||||
const keyword = ref('')
|
||||
const filterStatus = ref<number | ''>('')
|
||||
|
||||
const dialog = ref(false)
|
||||
const editing = ref<PastReviewRow | null>(null)
|
||||
const form = ref({
|
||||
title: '',
|
||||
cover_url: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
page: page.value,
|
||||
page_size: meta.value.per_page,
|
||||
}
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (filterStatus.value !== '') params.status = filterStatus.value
|
||||
const res = await fetchPastReviews(params)
|
||||
items.value = res.items
|
||||
meta.value = res.meta
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function searchList() {
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
keyword.value = ''
|
||||
filterStatus.value = ''
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
form.value = { title: '', cover_url: '', sort: 0, status: 1 }
|
||||
dialog.value = true
|
||||
}
|
||||
|
||||
async function openEdit(row: PastReviewRow) {
|
||||
editing.value = row
|
||||
const detail = await fetchPastReview(row.id)
|
||||
form.value = {
|
||||
title: detail.title,
|
||||
cover_url: detail.cover_url || '',
|
||||
sort: detail.sort,
|
||||
status: detail.status,
|
||||
}
|
||||
dialog.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.title.trim()) {
|
||||
ElMessage.warning('请填写标题')
|
||||
return
|
||||
}
|
||||
if (!form.value.cover_url) {
|
||||
ElMessage.warning('请上传封面图')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title: form.value.title.trim(),
|
||||
cover_url: form.value.cover_url,
|
||||
sort: form.value.sort,
|
||||
status: form.value.status,
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await updatePastReview(editing.value.id, payload)
|
||||
} else {
|
||||
await createPastReview(payload)
|
||||
}
|
||||
ElMessage.success('已保存')
|
||||
dialog.value = false
|
||||
await load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: PastReviewRow) {
|
||||
await ElMessageBox.confirm(`确定删除往期回顾「${row.title}」?`, '提示', { type: 'warning' })
|
||||
await deletePastReview(row.id)
|
||||
ElMessage.success('已删除')
|
||||
await load()
|
||||
}
|
||||
|
||||
async function handleCoverUpload(opt: UploadRequestOptions) {
|
||||
const raw = opt.file
|
||||
const file = raw instanceof File ? raw : (raw as { raw?: File }).raw
|
||||
if (!file) {
|
||||
opt.onError?.(new Error('no file') as never)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await uploadBannerCover(file)
|
||||
form.value.cover_url = res.url
|
||||
ElMessage.success('封面上传成功')
|
||||
opt.onSuccess?.({} as never)
|
||||
} catch {
|
||||
ElMessage.error('封面上传失败')
|
||||
opt.onError?.(new Error('upload failed') as never)
|
||||
}
|
||||
}
|
||||
|
||||
usePageLoad(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-page">
|
||||
<div class="page-header">
|
||||
<PageTitle />
|
||||
<el-button type="primary" size="small" class="btn-create" @click="openCreate">新增往期回顾</el-button>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="admin-list-card">
|
||||
<div class="list-filter-bar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索标题"
|
||||
clearable
|
||||
class="filter-search"
|
||||
@keyup.enter="searchList"
|
||||
/>
|
||||
<el-select v-model="filterStatus" clearable placeholder="是否显示" class="filter-select">
|
||||
<el-option label="显示" :value="1" />
|
||||
<el-option label="隐藏" :value="0" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="searchList">搜索</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="items" row-key="id">
|
||||
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="封面图" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.cover_url"
|
||||
:src="row.cover_url"
|
||||
:preview-src-list="[row.cover_url]"
|
||||
fit="cover"
|
||||
class="list-cover-thumb"
|
||||
preview-teleported
|
||||
/>
|
||||
<span v-else class="text-mute">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||
<el-table-column label="是否显示" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="status-badge" :class="enabledStatusClass(row.status)">
|
||||
{{ row.status === 1 ? '显示' : '隐藏' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="table-row-actions">
|
||||
<el-button class="btn-action-primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button class="btn-action-brand" @click="remove(row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="list-pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
layout="total, prev, pager, next"
|
||||
:total="meta.total"
|
||||
:page-size="meta.per_page"
|
||||
@current-change="load"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialog"
|
||||
:title="editing ? '编辑往期回顾' : '新增往期回顾'"
|
||||
width="640px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="标题" required>
|
||||
<el-input v-model="form.title" placeholder="请输入标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="封面图" required>
|
||||
<div class="cover-upload-row">
|
||||
<el-upload :show-file-list="false" :http-request="handleCoverUpload" accept="image/*">
|
||||
<el-button type="primary" plain>上传封面</el-button>
|
||||
</el-upload>
|
||||
<el-image
|
||||
v-if="form.cover_url"
|
||||
:src="form.cover_url"
|
||||
fit="cover"
|
||||
class="list-cover-thumb"
|
||||
preview-teleported
|
||||
:preview-src-list="[form.cover_url]"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="排序" required>
|
||||
<el-input-number v-model="form.sort" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="是否显示">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :value="1">显示</el-radio>
|
||||
<el-radio :value="0">隐藏</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialog = false">取消</el-button>
|
||||
<el-button type="primary" class="btn-create" :loading="saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cover-upload-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in new issue