Initial commit of akmon project

This commit is contained in:
2026-01-20 08:04:15 +08:00
commit 77a2bab985
1309 changed files with 343305 additions and 0 deletions

934
pages/info/index.uvue Normal file
View File

@@ -0,0 +1,934 @@
<!-- 多语言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>