- 后台全面引入 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>
299 行
5.8 KiB
Vue
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>
|