Mimingguang ee078b61bc feat: 后台管理系统完整重构 + 前后台数据联通
- 后台全面引入 Naive UI 组件库,统一 UI 规范
- 前台 usePublicApi 切换到 API 调用(GET /api/content/[key])
- 前后台数据源统一(site_content 表)
- 产品线统一管理(tour/camp/course 三套编辑器)
- 产品数据归一化(itinerary/faq/pricing 格式统一)
- 表单提交 API 改写入 submissions 表
- 新增 submissions 标记已读 API
- site-content PUT 支持 upsert
- 修复前台 bug(stories 路由冲突、占位符警告、selector breadcrumb)
- 消除 PageHero 类型警告(useSEO reactive 修复)
- 25 个前台页面全部 HTTP 200,展示效果不变

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:48:02 +08:00

299 行
5.8 KiB
Vue

<template>
<div class="data-table-wrapper">
<!-- 工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<span class="total-info"> <strong>{{ total }}</strong> </span>
<slot name="filter" />
</div>
<div class="toolbar-right">
<div class="search-box" v-if="searchable">
<input
v-model="searchInput"
type="text"
:placeholder="searchPlaceholder || '搜索...'"
@keyup.enter="handleSearch"
/>
<button class="search-btn" @click="handleSearch">搜索</button>
<button v-if="searchInput" class="clear-btn" @click="searchInput=''; handleSearch()">清除</button>
</div>
<slot name="actions" />
</div>
</div>
<!-- 表格 -->
<div class="table-container">
<table>
<thead>
<tr>
<slot name="header" />
</tr>
</thead>
<tbody>
<slot name="body" />
<tr v-if="!loading && total === 0">
<td :colspan="colCount" class="empty-cell">暂无数据</td>
</tr>
</tbody>
</table>
<div v-if="loading" class="table-loading">加载中...</div>
</div>
<!-- 分页 -->
<div class="pagination" v-if="totalPages > 1">
<button class="page-btn" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button>
<template v-for="p in visiblePages" :key="p">
<span v-if="p === '...'" class="page-dots">...</span>
<button v-else class="page-btn" :class="{ active: p === page }" @click="changePage(p)">{{ p }}</button>
</template>
<button class="page-btn" :disabled="page >= totalPages" @click="changePage(page + 1)">下一页</button>
<span class="page-info">{{ page }} / {{ totalPages }} </span>
</div>
</div>
</template>
<script setup>
const props = defineProps({
total: { type: Number, default: 0 },
page: { type: Number, default: 1 },
totalPages: { type: Number, default: 1 },
loading: { type: Boolean, default: false },
searchable: { type: Boolean, default: true },
searchPlaceholder: { type: String, default: '' },
colCount: { type: Number, default: 6 },
})
const emit = defineEmits(['search', 'page-change'])
const searchInput = ref('')
function handleSearch() {
emit('search', searchInput.value)
}
function changePage(p) {
if (p < 1 || p > props.totalPages) return
emit('page-change', p)
}
const visiblePages = computed(() => {
const pages = []
const total = props.totalPages
const current = props.page
if (total <= 7) {
for (let i = 1; i <= total; i++) pages.push(i)
} else {
pages.push(1)
if (current > 3) pages.push('...')
const start = Math.max(2, current - 1)
const end = Math.min(total - 1, current + 1)
for (let i = start; i <= end; i++) pages.push(i)
if (current < total - 2) pages.push('...')
pages.push(total)
}
return pages
})
</script>
<style scoped>
.data-table-wrapper {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
overflow: hidden;
}
/* 工具栏 */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid #f0f0f0;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.total-info {
font-size: 13px;
color: #888;
}
.total-info strong {
color: #333;
}
.search-box {
display: flex;
gap: 4px;
}
.search-box input {
padding: 5px 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 13px;
width: 180px;
outline: none;
}
.search-box input:focus {
border-color: #3a7d44;
}
.search-btn {
padding: 5px 12px;
background: #3a7d44;
color: #fff;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.search-btn:hover {
background: #2d6235;
}
.clear-btn {
padding: 5px 8px;
background: none;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
color: #999;
}
/* 表格容器 */
.table-container {
overflow-x: auto;
position: relative;
max-height: calc(100vh - 280px);
overflow-y: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 600px;
}
:deep(thead) {
position: sticky;
top: 0;
z-index: 10;
}
:deep(thead tr) {
background: #fafafa;
}
:deep(th) {
padding: 10px 14px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: #666;
border-bottom: 1px solid #e8e8e8;
white-space: nowrap;
background: #fafafa;
}
:deep(td) {
padding: 10px 14px;
font-size: 13px;
color: #333;
border-bottom: 1px solid #f5f5f5;
vertical-align: middle;
}
:deep(tr:hover td) {
background: #fafbfc;
}
:deep(td strong) {
color: #1a1a2e;
}
.empty-cell {
text-align: center;
color: #ccc;
padding: 40px 14px !important;
font-size: 14px;
}
.table-loading {
position: absolute;
inset: 0;
background: rgba(255,255,255,0.8);
display: flex;
align-items: center;
justify-content: center;
color: #888;
font-size: 14px;
}
/* 分页 */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 14px 20px;
border-top: 1px solid #f0f0f0;
}
.page-btn {
padding: 5px 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fff;
font-size: 13px;
cursor: pointer;
color: #333;
min-width: 32px;
text-align: center;
}
.page-btn:hover:not(:disabled):not(.active) {
border-color: #3a7d44;
color: #3a7d44;
}
.page-btn.active {
background: #3a7d44;
color: #fff;
border-color: #3a7d44;
}
.page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.page-dots {
padding: 0 4px;
color: #ccc;
}
.page-info {
margin-left: 12px;
font-size: 12px;
color: #aaa;
}
</style>