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