Files
akmon/pages/info/topics.uvue
2026-01-20 08:04:15 +08:00

830 lines
18 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>
<scroll-view direction="vertical" class="topics-page" :scroll-y="true" :enable-back-to-top="true">
<!-- 顶部导航栏 -->
<view class="header">
<view class="header-content">
<text class="title">{{ $t('mt.topic.hot') }}</text>
<view class="header-actions">
<view class="action-btn" @click="showTypeSelector">
<text class="action-text">{{ currentTypeText }}</text>
</view>
<view class="action-btn" @click="navigateToSearch">
<text class="action-icon">🔍</text>
</view>
</view>
</view>
</view>
<!-- 专题类型筛选 -->
<view class="type-section">
<scroll-view direction="horizontal" class="type-scroll" :scroll-x="true">
<view class="type-tabs">
<view
v-for="(type, index) in topicTypesList"
:key="type.value"
class="type-tab"
:class="{ active: selectedTypeValue === type.value }"
@click="selectTopicType(type)">
<text class="type-text">{{ type.text }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 精选专题 -->
<view class="featured-section" v-if="featuredTopicsList.length > 0">
<view class="section-header">
<text class="section-title">{{ $t('mt.topic.featured') }}</text>
</view>
<scroll-view direction="horizontal" class="featured-scroll" :scroll-x="true">
<view class="featured-topics">
<view
v-for="(topic, index) in featuredTopicsList"
:key="topic.id"
class="featured-topic"
@click="navigateToTopicDetail(topic)">
<view class="topic-cover" :style="{ backgroundImage: `url(${topic.cover_image})` }">
<view class="topic-overlay">
<view class="topic-badge" :style="{ backgroundColor: getTopicStatusColor(topic.status) }">
<text class="badge-text">{{ getTopicTypeDisplayName(topic.topic_type) }}</text>
</view>
</view>
</view>
<view class="topic-info">
<text class="topic-title">{{ topic.title }}</text>
<text class="topic-desc">{{ topic.description }}</text>
<view class="topic-stats">
<text class="stat-text">{{ topic.content_count }}篇文章</text>
<text class="stat-text">{{ topic.view_count }}阅读</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 专题列表 -->
<view class="topics-section">
<view class="section-header">
<text class="section-title">{{ $t('mt.topic.all') }}</text>
<view class="section-actions">
<view class="sort-btn" @click="showSortOptions">
<text class="sort-text">{{ sortOptionText }}</text>
</view>
</view>
</view>
<!-- 加载状态 -->
<view class="loading-section" v-if="pageState.loading">
<text class="loading-text">{{ $t('mt.status.loading') }}</text>
</view>
<!-- 错误状态 -->
<view class="error-section" v-if="pageState.error !== null">
<text class="error-text">{{ pageState.error }}</text>
<view class="retry-btn" @click="retryLoad">
<text class="retry-text">{{ $t('mt.action.retry') }}</text>
</view>
</view>
<!-- 专题列表 -->
<view class="topics-list" v-if="topicsList.length > 0">
<view
v-for="(topic, index) in topicsList"
:key="topic.id"
class="topic-item"
@click="navigateToTopicDetail(topic)">
<view class="topic-header">
<view class="topic-type-badge" :style="{ backgroundColor: getTopicStatusColor(topic.status) }">
<text class="type-badge-text">{{ getTopicTypeDisplayName(topic.topic_type) }}</text>
</view>
<text class="topic-time">{{ formatRelativeTimeKey(topic.updated_at) }}</text>
</view>
<text class="topic-title">{{ topic.title }}</text>
<text class="topic-description">{{ topic.description }}</text>
<view class="topic-meta">
<view class="topic-stats">
<text class="stat-item">📄 {{ topic.content_count }}{{ $t('mt.topic.articleCount') }}</text>
<text class="stat-item">👁 {{ topic.view_count }}</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view class="empty-section" v-if="topicsList.length === 0 && !pageState.loading && pageState.error === null">
<text class="empty-text">{{ $t('mt.topic.empty') }}</text>
<view class="refresh-btn" @click="refreshData">
<text class="refresh-text">{{ $t('mt.action.refresh') }}</text>
</view>
</view>
<!-- 加载更多 -->
<view class="load-more-section" v-if="topicsList.length > 0 && hasMore">
<view class="load-more-btn" @click="loadMore" v-if="!loadingMore">
<text class="load-more-text">{{ $t('mt.button.loadMore') }}</text>
</view>
<view class="loading-more" v-if="loadingMore">
<text class="loading-more-text">{{ $t('mt.loadingMore') }}</text>
</view>
</view>
</view>
<!-- 类型选择弹窗 -->
<view class="modal-overlay" v-if="showTypeModal" @click="hideTypeSelector">
<view class="type-modal" @click.stop="">
<view class="modal-header">
<text class="modal-title">{{ $t('mt.topic.typeTitle') }}</text>
<view class="modal-close" @click="hideTypeSelector">
<text class="close-text">×</text>
</view>
</view>
<view class="type-list">
<view
v-for="(type, index) in topicTypesList"
:key="type.value"
class="type-item"
:class="{ active: selectedTypeValue === type.value }"
@click="selectTopicType(type)">
<text class="type-name">{{ type.text }}</text>
</view>
</view>
</view>
</view>
<!-- 排序选择弹窗 -->
<view class="modal-overlay" v-if="showSortModal" @click="hideSortOptions">
<view class="sort-modal" @click.stop="">
<view class="modal-header">
<text class="modal-title">{{ $t('mt.modal.sort') }}</text>
<view class="modal-close" @click="hideSortOptions">
<text class="close-text">×</text>
</view>
</view>
<view class="sort-list">
<view
v-for="(option, index) in sortOptionsList"
:key="option.value"
class="sort-item"
:class="{ active: currentSortOption === option.value }"
@click="selectSortOption(option)">
<text class="sort-name">{{ option.text }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import {
TopicData,
PageState,
ResponsiveState,
TOPIC_TYPES,
TOPIC_STATUS,
SORT_OPTIONS,
getTopicTypeDisplayName,
getTopicStatusColor,
formatRelativeTimeKey
} from './types.uts'
import supa from '@/components/supadb/aksupainstance.uts'
import { tt } from '@/utils/i18nfun.uts'
import i18n from '@/i18n/index.uts' // 保留用于语言切换
// 页面状态
const pageState = ref<PageState>({
loading: false,
error: null,
currentPage: 1,
pageSize: 20,
total: 0
})
// UI状态变量
const showTypeModal = ref<boolean>(false)
const showSortModal = ref<boolean>(false)
const loadingMore = ref<boolean>(false)
const hasMore = ref<boolean>(true)
// 当前选择状态
const selectedTypeValue = ref<string>('')
const currentTypeText = ref<string>(tt('mt.topic.allTypes'))
const currentSortOption = ref<string>('updated_at_desc')
const sortOptionText = ref<string>(tt('mt.topic.sort.recentUpdate'))
// 数据列表 - 直接使用强类型 TopicData 数组
const topicsList = ref<Array<TopicData>>([])
const featuredTopicsList = ref<Array<TopicData>>([])
// 选项列表
const topicTypesList = ref([
{ value: '', text: tt('mt.topic.allTypes') },
...TOPIC_TYPES.map(type => ({
value: type.value,
text: tt(`mt.topicType.${type.value}`)
}))
])
const sortOptionsList = ref([
{ value: 'updated_at_desc', text: tt('mt.topic.sort.recentUpdate') },
{ value: 'created_at_desc', text: tt('mt.topic.sort.newest') },
{ value: 'view_count_desc', text: tt('mt.topic.sort.popular') },
{ value: 'content_count_desc', text: tt('mt.topic.sort.contentCount') }
])
// 计算属性
const topicFilter = computed((): string => {
let filter = "status=in.(active,featured)"
if (selectedTypeValue.value !== '') {
filter += `&topic_type=eq.${selectedTypeValue.value}`
}
// 排序
const sortParts = currentSortOption.value.split('_')
const column = sortParts.slice(0, -1).join('_')
const direction = sortParts[sortParts.length - 1] === 'desc' ? 'desc' : 'asc'
filter += `&order=${column}.${direction}`
return filter
})
// 生命周期
onMounted(() => {
initializeData()
})
// 初始化数据
const initializeData = () => {
loadTopics()
loadFeaturedTopics()
}
// 加载专题数据
const loadTopics = async () => {
if (pageState.value.loading) return
pageState.value.loading = true
pageState.value.error = null
try {
let query = supa.from('ak_topics')
.select('*')
.in('status', ['active', 'featured'])
if (selectedTypeValue.value !== '') {
query = query.eq('topic_type', selectedTypeValue.value)
}
const sortParts = currentSortOption.value.split('_')
const column = sortParts.slice(0, -1).join('_')
const direction = sortParts[sortParts.length - 1] === 'desc'
if (direction) {
query = query.order(column, { ascending: false })
} else {
query = query.order(column, { ascending: true })
}
const start = (pageState.value.currentPage - 1) * pageState.value.pageSize
const end = start + pageState.value.pageSize - 1
query = query.range(start, end)
const result = await query.executeAs<Array<TopicData>>()
if (result.error !== null) {
throw new Error(result.error.message)
}
const data = result.data
if (data !== null) {
if (pageState.value.currentPage === 1) {
topicsList.value = data
} else {
topicsList.value = topicsList.value.concat(data)
}
hasMore.value = data.length === pageState.value.pageSize
}
} catch (e: any) {
pageState.value.error = tt('mt.error.loadTopicsFailed')
console.error('Topics loading error:', e)
} finally {
pageState.value.loading = false
}
}
// 加载精选专题
const loadFeaturedTopics = async () => {
try {
const result = await supa.from('ak_topics')
.select('*')
.eq('status', 'featured')
.order('updated_at', { ascending: false })
.limit(5)
.executeAs<Array<TopicData>>()
if (result.error !== null) {
console.error('Featured topics loading error:', result.error)
return
}
const data = result.data
if (data !== null) {
featuredTopicsList.value = data
}
} catch (e: any) {
console.error('Featured topics loading error:', e)
}
}
// 类型选择
const showTypeSelector = () => {
showTypeModal.value = true
}
const hideTypeSelector = () => {
showTypeModal.value = false
}
const selectTopicType = (type: any) => {
selectedTypeValue.value = type.value
currentTypeText.value = type.text
hideTypeSelector()
pageState.value.currentPage = 1
loadTopics()
}
// 排序选择
const showSortOptions = () => {
showSortModal.value = true
}
const hideSortOptions = () => {
showSortModal.value = false
}
const selectSortOption = (option: any) => {
currentSortOption.value = option.value
sortOptionText.value = option.text
hideSortOptions()
pageState.value.currentPage = 1
loadTopics()
}
// 加载更多
const loadMore = () => {
if (loadingMore.value || !hasMore.value) return
loadingMore.value = true
pageState.value.currentPage += 1
setTimeout(() => {
loadTopics()
loadingMore.value = false
}, 500)
}
// 刷新数据
const refreshData = () => {
pageState.value.currentPage = 1
loadTopics()
}
// 重试加载
const retryLoad = () => {
pageState.value.error = null
loadTopics()
}
// 导航函数
const navigateToTopicDetail = (topic: TopicData) => {
const topicId = topic.id
uni.navigateTo({
url: `/pages/info/topic-detail?id=${topicId}`
})
}
const navigateToSearch = () => {
uni.navigateTo({
url: '/pages/info/search?type=topic'
})
}
</script>
<style>
.topics-page {
flex: 1;
background-color: #f8fafc;
}
.header {
background-color: #ffffff;
border-bottom-width: 1px;
border-bottom-color: #e2e8f0;
padding-top: 10px;
padding-bottom: 10px;
}
.header-content {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding-left: 16px;
padding-right: 16px;
}
.title {
font-size: 20px;
font-weight: bold;
color: #1f2937;
}
.header-actions {
flex-direction: row;
align-items: center;
}
.action-btn {
padding: 8px 12px;
margin-left: 8px;
background-color: #f1f5f9;
border-radius: 20px;
}
.action-text {
font-size: 14px;
color: #475569;
}
.action-icon {
font-size: 16px;
}
.type-section {
background-color: #ffffff;
padding-top: 12px;
padding-bottom: 12px;
border-bottom-width: 1px;
border-bottom-color: #e2e8f0;
}
.type-scroll {
height: 40px;
}
.type-tabs {
flex-direction: row;
padding-left: 16px;
padding-right: 16px;
}
.type-tab {
padding: 8px 16px;
margin-right: 12px;
background-color: #f8fafc;
border-radius: 20px;
border-width: 1px;
border-color: #e2e8f0;
}
.type-tab.active {
background-color: #8b5cf6;
border-color: #8b5cf6;
}
.type-text {
font-size: 14px;
color: #64748b;
white-space: nowrap;
}
.type-tab.active .type-text {
color: #ffffff;
}
.featured-section {
background-color: #ffffff;
margin-top: 8px;
padding: 16px 0;
}
.section-header {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding-left: 16px;
padding-right: 16px;
margin-bottom: 12px;
}
.section-title {
font-size: 18px;
font-weight: bold;
color: #1f2937;
}
.section-actions {
flex-direction: row;
align-items: center;
}
.sort-btn {
padding: 6px 12px;
background-color: #f1f5f9;
border-radius: 16px;
}
.sort-text {
font-size: 12px;
color: #475569;
}
.featured-scroll {
height: 240px;
}
.featured-topics {
flex-direction: row;
padding-left: 16px;
padding-right: 16px;
}
.featured-topic {
width: 280px;
background-color: #ffffff;
border-radius: 12px;
margin-right: 12px;
border-width: 1px;
border-color: #e2e8f0;
}
.topic-cover {
height: 140px;
background-color: #f1f5f9;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
background-size: cover;
background-position: center;
position: relative;
}
.topic-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: 12px;
justify-content: flex-end;
align-items: flex-start;
}
.topic-badge {
background-color: #8b5cf6;
border-radius: 12px;
padding: 4px 8px;
}
.badge-text {
font-size: 12px;
color: #ffffff;
}
.topic-info {
padding: 12px;
}
.topic-title {
font-size: 16px;
font-weight: bold;
color: #1f2937;
line-height: 22px;
margin-bottom: 6px;
}
.topic-desc {
font-size: 14px;
color: #64748b;
line-height: 20px;
margin-bottom: 8px;
}
.topic-stats {
flex-direction: row;
align-items: center;
}
.stat-text {
font-size: 12px;
color: #94a3b8;
margin-right: 12px;
}
.topics-section {
background-color: #ffffff;
margin-top: 8px;
padding: 16px 0;
}
.topics-list {
padding-left: 16px;
padding-right: 16px;
}
.topic-item {
background-color: #f8fafc;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
border-width: 1px;
border-color: #e2e8f0;
}
.topic-header {
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.topic-type-badge {
background-color: #8b5cf6;
border-radius: 10px;
padding: 2px 8px;
}
.type-badge-text {
font-size: 12px;
color: #ffffff;
}
.topic-time {
font-size: 12px;
color: #94a3b8;
}
.topic-title {
font-size: 16px;
font-weight: bold;
color: #1f2937;
line-height: 22px;
margin-bottom: 6px;
}
.topic-description {
font-size: 14px;
color: #64748b;
line-height: 20px;
margin-bottom: 12px;
}
.topic-meta {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.topic-stats {
flex-direction: row;
align-items: center;
}
.stat-item {
font-size: 12px;
color: #94a3b8;
margin-right: 16px;
}
.loading-section, .error-section {
padding: 40px 16px;
align-items: center;
justify-content: center;
}
.loading-text {
font-size: 14px;
color: #94a3b8;
}
.error-text {
font-size: 14px;
color: #ef4444;
margin-bottom: 16px;
text-align: center;
}
.retry-btn, .refresh-btn {
background-color: #8b5cf6;
border-radius: 20px;
padding: 8px 24px;
}
.retry-text, .refresh-text {
font-size: 14px;
color: #ffffff;
}
.empty-section {
padding: 60px 16px;
align-items: center;
justify-content: center;
}
.empty-text {
font-size: 16px;
color: #94a3b8;
margin-bottom: 16px;
}
.load-more-section {
padding: 20px 16px;
align-items: center;
justify-content: center;
}
.load-more-btn {
background-color: #f1f5f9;
border-radius: 20px;
padding: 10px 24px;
border-width: 1px;
border-color: #e2e8f0;
}
.load-more-text {
font-size: 14px;
color: #475569;
}
.loading-more {
padding: 10px 24px;
}
.loading-more-text {
font-size: 14px;
color: #94a3b8;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
align-items: center;
justify-content: center;
z-index: 1000;
}
.type-modal, .sort-modal {
background-color: #ffffff;
border-radius: 16px;
margin: 20px;
max-height: 500px;
min-width: 280px;
}
.modal-header {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom-width: 1px;
border-bottom-color: #e2e8f0;
}
.modal-title {
font-size: 18px;
font-weight: bold;
color: #1f2937;
}
.modal-close {
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border-radius: 16px;
background-color: #f1f5f9;
}
.close-text {
font-size: 20px;
color: #64748b;
}
.type-list, .sort-list {
max-height: 400px;
}
.type-item, .sort-item {
padding: 16px 20px;
border-bottom-width: 1px;
border-bottom-color: #f1f5f9;
}
.type-item.active, .sort-item.active {
background-color: #f3f4f6;
}
.type-name, .sort-name {
font-size: 16px;
color: #1f2937;
}
.type-item.active .type-name,
.sort-item.active .sort-name {
color: #8b5cf6;
}
</style>