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

935 lines
23 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.
<!-- 多语言AI资讯系统主页 - 严格遵循UTS Android开发规范 -->
<template>
<scroll-view direction="vertical" class="info-home" :enable-back-to-top="true">
<view class="header">
<view class="header-content">
<view class="header-actions">
<view class="action-btn" @click="showLanguageSelector">
<text class="action-text">{{ currentLanguageName }}{{$t('mt.category.politics')}}</text>
</view>
<view class="action-btn" @click="navigateToTopics">
<text class="action-icon">📑</text>
</view>
<view class="action-btn" @click="navigateToSearch">
<text class="action-icon">🔍</text>
</view>
<view class="action-btn" @click="navigateToChat">
<text class="action-icon">💬</text>
</view>
</view>
</view>
</view>
<!-- 分类标签栏 -->
<view class="category-section">
<scroll-view direction="horizontal" class="category-scroll" >
<view style="white-space: nowrap;flex-direction: row;">
<view
v-for="(category, index) in categoriesList"
:key="category.id"
class="category-tab"
:class="{ 'is-active': selectedCategoryId === category.id, 'is-last': index === categoriesList.length - 1 }"
@click="selectCategory(category)"
style="display: inline-block; margin-right: 12px;">
<text class="category-text" :class="{ 'is-active': selectedCategoryId === category.id }">{{ getCategoryName(category) }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 精选内容区域 -->
<view class="featured-section" v-if="featuredContentsList.length > 0">
<view class="section-header">
<text class="section-title">{{ $t('mt.section.featured') }}</text>
</view>
<scroll-view direction="horizontal" class="featured-scroll" :scroll-x="true">
<view class="featured-cards">
<view
v-for="(content, index) in featuredContentsList"
:key="content.id"
class="featured-card"
:style="{ width: cardWidth + 'px' }"
@click="navigateToDetail(content)">
<view class="card-header">
<text class="card-title">{{ content.title }}</text>
<view class="quality-badge" :style="{ backgroundColor: getQualityScoreColorLocal(content.quality_score) }">
<text class="quality-text">{{ getQualityScoreTextLocal(content.quality_score) }}</text>
</view>
</view>
<text class="card-summary">{{ content.summary }}</text>
<view class="card-footer">
<text class="card-author">{{ content.author }}</text>
<text class="card-time">{{ formatRelativeTimeLocal(content.published_at) }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 内容列表区域 -->
<view class="content-section">
<view class="section-header">
<text class="section-title">{{ $t('mt.section.latest') }}</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="content-list" v-if="contentsList.length > 0">
<view
v-for="(content, index) in contentsList"
:key="content.id"
class="content-item"
@click="navigateToDetail(content)">
<view class="content-header">
<text class="content-title">{{ content.title }}</text>
<view class="content-meta">
<!-- <text class="content-category">{{ getCategoryDisplayNameByIdLocal(content.category_id) }}</text> -->
<text class="content-time">{{ formatRelativeTimeLocal(content.published_at) }}</text>
</view>
</view>
<text class="content-summary">{{ content.summary }}</text>
<view class="content-footer">
<view class="content-stats">
<text class="stat-item">👁 {{ content.view_count }}</text>
<text class="stat-item">👍 {{ content.like_count }}</text>
<text class="stat-item">📤 {{ content.share_count }}</text>
</view>
<view class="quality-badge" :style="{ backgroundColor: getQualityScoreColorLocal(content.quality_score) }">
<text class="quality-text">{{ getQualityScoreTextLocal(content.quality_score) }}</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view class="empty-section" v-if="contentsList.length === 0 && !pageState.loading && pageState.error === null">
<text class="empty-text">{{ $t('mt.empty.content') }}</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="contentsList.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="showLanguageModal" @click="hideLanguageSelector">
<view class="language-modal" @click.stop="">
<view class="modal-header">
<text class="modal-title">{{ $t('mt.modal.selectLanguage') }}</text>
<view class="modal-close" @click="hideLanguageSelector">
<text class="close-text">×</text>
</view>
</view>
<view class="language-list">
<view
v-for="(language, index) in languagesList"
:key="language.id"
class="language-item"
:class="{ active: currentLanguageCode === language.code }"
@click="selectLanguage(language)">
<text class="language-name">{{ getLanguageDisplayNameLocal(language.code) }}</text>
<text class="language-native">{{ language.native_name }}</text>
</view>
</view>
</view>
</view>
<!-- 排序选择弹窗 -->
<view class="modal-overlay" v-if="showSortModal" @click="hideSortOptions">
<view class="sort-modal">
<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 supa from '@/components/supadb/aksupainstance.uts'
import {OrderOptions} from '@/components/supadb/aksupa.uts'
import {
OptionItem,
InfoContent,
LanguageData,
Language,
PageState,
ResponsiveState,
LANGUAGE_OPTIONS,
SORT_OPTIONS,
formatRelativeTimeKey,
getQualityScoreColor,
getQualityScoreText,
CategoryData,
CategoryTranslation
} from './types.uts'
import { tt } from '@/utils/i18nfun.uts'
// 页面状态 - 严格使用简单类型避免复杂嵌套
const pageState = ref<PageState>({
loading: false,
error: null,
currentPage: 1,
pageSize: 20,
total: 0
})
// 响应式状态
const responsiveState = ref<ResponsiveState>({
isLargeScreen: false,
isSmallScreen: true,
screenWidth: 375,
cardColumns: 1
})
// UI状态变量 - 与template交互使用1维变量
const showLanguageModal = ref<boolean>(false)
const showSortModal = ref<boolean>(false)
const loadingMore = ref<boolean>(false)
const hasMore = ref<boolean>(true)
// 当前选择状态 - 使用简单字符串类型
const selectedCategoryId = ref<string>('all')
const currentLanguageCode = ref<string>('zh-CN')
const currentLanguageName = ref<string>('简体中文')
const currentSortOption = ref<string>('published_at_desc')
const sortOptionText = ref<string>('最新发布')
// 数据列表 - 直接使用强类型 InfoContent 数组
const contentsList = ref<Array<InfoContent>>([])
const featuredContentsList = ref<Array<InfoContent>>([])
const categoriesList = ref<CategoryData[]>([])
const languagesList = ref<Array<LanguageData>>([])
// 选项列表
const sortOptionsList = ref<Array<OptionItem>>(SORT_OPTIONS)
// 计算属性
const cardWidth = computed((): number => {
return responsiveState.value.isLargeScreen ? 300 : 280
})
const contentFilter = computed((): string => {
let filter = "status=eq.published"
if (selectedCategoryId.value !== '') {
filter += `&category_id=eq.${selectedCategoryId.value}`
}
// 根据排序选项构建order参数
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
})
// Supabase组件引用 - 移除,使用直接调用方式
// const contentsRef = ref<SupadbComponentPublicInstance | null>(null)
// 响应式处理
const handleResize = () => {
const systemInfo = uni.getSystemInfoSync()
const screenWidth = systemInfo.screenWidth
responsiveState.value.screenWidth = screenWidth
responsiveState.value.isLargeScreen = screenWidth >= 768
responsiveState.value.isSmallScreen = screenWidth < 768
responsiveState.value.cardColumns = screenWidth >= 768 ? 2 : 1
}
// 错误处理
const handleError = (error: any) => {
pageState.value.loading = false
pageState.value.error = '加载失败,请稍后重试'
console.error('Contents loading error:', error)
}
// 处理内容数据
const handleContentsData = (data: Array<InfoContent>) => {
pageState.value.loading = false
pageState.value.error = null
if (pageState.value.currentPage === 1) {
contentsList.value = []
}
// 直接赋值强类型数组
contentsList.value = contentsList.value.concat(data)
// 检查是否还有更多数据
hasMore.value = data.length === pageState.value.pageSize
}
// 本地工具函数 - 只保留必要的辅助函数,内容相关 getter 可全部移除,模板直接用 content.xxx
// 保留分类/语言/格式化等工具
const formatRelativeTimeLocal = (dateString: string): string => {
const key = formatRelativeTimeKey(dateString)
const now = new Date()
const date = new Date(dateString)
const diff = now.getTime() - date.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (key === 'mt.time.daysAgo') return tt(key, { days })
if (key === 'mt.time.hoursAgo') return tt(key, { hours })
if (key === 'mt.time.minutesAgo') return tt(key, { minutes })
return tt(key)
}
const getLanguageDisplayNameLocal = (languageCode: string): string => {
return tt(languageCode)
}
const getQualityScoreTextLocal = (score: number): string => {
// console.log(score)
// return score.toString()
return getQualityScoreText(score*100)
}
const getQualityScoreColorLocal = (score: number): string => {
return getQualityScoreColor(score*100)
}
// 语言选择
const showLanguageSelector = () => {
showLanguageModal.value = true
}
const hideLanguageSelector = () => {
showLanguageModal.value = false
}
const selectLanguage = (language: LanguageData) => {
currentLanguageCode.value = language.code
currentLanguageName.value = getLanguageDisplayNameLocal(language.code)
hideLanguageSelector()
}
// 排序选择
const showSortOptions = () => {
showSortModal.value = true
}
const hideSortOptions = () => {
showSortModal.value = false
}
// 排序选项类型定义,保证类型安全
// 排序选项转换,保证 v-for 传递类型安全
// const sortOptionsListTyped: Array<OptionItem> = SORT_OPTIONS.map(opt => ({
// value: opt.value,
// text: opt.text
// }))
// 加载内容数据 - 使用 executeAs 替代 supadb 组件
const loadContents = async () => {
if (supa === null) return
pageState.value.loading = true
pageState.value.error = null
try {
let query = supa.from('ak_contents')
.select('*,ak_contents_category_id_fkey(name_key)', {})
.order('published_at', { ascending: false } as OrderOptions)
// 应用分类筛选
if (selectedCategoryId.value !== 'all') {
query = query.eq('category_id', selectedCategoryId.value)
}
// 应用分页
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<InfoContent>()
if (result.data !== null && Array.isArray(result.data)) {
handleContentsData(result.data as Array<InfoContent>)
} else {
throw new Error('Failed to load contents')
}
} catch (e) {
handleError(e)
}
}
const selectSortOption = async (option: OptionItem) => {
currentSortOption.value = option.value
sortOptionText.value = option.text
hideSortOptions()
pageState.value.currentPage = 1
await loadContents()
}
// 分类选择
const selectCategory = async (category: CategoryData) => {
selectedCategoryId.value = category.id
pageState.value.currentPage = 1
await loadContents()
}
const getCategoryName = (category: CategoryData):string => {
const translations = category.translations;
const name = translations?.[0]?.name??'--';
return name;
}
// 加载精选内容 - 使用 executeAs 替代模拟数据
const loadFeaturedContents = async () => {
if (supa === null) return
try {
const result = await supa.from('ak_contents')
.select('*', {})
.eq('is_featured', true)
.order('published_at', { ascending: false })
.limit(5)
.executeAs<Array<InfoContent>>()
if (result.data !== null && Array.isArray(result.data)) {
featuredContentsList.value = result.data as Array<InfoContent>
}
} catch (e) {
console.error('Featured contents loading error:', e)
featuredContentsList.value = []
}
}
// 加载更多
const loadMore = async () => {
if (loadingMore.value || !hasMore.value) return
loadingMore.value = true
pageState.value.currentPage += 1
setTimeout(() => {
loadContents().then(() => {
loadingMore.value = false
})
}, 500)
}
// 刷新数据
const refreshData = async () => {
pageState.value.currentPage = 1
await loadContents()
}
// 重试加载
const retryLoad = async () => {
pageState.value.error = null
await loadContents()
}
// 导航函数
const navigateToDetail = (content: InfoContent) => {
const contentId = content.id
uni.navigateTo({
url: `/pages/info/detail?id=${contentId}`
})
}
const navigateToSearch = () => {
uni.navigateTo({
url: '/pages/info/search'
})
}
const navigateToChat = () => {
uni.navigateTo({
url: '/pages/info/chat'
})
}
const navigateToTopics = () => {
uni.navigateTo({
url: '/pages/info/topics'
})
}
// 从数据库动态加载分类
const loadCategories = async () => {
if (supa === null) return
try {
const lang = currentLanguageCode.value
console.log(lang)
const result = await supa
.from('ak_content_categories')
.select('*,translations:ak_content_category_translations(name)', {})
.eq('translations.language_code', lang)
.order('sort_order', { ascending: true })
.executeAs<CategoryData>()
console.log(result)
if (result.data !== null && Array.isArray(result.data)) {
categoriesList.value = result.data as CategoryData[]
} else {
categoriesList.value = []
}
} catch (e) {
console.error('加载分类失败:', e)
categoriesList.value = []
}
}
// 初始化数据
const initializeData = async () => {
// 动态加载分类列表
await loadCategories()
// 初始化语言列表(强类型 LanguageData
languagesList.value = []
for (let i: Int = 0; i < LANGUAGE_OPTIONS.length; i++) {
const language: LanguageData = LANGUAGE_OPTIONS[i]
languagesList.value.push(language)
}
// await loadContents()
// await loadFeaturedContents()
}
// 生命周期
onMounted(() => {
initializeData()
handleResize()
})
</script>
<style>
/* uts-android 兼容性重构:
1. 拆分所有嵌套选择器为扁平 class如 .category-tab.is-active、.category-text.is-active
2. 所有“最后一个”元素的 margin-right/margin-bottom 用 .is-last 控制,移除伪类。
3. 移除所有 gap、flex-wrap、嵌套选择器、伪类等不兼容写法。
4. 在注释中补充重构说明,便于后续维护。
*/
.info-home {
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 {
display: flex;
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 {
display: flex;
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;
}
.category-section {
background-color: #ffffff;
padding-top: 12px;
padding-bottom: 12px;
border-bottom-width: 1px;
border-bottom-color: #e2e8f0;
}
/* 横向滚动兼容 UTS Android */
.category-scroll {
height: 40px;
display: flex;
flex-direction: row;
padding-left: 16px;
padding-right: 16px;
min-width: max-content;
}
.category-tab {
flex: 0 0 auto;
padding: 8px 16px;
margin-right: 12px;
background-color: #f8fafc;
border-radius: 20px;
border-width: 1px;
border-color: #e2e8f0;
white-space: nowrap;
}
.category-tab.is-active {
background-color: #3b82f6;
border-color: #3b82f6;
}
.category-tab.is-last {
margin-right: 0;
}
.category-text {
font-size: 14px;
color: #64748b;
white-space: nowrap;
}
.category-text.is-active {
color: #ffffff;
}
.featured-section {
background-color: #ffffff;
margin-top: 8px;
padding: 16px 0;
}
.section-header {
display: flex;
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 {
display: flex;
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: 180px;
}
.featured-cards {
display: flex;
flex-direction: row;
padding-left: 16px;
padding-right: 16px;
}
.featured-card {
background-color: #f8fafc;
border-radius: 12px;
padding: 16px;
margin-right: 12px;
border-width: 1px;
border-color: #e2e8f0;
}
.featured-card.is-last {
margin-right: 0;
}
.card-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.card-title {
flex: 1;
font-size: 16px;
font-weight: bold;
color: #1f2937;
line-height: 22px;
}
.quality-badge {
background-color: #10b981;
border-radius: 12px;
padding: 4px 8px;
margin-left: 8px;
}
.quality-text {
font-size: 12px;
color: #ffffff;
}
.card-summary {
font-size: 14px;
color: #64748b;
line-height: 20px;
margin-bottom: 12px;
}
.card-footer {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.card-author {
font-size: 12px;
color: #94a3b8;
}
.card-time {
font-size: 12px;
color: #94a3b8;
}
.content-section {
background-color: #ffffff;
margin-top: 8px;
padding: 16px 0;
}
.loading-section {
padding: 40px 0;
display: flex;
align-items: center;
justify-content: center;
}
.loading-text {
font-size: 14px;
color: #94a3b8;
}
.error-section {
padding: 40px 16px;
display: flex;
align-items: center;
justify-content: center;
}
.error-text {
font-size: 14px;
color: #ef4444;
margin-bottom: 16px;
text-align: center;
}
.retry-btn {
background-color: #3b82f6;
border-radius: 20px;
padding: 8px 24px;
}
.retry-text {
font-size: 14px;
color: #ffffff;
}
.content-list {
padding-left: 16px;
padding-right: 16px;
}
.content-item {
background-color: #f8fafc;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
border-width: 1px;
border-color: #e2e8f0;
}
.content-header {
margin-bottom: 8px;
}
.content-title {
font-size: 16px;
font-weight: bold;
color: #1f2937;
line-height: 22px;
margin-bottom: 4px;
}
.content-meta {
display: flex;
flex-direction: row;
align-items: center;
}
.content-category {
font-size: 12px;
color: #3b82f6;
background-color: #eff6ff;
padding: 2px 8px;
border-radius: 10px;
margin-right: 8px;
}
.content-time {
font-size: 12px;
color: #94a3b8;
}
.content-summary {
font-size: 14px;
color: #64748b;
line-height: 20px;
margin-bottom: 12px;
}
.content-footer {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.content-stats {
display: flex;
flex-direction: row;
align-items: center;
}
.stat-item {
font-size: 12px;
color: #94a3b8;
margin-right: 16px;
}
.stat-item.is-last {
margin-right: 0;
}
.empty-section {
padding: 60px 16px;
display: flex;
align-items: center;
justify-content: center;
}
.empty-text {
font-size: 16px;
color: #94a3b8;
margin-bottom: 16px;
}
.refresh-btn {
background-color: #3b82f6;
border-radius: 20px;
padding: 10px 24px;
}
.refresh-text {
font-size: 14px;
color: #ffffff;
}
.load-more-section {
padding: 20px 16px;
display: flex;
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);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.language-modal, .sort-modal {
background-color: #ffffff;
border-radius: 16px;
margin: 20px;
max-height: 500px;
min-width: 280px;
}
.modal-header {
display: flex;
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;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
background-color: #f1f5f9;
}
.close-text {
font-size: 20px;
color: #64748b;
}
.language-list, .sort-list {
max-height: 400px;
}
.language-item, .sort-item {
padding: 16px 20px;
border-bottom-width: 1px;
border-bottom-color: #f1f5f9;
}
.language-item.is-active, .sort-item.is-active {
background-color: #eff6ff;
}
.language-name {
font-size: 16px;
color: #1f2937;
margin-bottom: 4px;
}
.language-name.is-active {
color: #3b82f6;
}
.language-native {
font-size: 14px;
color: #64748b;
}
</style>