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

1106 lines
25 KiB
Plaintext

<!-- 内容详情页面 - UTSJSONObject 优化版本 -->
<template>
<scroll-view direction="vertical" class="content-detail" :scroll-y="true" :enable-back-to-top="true">
<!-- 导航栏 -->
<view class="detail-header">
<view class="header-content">
<view class="back-btn" @click="goBack">
<text class="back-icon">←</text>
</view>
<text class="header-title">{{ $t('mt.title.news') }}</text>
<view class="header-actions">
<view class="action-btn" @click="showLanguageSwitcher">
<text class="action-icon">🌐</text>
</view>
<view class="action-btn" @click="shareContent">
<text class="action-icon">📤</text>
</view>
<view class="action-btn" @click="toggleBookmark">
<text class="action-icon">{{ isBookmarked ? '★' : '☆' }}</text>
</view>
</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="detail-content" v-if="contentData !== null && !pageState.loading">
<!-- 内容头部 -->
<view class="content-header">
<text class="content-title">{{ contentData?.title }}</text>
<view class="content-meta">
<view class="meta-row">
<text class="meta-label">{{ $t('mt.detail.author') }}</text>
<text class="meta-value">{{ contentData?.author }}</text>
</view>
<view class="meta-row">
<text class="meta-label">{{ $t('mt.detail.publishedAt') }}</text>
<text class="meta-value">{{ contentData?.published_at }}</text>
</view>
<view class="meta-row">
<text class="meta-label">{{ $t('mt.detail.originalLanguage') }}</text>
<text class="meta-value">{{ contentData?.original_language }}</text>
</view>
<view class="meta-row">
<text class="meta-label">{{ $t('mt.detail.qualityScore') }}</text>
<view class="quality-score">
<view class="quality-bar"
:style="{ width: `${(contentData?.quality_score ?? 0) * 100}%`, backgroundColor: getQualityScoreColor(contentData?.quality_score ?? 0) }">
</view>
<text
class="quality-text">{{ getQualityScoreText(contentData?.quality_score ?? 0) }}</text>
</view>
</view>
</view>
</view>
<!-- 内容摘要 -->
<view class="content-summary" v-if="contentData?.summary !== ''">
<text class="summary-title">{{ $t('mt.detail.summary') }}</text>
<text class="summary-text">{{ contentData?.summary }}</text>
</view>
<!-- 语言切换 -->
<view class="language-switcher" v-if="availableTranslations.length > 0">
<text class="switcher-title">{{ $t('mt.detail.selectLanguage') }}</text>
<scroll-view direction="horizontal" class="language-scroll" :scroll-x="true">
<view class="language-tabs">
<view class="language-tab" :class="{ active: currentLanguage === 'original' }"
@click="switchToOriginal">
<text class="language-text">{{ $t('mt.detail.originalText') }}</text>
</view>
<view v-for="translation in availableTranslations" :key="translation.id" class="language-tab"
:class="{ active: currentLanguage === translation.id }"
@click="switchToTranslation(translation)">
<text class="language-text">{{ translation.language_id }}</text>
<view class="translation-quality" v-if="translation.human_verified">
<text class="quality-mark">✓</text>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 内容正文 -->
<view class="content-body">
<text class="body-text">{{ currentContentText }}</text>
</view>
<!-- 内容标签 -->
<view class="content-tags" v-if="contentTags.length > 0">
<text class="tags-title">{{ $t('mt.detail.tags') }}</text>
<view class="tags-list">
<view v-for="tag in contentTags" :key="tag" class="tag-item" @click="searchByTag(tag)">
<text class="tag-text"># {{ tag }}</text>
</view>
</view>
</view>
<!-- 统计信息 -->
<view class="content-stats">
<view class="stats-row">
<view class="stat-item" @click="likeContent">
<text class="stat-icon">👍</text>
<text class="stat-text">{{ contentData?.like_count }}</text>
</view>
<view class="stat-item" @click="shareContent">
<text class="stat-icon">📤</text>
<text class="stat-text">{{ contentData?.share_count }}</text>
</view>
<view class="stat-item">
<text class="stat-icon">👁</text>
<text class="stat-text">{{ contentData?.view_count }}</text>
</view>
</view>
</view>
<!-- 来源信息 -->
<view class="content-source" v-if="contentData?.source_url !== ''">
<text class="source-title">{{ $t('mt.detail.source') }}</text>
<view class="source-link" @click="openSource">
<text class="link-text">{{ contentData?.source_url }}</text>
<text class="link-icon">🔗</text>
</view>
</view>
</view>
<!-- 相关推荐 -->
<view class="related-section" v-if="relatedContentsList.length > 0">
<view class="section-header">
<text class="section-title">{{ $t('mt.detail.related') }}</text>
</view>
<view class="related-list">
<view v-for="content in relatedContentsList" :key="content.id" class="related-item"
@click="navigateToContent(content)">
<view class="related-content">
<text class="related-title">{{ content.title }}</text>
<text class="related-summary">{{ content.summary }}</text>
<view class="related-meta">
<text class="related-time">{{ formatRelativeTimeKey(content.published_at) }}</text>
<view class="related-quality"
:style="{ backgroundColor: getQualityScoreColor(content.quality_score) }">
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 评论区域 -->
<view class="comments-section" v-if="contentData !== null">
<Comments :targetType="'content'" :targetId="contentData?.id ?? ''" />
</view>
</scroll-view>
<!-- AI助手按钮 -->
<view class="ai-assistant-btn" @click="openAIChat">
<text class="ai-icon">🤖</text>
<text class="ai-text">{{ $t('mt.title.chat') }}</text>
</view>
<!-- 语言切换弹窗 -->
<view class="language-modal" v-if="showLanguageModal" @click="hideLanguageSwitcher">
<view class="language-modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">选择语言 / Select Language</text>
<view class="modal-close" @click="hideLanguageSwitcher">
<text class="close-icon">✕</text>
</view>
</view>
<view class="language-options">
<view class="language-option"
:class="{ active: currentAppLanguage === 'zh-CN' }"
@click="switchAppLanguage('zh-CN')">
<text class="language-name">简体中文</text>
<text class="language-code">zh-CN</text>
<text v-if="currentAppLanguage === 'zh-CN'" class="selected-mark">✓</text>
</view>
<view class="language-option"
:class="{ active: currentAppLanguage === 'en' }"
@click="switchAppLanguage('en')">
<text class="language-name">English</text>
<text class="language-code">en</text>
<text v-if="currentAppLanguage === 'en'" class="selected-mark">✓</text>
</view>
<view class="language-option"
:class="{ active: currentAppLanguage === 'zh-TW' }"
@click="switchAppLanguage('zh-TW')">
<text class="language-name">繁體中文</text>
<text class="language-code">zh-TW</text>
<text v-if="currentAppLanguage === 'zh-TW'" class="selected-mark">✓</text>
</view>
<view class="language-option"
:class="{ active: currentAppLanguage === 'ja' }"
@click="switchAppLanguage('ja')">
<text class="language-name">日本語</text>
<text class="language-code">ja</text>
<text v-if="currentAppLanguage === 'ja'" class="selected-mark">✓</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import Comments from './comments.uvue'
import type {
InfoContent,
TranslationData,
PageState
} from './types.uts'
import {
getQualityScoreText,
getQualityScoreColor,
formatRelativeTimeKey,
} from './types.uts'
import { state as userState, getCurrentUser, setUserProfile, setIsLoggedIn } from '@/utils/store.uts'
import { setClipboardData } from '@/uni_modules/lime-clipboard'
import { tt } from '@/utils/i18nfun.uts'
import i18n from '@/i18n/index.uts' // 保留用于语言切换
// 页面参数
const contentId = ref<string>('')
// 页面状态
const pageState = ref<PageState>({
loading: true,
error: null,
currentPage: 1,
pageSize: 20,
total: 0
})
// 数据状态
const contentData = ref<InfoContent | null>(null)
const availableTranslations = ref<Array<TranslationData>>([])
const relatedContentsList = ref<Array<InfoContent>>([])
// 交互状态
const currentLanguage = ref<string>('original')
const isBookmarked = ref<boolean>(false)
// 是否已点赞,类型安全,后端接口应返回 is_liked 字段
const hasLiked = ref<boolean>(false)
// 语言切换相关状态
const showLanguageModal = ref<boolean>(false)
const currentAppLanguage = ref<string>('zh-CN') // 当前应用语言
// 用户信息 - 统一用全局 userState
const userProfile = computed(() : void => {
userState.userProfile
})
const isLoggedIn = computed(() => userState.isLoggedIn)
// 计算属性
const currentContentText = computed(() : string => {
const data = contentData.value
if (data == null) return ''
if (currentLanguage.value === 'original') {
return data.content
} else {
const translation = availableTranslations.value.find(t => t.id === currentLanguage.value)
if (translation != null) {
return translation.content
}
}
return data.content
})
const contentTags = computed(() : Array<string> => {
const data = contentData.value
if (data == null) return []
return data.tags ?? []
})
// 监听语言变化,保留 i18n 实例用于语言切换
const currentI18nLocale = computed(() => i18n.global.locale.value)
// 监听全局语言变化,同步本地状态
watch(currentI18nLocale, (newLocale) => {
if (newLocale !== currentAppLanguage.value) {
currentAppLanguage.value = newLocale
}
})
const loadTranslations = async () => {
if (contentId.value === '' || supa === null) return
try {
const result = await supa.from('ak_content_translations')
.select('*', {})
.eq('content_id', contentId.value)
.executeAs<TranslationData>()
if (result.data !== null && Array.isArray(result.data)) {
availableTranslations.value = result.data as Array<TranslationData>
}
} catch (e) {
console.error('Translations loading error:', e)
availableTranslations.value = []
}
}
const loadRelatedContents = async () => {
if (contentId.value === '' || supa === null) return
try {
// 获取相关内容(同分类的其他内容)
const result = await supa.from('ak_contents')
.select('*', {})
.neq('id', contentId.value)
.limit(5)
.order('published_at', { ascending: false })
.executeAs<InfoContent>()
if (result.data !== null && Array.isArray(result.data)) {
relatedContentsList.value = result.data as Array<InfoContent>
}
} catch (e) {
console.error('Related contents loading error:', e)
relatedContentsList.value = []
}
}
const recordViewBehavior = () => {
// 记录用户查看行为
console.log('Recording view behavior for content:', contentId.value)
// 这里可以调用API记录用户行为
}
// 交互函数
const switchToOriginal = () => {
currentLanguage.value = 'original'
}
const switchToTranslation = (translation : TranslationData) => {
currentLanguage.value = translation.id
}
const toggleBookmark = () => {
if (!isLoggedIn.value) {
uni.showToast({
title: '请先登录后再收藏',
icon: 'none'
})
// 可引导跳转登录页
return
}
isBookmarked.value = !isBookmarked.value
const action = isBookmarked.value ? '收藏' : '取消收藏'
uni.showToast({
title: `${action}成功`,
icon: 'success'
})
}
const likeContent = () => {
if (!isLoggedIn.value) {
uni.showToast({
title: '请先登录后再点赞',
icon: 'none'
})
// 可引导跳转登录页
return
}
if (hasLiked.value) {
uni.showToast({
title: '您已经点过赞了',
icon: 'none'
})
return
}
hasLiked.value = true
// 更新点赞数
const data = contentData.value
if (data != null) {
const currentLikes = data.like_count
// 直接赋值,避免 set 方法和类型推断问题
data.like_count = currentLikes + 1
}
uni.showToast({
title: '点赞成功',
icon: 'success'
})
}
const shareContent = () => {
const data = contentData.value
if (data == null) return
const title = data.title
const summary = data.summary
const url = `https://example.com/info/detail?id=${contentId.value}`
uni.showActionSheet({
itemList: ['复制链接', '分享到微信', '分享到微博'],
success: (res) => {
switch (res.tapIndex) {
case 0:
// 复制链接,使用 lime-clipboard
setClipboardData({
data: `${title}\n${summary}\n${url}`,
success: (result) => {
uni.showToast({
title: '链接已复制',
icon: 'success'
})
},
fail: (err) => {
uni.showToast({
title: '复制失败',
icon: 'none'
})
}
})
break
case 1:
// 分享到微信
uni.showToast({
title: '请使用系统分享功能',
icon: 'none'
})
break
case 2:
// 分享到微博
uni.showToast({
title: '请使用系统分享功能',
icon: 'none'
})
break
}
}
})
}
const searchByTag = (tag : string) => {
try {
uni.navigateTo({
url: `/pages/info/search?keyword=${encodeURIComponent(tag)}`
})
} catch (error) {
console.error('导航异常:', error)
}
}
const openSource = () => {
const data = contentData.value
if (data == null) return
const sourceUrl = data.source_url
if (sourceUrl === '') return
uni.showModal({
title: '打开原文链接',
content: '是否在浏览器中打开原文链接?',
success: (res) => {
if (res.confirm) {
// 在这里可以调用系统浏览器打开链接
console.log('Opening source URL:', sourceUrl)
}
}
})
}
const openAIChat = () => {
const data = contentData.value
if (data == null) return
const title = data.title
try {
uni.navigateTo({
url: `/pages/info/chat?context=${encodeURIComponent(title)}`
})
} catch (error) {
console.error('导航异常:', error)
}
}
const navigateToContent = (content : InfoContent) => {
const relatedContentId = content.id
try {
uni.redirectTo({
url: `/pages/info/detail?id=${relatedContentId}`
})
} catch (error) {
console.error('导航异常:', error)
}
}
const goBack = () => {
try {
uni.navigateBack({
delta: 1
})
} catch (error) {
console.error('返回异常:', error)
}
}
// 语言切换相关函数
const showLanguageSwitcher = () => {
showLanguageModal.value = true
}
const hideLanguageSwitcher = () => {
showLanguageModal.value = false
}
const switchAppLanguage = (languageCode: string) => {
currentAppLanguage.value = languageCode
// 使用 i18n 全局语言切换
i18n.global.locale.value = languageCode
hideLanguageSwitcher()
// 显示切换成功提示(使用新语言显示)
const successMessages = {
'zh-CN': '已切换到简体中文',
'en': 'Switched to English',
'zh-TW': '已切換到繁體中文',
'ja': '日本語に切り替えました'
}
const message = successMessages[languageCode] || `Language switched to ${languageCode}`
uni.showToast({
title: message,
icon: 'success',
duration: 2000
})
}
// 数据加载函数 - 使用 executeAs 替代模拟数据
const loadContentData = async () => {
if (contentId.value === '' || supa === null) return
pageState.value.loading = true
pageState.value.error = null
try {
const result = await supa.from('ak_contents')
.select('*', {})
.eq('id', contentId.value)
.single()
.executeAs<InfoContent>()
if (result.data !== null && Array.isArray(result.data)) {
const realcontent = result.data as InfoContent[]
contentData.value = realcontent[0]
pageState.value.loading = false
// 记录访问行为
recordViewBehavior()
// 加载翻译和相关内容
await loadTranslations()
await loadRelatedContents()
} else {
throw new Error('Content not found')
}
} catch (e) {
pageState.value.loading = false
pageState.value.error = '内容加载失败,请稍后重试'
console.error('Content loading error:', e)
}
}
const retryLoad = () => {
loadContentData()
}
onLoad((options) => {
// 初始化当前语言,从 i18n 获取
currentAppLanguage.value = i18n.global.locale.value || 'zh-CN'
if (options["id"] !== null) {
contentId.value = options.getString("id") ?? ''
loadContentData()
}
if (userState.isLoggedIn) {
getCurrentUser()
}
})
onUnmounted(() => {
// 清理工作
})
</script>
<style>
.content-detail {
flex: 1;
background-color: #f5f5f5;
}
.detail-header {
background-color: #ffffff;
border-bottom: 1px solid #e5e5e5;
}
.header-content {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
}
.back-btn {
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 20px;
background-color: #f3f4f6;
}
.back-icon {
font-size: 18px;
color: #374151;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #1f2937;
}
.header-actions {
display: flex;
flex-direction: row;
align-items: center;
}
.action-btn {
margin-left: 8px;
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 20px;
background-color: #f3f4f6;
}
.action-icon {
font-size: 16px;
color: #374151;
}
.loading-section,
.error-section {
padding: 60px 16px;
display: flex;
align-items: center;
}
.loading-text,
.error-text {
font-size: 16px;
color: #6b7280;
}
.retry-btn {
margin-top: 16px;
padding: 8px 16px;
background-color: #3b82f6;
border-radius: 8px;
}
.retry-text {
font-size: 14px;
color: #ffffff;
}
.detail-content {
background-color: #ffffff;
margin: 12px;
border-radius: 12px;
padding: 20px;
}
.content-header {
margin-bottom: 20px;
}
.content-title {
font-size: 24px;
font-weight: bold;
color: #1f2937;
line-height: 32px;
margin-bottom: 16px;
}
.content-meta {
border-top: 1px solid #e5e5e5;
padding-top: 16px;
}
.meta-row {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 8px;
}
.meta-label {
font-size: 14px;
color: #6b7280;
width: 80px;
}
.meta-value {
font-size: 14px;
color: #374151;
flex: 1;
}
.quality-score {
display: flex;
flex-direction: row;
align-items: center;
flex: 1;
}
.quality-bar {
flex: 1;
height: 6px;
background-color: #e5e5e5;
border-radius: 3px;
margin-right: 8px;
}
.quality-fill {
height: 100%;
border-radius: 3px;
}
.quality-text {
font-size: 12px;
color: #6b7280;
}
.content-summary {
margin-bottom: 20px;
padding: 16px;
background-color: #f9fafb;
border-radius: 8px;
border-left: 4px solid #3b82f6;
}
.summary-title {
font-size: 16px;
font-weight: bold;
color: #1f2937;
margin-bottom: 8px;
}
.summary-text {
font-size: 14px;
color: #6b7280;
line-height: 22px;
}
.language-switcher {
margin-bottom: 20px;
}
.switcher-title {
font-size: 16px;
font-weight: bold;
color: #1f2937;
margin-bottom: 12px;
}
.language-scroll {
height: 50px;
}
.language-tabs {
display: flex;
flex-direction: row;
align-items: center;
}
.language-tab {
display: flex;
flex-direction: row;
align-items: center;
margin-right: 12px;
padding: 8px 16px;
border-radius: 20px;
background-color: #f3f4f6;
}
.language-tab.is-active {
background-color: #3b82f6;
}
.language-text {
font-size: 14px;
color: #374151;
white-space: nowrap;
}
.language-tab.is-active .language-text {
color: #ffffff;
}
.translation-quality {
margin-left: 4px;
width: 16px;
height: 16px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
background-color: #10b981;
}
.quality-mark {
font-size: 10px;
color: #ffffff;
}
.content-body {
margin-bottom: 24px;
}
.body-text {
font-size: 16px;
color: #374151;
line-height: 28px;
}
.content-tags {
margin-bottom: 24px;
}
.tags-title {
font-size: 16px;
font-weight: bold;
color: #1f2937;
margin-bottom: 12px;
}
.tags-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.tag-item {
margin-right: 8px;
margin-bottom: 8px;
padding: 6px 12px;
background-color: #eff6ff;
border-radius: 16px;
}
.tag-text {
font-size: 14px;
color: #3b82f6;
}
.content-stats {
margin-bottom: 24px;
padding: 16px;
background-color: #f9fafb;
border-radius: 8px;
}
.stats-row {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
}
.stat-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 8px 16px;
}
.stat-icon {
font-size: 20px;
margin-right: 8px;
}
.stat-text {
font-size: 16px;
color: #374151;
}
.content-source {
padding: 16px;
background-color: #f9fafb;
border-radius: 8px;
}
.source-title {
font-size: 14px;
color: #6b7280;
margin-bottom: 8px;
}
.source-link {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.link-text {
font-size: 14px;
color: #3b82f6;
flex: 1;
}
.link-icon {
font-size: 16px;
color: #3b82f6;
}
.related-section {
background-color: #ffffff;
margin: 12px;
border-radius: 12px;
}
.section-header {
padding: 16px;
border-bottom: 1px solid #e5e5e5;
}
.section-title {
font-size: 18px;
font-weight: bold;
color: #1f2937;
}
.related-list {
padding: 0 16px;
}
.related-item {
padding: 16px 0;
border-bottom: 1px solid #f3f4f6;
}
.related-content {
flex: 1;
}
.related-title {
font-size: 16px;
font-weight: bold;
color: #1f2937;
line-height: 22px;
margin-bottom: 8px;
}
.related-summary {
font-size: 14px;
color: #6b7280;
line-height: 20px;
margin-bottom: 8px;
}
.related-meta {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.related-time {
font-size: 12px;
color: #9ca3af;
}
.related-quality {
width: 8px;
height: 8px;
border-radius: 4px;
}
.comments-section {
background-color: #ffffff;
margin-top: 8px;
}
.ai-assistant-btn {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: row;
align-items: center;
padding: 12px 16px;
background-color: #3b82f6;
border-radius: 24px;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.ai-icon {
font-size: 20px;
margin-right: 8px;
}
.ai-text {
font-size: 14px;
color: #ffffff;
}
/* 语言切换弹窗样式 */
.language-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.language-modal-content {
background-color: #ffffff;
border-radius: 12px;
width: 280px;
max-width: 90%;
overflow: hidden;
}
.modal-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e5e5e5;
}
.modal-title {
font-size: 18px;
font-weight: bold;
color: #1f2937;
}
.modal-close {
width: 32px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 16px;
background-color: #f3f4f6;
}
.close-icon {
font-size: 14px;
color: #6b7280;
}
.language-options {
padding: 8px 0;
}
.language-option {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
transition: background-color 0.2s;
}
.language-option:hover {
background-color: #f9fafb;
}
.language-option.active {
background-color: #eff6ff;
}
.language-name {
font-size: 16px;
color: #1f2937;
font-weight: 500;
}
.language-code {
font-size: 14px;
color: #6b7280;
}
.selected-mark {
font-size: 16px;
color: #3b82f6;
font-weight: bold;
}
</style>