Files
medical-mall/pages/mall/admin/cms/article/list.uvue
2026-02-13 17:29:50 +08:00

468 lines
20 KiB
Plaintext
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>
<view class="admin-cms-article">
<view class="content-body">
<!-- 顶部过滤栏 -->
<view class="filter-card border-shadow">
<view class="filter-item">
<text class="label-txt">文章分类:</text>
<picker :value="categoryIndex" :range="categoryOptions" range-key="label" @change="onFilterCategoryChange">
<view class="select-mock">
<text class="select-val">{{ categoryOptions[categoryIndex]?.label || '全部' }}</text>
<text class="arrow-down">▼</text>
</view>
</picker>
</view>
<view class="filter-item">
<text class="label-txt">文章搜索:</text>
<input class="search-input" placeholder="请输入标题搜索" v-model="filterKeyword" @confirm="handleQuery" />
</view>
<view class="btn-query" @click="handleQuery">
<text class="query-txt">查询</text>
</view>
<view class="btn-reset" @click="handleReset">
<text class="reset-txt">重置</text>
</view>
</view>
<!-- 主要内容区域 -->
<view class="table-card border-shadow">
<view class="card-header">
<view class="btn-primary" @click="handleAdd">
<text class="btn-txt">添加文章</text>
</view>
</view>
<!-- 数据表格 -->
<view class="table-header">
<view class="th col-id"><text class="th-txt">序号</text></view>
<view class="th col-img"><text class="th-txt">封面图</text></view>
<view class="th col-name"><text class="th-txt">文章标题</text></view>
<view class="th col-v"><text class="th-txt">浏览量</text></view>
<view class="th col-time"><text class="th-txt">最后更新</text></view>
<view class="th col-status"><text class="th-txt">状态</text></view>
<view class="th col-op"><text class="th-txt">操作</text></view>
</view>
<view class="table-body">
<view v-if="loading" class="table-loading" style="padding: 40px; text-align: center;">
<text>加载中...</text>
</view>
<view v-else-if="articleList.length === 0" class="table-empty" style="padding: 40px; text-align: center;">
<text>暂无文章数据</text>
</view>
<view v-else class="table-row" v-for="(item, index) in articleList" :key="item.id">
<view class="td col-id"><text class="td-txt">{{ (page - 1) * pageSize + index + 1 }}</text></view>
<view class="td col-img">
<image v-if="item.image" :src="item.image" mode="aspectFill" class="article-cover"></image>
<view v-else class="img-placeholder"><text class="p-txt">无图</text></view>
</view>
<view class="td col-name">
<view class="title-box">
<text class="td-txt ellipsis-2">{{ item.title }}</text>
<text class="sub-txt">分类: {{ item.category_name }}</text>
</view>
</view>
<view class="td col-v"><text class="td-txt">{{ item.views }}</text></view>
<view class="td col-time"><text class="td-txt">{{ item.updated_at.substring(0, 16).replace('T', ' ') }}</text></view>
<view class="td col-status">
<view :class="['switch-mock', item.status === 1 ? 'active' : '']" @click="toggleStatus(item)">
<view class="switch-handle"></view>
</view>
</view>
<view class="td col-op">
<view class="op-links">
<text class="link-txt" @click="handleEdit(item)">编辑</text>
<view class="divider"></view>
<text class="link-txt danger" @click="handleDelete(item)">删除</text>
</view>
</view>
</view>
</view>
<view class="pagination-bar">
<text class="page-total">共 {{ total }} 条</text>
<view class="page-nav">
<view class="nav-btn" :class="{ disabled: page <= 1 }" @click="prevPage"><text class="nav-icon"> < </text></view>
<view class="nav-item active"><text class="nav-num">{{ page }}</text></view>
<view class="nav-btn" :class="{ disabled: page >= totalPages }" @click="nextPage"><text class="nav-icon"> > </text></view>
</view>
</view>
</view>
</view>
<!-- 侧边弹窗 (Drawer) -->
<view v-if="showDrawer" :class="['drawer-mask', isClosing ? 'mask-fade-out' : '']" @click="closeDrawer">
<view :class="['drawer-content', isClosing ? 'slide-out' : '']" @click.stop="">
<view class="drawer-header">
<view class="tab-item active">
<text class="tab-txt">{{ isEdit ? '编辑文章' : '添加文章' }}</text>
<view class="tab-line"></view>
</view>
<text class="close-btn" @click="closeDrawer">×</text>
</view>
<scroll-view class="drawer-body" :scroll-y="true">
<view class="form-grid">
<view class="form-item">
<view class="label-box"><text class="required">*</text><text class="label-txt">标题:</text></view>
<view class="input-box">
<input class="input-base" v-model="form.title" placeholder="请输入文章标题" />
</view>
</view>
<view class="form-item">
<view class="label-box"><text class="required">*</text><text class="label-txt">文章分类:</text></view>
<view class="input-box">
<picker :value="formCategoryIndex" :range="categoryOptions" range-key="label" @change="onFormCategoryChange">
<view class="select-mock">
<text class="select-val">{{ categoryOptions[formCategoryIndex]?.label || '请选择分类' }}</text>
<text class="arrow-down">▼</text>
</view>
</picker>
</view>
</view>
<view class="form-item">
<view class="label-box"><text class="label-txt">作者:</text></view>
<view class="input-box">
<input class="input-base" v-model="form.author" placeholder="请输入作者" />
</view>
</view>
<view class="form-item align-start">
<view class="label-box pt-10"><text class="label-txt">文章简介:</text></view>
<view class="input-box">
<textarea class="textarea-base" v-model="form.description" placeholder="请输入简要说明" />
</view>
</view>
<view class="form-item align-start">
<view class="label-box pt-10"><text class="required">*</text><text class="label-txt">文章内容:</text></view>
<view class="input-box">
<textarea class="textarea-base content-area" v-model="form.content" placeholder="请输入文章正文" />
</view>
</view>
<view class="form-item">
<view class="label-box"><text class="label-txt">发布状态:</text></view>
<view class="radio-group">
<view class="radio-item" @click="form.status = 1">
<view :class="['radio-circle', form.status === 1 ? 'checked' : '']"><view v-if="form.status === 1" class="radio-in"></view></view>
<text class="radio-la">立即发布</text>
</view>
<view class="radio-item" @click="form.status = 0">
<view :class="['radio-circle', form.status === 0 ? 'checked' : '']"><view v-if="form.status === 0" class="radio-in"></view></view>
<text class="radio-la">暂不发布</text>
</view>
</view>
</view>
</view>
<view class="submit-container mt-40">
<view class="btn-submit" @click="handleConfirm">
<text class="submit-txt">提交</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, onMounted, computed } from 'vue'
import {
fetchArticlePage,
fetchArticleCategoryPage,
saveArticle,
deleteArticle,
setArticleStatus,
fetchArticleDetail,
type ArticleItem,
type ArticleCategory
} from '@/services/admin/cmsService.uts'
const filterKeyword = ref('')
const articleList = ref<ArticleItem[]>([])
const categories = ref<ArticleCategory[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const categoryIndex = ref(0)
const categoryOptions = computed(() : any[] => {
const opts = [{ label: '全部分类', value: null }]
categories.value.forEach(c => {
opts.push({ label: c.name, value: c.id })
})
return opts
})
const showDrawer = ref(false)
const isClosing = ref(false)
const isEdit = ref(false)
const formCategoryIndex = ref(0)
const form = reactive({
id: '' as string | null,
category_id: '',
title: '',
author: '',
description: '',
content: '',
status: 1,
image: null as string | null
})
const totalPages = computed((): number => {
if (pageSize.value <= 0) return 1
return Math.ceil(total.value / pageSize.value)
})
onMounted(() => {
loadCategories()
loadData()
})
async function loadCategories() {
const res = await fetchArticleCategoryPage(1, 100) // 获取所有分类用于筛选
categories.value = res.items
}
async function loadData() {
loading.value = true
try {
const catId = categoryOptions.value[categoryIndex.value]?.value as string | null
const res = await fetchArticlePage(
page.value,
pageSize.value,
catId,
null,
filterKeyword.value || null
)
articleList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function handleQuery() {
page.value = 1
loadData()
}
function handleReset() {
filterKeyword.value = ''
categoryIndex.value = 0
page.value = 1
loadData()
}
function onFilterCategoryChange(e : any) {
categoryIndex.value = e.detail.value as number
handleQuery()
}
function onFormCategoryChange(e : any) {
formCategoryIndex.value = e.detail.value as number
form.category_id = categoryOptions.value[formCategoryIndex.value].value as string
}
function handleAdd() {
isEdit.value = false
form.id = null
form.title = ''
form.author = ''
form.category_id = ''
form.description = ''
form.content = ''
form.status = 1
formCategoryIndex.value = 0
isClosing.value = false
showDrawer.value = true
}
async function handleEdit(item : ArticleItem) {
loading.value = true
try {
const detail = await fetchArticleDetail(item.id)
if (detail != null) {
isEdit.value = true
form.id = detail.id
form.title = detail.title
form.author = detail.author || ''
form.category_id = detail.category_id
form.description = detail.description || ''
form.content = detail.content
form.status = detail.status
const idx = categoryOptions.value.findIndex(opt => opt.value == detail.category_id)
formCategoryIndex.value = idx > -1 ? idx : 0
isClosing.value = false
showDrawer.value = true
}
} catch (e) {
uni.showToast({ title: '获取详情失败', icon: 'none' })
} finally {
loading.value = false
}
}
async function toggleStatus(item : ArticleItem) {
const target = item.status === 1 ? 0 : 1
const ok = await setArticleStatus(item.id, target)
if (ok) {
item.status = target
uni.showToast({ title: '状态已更新' })
}
}
async function handleDelete(item : ArticleItem) {
uni.showModal({
title: '提示',
content: `确定删除文章 "${item.title}" 吗?`,
success: async (res) => {
if (res.confirm) {
const ok = await deleteArticle(item.id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadData()
}
}
}
})
}
async function handleConfirm() {
if (!form.title || !form.category_id || !form.content) {
uni.showToast({ title: '请完善文章必填信息', icon: 'none' })
return
}
const resId = await saveArticle(form)
if (resId != null) {
uni.showToast({ title: '保存成功' })
closeDrawer()
loadData()
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
function closeDrawer() {
isClosing.value = true
setTimeout(() => {
showDrawer.value = false
isClosing.value = false
}, 300)
}
function prevPage() { if (page.value > 1) { page.value--; loadData(); } }
function nextPage() { if (page.value < totalPages.value) { page.value++; loadData(); } }
</script>
<style scoped lang="scss">
.admin-cms-article { padding: 20px; background-color: #f5f7fa; min-height: 100vh; }
.border-shadow { background-color: #fff; border-radius: 4px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
.filter-card { padding: 20px; display: flex; flex-direction: row; align-items: center; gap: 15px; margin-bottom: 20px; }
.filter-item { display: flex; flex-direction: row; align-items: center; }
.label-txt { font-size: 14px; color: #606266; margin-right: 12px; }
.select-mock { min-width: 160px; height: 38px; border: 1px solid #dcdfe6; border-radius: 4px; display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 0 15px; }
.select-val { font-size: 13px; color: #333; }
.arrow-down { font-size: 10px; color: #999; }
.search-input { width: 220px; height: 38px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 15px; font-size: 13px; }
.btn-query { padding: 0 20px; height: 34px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn-reset { padding: 0 20px; height: 34px; background-color: #fff; border: 1px solid #dcdfe6; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.query-txt { color: #fff; font-size: 14px; }
.reset-txt { color: #606266; font-size: 14px; }
.table-card { display: flex; flex-direction: column; }
.card-header { padding: 20px; }
.btn-primary { width: 100px; height: 36px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn-txt { color: #fff; font-size: 13px; }
.table-header { height: 50px; background-color: #eaf2ff; display: flex; flex-direction: row; align-items: center; }
.th { padding: 0 15px; }
.th-txt { font-size: 13px; color: #606266; font-weight: bold; }
.table-row { height: 80px; display: flex; flex-direction: row; align-items: center; border-bottom: 1px solid #f0f0f0; }
.td { padding: 0 15px; }
.td-txt { font-size: 13px; color: #606266; }
.article-cover { width: 60px; height: 40px; border-radius: 4px; background-color: #f5f7fa; }
.img-placeholder { width: 60px; height: 40px; background-color: #f5f7fa; border-radius: 4px; display: flex; align-items: center; justify-content: center; .p-txt { font-size: 10px; color: #ccc; } }
.title-box { display: flex; flex-direction: column; gap: 4px; .sub-txt { font-size: 11px; color: #999; } }
.ellipsis-2 { overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.col-id { width: 60px; justify-content: center; }
.col-img { width: 90px; }
.col-name { flex: 1; }
.col-v { width: 80px; justify-content: center; }
.col-time { width: 160px; justify-content: center; }
.col-status { width: 100px; justify-content: center; }
.col-op { width: 180px; }
.switch-mock { width: 40px; height: 20px; background-color: #dcdfe6; border-radius: 10px; position: relative; transition: all 0.3s; cursor: pointer; }
.switch-mock.active { background-color: #2d8cf0; }
.switch-handle { width: 16px; height: 16px; background-color: #fff; border-radius: 8px; position: absolute; top: 2px; left: 2px; transition: all 0.3s; }
.active .switch-handle { left: 22px; }
.op-links { display: flex; flex-direction: row; align-items: center; }
.link-txt { font-size: 13px; color: #2d8cf0; cursor: pointer; }
.danger { color: #ed4014; }
.divider { width: 1px; height: 12px; background-color: #e8eaec; margin: 0 8px; }
.pagination-bar { padding: 20px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; }
.page-total { font-size: 13px; color: #606266; margin-right: 15px; }
.page-nav { display: flex; flex-direction: row; align-items: center; }
.nav-btn { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; margin: 0 4px; cursor: pointer; }
.nav-item { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; margin: 0 4px; }
.nav-item.active { background-color: #2d8cf0; border-color: #2d8cf0; .nav-num { color: #fff; } }
.nav-num, .nav-icon { font-size: 13px; color: #606266; }
.disabled { opacity: 0.5; cursor: not-allowed; }
/* Drawer Drawer */
.drawer-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.4); z-index: 2000; }
.mask-fade-out { opacity: 0; }
.drawer-content { position: absolute; top: 0; right: 0; width: 60%; height: 100%; background-color: #fff; display: flex; flex-direction: column; box-shadow: -2px 0 12px rgba(0, 0, 0, 0.2); animation: slideIn 0.3s ease; }
.slide-out { animation: slideOut 0.3s ease forwards; }
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
@keyframes slideOut { from { transform: translateX(0); } to { transform: translateX(100%); } }
.drawer-header { height: 54px; padding: 0 20px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.tab-item { height: 100%; display: flex; align-items: center; position: relative; }
.tab-txt { font-size: 15px; color: #2d8cf0; font-weight: 500; }
.tab-line { position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background-color: #2d8cf0; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; padding: 5px; }
.drawer-body { flex: 1; padding: 24px 30px; background-color: #fff; }
.form-grid { display: flex; flex-direction: column; gap: 24px; }
.form-item { display: flex; flex-direction: row; align-items: center; }
.align-start { align-items: flex-start; }
.label-box { width: 100px; display: flex; flex-direction: row; justify-content: flex-end; margin-right: 15px; flex-shrink: 0; }
.pt-10 { padding-top: 10px; }
.required { color: #ed4014; margin-right: 4px; }
.label-txt { font-size: 14px; color: #606266; }
.input-box { flex: 1; }
.input-base { width: 100%; height: 38px; border: 1px solid #dcdee2; border-radius: 4px; padding: 0 12px; font-size: 14px; }
.textarea-base { width: 100%; height: 80px; border: 1px solid #dcdee2; border-radius: 4px; padding: 10px 12px; font-size: 14px; }
.content-area { height: 300px; }
.radio-group { display: flex; flex-direction: row; gap: 30px; }
.radio-item { display: flex; flex-direction: row; align-items: center; cursor: pointer; }
.radio-circle { width: 16px; height: 16px; border: 1px solid #dcdee2; border-radius: 50%; margin-right: 8px; display: flex; align-items: center; justify-content: center; }
.radio-circle.checked { border-color: #2d8cf0; }
.radio-in { width: 8px; height: 8px; background-color: #2d8cf0; border-radius: 50%; }
.radio-la { font-size: 14px; color: #606266; }
.submit-container { display: flex; justify-content: flex-end; padding-bottom: 40px; }
.btn-submit { width: 100px; height: 38px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.submit-txt { color: #fff; font-size: 14px; }
</style>