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

429
pages/info/README.md Normal file
View File

@@ -0,0 +1,429 @@
# 多语言AI资讯系统 (Multilingual AI Information System)
基于 uni-app-x (UTS Android) 平台开发的多语言AI资讯系统严格遵循 UTS Android 开发规范。
## 系统特性
### 核心功能
- **多语言支持**: 支持中文、英文、日文、韩文等多种语言
- **AI助手**: 集成智能聊天助手,提供内容推荐和分析
- **智能搜索**: 支持关键词搜索、分类筛选、高级过滤
- **响应式设计**: 兼容大屏和小屏设备,自适应布局
-**高性能**: 基于 UTSJSONObject 优化,确保流畅体验
- **数据分析**: 内容质量评分、用户行为分析
### 技术特点
- 严格遵循 UTS Android 开发规范
- 全部使用 UTSJSONObject 作为核心数据结构
- 与 Supabase 数据库深度集成
- 支持实时数据同步和离线缓存
- 模块化设计,易于扩展和维护
## 文件结构
```
pages/info/
├── index.uvue # 主页面 - 资讯列表、分类导航、精选推荐
├── detail.uvue # 详情页面 - 内容展示、多语言切换、相关推荐、评论互动
├── search.uvue # 搜索页面 - 智能搜索、筛选、历史记录
├── chat.uvue # AI助手页面 - 智能对话、内容推荐
├── settings.uvue # 设置页面 - 个人偏好、语言设置、通知配置
├── topics.uvue # 专题列表页面 - 专题分类、精选专题、热门专题
├── topic-detail.uvue # 专题详情页面 - 专题内容、时间轴、相关推荐、评论互动
├── comments.uvue # 评论组件 - 多层级评论、回复、点赞、举报功能
├── types.uts # 类型定义 - 所有数据结构和工具函数
├── test-utils.uts # 测试工具 - 功能验证和性能测试
├── index-backup.uvue # 原始备份文件
└── README.md # 项目文档
```
## 页面功能详解
### 1. 主页面 (index.uvue)
- **分类导航**: 水平滚动的分类标签,支持全部/各分类切换
- **内容列表**: 瀑布流式布局,展示标题、摘要、作者等信息
- **精选推荐**: 高质量内容推荐区域
- **语言选择**: 弹窗式语言选择器
- **实时统计**: 显示总内容数、发布数、趋势等统计信息
- **响应式布局**: 大屏2列小屏1列自适应
### 2. 详情页面 (detail.uvue)
- **内容展示**: 支持原文和多语言翻译切换
- **质量指标**: 显示内容质量评分和可视化指示器
- **交互功能**: 点赞、分享、收藏等社交功能
- **相关推荐**: 基于内容相似度的智能推荐
- **标签导航**: 点击标签跳转到相关搜索
- **AI助手入口**: 浮动按钮快速访问AI助手
### 3. 搜索页面 (search.uvue)
- **智能搜索**: 支持标题、内容、标签等多维度搜索
- **高级筛选**: 分类、语言、质量、时间等多条件筛选
- **搜索建议**: 实时搜索建议和自动补全
- **历史记录**: 搜索历史管理和快速重用
- **热门搜索**: 展示热门搜索词和趋势
- **结果排序**: 支持相关度、时间、质量等多种排序
### 4. AI助手页面 (chat.uvue)
- **智能对话**: 支持自然语言交互
- **内容推荐**: 基于用户偏好的个性化推荐
- **快速操作**: 预设常用问题和快捷操作
- **会话管理**: 支持多会话和历史记录
- **消息操作**: 复制、点赞、反馈等消息交互
- **上下文理解**: 结合当前浏览内容提供相关建议
### 5. 设置页面 (settings.uvue)
- **语言偏好**: 界面语言和内容语言偏好设置
- **内容偏好**: 感兴趣的分类、阅读模式、字体大小
- **通知设置**: 推送通知、提醒频率等配置
- **隐私安全**: 数据使用、隐私保护相关设置
- **账户管理**: 个人信息编辑和账户设置
- **应用信息**: 版本信息、用户协议、反馈渠道
### 6. 专题列表页面 (topics.uvue)
- **专题分类**: 按类型筛选专题(突发事件、热门话题、系列专题等)
- **精选专题**: 编辑推荐的高质量专题展示
- **专题预览**: 专题封面、标题、描述、内容数量等信息
- **排序筛选**: 支持按更新时间、热门度、内容数量等排序
- **快速导航**: 一键跳转到专题详情或搜索页面
### 7. 专题详情页面 (topic-detail.uvue)
- **专题概览**: 专题标题、描述、统计信息、封面展示
- **内容组织**: 提供时间轴、分类、精选三种内容展示模式
- **时间轴视图**: 按时间顺序展示专题相关内容发展脉络
- **分类视图**: 按内容类别组织专题文章
- **精选视图**: 展示专题中高质量内容
- **相关推荐**: 推荐相关专题和扩展阅读
- **互动功能**: 分享、订阅、意见反馈等操作
- **评论系统**: 专题评论、讨论互动
### 8. 评论组件 (comments.uvue)
- **多层级评论**: 支持评论和回复的树形结构显示
- **评论互动**: 点赞、回复、举报、删除等完整功能
- **排序筛选**: 支持按时间、热度、回复数等多种排序方式
- **实时更新**: 评论数据实时同步,支持分页加载
- **内容审核**: 评论状态管理和内容过滤机制
- **用户权限**: 根据用户身份显示不同操作权限
- **响应式设计**: 适配不同屏幕尺寸的评论界面
## 数据结构
### 核心数据类型 (基于 UTSJSONObject)
```typescript
// 内容数据
export type InfoContent = UTSJSONObject
// 字段: id, title, content, summary, author, published_at, quality_score,
// view_count, like_count, share_count, original_language, source_url, tags
// 翻译数据
export type TranslationData = UTSJSONObject
// 字段: id, content_id, language_id, title, content, summary,
// human_verified, quality_score, created_at
// 分类数据
export type CategoryData = UTSJSONObject
// 字段: id, name_key, display_order, is_active, icon, color
// 专题数据
export type TopicData = UTSJSONObject
// 字段: id, title, description, topic_type, status, cover_image_url,
// content_count, view_count, created_at, updated_at
// 专题内容关联
export type TopicContentData = UTSJSONObject
// 字段: id, topic_id, content_id, display_order, editor_note, created_at
// 评论数据
export type CommentData = UTSJSONObject
// 字段: id, target_type, target_id, parent_id, author_id, author_name,
// author_avatar, content, status, like_count, reply_count, level,
// thread_path, is_liked, is_author, created_at, updated_at
// 评论回复数据
export type CommentReplyData = UTSJSONObject
// 字段: id, comment_id, author_id, author_name, target_name, content,
// like_count, is_liked, is_author, created_at
// 聊天消息
export type ChatMessageData = UTSJSONObject
// 字段: id, session_id, type, content, created_at, metadata
// 用户设置
export type UserSettingsData = UTSJSONObject
// 字段: user_id, interface_language, preferred_languages, preferred_categories,
// auto_translate, notification_enabled, reading_mode, font_size, theme
```
### 工具函数
```typescript
// 内容数据获取
getContentId(), getContentTitle(), getContentSummary(), getContentContent()
getContentAuthor(), getContentPublishedAt(), getContentQualityScore()
getContentViewCount(), getContentLikeCount(), getContentShareCount()
getContentOriginalLanguage(), getContentSourceUrl(), getContentTags()
// 时间格式化
formatDateTime(), formatRelativeTime()
// 质量评分
getQualityScoreText(), getQualityScoreColor()
// 分类和语言
getCategoryDisplayName(), getLanguageName()
```
## 技术规范
### UTS Android 开发规范遵循
1. **变量声明**: 使用 `let``const`,避免 `var`
2. **类型系统**: 使用 `type` 而非 `interface`,避免复杂类型嵌套
3. **数据结构**: 全部基于 `UTSJSONObject`,避免 `safeGet` 等函数
4. **循环遍历**: 使用 `for` 循环,避免 `forEach``map`
5. **条件判断**: 使用 `!== null` 而非 `!` 操作符
6. **空值处理**: 使用 `??` 而非 `||` 进行默认值设置
7. **CSS限制**: 只支持 `display: flex`,不支持 `grid``gap``calc()`
### 数据交互规范
1. **Supabase集成**: 使用 `supadb` 组件进行数据交互
2. **响应式状态**: 使用 `ref()``computed()` 管理状态
3. **生命周期**: 合理使用 `onMounted()``onUnmounted()`
4. **错误处理**: 统一的错误处理和用户反馈机制
### UI/UX 设计规范
1. **响应式设计**: 大屏(>768px)和小屏(<768px)自适应
2. **色彩系统**: 统一的色彩规范和主题支持
3. **交互反馈**: 加载状态、错误提示、成功反馈
4. **无障碍支持**: 语义化标签和清晰的视觉层次
## 性能优化
### 数据处理优化
- 使用 UTSJSONObject 避免类型转换开销
- 实现数据分页和虚拟滚动
- 合理的缓存策略和数据预加载
### 渲染性能优化
- 使用 `v-if` 而非 `v-show` 控制渲染
- 避免深层嵌套和复杂计算属性
- 合理使用 `key` 优化列表渲染
### 内存管理
- 及时清理事件监听器和定时器
- 避免内存泄漏和循环引用
- 合理的组件生命周期管理
## 测试验证
### 功能测试
- 使用 `test-utils.uts` 进行单元测试
- 验证数据结构和工具函数正确性
- 模拟用户操作和边界条件测试
### 性能测试
- 大数据量处理性能测试
- 内存使用和渲染性能监控
- 网络请求和响应时间优化
### 兼容性测试
- 不同屏幕尺寸适配测试
- Android 版本兼容性验证
- 多语言显示和交互测试
## 部署和维护
### 开发环境
- HBuilderX 3.8+
- uni-app-x 框架
- Supabase 数据库
### 构建和部署
- Android APK 打包
- 性能监控和错误报告
- 版本更新和热修复机制
### 数据库维护
- Supabase 表结构管理
- 数据备份和恢复
- 性能监控和优化
## 扩展计划
### 功能扩展
- [ ] 离线阅读支持
- [ ] 音频朗读功能
- [ ] 内容评论系统
- [ ] 社交分享集成
- [ ] 个性化推荐算法优化
### 技术升级
- [ ] PWA 支持
- [ ] 深色模式完善
- [ ] 无障碍功能增强
- [ ] 性能监控仪表板
## 评论系统架构
### 评论数据模型
评论系统采用**树形结构**设计,支持多层级嵌套回复:
```sql
-- 评论表 (ak_comments)
CREATE TABLE ak_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
target_type VARCHAR(20) NOT NULL, -- 'content' | 'topic'
target_id UUID NOT NULL, -- 目标内容/专题ID
parent_id UUID, -- 父评论ID (NULL表示顶级评论)
author_id UUID NOT NULL, -- 评论作者ID
author_name VARCHAR(100) NOT NULL, -- 作者显示名称
author_avatar TEXT, -- 作者头像URL
content TEXT NOT NULL, -- 评论内容
status VARCHAR(20) DEFAULT 'active', -- 评论状态
like_count INTEGER DEFAULT 0, -- 点赞数
reply_count INTEGER DEFAULT 0, -- 回复数
level INTEGER DEFAULT 0, -- 评论层级 (0=顶级, 1=一级回复, ...)
thread_path TEXT, -- 线程路径 (如: "1/5/12")
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
### 评论功能特性
#### 1. **多层级结构**
- **顶级评论**: 直接针对内容/专题的评论
- **嵌套回复**: 支持对评论的回复,形成对话链
- **层级限制**: 最多支持3层嵌套避免过深结构
- **线程追踪**: 通过thread_path字段追踪评论关系
#### 2. **交互功能**
- **点赞系统**: 支持评论点赞/取消点赞
- **回复机制**: @用户回复,保持对话连贯性
- **删除权限**: 作者可删除自己的评论
- **举报功能**: 支持6种举报类型的内容审核
#### 3. **内容管理**
- **状态控制**: active/hidden/deleted/pending_review/rejected
- **自动审核**: 关键词过滤和AI内容检测
- **人工审核**: 举报内容的人工复审机制
- **批量操作**: 管理员批量处理评论功能
#### 4. **排序和展示**
- **时间排序**: 最新/最早评论优先
- **热度排序**: 按点赞数排序
- **回复排序**: 按回复数排序
- **智能排序**: 综合质量和时间的算法排序
### 评论组件设计
#### **组件特点**
- **高度复用**: 同时支持内容评论和专题评论
- **响应式设计**: 适配不同屏幕尺寸
- **性能优化**: 虚拟滚动和分页加载
- **UTS规范**: 严格遵循UTS Android开发规范
#### **接口设计**
```typescript
interface CommentProps {
targetType: 'content' | 'topic' // 评论目标类型
targetId: string // 目标ID
userId?: string // 当前用户ID
userName?: string // 当前用户名
}
```
#### **状态管理**
```typescript
// 评论列表状态
const commentsList = ref<Array<CommentData>>([])
const loading = ref<boolean>(false)
const hasMore = ref<boolean>(true)
// 交互状态
const activeReplyId = ref<string>('') // 当前回复的评论ID
const showCommentInput = ref<boolean>(false) // 显示评论输入框
const commentContent = ref<string>('') // 评论内容
// 排序和筛选
const currentSort = ref<string>('created_at_desc')
const showSortModal = ref<boolean>(false)
```
### 评论业务流程
#### **发表评论流程**
1. 用户点击"写评论"按钮
2. 显示评论输入框,验证登录状态
3. 输入评论内容,实时字数统计
4. 提交评论调用API进行内容检测
5. 评论状态为pending_review或active
6. 刷新评论列表,显示新评论
#### **回复评论流程**
1. 点击评论的"回复"按钮
2. 显示回复输入框,显示@目标用户
3. 输入回复内容,支持@提及功能
4. 提交回复建立parent_id关系
5. 更新父评论的reply_count
6. 在评论树中正确显示回复
#### **评论审核流程**
1. **自动审核**: 关键词过滤、长度检查
2. **AI检测**: 违规内容、垃圾信息识别
3. **人工审核**: 举报内容的人工复审
4. **状态更新**: 根据审核结果更新评论状态
5. **通知机制**: 审核结果通知评论作者
### 评论数据库优化
#### **索引策略**
```sql
-- 复合索引优化查询性能
CREATE INDEX idx_comments_target ON ak_comments(target_type, target_id, status, created_at);
CREATE INDEX idx_comments_parent ON ak_comments(parent_id, created_at);
CREATE INDEX idx_comments_author ON ak_comments(author_id, created_at);
CREATE INDEX idx_comments_thread ON ak_comments(thread_path);
```
#### **查询优化**
- **分页查询**: 使用cursor-based pagination
- **预加载**: 评论作者信息和点赞状态预加载
- **缓存策略**: 热门评论Redis缓存
- **读写分离**: 评论读取和写入分离优化
### 评论系统集成
#### **与内容系统集成**
- 内容详情页自动加载评论组件
- 评论数统计实时更新到内容表
- 评论质量影响内容推荐算法
- 热门评论作为内容推荐依据
#### **与用户系统集成**
- 用户评论历史和统计
- 评论行为分析和画像构建
- 评论权限和等级管理
- 评论通知和消息推送
#### **与AI系统集成**
- AI自动生成评论摘要
- 智能评论推荐和排序
- 评论情感分析和标签
- AI辅助内容审核和过滤
## 联系和支持
如有问题或建议,请联系开发团队:
- 技术支持: [支持邮箱]
- 问题反馈: [反馈渠道]
- 文档更新: [文档地址]
---
*最后更新: 2025年6月*
*版本: v1.0.0*
*开发团队: AI资讯系统开发组*

1096
pages/info/chat.uvue Normal file

File diff suppressed because it is too large Load Diff

2860
pages/info/comindex.uvue Normal file

File diff suppressed because it is too large Load Diff

370
pages/info/cominfoedit.uvue Normal file
View File

@@ -0,0 +1,370 @@
<template>
<view class="config-edit-page">
<text class="page-title">公司全局配置管理</text>
<button type="primary" @click="openAddModal">新增配置项</button>
<list-view :loading="isLoading" class="config-list-view">
<template #header>
<view class="list-header-row">
<text class="list-header-cell">配置键</text>
<text class="list-header-cell">配置值</text>
<text class="list-header-cell">类型</text>
<text class="list-header-cell">分类</text>
<text class="list-header-cell">可翻译</text>
<text class="list-header-cell">排序</text>
<text class="list-header-cell">启用</text>
<text class="list-header-cell">操作</text>
</view>
</template>
<list-item v-for="item in configList" :key="item.id" class="list-row">
<text class="list-cell">{{ item.config_key }}</text>
<text class="list-cell">{{ item.default_value }}</text>
<text class="list-cell">{{ item.config_type }}</text>
<text class="list-cell">{{ item.default_key }}</text>
<text class="list-cell">{{ item.config_category }}</text>
<text class="list-cell">{{ item.is_translatable ? '是' : '否' }}</text>
<text class="list-cell">{{ item.sort_order }}</text>
<view class="list-cell">
<text
:class="['tag', item.is_active ? 'tag-success' : 'tag-default']">{{ item.is_active ? '启用' : '停用' }}</text>
</view>
<view class="list-cell">
<button size="mini" @click="openEditModal(item)">编辑</button>
</view>
</list-item>
</list-view>
<button type="default" @click="loadConfigList">刷新</button>
<!-- 编辑/新增弹窗 -->
<l-popup v-model="editPopupVisible" position="bottom" :closeable="true" @click-close="closeEditModal">
<view class="popup-content">
<view class="popup-title-bar">
<text class="popup-title">{{ editMode === 'add' ? '新增配置项' : '编辑配置项' }}</text>
<button class="popup-close-btn" @click="closeEditModal">关闭</button>
</view>
<form @submit="saveConfig">
<view class="form-item">
<text class="form-label">配置键</text>
<input v-model="editForm.config_key" placeholder="如 social_weibo_url" required />
</view>
<view class="form-item">
<text class="form-label">提示键</text>
<input v-model="editForm.default_key" placeholder="如 social_weibo_url" required />
</view>
<view class="form-item">
<text class="form-label">配置值</text>
<input v-model="editForm.default_value" placeholder="如 https://weibo.com/innovation-tech" required />
</view>
<view class="form-item">
<text class="form-label">类型</text>
<input v-model="editForm.config_type" placeholder="如 string" />
</view>
<view class="form-item">
<text class="form-label">分类</text>
<input v-model="editForm.config_category" placeholder="如 social" />
</view>
<view class="form-item">
<text class="form-label">可翻译</text>
<switch :checked="!!editForm.is_translatable" @change="e => editForm.is_translatable = e.detail.value" />
</view>
<view class="form-item">
<text class="form-label">排序</text>
<input v-model="editForm.sort_order" type="number" placeholder="如 31" />
</view>
<view class="form-item">
<text class="form-label">启用</text>
<switch :checked="!!editForm.is_active" @change="e => editForm.is_active = e.detail.value" />
</view>
<view class="popup-actions">
<button type="primary" form-type="submit">保存</button>
<button @click="closeEditModal" type="button">取消</button>
</view>
</form>
</view>
</l-popup>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, onMounted } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
type CompanyConfig = {
id ?: string
config_key : string
default_key : string
default_value : string
config_type : string
config_category ?: string
is_translatable ?: boolean
sort_order ?: number
is_active ?: boolean
created_at ?: string
updated_at ?: string
}
const configList = ref<Array<CompanyConfig>>([])
const isLoading = ref(false)
const editMode = ref<'add' | 'edit'>('add')
const editForm = reactive<CompanyConfig>({
config_key: '',
default_key:'',
default_value: '',
config_type: '',
config_category: '',
is_translatable: true,
sort_order: 0,
is_active: true
})
const editPopupVisible = ref(false)
function loadConfigList() {
isLoading.value = true
supa
.from('ak_global_config')
.select('*')
.order('created_at', { ascending: false })
.execute()
.then((res) => {
configList.value = res.data ?? []
})
.catch((err) => {
uni.showToast({ title: '加载失败', icon: 'none' })
})
.finally(() => {
isLoading.value = false
})
}
function openAddModal() {
editMode.value = 'add'
Object.assign(editForm, {
config_key: '',
default_value: '',
config_type: '',
config_category: '',
is_translatable: true,
sort_order: 0,
is_active: true
})
editPopupVisible.value = true
}
function openEditModal(item : CompanyConfig) {
editMode.value = 'edit'
Object.assign(editForm, item)
editPopupVisible.value = true
}
function closeEditModal() {
editPopupVisible.value = false
}
function saveConfig() {
if (!editForm.config_key || !editForm.default_value) {
uni.showToast({ title: '请填写必填项', icon: 'none' })
return
}
isLoading.value = true
if (editMode.value === 'add') {
supa
.from('ak_global_config')
.insert([editForm])
.execute()
.then(() => {
uni.showToast({ title: '新增成功', icon: 'success' })
loadConfigList()
closeEditModal()
})
.catch(() => {
uni.showToast({ title: '新增失败', icon: 'none' })
})
.finally(() => {
isLoading.value = false
})
} else {
supa
.from('ak_global_config')
.update(editForm)
.eq('id', editForm.id)
.execute()
.then(() => {
uni.showToast({ title: '保存成功', icon: 'success' })
loadConfigList()
closeEditModal()
})
.catch(() => {
uni.showToast({ title: '保存失败', icon: 'none' })
})
.finally(() => {
isLoading.value = false
})
}
}
function deleteConfig(id : string) {
uni.showModal({
title: '确认删除',
content: '确定要删除该配置项吗?',
success: (res) => {
if (res.confirm) {
isLoading.value = true
supa
.from('ak_global_config')
.delete()
.eq('id', id)
.then(() => {
uni.showToast({ title: '删除成功', icon: 'success' })
loadConfigList()
})
.catch(() => {
uni.showToast({ title: '删除失败', icon: 'none' })
})
.finally(() => {
isLoading.value = false
})
}
}
})
}
onMounted(() => {
loadConfigList()
})
</script>
<style>
.config-edit-page {
padding: 20px;
background: #f8fafc;
}
.page-title {
font-size: 22px;
font-weight: bold;
text-align: center;
margin-bottom: 18px;
color: #1f2937;
}
.config-list-view {
margin-bottom: 16px;
}
.list-header-row {
display: flex;
flex-direction: row;
background: #f1f5f9;
padding: 8px 0;
font-weight: bold;
border-bottom-width: 1px;
border-bottom-color: #e2e8f0;
}
.list-header-cell {
flex: 1;
text-align: center;
color: #475569;
font-size: 14px;
}
.list-row {
display: flex;
flex-direction: row;
align-items: center;
padding: 8px 0;
border-bottom-width: 1px;
border-bottom-color: #e5e7eb;
background: #fff;
}
.list-cell {
flex: 1;
text-align: center;
font-size: 13px;
color: #334155;
padding: 0 4px;
}
.tag {
padding: 2px 8px;
border-radius: 8px;
font-size: 12px;
color: #fff;
background: #9ca3af;
margin: 0 auto;
min-width: 40px;
text-align: center;
}
.tag-success {
background: #3b82f6;
}
.tag-default {
background: #9ca3af;
}
.popup-content {
padding: 20px;
}
.popup-title-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.popup-title {
font-size: 18px;
font-weight: bold;
color: #1f2937;
}
.popup-close-btn {
background: none;
border: none;
color: #475569;
font-size: 16px;
cursor: pointer;
}
.form-item {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 12px;
}
.form-label {
width: 80px;
font-size: 14px;
color: #475569;
margin-right: 8px;
}
input {
flex: 1;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 6px 10px;
font-size: 14px;
color: #334155;
background: #fff;
}
.picker-value {
padding: 6px 10px;
font-size: 14px;
color: #334155;
background: #f1f5f9;
border-radius: 6px;
}
.popup-actions {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 16px;
}
</style>

1000
pages/info/comments.uvue Normal file

File diff suppressed because it is too large Load Diff

1106
pages/info/detail.uvue Normal file

File diff suppressed because it is too large Load Diff

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>

1397
pages/info/search.uvue Normal file

File diff suppressed because it is too large Load Diff

1036
pages/info/settings.uvue Normal file

File diff suppressed because it is too large Load Diff

258
pages/info/test-utils.uts Normal file
View File

@@ -0,0 +1,258 @@
// 测试工具函数 - 验证多语言AI资讯系统各组件功能
// 严格遵循 UTS Android 开发规范
import type {
InfoContent,
TranslationData,
CategoryData,
ChatMessageData,
UserSettingsData,
PageState
} from './types.uts'
import {
getContentId,
getContentTitle,
getContentSummary,
getContentContent,
getContentAuthor,
getContentPublishedAt,
getContentQualityScore,
getContentViewCount,
getContentLikeCount,
getContentShareCount,
getContentOriginalLanguage,
getContentSourceUrl,
getContentTags,
formatDateTime,
formatRelativeTime,
getQualityScoreText,
getQualityScoreColor,
CATEGORIES,
LANGUAGES,
SORT_OPTIONS,
DEFAULT_PAGE_SIZE
} from './types.uts'
// 测试数据生成器
export const createMockContent = (): InfoContent => {
const content: InfoContent = {}
content.set('id', `content_${Date.now()}`)
content.set('title', 'AI技术发展趋势分析')
content.set('content', '人工智能技术正在快速发展...')
content.set('summary', '本文分析了AI技术的最新发展趋势')
content.set('author', 'AI研究院')
content.set('published_at', new Date().toISOString())
content.set('quality_score', 0.85)
content.set('view_count', 1250)
content.set('like_count', 89)
content.set('share_count', 23)
content.set('original_language', 'zh-CN')
content.set('source_url', 'https://example.com/ai-trends')
content.set('tags', ['人工智能', '机器学习', '深度学习'])
return content
}
export const createMockTranslation = (): TranslationData => {
const translation: TranslationData = {}
translation.set('id', `trans_${Date.now()}`)
translation.set('content_id', 'content_123')
translation.set('language_id', 'en-US')
translation.set('title', 'AI Technology Development Trends Analysis')
translation.set('content', 'Artificial intelligence technology is rapidly developing...')
translation.set('summary', 'This article analyzes the latest AI development trends')
translation.set('human_verified', true)
translation.set('quality_score', 0.82)
translation.set('created_at', new Date().toISOString())
return translation
}
export const createMockCategory = (): CategoryData => {
const category: CategoryData = {}
category.set('id', `cat_${Date.now()}`)
category.set('name_key', 'technology')
category.set('display_order', 1)
category.set('is_active', true)
category.set('icon', '')
category.set('color', '#3b82f6')
return category
}
export const createMockChatMessage = (type: string): ChatMessageData => {
const message: ChatMessageData = {}
message.set('id', `msg_${Date.now()}`)
message.set('session_id', 'session_123')
message.set('type', type) // 'user' | 'assistant'
message.set('content', type === 'user' ? '请推荐一些AI相关的资讯' : '为您推荐以下AI相关资讯...')
message.set('created_at', new Date().toISOString())
message.set('metadata', {})
return message
}
export const createMockUserSettings = (): UserSettingsData => {
const settings: UserSettingsData = {}
settings.set('user_id', 'user_123')
settings.set('interface_language', 'zh-CN')
settings.set('preferred_languages', ['zh-CN', 'en-US'])
settings.set('preferred_categories', ['technology', 'science'])
settings.set('auto_translate', true)
settings.set('notification_enabled', true)
settings.set('reading_mode', 'normal')
settings.set('font_size', 'medium')
settings.set('theme', 'light')
return settings
}
// 验证函数
export const validateContentData = (content: InfoContent): boolean => {
try {
// 验证必需字段
const id = getContentId(content)
const title = getContentTitle(content)
const contentText = getContentContent(content)
const author = getContentAuthor(content)
const publishedAt = getContentPublishedAt(content)
if (id === '' || title === '' || contentText === '' || author === '' || publishedAt === '') {
console.error('Content validation failed: missing required fields')
return false
}
// 验证数值字段
const qualityScore = getContentQualityScore(content)
const viewCount = getContentViewCount(content)
const likeCount = getContentLikeCount(content)
const shareCount = getContentShareCount(content)
if (qualityScore < 0 || qualityScore > 1) {
console.error('Content validation failed: invalid quality_score')
return false
}
if (viewCount < 0 || likeCount < 0 || shareCount < 0) {
console.error('Content validation failed: invalid count values')
return false
}
// 验证标签数组
const tags = getContentTags(content)
if (tags.length > 10) {
console.error('Content validation failed: too many tags')
return false
}
return true
} catch (error) {
console.error('Content validation error:', error)
return false
}
}
export const validateTranslationData = (translation: TranslationData): boolean => {
try {
const id = translation.get('id') as string
const contentId = translation.get('content_id') as string
const languageId = translation.get('language_id') as string
const title = translation.get('title') as string
const content = translation.get('content') as string
if (id === '' || contentId === '' || languageId === '' || title === '' || content === '') {
console.error('Translation validation failed: missing required fields')
return false
}
return true
} catch (error) {
console.error('Translation validation error:', error)
return false
}
}
// 性能测试函数
export const performanceTest = () => {
console.log('开始性能测试...')
const startTime = Date.now()
// 创建大量测试数据
const contents: Array<InfoContent> = []
for (let i: Int = 0; i < 1000; i++) {
contents.push(createMockContent())
}
// 测试数据处理速度
let validCount: Int = 0
for (let i: Int = 0; i < contents.length; i++) {
const content = contents[i]
if (validateContentData(content)) {
validCount++
}
}
const endTime = Date.now()
const duration = endTime - startTime
console.log(`性能测试完成:`)
console.log(`- 处理数据量: ${contents.length}`)
console.log(`- 有效数据: ${validCount}`)
console.log(`- 耗时: ${duration}ms`)
console.log(`- 平均处理速度: ${contents.length / duration * 1000}条/秒`)
}
// 功能测试函数
export const functionalTest = () => {
console.log('开始功能测试...')
// 测试内容数据处理
const mockContent = createMockContent()
console.log('✓ 内容数据创建成功')
const isValidContent = validateContentData(mockContent)
console.log(`✓ 内容数据验证: ${isValidContent ? '通过' : '失败'}`)
// 测试翻译数据处理
const mockTranslation = createMockTranslation()
console.log('✓ 翻译数据创建成功')
const isValidTranslation = validateTranslationData(mockTranslation)
console.log(`✓ 翻译数据验证: ${isValidTranslation ? '通过' : '失败'}`)
// 测试分类数据处理
const mockCategory = createMockCategory()
console.log('✓ 分类数据创建成功')
// 测试聊天消息处理
const mockUserMessage = createMockChatMessage('user')
const mockAssistantMessage = createMockChatMessage('assistant')
console.log('✓ 聊天消息创建成功')
// 测试用户设置处理
const mockSettings = createMockUserSettings()
console.log('✓ 用户设置创建成功')
// 测试工具函数
const testDateTime = new Date().toISOString()
const formattedDateTime = formatDateTime(testDateTime)
const relativeTime = formatRelativeTime(testDateTime)
console.log(`✓ 时间格式化测试: ${formattedDateTime}, ${relativeTime}`)
const qualityScore = 0.85
const qualityText = getQualityScoreText(qualityScore)
const qualityColor = getQualityScoreColor(qualityScore)
console.log(`✓ 质量分数测试: ${qualityText}, ${qualityColor}`)
console.log('功能测试完成!')
}
// 集成测试函数
export const runAllTests = () => {
console.log('=== 多语言AI资讯系统测试开始 ===')
try {
functionalTest()
performanceTest()
console.log('=== 所有测试通过 ===')
} catch (error) {
console.error('=== 测试失败 ===', error)
}
}

View File

@@ -0,0 +1,37 @@
<template>
<view class="i18n-test-page">
<view class="section">
<text class="section-title">{{ $t('mt.title.news') }}</text>
<text class="section-desc">{{ $t('prev') }}</text>
</view>
</view>
</template>
<script setup lang="uts">
import { tt } from '@/utils/i18nfun.uts'
// 也可在脚本中直接调用 tSmart参数智能判断
const techCategory = tt('prev')
console.log(techCategory)
// const enLang = tSmart('prev', 'en-US')
</script>
<style>
.i18n-test-page {
padding: 24px;
}
.section {
margin-bottom: 20px;
}
.section-title {
font-size: 18px;
font-weight: bold;
}
.section-desc {
color: #888;
}
</style>

View File

@@ -0,0 +1,915 @@
<!-- 专题详情页面 - 专题内容展示和管理 -->
<template>
<scroll-view direction="vertical" class="topic-detail" :scroll-y="true" :enable-back-to-top="true">
<!-- 专题头部 -->
<view class="topic-header" v-if="topicData !== null">
<view class="header-cover" :style="{ backgroundImage: `url(${topicData.cover_image})` }">
<view class="header-overlay">
<view class="back-btn" @click="goBack">
<text class="back-icon">←</text>
</view>
<view class="topic-badges">
<view class="type-badge" :style="{ backgroundColor: getTopicStatusColor(topicData.status) }">
<text class="badge-text">{{ $t('mt.topic.type.' + topicData.topic_type) }}</text>
</view>
</view>
</view>
</view>
<view class="header-info">
<text class="topic-title">{{ topicData.title }}</text>
<text class="topic-description">{{ topicData.description }}</text>
<view class="topic-meta">
<view class="meta-stats">
<text class="stat-item">📄 {{ topicData.content_count }}{{ $t('mt.topic.contentCountUnit') }}</text>
<text class="stat-item">👁 {{ topicData.view_count }}{{ $t('mt.topic.viewCountUnit') }}</text>
<text class="stat-item">📅 {{ formatRelativeTimeKey(topicData.updated_at) }}</text>
</view>
</view>
</view>
</view>
<!-- 专题内容区域 -->
<view class="topic-content">
<!-- 内容筛选 -->
<view class="content-filters">
<view class="filter-tabs">
<view
class="filter-tab"
:class="{ active: currentViewMode === 'timeline' }"
@click="setViewMode('timeline')">
<text class="filter-text">{{ $t('mt.topic.filter.timeline') }}</text>
</view>
<view
class="filter-tab"
:class="{ active: currentViewMode === 'category' }"
@click="setViewMode('category')">
<text class="filter-text">{{ $t('mt.topic.filter.category') }}</text>
</view>
<view
class="filter-tab"
:class="{ active: currentViewMode === 'quality' }"
@click="setViewMode('quality')">
<text class="filter-text">{{ $t('mt.topic.filter.quality') }}</text>
</view>
</view>
</view>
<!-- 加载状态 -->
<view class="loading-section" v-if="contentState.loading">
<text class="loading-text">{{ $t('loading') }}</text>
</view>
<!-- 错误状态 -->
<view class="error-section" v-if="contentState.error !== null">
<text class="error-text">{{ contentState.error }}</text>
<view class="retry-btn" @click="retryLoadContent">
<text class="retry-text">{{ $t('mt.common.retry') }}</text>
</view>
</view>
<!-- 时间轴视图 -->
<view class="timeline-view" v-if="currentViewMode === 'timeline' && topicContentsList.length > 0">
<view
v-for="(content, index) in topicContentsList"
:key="content.id"
class="timeline-item">
<view class="timeline-dot"></view>
<view class="timeline-content" @click="navigateToContent(content)">
<view class="timeline-header">
<text class="timeline-title">{{ content.title }}</text>
<text class="timeline-time">{{ formatRelativeTimeKey(content.published_at) }}</text>
</view>
<text class="timeline-summary">{{ content.summary }}</text>
<view class="timeline-meta">
<text class="meta-author">{{ content.author }}</text>
<view class="quality-badge" :style="{ backgroundColor: getQualityScoreColor(content.quality_score) }">
<text class="quality-text">{{ getQualityScoreText(content.quality_score) }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 分类视图 -->
<view class="category-view" v-if="currentViewMode === 'category' && topicContentsList.length > 0">
<view
v-for="(content, index) in topicContentsList"
:key="content.id"
class="category-item"
@click="navigateToContent(content)">
<view class="category-header">
<text class="category-title">{{ content.title }}</text>
<view class="category-badge">
<text class="category-text">{{ content.category_id }}</text>
</view>
</view>
<text class="category-summary">{{ content.summary }}</text>
<view class="category-footer">
<view class="category-stats">
<text class="stat-text">👁 {{ content.view_count }}</text>
<text class="stat-text">👍 {{ content.like_count }}</text>
</view>
<text class="category-time">{{ formatRelativeTimeKey(content.published_at) }}</text>
</view>
</view>
</view>
<!-- 精选视图 -->
<view class="quality-view" v-if="currentViewMode === 'quality' && topicContentsList.length > 0">
<view
v-for="(content, index) in qualityContentsList"
:key="content.id"
class="quality-item"
@click="navigateToContent(content)">
<view class="quality-header">
<text class="quality-title">{{ content.title }}</text>
<view class="quality-score" :style="{ backgroundColor: getQualityScoreColor(content.quality_score) }">
<text class="score-text">{{ getQualityScoreText(content.quality_score) }}</text>
</view>
</view>
<text class="quality-summary">{{ content.summary }}</text>
<view class="quality-footer">
<text class="quality-author">{{ content.author }}</text>
<view class="quality-stats">
<text class="stat-text">👁 {{ content.view_count }}</text>
<text class="stat-text">👍 {{ content.like_count }}</text>
<text class="stat-text">📤 {{ content.share_count }}</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view class="empty-section" v-if="topicContentsList.length === 0 && !contentState.loading && contentState.error === null">
<text class="empty-text">{{ $t('mt.common.empty') }}</text>
<view class="refresh-btn" @click="refreshContent">
<text class="refresh-text">{{ $t('mt.common.refresh') }}</text>
</view>
</view>
<!-- 加载更多 -->
<view class="load-more-section" v-if="topicContentsList.length > 0 && hasMoreContent">
<view class="load-more-btn" @click="loadMoreContent" v-if="!loadingMoreContent">
<text class="load-more-text">{{ $t('mt.common.loadMore') }}</text>
</view>
<view class="loading-more" v-if="loadingMoreContent">
<text class="loading-more-text">{{ $t('mt.common.loadingMore') }}</text>
</view>
</view>
</view>
<!-- 相关专题推荐 -->
<view class="related-topics" v-if="relatedTopicsList.length > 0">
<view class="section-header">
<text class="section-title">{{ $t('mt.topic.related') }}</text>
</view>
<scroll-view direction="horizontal" class="related-scroll" :scroll-x="true">
<view class="related-list">
<view
v-for="(topic, index) in relatedTopicsList"
:key="topic.id"
class="related-item"
@click="navigateToTopic(topic)">
<text class="related-title">{{ topic.title }}</text>
<text class="related-desc">{{ topic.description }}</text>
<view class="related-stats">
<text class="related-stat">{{ topic.content_count }}{{ $t('mt.topic.contentCountUnit') }}</text>
<text class="related-stat">{{ topic.view_count }}{{ $t('mt.topic.viewCountUnit') }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 评论区域 -->
<view class="comments-section" v-if="topicData !== null">
<Comments
:targetType="'topic'"
:targetId="topicData.id"
:userId="currentUserId"
:userName="currentUserName">
</Comments>
</view>
<!-- 底部操作栏 -->
<view class="bottom-actions">
<view class="action-item" @click="shareTopic">
<text class="action-icon">📤</text>
<text class="action-text">{{ $t('mt.topic.action.share') }}</text>
</view>
<view class="action-item" @click="subscribeTopic">
<text class="action-icon">🔔</text>
<text class="action-text">{{ $t('mt.topic.action.subscribe') }}</text>
</view>
<view class="action-item" @click="openChat">
<text class="action-icon">💬</text>
<text class="action-text">{{ $t('mt.topic.action.aiAssistant') }}</text>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import Comments from './comments.uvue'
import {
Topic,
InfoContent,
PageState,
formatRelativeTimeKey,
getTopicStatusColor,
getQualityScoreText,
getQualityScoreColor
} from './types.uts'
import supa from '@/components/supadb/aksupainstance.uts'
import { tt } from '@/utils/i18nfun.uts'
import i18n from '@/i18n/index.uts' // 保留用于语言切换
// 页面参数
const topicId = ref<string>('')
// 专题数据
const topicData = ref<Topic | null>(null)
// 内容状态
const contentState = ref<PageState>({
loading: false,
error: null,
currentPage: 1,
pageSize: 20,
total: 0
})
// UI状态
const currentViewMode = ref<string>('timeline')
const hasMoreContent = ref<boolean>(true)
const loadingMoreContent = ref<boolean>(false)
const contentPageSize = ref<number>(20)
// 用户信息 - 评论功能需要
const currentUserId = ref<string>('user-demo-001')
const currentUserName = ref<string>('演示用户')
// 数据列表
const topicContentsList = ref<Array<InfoContent>>([])
const relatedTopicsList = ref<Array<Topic>>([])
// 计算属性
const qualityContentsList = computed((): Array<InfoContent> => {
return topicContentsList.value.filter(c => c.quality_score >= 0.8)
})
const topicContentFilter = computed((): string => {
let filter = `topic_id=eq.${topicId.value}`
// 根据视图模式调整排序
if (currentViewMode.value === 'timeline') {
filter += "&order=display_order.asc"
} else if (currentViewMode.value === 'category') {
filter += "&order=category_id.asc,display_order.asc"
} else if (currentViewMode.value === 'quality') {
filter += "&order=quality_score.desc,display_order.asc"
}
return filter
})
// 生命周期
onLoad((options: OnLoadOptions) => {
if (options.id !== undefined) {
topicId.value = options.id as string
}
if (topicId.value !== '') {
loadTopicData()
loadTopicContents()
loadRelatedTopics()
}
})
// 加载专题数据
const loadTopicData = async () => {
try {
const result = await supa.from('ak_topics')
.select('*')
.eq('id', topicId.value)
.single()
.executeAs<Topic>()
if (result.error !== null) {
console.error('Topic data loading error:', result.error)
return
}
const data = result.data
if (data !== null) {
topicData.value = data
}
} catch (e: any) {
console.error('Topic data loading error:', e)
}
}
// 加载专题内容
const loadTopicContents = async () => {
contentState.value.loading = true
contentState.value.error = null
try {
// TODO: 替换为实际接口调用
// 这里模拟异步加载
await new Promise(resolve => setTimeout(resolve, 500))
// 假设返回模拟数据
const mockContents: Array<InfoContent> = [
{
id: 'content-1',
title: 'AI赋能医疗行业',
summary: '人工智能正在深刻改变医疗行业的诊断和服务模式。',
content: '',
author: '张三',
published_at: '2025-07-01T10:00:00Z',
quality_score: 92,
view_count: 1200,
like_count: 88,
share_count: 12,
category_id: 'health',
original_language: 'zh-CN',
source_url: '',
tags: ['AI', '医疗'],
created_at: '2025-07-01T09:00:00Z',
updated_at: '2025-07-01T10:00:00Z'
}
]
topicContentsList.value = mockContents
// 可根据分页逻辑设置 hasMoreContent
hasMoreContent.value = false
} catch (e) {
contentState.value.error = '加载失败,请稍后重试'
} finally {
contentState.value.loading = false
}
}
// 处理专题内容数据
const handleTopicContentsData = (data: any) => {
contentState.value.loading = false
contentState.value.error = null
if (data !== null && Array.isArray(data)) {
const newContents = data as Array<InfoContent>
if (contentState.value.currentPage === 1) {
topicContentsList.value = newContents
} else {
topicContentsList.value = topicContentsList.value.concat(newContents)
}
hasMoreContent.value = newContents.length === contentPageSize.value
}
}
// 加载相关专题
const loadRelatedTopics = () => {
// 模拟相关专题数据
const relatedTopic: Topic = {
id: 'topic-related-1',
title: '机器学习算法详解',
description: '深入解析机器学习核心算法原理和应用',
created_by: '',
is_active: true,
content_count: 12,
created_at: '',
updated_at: '',
view_count: 15600
}
relatedTopicsList.value = [relatedTopic]
}
// 错误处理
const handleContentError = (error: any) => {
contentState.value.loading = false
contentState.value.error = '加载失败,请稍后重试'
console.error('Topic contents loading error:', error)
}
// 视图模式切换
const setViewMode = (mode: string) => {
currentViewMode.value = mode
contentState.value.currentPage = 1
loadTopicContents()
}
// 加载更多内容
const loadMoreContent = () => {
if (loadingMoreContent.value || !hasMoreContent.value) return
loadingMoreContent.value = true
contentState.value.currentPage += 1
setTimeout(() => {
loadTopicContents()
loadingMoreContent.value = false
}, 500)
}
// 刷新内容
const refreshContent = () => {
contentState.value.currentPage = 1
loadTopicContents()
}
// 重试加载内容
const retryLoadContent = () => {
contentState.value.error = null
loadTopicContents()
}
// 导航函数
const goBack = () => {
uni.navigateBack()
}
const navigateToContent = (content: InfoContent) => {
const contentId = content.id
uni.navigateTo({
url: `/pages/info/detail?id=${contentId}&from=topic`
})
}
const navigateToTopic = (topic: Topic) => {
const relatedTopicId = topic.id
uni.navigateTo({
url: `/pages/info/topic-detail?id=${relatedTopicId}`
})
}
// 操作函数
const shareTopic = () => {
// 分享专题
uni.showToast({
title: '分享功能开发中',
icon: 'none'
})
}
const subscribeTopic = () => {
// 订阅专题
uni.showToast({
title: '订阅功能开发中',
icon: 'none'
})
}
const openChat = () => {
// 打开AI助手
uni.navigateTo({
url: `/pages/info/chat?context=topic&id=${topicId.value}`
})
}
</script>
<style>
.topic-detail {
flex: 1;
background-color: #f8fafc;
}
.topic-header {
background-color: #ffffff;
margin-bottom: 8px;
}
.header-cover {
height: 200px;
background-color: #8b5cf6;
background-size: cover;
background-position: center;
position: relative;
}
.header-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
padding: 16px;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.back-btn {
background-color: rgba(255, 255, 255, 0.9);
border-radius: 20px;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
}
.back-icon {
font-size: 18px;
color: #1f2937;
}
.topic-badges {
flex-direction: row;
}
.type-badge {
background-color: #8b5cf6;
border-radius: 12px;
padding: 4px 8px;
}
.badge-text {
font-size: 12px;
color: #ffffff;
}
.header-info {
padding: 20px;
}
.topic-title {
font-size: 24px;
font-weight: bold;
color: #1f2937;
line-height: 32px;
margin-bottom: 12px;
}
.topic-description {
font-size: 16px;
color: #64748b;
line-height: 24px;
margin-bottom: 16px;
}
.topic-meta {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.meta-stats {
flex-direction: row;
align-items: center;
}
.stat-item {
font-size: 14px;
color: #94a3b8;
margin-right: 16px;
}
.topic-content {
background-color: #ffffff;
padding: 16px 0;
}
.content-filters {
padding: 0 16px 16px 16px;
border-bottom-width: 1px;
border-bottom-color: #e2e8f0;
}
.filter-tabs {
flex-direction: row;
background-color: #f1f5f9;
border-radius: 24px;
padding: 4px;
}
.filter-tab {
flex: 1;
padding: 8px 16px;
align-items: center;
border-radius: 20px;
}
.filter-tab.active {
background-color: #8b5cf6;
}
.filter-text {
font-size: 14px;
color: #64748b;
}
.filter-tab.active .filter-text {
color: #ffffff;
}
.timeline-view {
padding: 16px;
}
.timeline-item {
flex-direction: row;
margin-bottom: 24px;
position: relative;
}
.timeline-dot {
width: 12px;
height: 12px;
border-radius: 6px;
background-color: #8b5cf6;
margin-top: 6px;
margin-right: 16px;
}
.timeline-content {
flex: 1;
background-color: #f8fafc;
border-radius: 12px;
padding: 16px;
border-width: 1px;
border-color: #e2e8f0;
}
.timeline-header {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.timeline-title {
flex: 1;
font-size: 16px;
font-weight: bold;
color: #1f2937;
line-height: 22px;
margin-right: 12px;
}
.timeline-time {
font-size: 12px;
color: #94a3b8;
}
.timeline-summary {
font-size: 14px;
color: #64748b;
line-height: 20px;
margin-bottom: 12px;
}
.timeline-meta {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.meta-author {
font-size: 12px;
color: #94a3b8;
}
.quality-badge {
background-color: #10b981;
border-radius: 10px;
padding: 2px 6px;
}
.quality-text {
font-size: 11px;
color: #ffffff;
}
.category-view, .quality-view {
padding: 16px;
}
.category-item, .quality-item {
background-color: #f8fafc;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
border-width: 1px;
border-color: #e2e8f0;
}
.category-header, .quality-header {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.category-title, .quality-title {
flex: 1;
font-size: 16px;
font-weight: bold;
color: #1f2937;
line-height: 22px;
margin-right: 12px;
}
.category-badge {
background-color: #3b82f6;
border-radius: 10px;
padding: 2px 8px;
}
.category-text {
font-size: 12px;
color: #ffffff;
}
.quality-score {
background-color: #10b981;
border-radius: 10px;
padding: 2px 8px;
}
.score-text {
font-size: 12px;
color: #ffffff;
}
.category-summary, .quality-summary {
font-size: 14px;
color: #64748b;
line-height: 20px;
margin-bottom: 12px;
}
.category-footer, .quality-footer {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.category-stats, .quality-stats {
flex-direction: row;
align-items: center;
}
.stat-text {
font-size: 12px;
color: #94a3b8;
margin-right: 12px;
}
.category-time, .quality-author {
font-size: 12px;
color: #94a3b8;
}
.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;
}
.related-topics {
background-color: #ffffff;
margin-top: 8px;
padding: 16px 0;
}
.section-header {
padding: 0 16px 12px 16px;
}
.section-title {
font-size: 18px;
font-weight: bold;
color: #1f2937;
}
.related-scroll {
height: 120px;
}
.related-list {
flex-direction: row;
padding-left: 16px;
padding-right: 16px;
}
.related-item {
width: 200px;
background-color: #f8fafc;
border-radius: 12px;
padding: 12px;
margin-right: 12px;
border-width: 1px;
border-color: #e2e8f0;
}
.related-title {
font-size: 14px;
font-weight: bold;
color: #1f2937;
line-height: 20px;
margin-bottom: 4px;
}
.related-desc {
font-size: 12px;
color: #64748b;
line-height: 16px;
margin-bottom: 8px;
}
.related-stats {
flex-direction: row;
justify-content: space-between;
}
.related-stat {
font-size: 11px;
color: #94a3b8;
}
.comments-section {
background-color: #ffffff;
margin-top: 8px;
}
.bottom-actions {
background-color: #ffffff;
flex-direction: row;
padding: 16px;
border-top-width: 1px;
border-top-color: #e2e8f0;
}
.action-item {
flex: 1;
align-items: center;
padding: 12px;
}
.action-icon {
font-size: 20px;
margin-bottom: 4px;
}
.action-text {
font-size: 12px;
color: #64748b;
}
</style>

829
pages/info/topics.uvue Normal file
View File

@@ -0,0 +1,829 @@
<!-- 专题页面 - 专题列表和专题详情 -->
<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>

708
pages/info/types.uts Normal file
View File

@@ -0,0 +1,708 @@
import { tt } from '@/utils/i18nfun.uts'
// 获取语言本地化 key如 mt.language.zh-CN用于 $t()
export const getLanguageDisplayNameKey = (code: string): string => {
if (code === 'zh-CN') return 'mt.language.zh-CN'
if (code === 'zh-TW') return 'mt.language.zh-TW'
if (code === 'en-US') return 'mt.language.en-US'
if (code === 'ja-JP') return 'mt.language.ja-JP'
if (code === 'ko-KR') return 'mt.language.ko-KR'
if (code === 'fr-FR') return 'mt.language.fr-FR'
if (code === 'de-DE') return 'mt.language.de-DE'
if (code === 'es-ES') return 'mt.language.es-ES'
return code
}
// 基础数据类型 - 全部使用强类型定义,便于类型推断和类型安全
export type InfoContent = {
id: string
title?: string
summary?: string | null
content?: string
author?: string
trans_data?:TranslationData
published_at?: string
quality_score: number
view_count?: number
like_count?: number
share_count?: number
category_id?: string
category_name?: CategoryTranslation
category_name_text?: string // 兼容性字段,用于简单的字符串显示
original_language?: string
source_url?: string | null
tags?: string[] | null
created_at?: string
updated_at?: string
is_liked?: boolean
loading?: boolean // UI状态字段用于加载状态显示
// 扩展字段 - 支持视频、音频和图集模式
raw_content_id?: string | null
keywords?: string[] | null
entities?: any | null // jsonb
sentiment_score?: number | null
readability_score?: number | null
credibility_score?: number | null
comment_count?: number | null
featured_until?: string | null
status?: string | null
ai_processed_at?: string | null
favorite_count?: number | null
is_featured?: boolean | null
content_type?: string | null
// 视频相关字段
video_url?: string | null
video_duration?: number | null
video_poster?: string | null
video_width?: number | null
video_height?: number | null
video_size?: number | null
video_format?: string | null
video_quality?: string | null
// 音频相关字段
audio_url?: string | null
audio_duration?: number | null
audio_size?: number | null
audio_format?: string | null
audio_bitrate?: number | null
audio_sample_rate?: number | null
audio_cover?: string | null
// 图片相关字段
image_url?: string | null
image_width?: number | null
image_height?: number | null
image_size?: number | null
image_format?: string | null
image_quality?: string | null
image_alt_text?: string | null
images?: any | null // jsonb - 图集模式
// 多媒体设置
allow_danmu?: boolean | null
allow_download?: boolean | null
media_metadata?: any | null // jsonb
// 序列号字段
cid?: number | null
}
export type TranslationData = {
id: string
content_id: string
language_id: string
title: string
content: string
summary: string | null
human_verified: boolean
created_at: string
updated_at: string
}
// 新增CategoryTranslation 类型
export type CategoryTranslation = {
name: string
}
// 新增CategoryData 类型,严格对应 Supabase 返回结构
export type CategoryData = {
id: string
name_key: string
parent_id: string | null
level?: number
ai_keywords?: string[]
confidence_threshold?: number
sort_order?: number
is_active?: boolean
created_at?: string
updated_at?: string
translations?: CategoryTranslation[]
}
// 新增专题相关类型
export type TopicData = {
id: string
title: string
description: string
created_by: string
is_active: boolean
content_count: number
created_at: string
updated_at: string
}
export type TopicContentData = {
id: string
topic_id: string
content_id: string
display_order: number
created_at: string
updated_at: string
is_liked?:boolean
}
export type TopicTimelineData = {
id: string
topic_id: string
event: string
event_time: string
created_at: string
updated_at: string
}
// 评论系统相关类型
export type CommentData = {
id: string
content_id: string
user_id: string
user_name: string
content: string
like_count: number
reply_count: number
status: string
created_at: string
updated_at: string
is_liked?: boolean
level?: number // 多级评论层级0为主评论1为一级回复依此类推
is_author?: boolean // 是否为当前用户本人评论
}
export type CommentReplyData = {
id: string
comment_id: string
user_id: string
user_name: string
content: string
created_at: string
updated_at: string
is_liked?:boolean
}
export type CommentReactionData = {
id: string
comment_id: string
user_id: string
reaction_type: string
created_at: string
is_liked?:boolean
}
export type UserBehaviorData = {
id: string
user_id: string
content_id: string
behavior_type: string
behavior_data: any
duration_seconds: number | null
scroll_percentage: number | null
device_type: string
source: string
session_id: string
ip_address: string
user_agent: string
created_at: string
}
export type RecommendationData = {
id: string
user_id: string
content_id: string
algorithm_type: string
score: number
reason: string
position: number
shown_at: string
clicked_at: string
feedback_score: number
feedback_reason: string
created_at: string
}
export type ChatSessionData = {
id: string
user_id: string
session_name: string
language: string
context: any
ai_model: string
total_messages: number
total_tokens: number
cost_usd: number
last_message_at: string
is_active: boolean
created_at: string
updated_at: string
}
export type ChatMessageData = {
id: string
session_id: string
message_type: string
content: string
intent?: string
attachments?: any
ai_provider?: string
tokens_used?: number
processing_time_ms?: number
cost_usd?: number
feedback_score?: number
feedback_reason?: string
created_at?: string
}
export type LanguageData = {
id: string
code: string
name: string
native_name: string
is_active: boolean
}
export type UserSettingsData = {
id: string
user_id: string
preferred_languages: string[]
preferred_categories: string[]
reading_mode: string
font_size: string
auto_translate: boolean
notification_enabled: boolean
created_at: string
updated_at: string
}
export type SearchHistoryData = {
id: string
user_id: string
keyword: string
searched_at: string
}
// 已合并到 TranslationData避免重复定义
// export type Translation = TranslationData
export type Topic = {
id: string
title: string
description: string
topic_type?: string
status?: string
cover_image?: string
created_by?: string
is_active?: boolean
content_count?: number
view_count?: number
created_at?: string
updated_at?: string
}
export type Comment = {
id: string
content_id: string
user_id: string
user_name: string
content: string
like_count: number
reply_count: number
created_at: string
updated_at: string
}
export type Language = {
id: string
code: string
name: string
native_name: string
is_active: boolean
}
// 状态和UI类型 - 与template交互的变量使用1维变量
export type PageState = {
loading: boolean
error: string | null
currentPage: number
pageSize: number
total: number
}
export type StatsData = {
total_contents: number
published_contents: number
trending_contents: number
avg_quality_score: string
}
export type ResponsiveState = {
isLargeScreen: boolean
isSmallScreen: boolean
screenWidth: number
cardColumns: number
}
// 选择器选项类型 - UTS Android支持的简单类型
export type PickerOption = {
value: string
text: string
}
export type SortOption = {
column: string
ascending: boolean
}
// 表单数据类型 - 避免复杂嵌套,使用简单类型
export type ContentFormData = {
title: string
content: string
summary: string
category_id: string
tags: string // 改为字符串,用逗号分隔
source_url: string
author: string
content_type?: string
// 视频相关字段
video_url?: string
video_duration?: number
video_poster?: string
video_width?: number
video_height?: number
video_quality?: string
// 音频相关字段
audio_url?: string
audio_duration?: number
audio_cover?: string
audio_format?: string
// 图片相关字段
image_url?: string
image_alt_text?: string
images?: string // JSON字符串存储图集数据
// 多媒体设置
allow_danmu?: boolean
allow_download?: boolean
}
export type TranslationFormData = {
content_id: string
language_id: string
title: string
content: string
summary: string
}
// 筛选器类型 - 使用简单字符串类型
export type ContentFilterData = {
category_id: string | null
language: string | null
status: string
quality_min: string | null
date_from: string | null
date_to: string | null
search_text: string | null
date_range?: string | null
content_type?: string | null // 新增:按内容类型筛选
is_featured?: boolean | null // 新增:是否精选
has_video?: boolean | null // 新增:是否包含视频
has_audio?: boolean | null // 新增:是否包含音频
has_images?: boolean | null // 新增:是否包含图片
}
// 聊天相关类型
export type ChatState = {
isTyping: boolean
currentSession: string |null
messageCount: number
}
// 用户偏好类型 - 使用字符串存储数组数据
export type UserPreferences = {
preferred_languages: string // JSON字符串存储数组
preferred_categories: string // JSON字符串存储数组
reading_mode: string // 'light', 'dark', 'auto'
font_size: string // 'small', 'medium', 'large'
auto_translate: boolean
notification_enabled: boolean
}
// 常量定义 - 内容状态
export const CONTENT_STATUS = {
DRAFT: 'draft',
PUBLISHED: 'published',
ARCHIVED: 'archived',
DELETED: 'deleted'
}
// 内容类型常量 - 支持多媒体
export const CONTENT_TYPES = {
TEXT: 'text', // 纯文本
IMAGE: 'image', // 图片
VIDEO: 'video', // 视频
AUDIO: 'audio', // 音频
GALLERY: 'gallery', // 图集
MIXED: 'mixed' // 混合内容
}
// 通用选项类型(用于 value/text 结构的所有 option
export type OptionItem = {
value: string
text: string
}
// 视频质量选项
export const VIDEO_QUALITY_OPTIONS: Array<OptionItem> = [
{ value: '4K', text: 'mt.video.quality.4k' },
{ value: '1080P', text: 'mt.video.quality.1080p' },
{ value: '720P', text: 'mt.video.quality.720p' },
{ value: '480P', text: 'mt.video.quality.480p' },
{ value: '360P', text: 'mt.video.quality.360p' }
]
// 音频格式选项
export const AUDIO_FORMAT_OPTIONS: Array<OptionItem> = [
{ value: 'mp3', text: 'mt.audio.format.mp3' },
{ value: 'wav', text: 'mt.audio.format.wav' },
{ value: 'flac', text: 'mt.audio.format.flac' },
{ value: 'aac', text: 'mt.audio.format.aac' },
{ value: 'm4a', text: 'mt.audio.format.m4a' }
]
// 行为类型常量
export const BEHAVIOR_TYPES = {
VIEW: 'view',
LIKE: 'like',
SHARE: 'share',
COMMENT: 'comment',
SAVE: 'save',
CLICK: 'click'
}
// 消息类型常量
export const MESSAGE_TYPES = {
USER: 'user',
ASSISTANT: 'assistant',
SYSTEM: 'system'
}
// 专题类型常量
export const TOPIC_TYPES: Array<OptionItem> = [
{ value: 'breaking', text: 'mt.topicType.breaking' },
{ value: 'trending', text: 'mt.topicType.trending' },
{ value: 'series', text: 'mt.topicType.series' },
{ value: 'analysis', text: 'mt.topicType.analysis' },
{ value: 'guide', text: 'mt.topicType.guide' },
{ value: 'interview', text: 'mt.topicType.interview' },
{ value: 'report', text: 'mt.topicType.report' },
{ value: 'timeline', text: 'mt.topicType.timeline' }
]
// 专题状态常量
export const TOPIC_STATUS = {
DRAFT: 'draft',
ACTIVE: 'active',
FEATURED: 'featured',
ARCHIVED: 'archived',
CLOSED: 'closed'
}
// 评论状态常量
export const COMMENT_STATUS = {
ACTIVE: 'active',
HIDDEN: 'hidden',
DELETED: 'deleted',
PENDING_REVIEW: 'pending_review',
REJECTED: 'rejected'
}
// 评论类型常量
export const COMMENT_TYPES = {
CONTENT: 'content', // 内容评论
TOPIC: 'topic', // 专题评论
REPLY: 'reply' // 回复评论
}
// 评论排序选项
export const COMMENT_SORT_OPTIONS: Array<OptionItem> = [
{ value: 'created_at_desc', text: 'mt.comment.sort.latest' },
{ value: 'created_at_asc', text: 'mt.comment.sort.earliest' },
{ value: 'like_count_desc', text: 'mt.comment.sort.mostLiked' },
{ value: 'reply_count_desc', text: 'mt.comment.sort.mostReplied' }
]
// 评论举报类型
export const COMMENT_REPORT_TYPES: Array<OptionItem> = [
{ value: 'spam', text: 'mt.comment.report.spam' },
{ value: 'inappropriate', text: 'mt.comment.report.inappropriate' },
{ value: 'harassment', text: 'mt.comment.report.harassment' },
{ value: 'misinformation', text: 'mt.comment.report.misinformation' },
{ value: 'copyright', text: 'mt.comment.report.copyright' },
{ value: 'other', text: 'mt.comment.report.other' }
]
// 语言选项常量
export const LANGUAGE_OPTIONS: Array<LanguageData> = [
{ id: 'zh-CN', code: 'zh-CN', name: 'mt.language.zh-CN', native_name: 'mt.language.zh-CN', is_active: true },
{ id: 'zh-TW', code: 'zh-TW', name: 'mt.language.zh-TW', native_name: 'mt.language.zh-TW', is_active: true },
{ id: 'en-US', code: 'en-US', name: 'mt.language.en-US', native_name: 'mt.language.en-US', is_active: true },
{ id: 'ja-JP', code: 'ja-JP', name: 'mt.language.ja-JP', native_name: 'mt.language.ja-JP', is_active: true },
{ id: 'ko-KR', code: 'ko-KR', name: 'mt.language.ko-KR', native_name: 'mt.language.ko-KR', is_active: true },
{ id: 'fr-FR', code: 'fr-FR', name: 'mt.language.fr-FR', native_name: 'mt.language.fr-FR', is_active: true },
{ id: 'de-DE', code: 'de-DE', name: 'mt.language.de-DE', native_name: 'mt.language.de-DE', is_active: true },
{ id: 'es-ES', code: 'es-ES', name: 'mt.language.es-ES', native_name: 'mt.language.es-ES', is_active: true }
]
export const SORT_OPTIONS: Array<OptionItem> = [
{ value: 'published_at_desc', text: 'mt.sort.latest' },
{ value: 'published_at_asc', text: 'mt.sort.earliest' },
{ value: 'quality_score_desc', text: 'mt.sort.highestScore' },
{ value: 'view_count_desc', text: 'mt.sort.mostViewed' },
{ value: 'like_count_desc', text: 'mt.sort.mostLiked' },
{ value: 'share_count_desc', text: 'mt.sort.mostShared' }
]
export const getCommentStatusTextKey = (status: string): string => {
console.log(status,COMMENT_STATUS.HIDDEN)
if (status === COMMENT_STATUS["ACTIVE"]) return 'mt.comment.status.active'
if (status === COMMENT_STATUS.HIDDEN) return 'mt.comment.status.hidden'
if (status === COMMENT_STATUS.DELETED) return 'mt.comment.status.deleted'
if (status === COMMENT_STATUS.PENDING_REVIEW) return 'mt.comment.status.pending'
if (status === COMMENT_STATUS.REJECTED) return 'mt.comment.status.rejected'
return 'mt.comment.status.unknown'
}
// 格式化相对时间,返回 i18n key
export const formatRelativeTimeKey = (dateString: string | null): string => {
if (dateString == null || dateString === '') return ''
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 (days > 0) return days + tt('mt.time.daysAgo')
if (hours > 0) return hours + tt('mt.time.hoursAgo')
if (minutes > 0) return minutes + tt('mt.time.minutesAgo')
return tt('mt.time.justNow')
}
// 语言显示名称,返回本地化字符串
export const getLanguageDisplayName = (code: string): string => {
const map = {
'zh-CN': 'mt.language.zh-CN',
'zh-TW': 'mt.language.zh-TW',
'en-US': 'mt.language.en-US',
'ja-JP': 'mt.language.ja-JP',
'ko-KR': 'mt.language.ko-KR',
'fr-FR': 'mt.language.fr-FR',
'de-DE': 'mt.language.de-DE',
'es-ES': 'mt.language.es-ES'
}
const key = map[code] ?? code
return tt(key)
}
// 质量分数对应颜色,返回颜色字符串(如需 className 可调整)
export const getQualityScoreColor = (score: number): string => {
if (score >= 90) return '#4CAF50' // excellent - green
if (score >= 75) return '#8BC34A' // good - light green
if (score >= 60) return '#FFC107' // normal - amber
return '#F44336' // poor - red
}
// 质量分数对应文本,返回 i18n key
export const getQualityScoreText = (score: number): string => {
if (score >= 90) return tt('mt.quality.excellent')
if (score >= 75) return tt('mt.quality.good')
if (score >= 60) return tt('mt.quality.normal')
return tt('mt.quality.poor')
}
// 专题类型显示名称,返回本地化字符串
export const getTopicTypeDisplayName = (typeCode: string): string => {
const typeItem = TOPIC_TYPES.find(item => item.value === typeCode);
return typeItem != null ? tt(typeItem.text) : typeCode;
};
// 专题状态对应颜色,返回颜色字符串
export const getTopicStatusColor = (status: string): string => {
if (status === TOPIC_STATUS.FEATURED) return '#FF6B35' // featured - orange
if (status === TOPIC_STATUS.ACTIVE) return '#4CAF50' // active - green
if (status === TOPIC_STATUS.DRAFT) return '#9E9E9E' // draft - gray
if (status === TOPIC_STATUS.ARCHIVED) return '#607D8B' // archived - blue gray
if (status === TOPIC_STATUS.CLOSED) return '#F44336' // closed - red
return '#9E9E9E' // default - gray
}
// 获取内容类型显示名称,返回本地化字符串
export const getContentTypeDisplayName = (contentType: string | null): string => {
if (contentType === CONTENT_TYPES.TEXT) return tt('mt.content.type.text')
if (contentType === CONTENT_TYPES.IMAGE) return tt('mt.content.type.image')
if (contentType === CONTENT_TYPES.VIDEO) return tt('mt.content.type.video')
if (contentType === CONTENT_TYPES.AUDIO) return tt('mt.content.type.audio')
if (contentType === CONTENT_TYPES.GALLERY) return tt('mt.content.type.gallery')
if (contentType === CONTENT_TYPES.MIXED) return tt('mt.content.type.mixed')
return tt('mt.content.type.text') // 默认为文本
}
// 获取内容类型对应图标
export const getContentTypeIcon = (contentType: string | null): string => {
if (contentType === CONTENT_TYPES.TEXT) return 'text-outline'
if (contentType === CONTENT_TYPES.IMAGE) return 'image-outline'
if (contentType === CONTENT_TYPES.VIDEO) return 'videocam-outline'
if (contentType === CONTENT_TYPES.AUDIO) return 'volume-high-outline'
if (contentType === CONTENT_TYPES.GALLERY) return 'images-outline'
if (contentType === CONTENT_TYPES.MIXED) return 'layers-outline'
return 'text-outline' // 默认图标
}
// 格式化文件大小
export const formatFileSize = (bytes: number | null): string => {
if (bytes == null || bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 格式化时长(秒转为 mm:ss 或 hh:mm:ss
export const formatDuration = (seconds: number | null): string => {
if (seconds == null || seconds === 0) return '00:00'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const remainingSeconds = Math.floor(seconds % 60)
if (hours > 0) {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
} else {
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
}
}
// 检查内容是否有多媒体
export const hasMultimedia = (content: InfoContent): boolean => {
return !!(content.video_url || content.audio_url || content.image_url || (content.images && content.images !== null))
}
// 获取主要媒体类型
export const getPrimaryMediaType = (content: InfoContent): string => {
if (content.video_url) return CONTENT_TYPES.VIDEO
if (content.audio_url) return CONTENT_TYPES.AUDIO
if (content.images && content.images !== null) return CONTENT_TYPES.GALLERY
if (content.image_url) return CONTENT_TYPES.IMAGE
return CONTENT_TYPES.TEXT
}

1204
pages/info/video-player.uvue Normal file

File diff suppressed because it is too large Load Diff

277
pages/info/video-types.uts Normal file
View File

@@ -0,0 +1,277 @@
import { tt } from '@/utils/i18nfun.uts'
// 视频内容类型扩展
export type VideoContent = {
id: string
title: string
summary: string | null
content: string
author: string
published_at: string
quality_score: number
category_id: string
original_language: string
source_url: string | null
tags: string[] | null
created_at: string
updated_at: string
// 视频特有字段
content_type: 'video'
video_url: string
video_duration: number
video_poster: string
video_width: number
video_height: number
video_size: number
video_format: string
video_quality: string
allow_danmu: boolean
allow_download: boolean
// 统计数据
view_count: number
like_count: number
favorite_count: number
share_count: number
comment_count: number
danmu_count: number
play_completion_rate: number
average_play_duration: number
// 用户状态
is_liked?: boolean
is_favorited?: boolean
}
// 弹幕类型
export type DanmuData = {
id: string
content_id: string
user_id: string
user_name: string
text: string
time_point: number
color: string
font_size: number
position_type: 'scroll' | 'top' | 'bottom'
speed: number
is_visible: boolean
status: string
created_at: string
}
// 弹幕发送数据
export type DanmuSendData = {
text: string
time_point: number
color?: string
font_size?: number
position_type?: 'scroll' | 'top' | 'bottom'
speed?: number
}
// 用户交互类型
export type UserInteraction = {
id: string
user_id: string
content_id: string
interaction_type: 'like' | 'favorite' | 'share' | 'view' | 'download'
interaction_data?: any
created_at: string
}
// 视频评论类型
export type VideoComment = {
id: string
content_id: string
user_id: string
user_name: string
parent_id: string | null
reply_to_user_id: string | null
reply_to_user_name: string | null
content: string
like_count: number
reply_count: number
status: string
is_pinned: boolean
level: number
created_at: string
updated_at: string
is_liked_by_user?: boolean
}
// 播放记录类型
export type PlayRecord = {
id: string
content_id: string
user_id: string
play_position: number
play_duration: number
play_percentage: number
is_completed: boolean
device_type: string
resolution: string
play_speed: number
created_at: string
updated_at: string
}
// 视频页面状态
export type VideoPageState = {
loading: boolean
error: string | null
danmu_loading: boolean
comment_loading: boolean
sending_danmu: boolean
posting_comment: boolean
}
// 弹幕配置
export type DanmuConfig = {
enabled: boolean
opacity: number
font_size: number
speed: number
show_area: number // 显示区域百分比
max_count: number // 同时显示最大数量
filter_enabled: boolean // 是否开启弹幕过滤
filter_keywords: string[] // 过滤关键词
}
// 视频播放器状态
export type VideoPlayerState = {
playing: boolean
current_time: number
duration: number
volume: number
playback_rate: number
fullscreen: boolean
quality: string
loading: boolean
error: string | null
}
// 分享选项
export type ShareOption = {
platform: string
name: string
icon: string
color: string
}
// 视频质量选项
export const VIDEO_QUALITY_OPTIONS = [
{ value: '360p', text: 'mt.video.quality.360p' },
{ value: '480p', text: 'mt.video.quality.480p' },
{ value: '720p', text: 'mt.video.quality.720p' },
{ value: '1080p', text: 'mt.video.quality.1080p' },
{ value: '4k', text: 'mt.video.quality.4k' }
]
// 播放速度选项
export const PLAYBACK_RATE_OPTIONS = [
{ value: 0.5, text: '0.5x' },
{ value: 0.75, text: '0.75x' },
{ value: 1.0, text: 'mt.video.speed.normal' },
{ value: 1.25, text: '1.25x' },
{ value: 1.5, text: '1.5x' },
{ value: 2.0, text: '2.0x' }
]
// 弹幕位置选项
export const DANMU_POSITION_OPTIONS = [
{ value: 'scroll', text: 'mt.video.danmu.position.scroll' },
{ value: 'top', text: 'mt.video.danmu.position.top' },
{ value: 'bottom', text: 'mt.video.danmu.position.bottom' }
]
// 弹幕颜色选项
export const DANMU_COLOR_OPTIONS = [
'#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00',
'#FF00FF', '#00FFFF', '#FFA500', '#FFC0CB', '#800080'
]
// 分享平台选项
export const SHARE_PLATFORM_OPTIONS: ShareOption[] = [
{ platform: 'wechat', name: 'mt.share.wechat', icon: '💬', color: '#07C160' },
{ platform: 'weibo', name: 'mt.share.weibo', icon: '📱', color: '#E6162D' },
{ platform: 'qq', name: 'mt.share.qq', icon: '🐧', color: '#12B7F5' },
{ platform: 'link', name: 'mt.share.copyLink', icon: '🔗', color: '#666666' }
]
// 工具函数
// 格式化视频时长
export const formatVideoDuration = (seconds: number): string => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
} else {
return `${minutes}:${secs.toString().padStart(2, '0')}`
}
}
// 格式化文件大小
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 格式化播放次数
export const formatViewCount = (count: number): string => {
if (count < 1000) return count.toString()
if (count < 10000) return (count / 1000).toFixed(1) + 'K'
if (count < 1000000) return Math.floor(count / 10000) + tt('mt.video.unit.wan')
return (count / 1000000).toFixed(1) + 'M'
}
// 获取视频质量显示文本
export const getVideoQualityText = (quality: string): string => {
const option = VIDEO_QUALITY_OPTIONS.find(opt => opt.value === quality)
return option ? tt(option.text) : quality
}
// 获取弹幕位置显示文本
export const getDanmuPositionText = (position: string): string => {
const option = DANMU_POSITION_OPTIONS.find(opt => opt.value === position)
return option ? tt(option.text) : position
}
// 获取分享平台显示文本
export const getSharePlatformText = (platform: string): string => {
const option = SHARE_PLATFORM_OPTIONS.find(opt => opt.platform === platform)
return option ? tt(option.name) : platform
}
// 弹幕校验结果类型
export type DanmuValidateResult = { valid: boolean; error?: string };
// 验证弹幕内容
export function validateDanmuText(text: string): DanmuValidateResult {
if ( text.trim().length === 0) {
return { valid: false, error: tt('mt.video.danmu.error.empty') };
}
if (text.length > 100) {
return { valid: false, error: tt('mt.video.danmu.error.tooLong') };
}
// 检查是否包含敏感词
const sensitiveWords = ['spam', 'advertisement']; // 简化示例
const hasSensitive = sensitiveWords.some(word => text.toLowerCase().includes(word));
if (hasSensitive) {
return { valid: false, error: tt('mt.video.danmu.error.sensitive') };
}
return { valid: true };
}
// 计算弹幕显示时间
export const calculateDanmuDisplayTime = (textLength: number, speed: number): number => {
// 基础显示时间 + 文本长度影响 / 速度
return Math.max(3, (5 + textLength * 0.1) / speed)
}