refactor: 项目架构重构 - 目录标准化 + 清理冗余
- 目录重命名: backend/wdkj-server/ → server/, frontend/ai-dimension/ → client/ - 删除 20+ 冗余文件(WDKJ 旧脚本、Windows 脚本、过时文档、设计稿) - 更新 package.json 元数据(移除 wdkj 命名) - 完善三级 .gitignore(根 + server + client) - 重写 README.md 和 CHANGELOG.md - 工具脚本移至 scripts/
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<view class="app-root">
|
||||
<slot />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||
|
||||
onLaunch(() => {
|
||||
console.log('[AI维度] App launched')
|
||||
initTheme()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
console.log('[AI维度] App shown')
|
||||
})
|
||||
|
||||
onHide(() => {
|
||||
console.log('[AI维度] App hidden')
|
||||
})
|
||||
|
||||
function initTheme() {
|
||||
const theme = uni.getStorageSync('theme') || 'light'
|
||||
applyTheme(theme)
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
try {
|
||||
// H5 环境:操作 document class
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('theme-dark')
|
||||
root.classList.remove('theme-light')
|
||||
} else {
|
||||
root.classList.remove('theme-dark')
|
||||
root.classList.add('theme-light')
|
||||
}
|
||||
} catch (e) {
|
||||
// 小程序环境:document 不存在
|
||||
}
|
||||
// 小程序 tabBar 原生组件同步切换
|
||||
applyTabBarTheme(theme)
|
||||
}
|
||||
|
||||
function applyTabBarTheme(theme) {
|
||||
try {
|
||||
if (theme === 'dark') {
|
||||
uni.setTabBarStyle({
|
||||
color: 'rgba(255,255,255,0.25)',
|
||||
selectedColor: '#b388ff',
|
||||
backgroundColor: '#08080f',
|
||||
borderStyle: 'black'
|
||||
})
|
||||
} else {
|
||||
uni.setTabBarStyle({
|
||||
color: 'rgba(0,0,0,0.35)',
|
||||
selectedColor: '#7c4dff',
|
||||
backgroundColor: '#ffffff',
|
||||
borderStyle: 'white'
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// H5 环境 setTabBarStyle 可能不支持
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ============================================
|
||||
宇之然AI维度 — 全局主题
|
||||
默认:亮色模式 | .theme-dark:暗色模式
|
||||
============================================ */
|
||||
|
||||
/* ---- 亮色模式(默认) ---- */
|
||||
page, .theme-light {
|
||||
/* 基础色 — 清爽浅灰 */
|
||||
--bg-primary: #f5f5f7;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #e8e8ed;
|
||||
|
||||
/* 玻璃拟态 */
|
||||
--glass-bg: rgba(255, 255, 255, 0.7);
|
||||
--glass-border: rgba(0, 0, 0, 0.06);
|
||||
--glass-hover: rgba(255, 255, 255, 0.85);
|
||||
--glass-hover-border: rgba(0, 0, 0, 0.1);
|
||||
--glass-strong: rgba(255, 255, 255, 0.9);
|
||||
--glass-dim-bg: rgba(139, 92, 246, 0.05);
|
||||
|
||||
/* 文字 */
|
||||
--text-primary: rgba(0, 0, 0, 0.88);
|
||||
--text-secondary: rgba(0, 0, 0, 0.55);
|
||||
--text-tertiary: rgba(0, 0, 0, 0.4);
|
||||
--text-muted: rgba(0, 0, 0, 0.25);
|
||||
--text-disabled: rgba(0, 0, 0, 0.2);
|
||||
|
||||
/* 品牌色 */
|
||||
--color-brand: #7c4dff;
|
||||
--color-brand-light: #b388ff;
|
||||
--color-brand-dark: #651fff;
|
||||
--color-brand-bg: rgba(124, 77, 255, 0.08);
|
||||
--color-brand-glow: rgba(124, 77, 255, 0.12);
|
||||
|
||||
/* 维度色 */
|
||||
--dim1-color: #7c4dff;
|
||||
--dim1-bg: rgba(124, 77, 255, 0.08);
|
||||
--dim1-icon-bg: rgba(124, 77, 255, 0.15);
|
||||
--dim1-border: rgba(124, 77, 255, 0.15);
|
||||
--dim1-glow: rgba(124, 77, 255, 0.06);
|
||||
|
||||
--dim2-color: #0ea5e9;
|
||||
--dim2-bg: rgba(14, 165, 233, 0.08);
|
||||
--dim2-icon-bg: rgba(14, 165, 233, 0.15);
|
||||
--dim2-border: rgba(14, 165, 233, 0.15);
|
||||
--dim2-glow: rgba(14, 165, 233, 0.06);
|
||||
|
||||
--dim3-color: #10b981;
|
||||
--dim3-bg: rgba(16, 185, 129, 0.08);
|
||||
--dim3-icon-bg: rgba(16, 185, 129, 0.15);
|
||||
--dim3-border: rgba(16, 185, 129, 0.15);
|
||||
--dim3-glow: rgba(16, 185, 129, 0.06);
|
||||
|
||||
--dim4-color: #f59e0b;
|
||||
--dim4-bg: rgba(245, 158, 11, 0.08);
|
||||
--dim4-icon-bg: rgba(245, 158, 11, 0.15);
|
||||
--dim4-border: rgba(245, 158, 11, 0.15);
|
||||
--dim4-glow: rgba(245, 158, 11, 0.06);
|
||||
|
||||
--dim5-color: #ef4444;
|
||||
--dim5-bg: rgba(239, 68, 68, 0.08);
|
||||
--dim5-icon-bg: rgba(239, 68, 68, 0.15);
|
||||
--dim5-border: rgba(239, 68, 68, 0.15);
|
||||
--dim5-glow: rgba(239, 68, 68, 0.06);
|
||||
|
||||
/* 功能色 */
|
||||
--color-free: #10b981;
|
||||
--color-free-bg: rgba(16, 185, 129, 0.1);
|
||||
--color-pro: #f59e0b;
|
||||
--color-pro-bg: rgba(245, 158, 11, 0.1);
|
||||
--color-vip: #ef4444;
|
||||
--color-vip-bg: rgba(239, 68, 68, 0.1);
|
||||
--color-hot: #ef4444;
|
||||
--color-hot-bg: rgba(239, 68, 68, 0.12);
|
||||
--color-new: #0ea5e9;
|
||||
--color-new-bg: rgba(14, 165, 233, 0.12);
|
||||
--color-pick: #7c4dff;
|
||||
--color-pick-bg: rgba(124, 77, 255, 0.12);
|
||||
|
||||
/* 圆角/间距/过渡 */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 12px;
|
||||
--spacing-lg: 16px;
|
||||
--spacing-xl: 20px;
|
||||
--spacing-2xl: 24px;
|
||||
--blur-bg: 20px;
|
||||
--transition-fast: 0.2s ease;
|
||||
--transition-normal: 0.3s ease;
|
||||
--glow-brand: 0 0 20px rgba(124, 77, 255, 0.08);
|
||||
--glow-brand-strong: 0 0 30px rgba(124, 77, 255, 0.12);
|
||||
}
|
||||
|
||||
/* ---- 暗色模式 ---- */
|
||||
.theme-dark {
|
||||
/* 基础色 */
|
||||
--bg-primary: #14141e;
|
||||
--bg-secondary: #181828;
|
||||
--bg-tertiary: #1c1a34;
|
||||
|
||||
--glass-bg: rgba(255, 255, 255, 0.06);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--glass-hover: rgba(255, 255, 255, 0.1);
|
||||
--glass-hover-border: rgba(255, 255, 255, 0.15);
|
||||
--glass-strong: rgba(255, 255, 255, 0.12);
|
||||
--glass-dim-bg: rgba(124, 77, 255, 0.06);
|
||||
|
||||
--text-primary: rgba(255, 255, 255, 0.95);
|
||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
||||
--text-tertiary: rgba(255, 255, 255, 0.5);
|
||||
--text-muted: rgba(255, 255, 255, 0.3);
|
||||
--text-disabled: rgba(255, 255, 255, 0.15);
|
||||
|
||||
--color-brand: #8b5cf6;
|
||||
--color-brand-light: #a78bfa;
|
||||
--color-brand-dark: #7c3aed;
|
||||
--color-brand-bg: rgba(139, 92, 246, 0.12);
|
||||
--color-brand-glow: rgba(139, 92, 246, 0.15);
|
||||
|
||||
--dim1-color: #a78bfa;
|
||||
--dim1-bg: rgba(139, 92, 246, 0.12);
|
||||
--dim1-icon-bg: rgba(139, 92, 246, 0.2);
|
||||
--dim1-border: rgba(139, 92, 246, 0.18);
|
||||
--dim1-glow: rgba(139, 92, 246, 0.08);
|
||||
|
||||
--dim2-color: #60a5fa;
|
||||
--dim2-bg: rgba(96, 165, 250, 0.12);
|
||||
--dim2-icon-bg: rgba(96, 165, 250, 0.2);
|
||||
--dim2-border: rgba(96, 165, 250, 0.18);
|
||||
--dim2-glow: rgba(96, 165, 250, 0.08);
|
||||
|
||||
--dim3-color: #34d399;
|
||||
--dim3-bg: rgba(52, 211, 153, 0.12);
|
||||
--dim3-icon-bg: rgba(52, 211, 153, 0.2);
|
||||
--dim3-border: rgba(52, 211, 153, 0.18);
|
||||
--dim3-glow: rgba(52, 211, 153, 0.08);
|
||||
|
||||
--dim4-color: #fbbf24;
|
||||
--dim4-bg: rgba(251, 191, 36, 0.12);
|
||||
--dim4-icon-bg: rgba(251, 191, 36, 0.2);
|
||||
--dim4-border: rgba(251, 191, 36, 0.18);
|
||||
--dim4-glow: rgba(251, 191, 36, 0.08);
|
||||
|
||||
--dim5-color: #fb7185;
|
||||
--dim5-bg: rgba(251, 113, 133, 0.12);
|
||||
--dim5-icon-bg: rgba(251, 113, 133, 0.2);
|
||||
--dim5-border: rgba(251, 113, 133, 0.18);
|
||||
--dim5-glow: rgba(251, 113, 133, 0.08);
|
||||
|
||||
--color-free: #34d399;
|
||||
--color-free-bg: rgba(52, 211, 153, 0.12);
|
||||
--color-pro: #fbbf24;
|
||||
--color-pro-bg: rgba(251, 191, 36, 0.12);
|
||||
--color-vip: #fb7185;
|
||||
--color-vip-bg: rgba(251, 113, 133, 0.12);
|
||||
--color-hot: #fb7185;
|
||||
--color-hot-bg: rgba(251, 113, 133, 0.15);
|
||||
--color-new: #60a5fa;
|
||||
--color-new-bg: rgba(96, 165, 250, 0.15);
|
||||
--color-pick: #a78bfa;
|
||||
--color-pick-bg: rgba(139, 92, 246, 0.15);
|
||||
|
||||
--glow-brand: 0 0 20px rgba(139, 92, 246, 0.1);
|
||||
--glow-brand-strong: 0 0 30px rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
|
||||
/* 全局排版 */
|
||||
page {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
view {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* 卡片通用 */
|
||||
.card {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
backdrop-filter: blur(var(--blur-bg));
|
||||
-webkit-backdrop-filter: blur(var(--blur-bg));
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.gradient-brand {
|
||||
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, var(--color-brand-light), var(--color-brand));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<view
|
||||
class="dim-card-comp"
|
||||
:class="{ 'dim-full': dim.isFull }"
|
||||
:style="{ background: dim.bg }"
|
||||
@tap="$emit('tap', dim)"
|
||||
>
|
||||
<view class="dim-icon" :style="{ background: dim.iconBg, color: dim.color }">
|
||||
<text>{{ dim.icon }}</text>
|
||||
</view>
|
||||
<text class="dim-name" :style="{ color: dim.color }">{{ dim.name }}</text>
|
||||
<text class="dim-desc">{{ dim.desc }}</text>
|
||||
<text class="dim-count">
|
||||
{{ dim.isFull ? '今日已更新 8 条' : `${dim.articleCount} 篇 · ${dim.quizCount} 个问答` }}
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
dim: { type: Object, required: true }
|
||||
})
|
||||
defineEmits(['tap'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dim-card-comp {
|
||||
border-radius: 18px;
|
||||
padding: 20px 16px;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
.dim-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.dim-icon {
|
||||
width: 42px; height: 42px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.dim-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
display: block;
|
||||
}
|
||||
.dim-desc {
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
}
|
||||
.dim-count {
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.15);
|
||||
margin-top: 10px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<view
|
||||
class="news-card-comp"
|
||||
@tap="$emit('tap', item)"
|
||||
>
|
||||
<view class="news-tag" :class="item.tagClass">{{ item.tag }}</view>
|
||||
<text class="news-title">{{ item.title }}</text>
|
||||
<view class="news-meta">
|
||||
<text>{{ item.source }}</text>
|
||||
<text>{{ item.time }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
item: { type: Object, required: true }
|
||||
})
|
||||
defineEmits(['tap'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.news-card-comp {
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.news-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.news-tag.hot { background: rgba(239,83,80,0.12); color: #ef5350; }
|
||||
.news-tag.new { background: rgba(0,188,212,0.12); color: #4dd0e1; }
|
||||
.news-tag.pick { background: rgba(124,77,255,0.12); color: #b388ff; }
|
||||
|
||||
.news-title {
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.85);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.news-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
return { app }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "宇之然AI维度",
|
||||
"appid": "wxdad62baf4ccd09e3",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "100",
|
||||
"description": "AI 知识探索社区 — 从5个维度系统理解人工智能",
|
||||
"uni-app": {
|
||||
"compilerVersion": "3.0.0"
|
||||
},
|
||||
"mp-weixin": {
|
||||
"appid": "wxdad62baf4ccd09e3",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": true,
|
||||
"postcss": true,
|
||||
"minified": true
|
||||
},
|
||||
"usingComponents": true,
|
||||
"permission": {},
|
||||
"requiredPrivateInfos": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"pages": [
|
||||
{"path": "pages/index/index", "style": {"navigationBarTitleText": "AI 维度", "navigationStyle": "custom"}},
|
||||
{"path": "pages/login/login", "style": {"navigationBarTitleText": "登录", "navigationStyle": "custom"}},
|
||||
{"path": "pages/detail/detail", "style": {"navigationBarTitleText": "AI 知识", "navigationStyle": "custom"}},
|
||||
{"path": "pages/payment/payment", "style": {"navigationBarTitleText": "订阅升级", "navigationStyle": "custom"}},
|
||||
{"path": "pages/favorites/favorites", "style": {"navigationBarTitleText": "我的收藏", "navigationStyle": "custom"}},
|
||||
{"path": "pages/achievements/achievements", "style": {"navigationBarTitleText": "成就", "navigationStyle": "custom"}},
|
||||
{"path": "pages/leaderboard/leaderboard", "style": {"navigationBarTitleText": "排行榜", "navigationStyle": "custom"}},
|
||||
{"path": "pages/search/search", "style": {"navigationBarTitleText": "搜索", "navigationStyle": "custom"}},
|
||||
{"path": "pages/progress/progress", "style": {"navigationBarTitleText": "学习进度", "navigationStyle": "custom"}},
|
||||
{"path": "pages/feedback/feedback", "style": {"navigationBarTitleText": "意见反馈", "navigationStyle": "custom"}},
|
||||
{"path": "pages/settings/settings", "style": {"navigationBarTitleText": "设置", "navigationStyle": "custom"}},
|
||||
{"path": "pages/news/news", "style": {"navigationBarTitleText": "AI 资讯", "navigationStyle": "custom"}},
|
||||
{"path": "pages/dimension/dimension", "style": {"navigationBarTitleText": "", "navigationStyle": "custom"}},
|
||||
{"path": "pages/chat/chat", "style": {"navigationBarTitleText": "AI 问答", "navigationStyle": "custom"}},
|
||||
{"path": "pages/trend/trend", "style": {"navigationBarTitleText": "AI 趋势", "navigationStyle": "custom"}},
|
||||
{"path": "pages/profile/profile", "style": {"navigationBarTitleText": "我的", "navigationStyle": "custom"}}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "宇之然AI维度",
|
||||
"navigationBarBackgroundColor": "#f5f5f7",
|
||||
"backgroundColor": "#f5f5f7",
|
||||
"backgroundColorTop": "#f5f5f7",
|
||||
"backgroundColorBottom": "#f5f5f7",
|
||||
"app-plus": {
|
||||
"background": "#f5f5f7"
|
||||
}
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "rgba(0,0,0,0.35)",
|
||||
"selectedColor": "#7c4dff",
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderStyle": "white",
|
||||
"list": [
|
||||
{"pagePath": "pages/index/index", "text": "探索"},
|
||||
{"pagePath": "pages/chat/chat", "text": "问答"},
|
||||
{"pagePath": "pages/trend/trend", "text": "趋势"},
|
||||
{"pagePath": "pages/profile/profile", "text": "我的"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
<template>
|
||||
<view class="page-achievements">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<view class="header">
|
||||
<text class="title">🏆 成就</text>
|
||||
<text class="unlock-count">{{ unlockedCount }}/{{ totalCount }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 总览卡片 -->
|
||||
<view class="overview-card" v-if="categories.length > 0">
|
||||
<view class="overview-bar">
|
||||
<view class="overview-fill" :style="{ width: progressPercent + '%' }"></view>
|
||||
</view>
|
||||
<text class="overview-text">
|
||||
已解锁 {{ unlockedCount }} / {{ totalCount }} 个成就
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 成就分类 -->
|
||||
<view class="categories" v-if="categories.length > 0">
|
||||
<view
|
||||
v-for="cat in categories"
|
||||
:key="cat.name"
|
||||
class="category"
|
||||
>
|
||||
<view class="category-header">
|
||||
<text class="category-icon">{{ catIcon(cat.name) }}</text>
|
||||
<text class="category-name">{{ cat.name }}</text>
|
||||
<text class="category-count">
|
||||
{{ cat.unlocked }}/{{ cat.achievements.length }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="achievement-list">
|
||||
<view
|
||||
v-for="ach in cat.achievements"
|
||||
:key="ach.id"
|
||||
class="achievement-item"
|
||||
:class="{ locked: !ach.unlocked }"
|
||||
>
|
||||
<view
|
||||
class="ach-icon"
|
||||
:style="ach.unlocked ? { color: achColor(ach.icon) } : {}"
|
||||
>
|
||||
{{ ach.icon }}
|
||||
</view>
|
||||
<view class="ach-info">
|
||||
<text class="ach-name">{{ ach.name }}</text>
|
||||
<text class="ach-desc">{{ ach.description }}</text>
|
||||
<text class="ach-date" v-if="ach.unlocked && ach.date">
|
||||
✦ {{ formatDate(ach.date) }}
|
||||
</text>
|
||||
<text class="ach-date" v-else>
|
||||
⌬ 未解锁
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view class="empty-state" v-else-if="!loading">
|
||||
<text class="empty-icon">🏆</text>
|
||||
<text class="empty-text">还没有获得任何成就</text>
|
||||
<text class="empty-sub">继续学习,解锁属于你的成就吧</text>
|
||||
</view>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<view class="empty-state" v-else>
|
||||
<text class="empty-icon" style="opacity:0.4">⏳</text>
|
||||
<text class="empty-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { userApi } from '@/utils/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const categories = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
// 成就分类的默认配置
|
||||
const CATEGORY_CONFIG = [
|
||||
{ name: '知识探索', icon: '📖', color: 'var(--dim1-color)' },
|
||||
{ name: '收藏成就', icon: '⭐', color: 'var(--dim2-color)' },
|
||||
{ name: 'AI 问答', icon: '🤖', color: 'var(--dim3-color)' },
|
||||
{ name: '社交成就', icon: '👥', color: 'var(--dim4-color)' }
|
||||
]
|
||||
|
||||
const unlockedCount = computed(() => {
|
||||
return categories.value.reduce(
|
||||
(sum, cat) => sum + (cat.unlocked || 0),
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
const totalCount = computed(() => {
|
||||
return categories.value.reduce(
|
||||
(sum, cat) => sum + cat.achievements.length,
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (totalCount.value === 0) return 0
|
||||
return Math.round((unlockedCount.value / totalCount.value) * 100)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
loadAchievements()
|
||||
})
|
||||
|
||||
async function loadAchievements() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await userApi.achievements(userStore.token)
|
||||
// 数据格式:[{name, achievements:[{id,name,description,icon,unlocked,date}]}]
|
||||
const raw = Array.isArray(data) ? data : data.categories || []
|
||||
categories.value = raw.map((cat) => ({
|
||||
...cat,
|
||||
unlocked: cat.achievements.filter((a) => a.unlocked).length
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('[achievements] load failed:', e)
|
||||
categories.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function catIcon(name) {
|
||||
const config = CATEGORY_CONFIG.find((c) => c.name === name)
|
||||
return config ? config.icon : '🏆'
|
||||
}
|
||||
|
||||
function achColor(icon) {
|
||||
// 根据成就图标或默认分类给彩色
|
||||
return 'var(--dim1-color)'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-achievements {
|
||||
min-height: 100vh;
|
||||
padding: 0 20px 40px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
// 顶部导航
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.unlock-count {
|
||||
font-size: 13px;
|
||||
padding: 3px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(77, 182, 172, 0.12);
|
||||
color: var(--dim3-color);
|
||||
}
|
||||
|
||||
// 总览卡片
|
||||
.overview-card {
|
||||
padding: 16px 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.overview-bar {
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.overview-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--dim1-color), var(--dim3-color));
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.overview-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
// 成就分类
|
||||
.categories {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 0 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
// 成就列表
|
||||
.achievement-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.achievement-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.achievement-item.locked {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.ach-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
background: rgba(179, 136, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.achievement-item.locked .ach-icon {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ach-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ach-name {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ach-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ach-date {
|
||||
font-size: 10px;
|
||||
color: rgba(179, 136, 255, 0.6);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.achievement-item.locked .ach-date {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 56px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
color: var(--text-tertiary);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<view class="page-chat">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 聊天头部 -->
|
||||
<view class="chat-header">
|
||||
<view class="chat-avatar">
|
||||
<text class="avatar-text">∞</text>
|
||||
</view>
|
||||
<view class="chat-info">
|
||||
<text class="chat-name">AI 助手</text>
|
||||
<text class="chat-status">随时为你解答</text>
|
||||
</view>
|
||||
<!-- 配额徽章 -->
|
||||
<view class="quota-badge" @click="goShare">
|
||||
<text class="quota-text">{{ quotaDisplay }}</text>
|
||||
<text class="quota-icon">🎁</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 未登录提示 -->
|
||||
<view class="login-hint" v-if="!userStore.isLoggedIn">
|
||||
<text class="hint-text">登录后即可向 AI 助手提问</text>
|
||||
<button class="login-btn" @click="goLogin">微信登录</button>
|
||||
</view>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view class="msg-list" scroll-y :scroll-into-view="scrollTo" v-else>
|
||||
<!-- 欢迎消息 -->
|
||||
<view class="welcome-card">
|
||||
<text class="welcome-icon">💡</text>
|
||||
<text class="welcome-text">试试问我这些问题:</text>
|
||||
<view class="suggest-list">
|
||||
<view class="suggest-item" @click="sendSuggest(item)" v-for="(item, idx) in suggestions" :key="idx">
|
||||
<text>{{ item }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-for="(msg, idx) in messages" :key="idx" :id="'msg-' + idx" class="chat-msg" :class="msg.role">
|
||||
<view v-if="msg.role === 'assistant'" class="mini-avatar">∞</view>
|
||||
<view class="msg-content">
|
||||
<view class="bubble" v-html="msg.content"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载动画 -->
|
||||
<view v-if="loading" class="chat-msg assistant">
|
||||
<view class="mini-avatar">∞</view>
|
||||
<view class="msg-content">
|
||||
<view class="bubble loading-dots">
|
||||
<text class="dot"></text>
|
||||
<text class="dot"></text>
|
||||
<text class="dot"></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 输入区 -->
|
||||
<view class="chat-input-area" v-if="userStore.isLoggedIn">
|
||||
<view class="chat-input">
|
||||
<input
|
||||
v-model="question"
|
||||
class="input-field"
|
||||
placeholder="输入你对 AI 的疑问..."
|
||||
:disabled="loading"
|
||||
@confirm="sendMessage"
|
||||
confirm-type="send"
|
||||
/>
|
||||
<view class="send-btn" @click="sendMessage">
|
||||
<text>➤</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { aiChatApi } from '@/utils/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const question = ref('')
|
||||
const messages = ref([])
|
||||
const loading = ref(false)
|
||||
const scrollTo = ref('')
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 建议问题
|
||||
const suggestions = ref([
|
||||
'"Transformer 的核心原理是什么?"',
|
||||
'"RAG 和微调有什么区别?"',
|
||||
'"2026 年最值得关注的 AI 趋势?"'
|
||||
])
|
||||
|
||||
// 配额显示
|
||||
const quotaDisplay = computed(() => {
|
||||
const q = userStore.quota
|
||||
if (!q) return '?次'
|
||||
const used = q.usedQuota || q.dailyUsed || 0
|
||||
const remaining = q.remainingQuota || 0
|
||||
const total = q.freeQuota || remaining + used
|
||||
return `${remaining}/${total}`
|
||||
})
|
||||
|
||||
// 监听登录状态变化,加载配额
|
||||
const isLoggedIn = computed(() => userStore.isLoggedIn)
|
||||
import { watch } from 'vue'
|
||||
watch(isLoggedIn, (val) => {
|
||||
if (val) {
|
||||
loadQuota()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function loadQuota() {
|
||||
if (userStore.token) {
|
||||
await userStore.fetchQuota(userStore.token)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const q = question.value.trim()
|
||||
if (!q || loading.value) return
|
||||
|
||||
messages.value.push({ role: 'user', content: escapeHtml(q) })
|
||||
question.value = ''
|
||||
scrollToLast()
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await aiChatApi.ask(q, 'general', userStore.token)
|
||||
messages.value.push({ role: 'assistant', content: escapeHtml(data.answer) })
|
||||
await userStore.fetchQuota(userStore.token)
|
||||
} catch (e) {
|
||||
console.error('AI 问答失败:', e)
|
||||
const errorMsg = e.message || 'AI 服务暂时不可用,请稍后再试'
|
||||
messages.value.push({
|
||||
role: 'assistant',
|
||||
content: escapeHtml(`抱歉,${errorMsg}`)
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
scrollToLast()
|
||||
}
|
||||
}
|
||||
|
||||
function sendSuggest(text) {
|
||||
question.value = text
|
||||
sendMessage()
|
||||
}
|
||||
|
||||
function scrollToLast() {
|
||||
setTimeout(() => {
|
||||
scrollTo.value = 'msg-' + (messages.value.length - 1)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br/>')
|
||||
}
|
||||
|
||||
function goLogin() {
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
}
|
||||
|
||||
function goShare() {
|
||||
uni.showModal({
|
||||
title: '获取更多次数',
|
||||
content: '将小程序分享给好友,即可获得 5 次额外问答机会',
|
||||
confirmText: '去分享',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// 微信小程序分享能力
|
||||
uni.showShareMenu({ withShareTicket: true })
|
||||
// 调用分享获得次数接口
|
||||
if (userStore.token) {
|
||||
aiChatApi.shareGain(userStore.token).then(() => {
|
||||
uni.showToast({ title: '已记录分享', icon: 'success' })
|
||||
loadQuota()
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-chat {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 20px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0 16px;
|
||||
}
|
||||
|
||||
.chat-avatar,
|
||||
.mini-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, rgba(124, 77, 255, 0.3), rgba(0, 188, 212, 0.2));
|
||||
border: 1px solid rgba(124, 77, 255, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mini-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-info { flex: 1; }
|
||||
|
||||
.chat-name {
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chat-status {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 配额徽章
|
||||
.quota-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(124, 77, 255, 0.1);
|
||||
border: 1px solid rgba(124, 77, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.quota-text {
|
||||
font-size: 12px;
|
||||
color: var(--dim1-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quota-icon { font-size: 14px; }
|
||||
|
||||
// 未登录提示
|
||||
.login-hint {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
background: linear-gradient(135deg, #07c160, #06ad56);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// 欢迎卡片
|
||||
.welcome-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.welcome-icon {
|
||||
font-size: 20px;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.suggest-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.suggest-item {
|
||||
padding: 8px 12px;
|
||||
background: rgba(124, 77, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
// 消息列表
|
||||
.msg-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.chat-msg.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.chat-msg.assistant {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
max-width: 82%;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-msg.assistant .bubble {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-msg.user .bubble {
|
||||
background: linear-gradient(135deg, rgba(124, 77, 255, 0.3), rgba(124, 77, 255, 0.15));
|
||||
border: 1px solid rgba(124, 77, 255, 0.15);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
// 加载动画
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
animation: blink 1.4s infinite;
|
||||
}
|
||||
|
||||
.dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes blink {
|
||||
0%, 20% { opacity: 0; transform: scale(0.6); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(0.6); }
|
||||
}
|
||||
|
||||
// 输入区
|
||||
.chat-input-area {
|
||||
padding: 12px 0 30px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
flex: 1;
|
||||
height: 24px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-field::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #7c4dff, #b388ff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
box-shadow: 0 0 20px rgba(124, 77, 255, 0.2);
|
||||
}
|
||||
|
||||
.send-btn text {
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,446 @@
|
||||
<template>
|
||||
<view class="detail-page">
|
||||
<!-- 顶部导航 -->
|
||||
<view class="nav-bar">
|
||||
<view class="nav-back" @click="goBack">‹</view>
|
||||
<text class="nav-title">AI 知识</text>
|
||||
<view class="nav-collect" @click="toggleCollect">
|
||||
<text v-if="isCollected" class="icon icon-collected">★</text>
|
||||
<text v-else class="icon">☆</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 知识头部 -->
|
||||
<view class="detail-header">
|
||||
<view class="dim-tag" :style="{ background: dimColor }">
|
||||
<text class="dim-name">{{ dimensionName }}</text>
|
||||
</view>
|
||||
<text class="detail-title">{{ detail?.title || '加载中...' }}</text>
|
||||
<view class="meta-row">
|
||||
<text class="meta-item">📖 {{ viewCountText }}</text>
|
||||
<text class="meta-item">⭐ {{ collectionCount }}</text>
|
||||
<text class="meta-item" v-if="detail?.category">
|
||||
{{ categoryLabel }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 标签 -->
|
||||
<view class="tag-row" v-if="detail?.tags?.length">
|
||||
<text class="tag" v-for="tag in detail.tags" :key="tag">#{{ tag }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<scroll-view class="content-scroll" scroll-y>
|
||||
<!-- 分段内容(如果存在 sections) -->
|
||||
<view class="content-section" v-if="detail?.sections?.length">
|
||||
<view class="section-block" v-for="(section, idx) in detail.sections" :key="idx">
|
||||
<text class="section-title">{{ section.title }}</text>
|
||||
<text class="section-content">{{ section.content }}</text>
|
||||
<view class="section-images" v-if="section.images?.length">
|
||||
<image
|
||||
class="section-img"
|
||||
v-for="(img, i) in section.images"
|
||||
:key="i"
|
||||
:src="img"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 普通内容 -->
|
||||
<view class="content-block" v-else>
|
||||
<text class="content-text">{{ detail?.content || '暂无内容' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 问答区域 -->
|
||||
<view class="qa-section" v-if="detail?.category === 'question' && detail?.question">
|
||||
<view class="qa-question">
|
||||
<text class="qa-label">问题</text>
|
||||
<text class="qa-text">{{ detail.question }}</text>
|
||||
</view>
|
||||
<view class="qa-options" v-if="detail?.options?.length">
|
||||
<view
|
||||
class="qa-option"
|
||||
v-for="(opt, idx) in detail.options"
|
||||
:key="idx"
|
||||
:class="{ correct: selectedOption === idx && opt.isCorrect }"
|
||||
@click="selectedOption = idx"
|
||||
>
|
||||
<text class="option-letter">{{ String.fromCharCode(65 + idx) }}</text>
|
||||
<text class="option-text">{{ opt.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="qa-answer" v-if="selectedOption !== null">
|
||||
<text class="answer-label">
|
||||
{{ detail.options?.[selectedOption]?.isCorrect ? '✅ 正确' : '❌ 错误' }}
|
||||
</text>
|
||||
<text class="answer-text" v-if="detail.answer">{{ detail.answer }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部 AI 问答入口 -->
|
||||
<view class="ai-banner" @click="goToAI">
|
||||
<text class="ai-icon">🤖</text>
|
||||
<text class="ai-text">还有疑问?问问 AI 助手</text>
|
||||
<text class="ai-arrow">→</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { knowledgeApi } from '@/utils/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { DIMENSIONS } from '@/utils/dimensions'
|
||||
|
||||
const detailId = ref('')
|
||||
const detail = ref(null)
|
||||
const isCollected = ref(false)
|
||||
const selectedOption = ref(null)
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 从路由参数获取 ID
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages()
|
||||
const currentPage = pages[pages.length - 1]
|
||||
const query = currentPage.$page?.query || {}
|
||||
detailId.value = query.id || ''
|
||||
|
||||
if (detailId.value) {
|
||||
fetchDetail()
|
||||
}
|
||||
})
|
||||
|
||||
// 维度信息
|
||||
const dimColor = computed(() => {
|
||||
const dim = detail.value?.dim
|
||||
if (!dim) return 'var(--dim1-color)'
|
||||
const dimInfo = DIMENSIONS.find(d => d.id === dim)
|
||||
return dimInfo ? dimInfo.color : 'var(--dim1-color)'
|
||||
})
|
||||
const dimensionName = computed(() => {
|
||||
const dim = detail.value?.dim
|
||||
if (!dim) return ''
|
||||
const dimInfo = DIMENSIONS.find(d => d.id === dim)
|
||||
return dimInfo ? dimInfo.name : ''
|
||||
})
|
||||
|
||||
// 分类标签
|
||||
const categoryLabel = computed(() => {
|
||||
const map = {
|
||||
concept: '📚 概念',
|
||||
history: '📜 历史',
|
||||
application: '🛠️ 应用',
|
||||
question: '❓ 问答'
|
||||
}
|
||||
return map[detail.value?.category] || ''
|
||||
})
|
||||
|
||||
const viewCountText = computed(() => {
|
||||
const count = detail.value?.viewCount || 0
|
||||
return count >= 10000 ? (count / 10000).toFixed(1) + '万' : count
|
||||
})
|
||||
const collectionCount = computed(() => detail.value?.collectionCount || 0)
|
||||
|
||||
async function fetchDetail() {
|
||||
try {
|
||||
detail.value = await knowledgeApi.detail(detailId.value)
|
||||
// 检查是否已收藏
|
||||
if (userStore.isLoggedIn) {
|
||||
const collected = await knowledgeApi.collected(userStore.token)
|
||||
isCollected.value = collected.some(item => item.id === detailId.value)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取知识详情失败:', err)
|
||||
uni.showToast({ title: '加载失败,请重试', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCollect() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (isCollected.value) {
|
||||
await knowledgeApi.uncollect(detailId.value, userStore.token)
|
||||
isCollected.value = false
|
||||
uni.showToast({ title: '已取消收藏', icon: 'none' })
|
||||
} else {
|
||||
await knowledgeApi.collect(detailId.value, userStore.token)
|
||||
isCollected.value = true
|
||||
uni.showToast({ title: '收藏成功', icon: 'success' })
|
||||
}
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function goToAI() {
|
||||
uni.switchTab({ url: '/pages/chat/chat' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
// 顶部导航
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 50px 20px 12px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.nav-back {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-collect {
|
||||
width: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
.icon {
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.icon-collected {
|
||||
color: var(--dim5-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 知识头部
|
||||
.detail-header {
|
||||
padding: 0 20px 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.dim-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.9;
|
||||
|
||||
.dim-name { color: white; }
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.meta-item { display: flex; align-items: center; }
|
||||
}
|
||||
}
|
||||
|
||||
// 标签
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 20px 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.tag {
|
||||
font-size: 12px;
|
||||
color: var(--dim1-color);
|
||||
background: rgba(179, 136, 255, 0.1);
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// 内容滚动区
|
||||
.content-scroll {
|
||||
flex: 1;
|
||||
padding: 0 20px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 80px;
|
||||
|
||||
.content-section,
|
||||
.content-block,
|
||||
.qa-section {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-block {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child { margin-bottom: 0; }
|
||||
|
||||
.section-title {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--dim1-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.section-content {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.8;
|
||||
}
|
||||
.section-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
.section-img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.8;
|
||||
}
|
||||
}
|
||||
|
||||
// 问答区域
|
||||
.qa-section {
|
||||
.qa-question {
|
||||
.qa-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.qa-text {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.qa-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
|
||||
.qa-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.correct {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
.option-letter {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.option-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qa-answer {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(179, 136, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
|
||||
.answer-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.answer-text {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AI 底部入口
|
||||
.ai-banner {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(135deg, var(--dim1-color), var(--dim3-color));
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px rgba(179, 136, 255, 0.3);
|
||||
|
||||
.ai-icon { font-size: 18px; }
|
||||
.ai-text { font-size: 14px; font-weight: 600; }
|
||||
.ai-arrow { font-size: 14px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<view class="page-dim">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<view class="back-btn" @tap="goBack">
|
||||
<text>← 返回</text>
|
||||
</view>
|
||||
|
||||
<!-- 维度横幅 -->
|
||||
<view class="dim-banner" :style="{ borderColor: currentDim?.border }">
|
||||
<text class="dim-banner-icon">{{ currentDim?.icon }}</text>
|
||||
<text class="dim-banner-title">{{ currentDim?.name }}</text>
|
||||
<text class="dim-banner-desc">{{ bannerDesc }}</text>
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" :style="{ width: progress + '%' }"></view>
|
||||
</view>
|
||||
<text class="progress-label">探索进度 {{ progress }}% · 已完成 {{ readCount }}/{{ totalCount }} 篇</text>
|
||||
</view>
|
||||
|
||||
<!-- 知识列表 -->
|
||||
<view v-for="item in knowledgeList" :key="item._id" class="knowledge-item" @tap="goDetail(item)">
|
||||
<view class="k-icon" :style="getItemStyle(item)">
|
||||
<text>{{ item.icon || '📄' }}</text>
|
||||
</view>
|
||||
<view class="k-info">
|
||||
<text class="k-title">{{ item.title }}</text>
|
||||
<text class="k-desc">{{ item.desc }}</text>
|
||||
<view class="k-meta">
|
||||
<text>📖 {{ item.readTime || 5 }} 分钟</text>
|
||||
<text v-if="item.collected">⭐ 已收藏</text>
|
||||
<text v-else>🔖 {{ item.collectCount || 0 }} 人收藏</text>
|
||||
<text class="k-badge" :class="item.isPremium ? 'pro' : 'free'">
|
||||
{{ item.isPremium ? 'Pro' : '免费' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getDimension } from '@/utils/dimensions'
|
||||
import { knowledgeApi } from '@/utils/api'
|
||||
|
||||
const dimId = ref(1)
|
||||
const currentDim = computed(() => getDimension(dimId.value))
|
||||
const knowledgeList = ref([])
|
||||
const loading = ref(false)
|
||||
const progress = ref(35)
|
||||
const readCount = ref(4)
|
||||
const totalCount = ref(12)
|
||||
|
||||
const bannerDesc = computed(() => {
|
||||
const descs = {
|
||||
1: '从 1950 年图灵测试到 2026 年通用人工智能,追溯 70 年演进之路',
|
||||
2: '从深度学习到 Transformer,从 MoE 到多模态,理解每一次技术跃迁',
|
||||
3: '大模型格局、Agent 生态、具身智能——2026 年 AI 世界全景图',
|
||||
4: '提示词工程、RAG 系统、模型微调、工具链——从入门到上手',
|
||||
5: '每日追踪 AI 前沿动态,不错过每一个重要时刻'
|
||||
}
|
||||
return descs[dimId.value] || '探索人工智能的精彩世界'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages()
|
||||
const page = pages[pages.length - 1]
|
||||
if (page.options?.id) {
|
||||
dimId.value = parseInt(page.options.id)
|
||||
}
|
||||
loadKnowledge()
|
||||
})
|
||||
|
||||
async function loadKnowledge() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await knowledgeApi.list({ dim: dimId.value })
|
||||
knowledgeList.value = data.map(item => ({
|
||||
...item,
|
||||
icon: getDimIcon(item.dim),
|
||||
desc: item.content?.substring(0, 40) + '...' || '',
|
||||
readTime: Math.ceil((item.content?.length || 300) / 200),
|
||||
isPremium: item.isPremium,
|
||||
collected: false,
|
||||
collectCount: item.collectionCount
|
||||
}))
|
||||
} catch (e) {
|
||||
// 用示例数据填充
|
||||
knowledgeList.value = getSampleData(dimId.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getItemStyle(item) {
|
||||
const colors = [
|
||||
{ bg: 'rgba(124,77,255,0.1)', color: '#b388ff' },
|
||||
{ bg: 'rgba(0,188,212,0.1)', color: '#4dd0e1' },
|
||||
{ bg: 'rgba(0,200,150,0.1)', color: '#4db6ac' },
|
||||
{ bg: 'rgba(255,193,7,0.1)', color: '#ffd54f' },
|
||||
{ bg: 'rgba(239,83,80,0.1)', color: '#ef5350' }
|
||||
]
|
||||
const c = colors[(item.dim || 1) - 1] || colors[0]
|
||||
return {
|
||||
background: c.bg,
|
||||
color: c.color,
|
||||
borderColor: c.bg
|
||||
}
|
||||
}
|
||||
|
||||
function getDimIcon(dim) {
|
||||
const icons = ['📜', '🏛', '♟', '🧠', '⚡']
|
||||
return icons[(dim || 1) - 1]
|
||||
}
|
||||
|
||||
function getSampleData(dim) {
|
||||
const samples = {
|
||||
1: [
|
||||
{ title: '图灵测试:人工智能的哲学起点', content: '1950 年,艾伦·图灵提出"机器能思考吗?"' },
|
||||
{ title: '达特茅斯会议:AI 的诞生时刻', content: '1956 年,一群科学家正式命名了"人工智能"' },
|
||||
{ title: '深蓝 vs 卡斯帕罗夫', content: '1997 年,IBM 深蓝首次击败国际象棋世界冠军' },
|
||||
],
|
||||
2: [
|
||||
{ title: '深度学习革命', content: '从神经网络到深度学习的演进之路' },
|
||||
{ title: 'Transformer 架构详解', content: '2017 年 Google 提出的革命性架构' },
|
||||
],
|
||||
3: [
|
||||
{ title: '2026 年大模型格局', content: 'GPT、Claude、Gemini、DeepSeek 全面对比' },
|
||||
{ title: 'Agent 智能体生态', content: '从 Chatbot 到自主 Agent 的进化' },
|
||||
],
|
||||
4: [
|
||||
{ title: '提示词工程入门', content: '如何写出高质量的 prompt' },
|
||||
{ title: 'RAG 系统搭建指南', content: '检索增强生成从零到一' },
|
||||
],
|
||||
5: [
|
||||
{ title: 'DeepSeek 自研推理芯片', content: '降低对英伟达依赖的重要一步' },
|
||||
{ title: 'WAIC 2026 即将开幕', content: '7 月 17 日上海,1100+ 企业参展' },
|
||||
]
|
||||
}
|
||||
const data = samples[dim] || samples[1]
|
||||
return data.map((item, i) => ({
|
||||
...item,
|
||||
_id: `sample-${dim}-${i}`,
|
||||
dim,
|
||||
icon: getDimIcon(dim),
|
||||
readTime: 5 + i * 2,
|
||||
isPremium: i >= 3,
|
||||
collected: i === 0,
|
||||
collectionCount: 10 - i * 2
|
||||
}))
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function goDetail(item) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/detail/detail?id=${item._id}`
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-dim {
|
||||
min-height: 100vh;
|
||||
padding: 0 20px 40px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
.back-btn {
|
||||
color: rgba(255,255,255,0.4);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 0 14px;
|
||||
}
|
||||
|
||||
.dim-banner {
|
||||
border-radius: 18px;
|
||||
padding: 24px 20px;
|
||||
margin-bottom: 20px;
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.1), rgba(0,0,0,0.3));
|
||||
border: 1px solid rgba(124,77,255,0.12);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dim-banner-icon { font-size: 28px; display: block; margin-bottom: 8px; }
|
||||
.dim-banner-title { font-size: 22px; color: var(--text-primary); display: block; margin-bottom: 6px; }
|
||||
.dim-banner-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 2px;
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #b388ff, #7c4dff);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.progress-label {
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.2);
|
||||
margin-top: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.knowledge-item {
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 10px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.k-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.k-info { flex: 1; }
|
||||
.k-title {
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.85);
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.k-desc {
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
.k-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.15);
|
||||
}
|
||||
.k-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.k-badge.free { background: rgba(76,175,80,0.12); color: #66bb6a; }
|
||||
.k-badge.pro { background: rgba(255,152,0,0.12); color: #ffa726; }
|
||||
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: rgba(255,255,255,0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<view class="page-favorites">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<view class="header">
|
||||
<text class="title">我的收藏</text>
|
||||
<text class="count-badge">{{ favorites.length }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 收藏列表 -->
|
||||
<view class="favorites-list" v-if="favorites.length > 0">
|
||||
<view
|
||||
v-for="(item, idx) in favorites"
|
||||
:key="item.id || idx"
|
||||
class="fav-item"
|
||||
@tap="goDetail(item.id)"
|
||||
>
|
||||
<!-- 维度标签 -->
|
||||
<view
|
||||
class="dim-tag"
|
||||
:style="{ color: dimColor(item.dimensionId) }"
|
||||
>
|
||||
{{ dimName(item.dimensionId) }}
|
||||
</view>
|
||||
<!-- 标题 -->
|
||||
<text class="fav-title">{{ item.title }}</text>
|
||||
<!-- 删除按钮 -->
|
||||
<view class="delete-btn" @tap.stop="removeFav(item.id, idx)">
|
||||
<text>✕</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view class="empty-state" v-else>
|
||||
<text class="empty-icon">⭐</text>
|
||||
<text class="empty-text">你还没有收藏任何内容</text>
|
||||
<text class="empty-sub">去首页发现有趣的 AI 知识吧</text>
|
||||
<view class="empty-btn" @tap="goHome">
|
||||
<text>开始探索</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { knowledgeApi } from '@/utils/api'
|
||||
import { getDimension } from '@/utils/dimensions'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const favorites = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
loadFavorites()
|
||||
})
|
||||
|
||||
async function loadFavorites() {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await knowledgeApi.collected(userStore.token)
|
||||
favorites.value = list || []
|
||||
} catch (e) {
|
||||
console.error('[favorites] load failed:', e)
|
||||
favorites.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function dimName(id) {
|
||||
const dim = getDimension(id)
|
||||
return dim ? dim.name : `维度 ${id}`
|
||||
}
|
||||
|
||||
function dimColor(id) {
|
||||
const dim = getDimension(id)
|
||||
return dim ? dim.color : 'rgba(255,255,255,0.4)'
|
||||
}
|
||||
|
||||
function goDetail(id) {
|
||||
if (!id) return
|
||||
uni.navigateTo({ url: `/pages/detail/detail?id=${id}` })
|
||||
}
|
||||
|
||||
async function removeFav(id, idx) {
|
||||
uni.showModal({
|
||||
title: '取消收藏',
|
||||
content: '确定要取消收藏这篇文章吗?',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
try {
|
||||
await knowledgeApi.uncollect(id, userStore.token)
|
||||
favorites.value.splice(idx, 1)
|
||||
uni.showToast({ title: '已取消收藏', icon: 'success' })
|
||||
} catch (e) {
|
||||
console.error('[favorites] uncollect failed:', e)
|
||||
uni.showToast({ title: '操作失败,请重试', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-favorites {
|
||||
min-height: 100vh;
|
||||
padding: 0 20px 40px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
// 顶部导航
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0 18px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
font-size: 13px;
|
||||
padding: 3px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(179, 136, 255, 0.12);
|
||||
color: var(--dim1-color);
|
||||
}
|
||||
|
||||
// 收藏列表
|
||||
.favorites-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fav-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.fav-item:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dim-tag {
|
||||
font-size: 12px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.fav-title {
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgba(239, 83, 80, 0.12);
|
||||
border: 1px solid rgba(239, 83, 80, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ef5350;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.delete-btn:active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 56px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
color: var(--text-tertiary);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
padding: 12px 32px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(135deg, var(--dim1-color), var(--dim3-color));
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 0 24px rgba(179, 136, 255, 0.2);
|
||||
}
|
||||
|
||||
.empty-btn:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,458 @@
|
||||
<template>
|
||||
<view class="feedback-page">
|
||||
<!-- 顶部导航 -->
|
||||
<view class="nav-bar">
|
||||
<view class="nav-back" @click="goBack">‹</view>
|
||||
<text class="nav-title">意见反馈</text>
|
||||
<view class="nav-spacer"></view>
|
||||
</view>
|
||||
|
||||
<!-- 反馈类型选择 -->
|
||||
<view class="form-section">
|
||||
<text class="form-title">反馈类型</text>
|
||||
<view class="type-grid">
|
||||
<view
|
||||
class="type-btn"
|
||||
:class="{ active: form.type === t.value }"
|
||||
v-for="t in feedbackTypes"
|
||||
:key="t.value"
|
||||
@click="form.type = t.value"
|
||||
>
|
||||
<text class="type-icon">{{ t.icon }}</text>
|
||||
<text class="type-name">{{ t.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 标题输入 -->
|
||||
<view class="form-section">
|
||||
<text class="form-title">标题</text>
|
||||
<input
|
||||
class="form-input"
|
||||
type="text"
|
||||
v-model="form.title"
|
||||
:placeholder="'简短描述您的问题或建议'"
|
||||
placeholder-style="color: var(--text-disabled); font-size: 14px;"
|
||||
maxlength="50"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 内容输入 -->
|
||||
<view class="form-section">
|
||||
<text class="form-title">内容</text>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
v-model="form.content"
|
||||
:placeholder="'请详细描述您遇到的问题或建议(至少 10 个字)'"
|
||||
placeholder-style="color: var(--text-disabled); font-size: 14px;"
|
||||
maxlength="1000"
|
||||
:auto-height="true"
|
||||
></textarea>
|
||||
</view>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<view class="submit-section">
|
||||
<button
|
||||
class="submit-btn"
|
||||
:disabled="submitting || !formValid"
|
||||
@click="submitFeedback"
|
||||
>
|
||||
<text>{{ submitting ? '提交中...' : '提交反馈' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 我的反馈 -->
|
||||
<view class="form-section">
|
||||
<view class="my-header">
|
||||
<text class="form-title">我的反馈</text>
|
||||
<text class="my-count" v-if="myFeedbacks.length > 0">
|
||||
共 {{ myFeedbacks.length }} 条
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 反馈列表 -->
|
||||
<scroll-view class="my-list" scroll-y>
|
||||
<view
|
||||
class="my-item"
|
||||
v-for="fb in myFeedbacks"
|
||||
:key="fb._id || fb.id"
|
||||
>
|
||||
<view class="my-item-top">
|
||||
<text class="my-title">{{ fb.title || '无标题' }}</text>
|
||||
<text class="my-status" :class="fb.status">{{ statusLabel(fb.status) }}</text>
|
||||
</view>
|
||||
<view class="my-item-bottom">
|
||||
<text class="my-type">{{ typeLabel(fb.type) }}</text>
|
||||
<text class="my-time">{{ formatTime(fb.createdAt || fb.time) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 无反馈提示 -->
|
||||
<view class="my-empty" v-if="myFeedbacks.length === 0">
|
||||
<text class="my-empty-text">暂无反馈记录</text>
|
||||
</view>
|
||||
|
||||
<view class="my-spacer"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { feedbackApi } from '@/utils/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 反馈类型
|
||||
const feedbackTypes = [
|
||||
{ value: 'bug', name: '问题报告', icon: '🐛' },
|
||||
{ value: 'feature', name: '功能建议', icon: '💡' },
|
||||
{ value: 'content', name: '内容纠错', icon: '📝' },
|
||||
{ value: 'other', name: '其他', icon: '💬' }
|
||||
]
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
type: 'bug',
|
||||
title: '',
|
||||
content: ''
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
const myFeedbacks = ref([])
|
||||
|
||||
// 表单验证
|
||||
const formValid = computed(() => {
|
||||
return form.value.title.trim().length > 0 && form.value.content.trim().length >= 10
|
||||
})
|
||||
|
||||
// 状态标签
|
||||
function statusLabel(status) {
|
||||
const map = {
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
resolved: '已解决',
|
||||
closed: '已关闭'
|
||||
}
|
||||
return map[status] || '待处理'
|
||||
}
|
||||
|
||||
// 类型标签
|
||||
function typeLabel(type) {
|
||||
const map = {
|
||||
bug: '🐛 问题报告',
|
||||
feature: '💡 功能建议',
|
||||
content: '📝 内容纠错',
|
||||
other: '💬 其他'
|
||||
}
|
||||
return map[type] || '💬 其他'
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
if (hours < 1) return '刚刚'
|
||||
if (hours < 24) return `${hours} 小时前`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) return `${days} 天前`
|
||||
const months = Math.floor(days / 30)
|
||||
if (months < 12) return `${months} 个月前`
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 提交反馈
|
||||
async function submitFeedback() {
|
||||
if (!formValid.value) {
|
||||
uni.showToast({ title: '请填写标题和至少 10 字内容', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
// 不传 token 可匿名提交;有 token 时带 token 提交
|
||||
await feedbackApi.create(
|
||||
{
|
||||
type: form.value.type,
|
||||
title: form.value.title.trim(),
|
||||
content: form.value.content.trim()
|
||||
},
|
||||
userStore.isLoggedIn ? userStore.token : null
|
||||
)
|
||||
|
||||
uni.showToast({ title: '反馈提交成功', icon: 'success' })
|
||||
|
||||
// 清空表单
|
||||
form.value.title = ''
|
||||
form.value.content = ''
|
||||
form.value.type = 'bug'
|
||||
|
||||
// 刷新反馈列表
|
||||
fetchMyFeedbacks()
|
||||
} catch (err) {
|
||||
console.error('[feedback] 提交失败:', err)
|
||||
uni.showToast({ title: err.message || '提交失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取我的反馈
|
||||
async function fetchMyFeedbacks() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
myFeedbacks.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await feedbackApi.my(userStore.token)
|
||||
myFeedbacks.value = Array.isArray(data) ? data : (data.list || data.items || [])
|
||||
} catch (err) {
|
||||
console.error('[feedback] 获取我的反馈失败:', err)
|
||||
myFeedbacks.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchMyFeedbacks()
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.feedback-page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
// 顶部导航
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 50px 20px 12px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.nav-back {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-spacer { width: 30px; }
|
||||
}
|
||||
|
||||
// 表单区域
|
||||
.form-section {
|
||||
padding: 0 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 类型选择网格
|
||||
.type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
|
||||
.type-btn {
|
||||
padding: 10px 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.3s;
|
||||
|
||||
.type-icon { font-size: 18px; }
|
||||
.type-name {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(179, 136, 255, 0.12);
|
||||
border-color: var(--dim1-color);
|
||||
box-shadow: 0 0 12px rgba(179, 136, 255, 0.15);
|
||||
|
||||
.type-name { color: var(--text-primary); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 输入框
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// 文本域
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
// 提交区域
|
||||
.submit-section {
|
||||
padding: 0 20px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, var(--dim1-color), var(--dim3-color));
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 16px rgba(179, 136, 255, 0.3);
|
||||
transition: all 0.3s;
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.5;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 我的反馈头部
|
||||
.my-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
|
||||
.my-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
// 反馈列表
|
||||
.my-list {
|
||||
margin-top: 10px;
|
||||
padding-bottom: 20px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.my-item {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.my-item-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.my-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.my-status {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.my-status.pending {
|
||||
background: rgba(255, 213, 79, 0.12);
|
||||
color: var(--dim4-color);
|
||||
}
|
||||
.my-status.processing {
|
||||
background: rgba(77, 208, 225, 0.12);
|
||||
color: var(--dim2-color);
|
||||
}
|
||||
.my-status.resolved {
|
||||
background: rgba(77, 182, 172, 0.12);
|
||||
color: var(--dim3-color);
|
||||
}
|
||||
.my-status.closed {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
.my-item-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 6px;
|
||||
|
||||
.my-type { font-size: 11px; color: var(--text-secondary); }
|
||||
.my-time { font-size: 11px; color: var(--text-disabled); }
|
||||
}
|
||||
|
||||
// 无反馈
|
||||
.my-empty {
|
||||
text-align: center;
|
||||
padding: 30px 0;
|
||||
|
||||
.my-empty-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
.my-spacer {
|
||||
height: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<view class="page-home">
|
||||
<!-- 状态栏占位 -->
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<view class="header">
|
||||
<view class="brand">
|
||||
<view class="brand-icon">✦</view>
|
||||
<view class="brand-text">
|
||||
<text class="title">AI 维度</text>
|
||||
<text class="subtitle">宇之然 · 探索</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="header-actions">
|
||||
<view class="icon-btn" @tap="onNotify">
|
||||
<text>🔔</text>
|
||||
</view>
|
||||
<view class="icon-btn" @tap="goProfile">
|
||||
<text>👤</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 问候语 -->
|
||||
<view class="greeting">
|
||||
<text class="greeting-title">探索 </text>
|
||||
<text class="greeting-highlight">人工智能</text>
|
||||
<text class="greeting-desc">从 5 个维度,系统理解 AI 的过去、现在与未来</text>
|
||||
</view>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<view class="search-bar" @tap="onSearch">
|
||||
<text class="search-icon">🔍</text>
|
||||
<text class="search-placeholder">搜搜你想了解的 AI 知识...</text>
|
||||
</view>
|
||||
|
||||
<!-- 五维度网格 -->
|
||||
<view class="section-header">
|
||||
<text class="section-title">✦ 五个维度</text>
|
||||
</view>
|
||||
<view class="dim-grid">
|
||||
<view
|
||||
v-for="dim in dimensions"
|
||||
:key="dim.id"
|
||||
class="dim-card"
|
||||
:class="{ 'dim-full': dim.isFull }"
|
||||
:style="{ background: dim.bg }"
|
||||
@tap="goDimension(dim)"
|
||||
>
|
||||
<view class="dim-icon" :style="{ background: dim.iconBg, color: dim.color }">
|
||||
<text>{{ dim.icon }}</text>
|
||||
</view>
|
||||
<text class="dim-name" :style="{ color: dim.color }">{{ dim.name }}</text>
|
||||
<text class="dim-desc">{{ dim.desc }}</text>
|
||||
<text class="dim-count">
|
||||
{{ dim.isFull ? '今日已更新 8 条' : `${dim.articleCount} 篇 · ${dim.quizCount} 个问答` }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 今日快讯 -->
|
||||
<view class="section-header">
|
||||
<text class="section-title">📡 今日快讯</text>
|
||||
<text class="section-more" @tap="goTrend">查看全部 →</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(item, idx) in newsList"
|
||||
:key="idx"
|
||||
class="news-card"
|
||||
@tap="goNewsDetail(item)"
|
||||
>
|
||||
<view class="news-tag" :class="item.tagClass">{{ item.tag }}</view>
|
||||
<text class="news-title">{{ item.title }}</text>
|
||||
<view class="news-meta">
|
||||
<text>{{ item.source }}</text>
|
||||
<text>{{ item.time }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="bottom-spacer"></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { DIMENSIONS } from '@/utils/dimensions'
|
||||
import { trendApi } from '@/utils/api'
|
||||
|
||||
const dimensions = ref(DIMENSIONS)
|
||||
|
||||
const newsList = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
fetchNews()
|
||||
})
|
||||
|
||||
async function fetchNews() {
|
||||
try {
|
||||
const data = await trendApi.list({ limit: 5 })
|
||||
newsList.value = data.list.map(item => ({
|
||||
tag: item.hot ? '🔥 热门' : item.picked ? '📌 精选' : '🚀 最新',
|
||||
tagClass: item.hot ? 'hot' : item.picked ? 'pick' : 'new',
|
||||
title: item.title,
|
||||
source: item.source,
|
||||
time: formatTime(item.newsDate),
|
||||
_id: item._id
|
||||
}))
|
||||
} catch (e) {
|
||||
// 降级:本地数据
|
||||
newsList.value = [
|
||||
{ tag: '🔥 热门', tagClass: 'hot', title: 'DeepSeek 被曝自研推理芯片', source: '量子位', time: '2 小时前' },
|
||||
{ tag: '🚀 最新', tagClass: 'new', title: '蚂蚁灵波开源 LingBot-World 2.0', source: '量子位', time: '4 小时前' },
|
||||
{ tag: '📌 精选', tagClass: 'pick', title: '阿里获 ACL 2026 最佳资源论文奖', source: '机器之心', time: '6 小时前' }
|
||||
]
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(dateStr) {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
if (hours < 1) return '刚刚'
|
||||
if (hours < 24) return `${hours} 小时前`
|
||||
return `${Math.floor(hours / 24)} 天前`
|
||||
}
|
||||
|
||||
function goDimension(dim) {
|
||||
uni.navigateTo({ url: `/pages/dimension/dimension?id=${dim.id}&name=${dim.name}` })
|
||||
}
|
||||
|
||||
function goTrend() {
|
||||
uni.switchTab({ url: '/pages/trend/trend' })
|
||||
}
|
||||
|
||||
function goProfile() {
|
||||
uni.switchTab({ url: '/pages/profile/profile' })
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
uni.navigateTo({ url: '/pages/search/search' })
|
||||
}
|
||||
|
||||
function onNotify() {
|
||||
uni.showToast({ title: '暂无新消息', icon: 'none' })
|
||||
}
|
||||
|
||||
function goNewsDetail(item) {
|
||||
uni.switchTab({ url: '/pages/trend/trend' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-home {
|
||||
min-height: 100vh;
|
||||
padding: 0 20px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0 20px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(0,188,212,0.2));
|
||||
border: 1px solid rgba(124,77,255,0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 0 20px rgba(124,77,255,0.1);
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.brand-text .title {
|
||||
font-size: 20px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.brand-text .subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.icon-btn {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.greeting-title {
|
||||
font-size: 26px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.greeting-highlight {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #b388ff, #7c4dff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.greeting-desc {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
.search-icon { color: var(--text-muted); }
|
||||
.search-placeholder { color: var(--text-muted); font-size: 14px; }
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 17px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.section-more {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dim-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.dim-card {
|
||||
border-radius: 18px;
|
||||
padding: 20px 16px;
|
||||
border: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
.dim-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.dim-icon {
|
||||
width: 42px; height: 42px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.dim-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
display: block;
|
||||
}
|
||||
.dim-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
}
|
||||
.dim-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.news-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.news-tag.hot {
|
||||
background: rgba(239,83,80,0.12);
|
||||
color: #ef5350;
|
||||
}
|
||||
.news-tag.new {
|
||||
background: rgba(0,188,212,0.12);
|
||||
color: #4dd0e1;
|
||||
}
|
||||
.news-tag.pick {
|
||||
background: rgba(124,77,255,0.12);
|
||||
color: #b388ff;
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.news-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.bottom-spacer {
|
||||
height: 40px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<view class="page-leaderboard">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<view class="header">
|
||||
<text class="title">🏅 排行榜</text>
|
||||
</view>
|
||||
|
||||
<!-- 维度切换 -->
|
||||
<view class="dim-tabs">
|
||||
<view
|
||||
v-for="tab in dimTabs"
|
||||
:key="tab.value"
|
||||
class="dim-tab"
|
||||
:class="{ active: activeDim === tab.value }"
|
||||
@tap="activeDim = tab.value; loadBoard()"
|
||||
>
|
||||
<text>{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 排名列表 -->
|
||||
<view class="board-list" v-if="board.length > 0">
|
||||
<view
|
||||
v-for="(item, idx) in board"
|
||||
:key="item.userId || item.username || idx"
|
||||
class="board-item"
|
||||
:class="{ current: item.isCurrentUser }"
|
||||
>
|
||||
<!-- 排名 -->
|
||||
<view class="rank">
|
||||
<text v-if="idx < 3" class="rank-top">{{ idx + 1 }}</text>
|
||||
<text v-else class="rank-num">{{ idx + 1 }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 头像 -->
|
||||
<view class="avatar">
|
||||
<image
|
||||
v-if="item.avatarUrl"
|
||||
:src="item.avatarUrl"
|
||||
class="avatar-img"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<text v-else class="avatar-text">
|
||||
{{ (item.nickName || item.username || '用').charAt(0) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 信息 -->
|
||||
<view class="user-info">
|
||||
<text class="user-name">{{ item.nickName || item.username || '用户' }}</text>
|
||||
<text class="user-score">{{ item.score || 0 }} 分</text>
|
||||
</view>
|
||||
|
||||
<!-- 已解锁维度 -->
|
||||
<view class="unlocked-dims" v-if="item.unlockedDims">
|
||||
<text
|
||||
v-for="(dimId, dIdx) in item.unlockedDims"
|
||||
:key="dIdx"
|
||||
class="dim-dot"
|
||||
:style="{ background: dimDotColor(dimId) }"
|
||||
></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view class="empty-state" v-else-if="!loading">
|
||||
<text class="empty-icon">🏅</text>
|
||||
<text class="empty-text">还没有排行数据</text>
|
||||
<text class="empty-sub">努力探索,看看你能排第几</text>
|
||||
</view>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<view class="empty-state" v-else>
|
||||
<text class="empty-icon" style="opacity:0.4">⏳</text>
|
||||
<text class="empty-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { userApi } from '@/utils/api'
|
||||
import { getDimension } from '@/utils/dimensions'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const board = ref([])
|
||||
const loading = ref(true)
|
||||
const activeDim = ref('all')
|
||||
|
||||
const dimTabs = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 1, label: 'AI 起源' },
|
||||
{ value: 2, label: 'AI 发展' },
|
||||
{ value: 3, label: 'AI 当前' },
|
||||
{ value: 4, label: 'AI 学习' },
|
||||
{ value: 5, label: 'AI 趋势' }
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
loadBoard()
|
||||
})
|
||||
|
||||
async function loadBoard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const dimParam = activeDim.value === 'all' ? null : activeDim.value
|
||||
const data = await userApi.leaderboard(dimParam)
|
||||
const list = Array.isArray(data) ? data : data.list || []
|
||||
// 标记当前用户
|
||||
const myId = userStore.userInfo?.id || userStore.userInfo?.userId || ''
|
||||
board.value = list.map((item) => ({
|
||||
...item,
|
||||
isCurrentUser: String(item.userId || item.id || '') === String(myId)
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('[leaderboard] load failed:', e)
|
||||
board.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function dimDotColor(id) {
|
||||
const dim = getDimension(id)
|
||||
return dim ? dim.color : 'rgba(255,255,255,0.2)'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-leaderboard {
|
||||
min-height: 100vh;
|
||||
padding: 0 20px 40px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
// 顶部导航
|
||||
.header {
|
||||
padding: 12px 0 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
// 维度切换
|
||||
.dim-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 0 0 14px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.dim-tab {
|
||||
flex-shrink: 0;
|
||||
padding: 7px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dim-tab.active {
|
||||
background: linear-gradient(135deg, rgba(179, 136, 255, 0.15), rgba(77, 182, 172, 0.15));
|
||||
border-color: rgba(179, 136, 255, 0.3);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 排名列表
|
||||
.board-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.board-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.board-item.current {
|
||||
background: rgba(179, 136, 255, 0.08);
|
||||
border-color: rgba(179, 136, 255, 0.25);
|
||||
box-shadow: 0 0 24px rgba(179, 136, 255, 0.08);
|
||||
}
|
||||
|
||||
// 排名
|
||||
.rank {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rank-top {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--dim1-color), var(--dim4-color));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.rank-num {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 头像
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(124, 77, 255, 0.3), rgba(0, 188, 212, 0.15));
|
||||
border: 1px solid rgba(124, 77, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
// 用户信息
|
||||
.user-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-score {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
// 已解锁维度
|
||||
.unlocked-dims {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dim-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 56px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
color: var(--text-tertiary);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<view class="login-page">
|
||||
<!-- 背景装饰 -->
|
||||
<view class="bg-orb orb-1"></view>
|
||||
<view class="bg-orb orb-2"></view>
|
||||
<view class="bg-orb orb-3"></view>
|
||||
|
||||
<view class="login-container">
|
||||
<!-- Logo -->
|
||||
<view class="logo-wrap">
|
||||
<view class="logo-ring"></view>
|
||||
<text class="logo-text">AI</text>
|
||||
</view>
|
||||
<text class="app-title">宇之然 AI 维度</text>
|
||||
<text class="app-subtitle">从 5 个维度系统理解人工智能</text>
|
||||
|
||||
<!-- Tab 切换 — 两种模式共用 -->
|
||||
<view class="tab-switch">
|
||||
<text class="tab-item" :class="{ active: activeTab === 'login' }" @click="activeTab = 'login'">登录</text>
|
||||
<text class="tab-item" :class="{ active: activeTab === 'register' }" @click="activeTab = 'register'">注册</text>
|
||||
</view>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<view class="form-card" v-if="activeTab === 'login'">
|
||||
<view class="form-item">
|
||||
<text class="form-label">用户名</text>
|
||||
<input class="form-input" v-model="loginForm.username" placeholder="请输入用户名" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">密码</text>
|
||||
<input class="form-input" v-model="loginForm.password" type="password" placeholder="请输入密码" />
|
||||
</view>
|
||||
<view class="submit-btn" :class="{ loading: loading }" @click="handleLogin">
|
||||
<text>{{ loading ? '登录中...' : '登录' }}</text>
|
||||
</view>
|
||||
<!-- 微信小程序一键登录入口 -->
|
||||
<view class="wx-login-row" v-if="isMiniApp">
|
||||
<view class="wx-login-btn" @click="wechatLogin">
|
||||
<text>微信一键登录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 注册表单 -->
|
||||
<view class="form-card" v-if="activeTab === 'register'">
|
||||
<view class="form-item">
|
||||
<text class="form-label">用户名</text>
|
||||
<input class="form-input" v-model="registerForm.username" placeholder="3-20 位字符" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">昵称</text>
|
||||
<input class="form-input" v-model="registerForm.nickName" placeholder="显示名称(选填)" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">密码</text>
|
||||
<input class="form-input" v-model="registerForm.password" type="password" placeholder="至少 6 位" />
|
||||
</view>
|
||||
<view class="submit-btn" :class="{ loading: loading }" @click="handleRegister">
|
||||
<text>{{ loading ? '注册中...' : '注册' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<view class="footer-info">
|
||||
<text>© 2026 宇之然 · AI 维度</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useUserStore } from '@/stores/index'
|
||||
|
||||
// 检测是否为微信小程序环境
|
||||
const isMiniApp = ref(false)
|
||||
const platform = uni.getSystemInfoSync().platform
|
||||
try {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
// 小程序环境:platform 为 ios/android/devtools,且有 brand/version 等字段
|
||||
if (sysInfo && sysInfo.version && sysInfo.platform) {
|
||||
// 有 weixin 特有字段即为小程序
|
||||
isMiniApp.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
// H5 环境
|
||||
}
|
||||
|
||||
const activeTab = ref('login')
|
||||
const loading = ref(false)
|
||||
const userStore = useUserStore()
|
||||
|
||||
// H5 表单
|
||||
const loginForm = ref({ username: '', password: '' })
|
||||
const registerForm = ref({ username: '', password: '', nickName: '' })
|
||||
|
||||
/**
|
||||
* 微信小程序登录
|
||||
*/
|
||||
const wechatLogin = async () => {
|
||||
try {
|
||||
const loginRes = await new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
|
||||
if (!loginRes.code) {
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
await userStore.login(loginRes.code, {})
|
||||
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
console.error('微信登录失败:', err)
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* H5 用户名密码登录
|
||||
*/
|
||||
const handleLogin = async () => {
|
||||
if (!loginForm.value.username || !loginForm.value.password) {
|
||||
uni.showToast({ title: '请填写完整信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.h5Login(loginForm.value.username, loginForm.value.password)
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '登录失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* H5 注册
|
||||
*/
|
||||
const handleRegister = async () => {
|
||||
if (!registerForm.value.username || !registerForm.value.password) {
|
||||
uni.showToast({ title: '请填写完整信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (registerForm.value.password.length < 6) {
|
||||
uni.showToast({ title: '密码至少 6 位', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.h5Register(
|
||||
registerForm.value.username,
|
||||
registerForm.value.password,
|
||||
registerForm.value.nickName || ''
|
||||
)
|
||||
uni.showToast({ title: '注册成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '注册失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
|
||||
// 背景装饰光球
|
||||
.bg-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(60px);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
.orb-1 {
|
||||
width: 200px; height: 200px;
|
||||
top: -50px; left: -50px;
|
||||
background: var(--dim1-color);
|
||||
}
|
||||
.orb-2 {
|
||||
width: 160px; height: 160px;
|
||||
top: 30%; right: -40px;
|
||||
background: var(--dim3-color);
|
||||
}
|
||||
.orb-3 {
|
||||
width: 180px; height: 180px;
|
||||
bottom: -60px; left: 30%;
|
||||
background: var(--dim5-color);
|
||||
}
|
||||
|
||||
.login-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Logo
|
||||
.logo-wrap {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.logo-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, var(--dim1-color), var(--dim3-color));
|
||||
box-shadow: 0 8px 32px rgba(124, 77, 255, 0.2);
|
||||
}
|
||||
.logo-text {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.app-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
// Tab 切换
|
||||
.tab-switch {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.active {
|
||||
color: var(--color-brand);
|
||||
border-bottom-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 表单卡片
|
||||
.form-card {
|
||||
width: 100%;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
padding: 24px 20px;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 10px;
|
||||
padding: 0 14px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交按钮
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--color-brand), var(--dim3-color));
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(124, 77, 255, 0.25);
|
||||
transition: opacity 0.3s;
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
&.loading {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
// 微信一键登录
|
||||
.wx-login-row {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.wx-login-btn {
|
||||
padding: 10px 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(135deg, #07c160, #06ad56);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(7, 193, 96, 0.3);
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 底部信息
|
||||
.footer-info {
|
||||
margin-top: 40px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,356 @@
|
||||
<template>
|
||||
<view class="page-news-detail">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
<!-- 导航 -->
|
||||
<view class="nav-bar">
|
||||
<view class="nav-back" @click="goBack">‹</view>
|
||||
<text class="nav-title">AI 资讯</text>
|
||||
<view class="nav-action">
|
||||
<text class="nav-share" @click="onShare">↗</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="content">
|
||||
<!-- 资讯头图 -->
|
||||
<view class="news-hero" :style="{background: heroBg}">
|
||||
<text class="hero-emoji">{{ heroEmoji }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 分类标签 -->
|
||||
<view class="category-tag">
|
||||
<text class="tag-text">{{ categoryLabel }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 标题 -->
|
||||
<text class="news-title">{{ news.title }}</text>
|
||||
|
||||
<!-- 元信息 -->
|
||||
<view class="meta-info">
|
||||
<view class="meta-item">
|
||||
<text class="meta-label">来源</text>
|
||||
<text class="meta-value">{{ news.source }}</text>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<text class="meta-label">时间</text>
|
||||
<text class="meta-value">{{ formatDate(news.newsDate) }}</text>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<text class="meta-label">阅读</text>
|
||||
<text class="meta-value">{{ news.viewCount || 0 }} 次</text>
|
||||
</view>
|
||||
<view class="meta-item" v-if="news.hot">
|
||||
<text class="meta-label hot">🔥</text>
|
||||
<text class="meta-value">{{ news.hotCount || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 摘要 -->
|
||||
<view class="summary-card">
|
||||
<text class="summary-label">摘要</text>
|
||||
<text class="summary-text">{{ news.summary }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 正文 -->
|
||||
<view class="news-body" v-if="newsBody">
|
||||
<rich-text :nodes="newsBody"></rich-text>
|
||||
</view>
|
||||
|
||||
<!-- 相关话题 -->
|
||||
<view class="related-tags" v-if="relatedTags.length">
|
||||
<text class="related-label">相关话题</text>
|
||||
<view class="tags-row">
|
||||
<view class="tag-item" v-for="(tag, i) in relatedTags" :key="i">
|
||||
<text class="tag-text">{{ tag }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<view class="action-bar">
|
||||
<view class="action-btn" @click="onFavorite">
|
||||
<text class="action-icon">{{ isCollected ? '❤️' : '🤍' }}</text>
|
||||
<text class="action-text">{{ isCollected ? '已收藏' : '收藏' }}</text>
|
||||
</view>
|
||||
<view class="action-btn" @click="onShare">
|
||||
<text class="action-icon">↗</text>
|
||||
<text class="action-text">分享</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer-spacer"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { trendApi, knowledgeApi } from '@/utils/api'
|
||||
|
||||
const news = ref({})
|
||||
const isCollected = ref(false)
|
||||
const loading = ref(true)
|
||||
|
||||
const heroBg = computed(() => {
|
||||
const c = news.value.category || 'product'
|
||||
return {
|
||||
product: 'linear-gradient(135deg, rgba(179,136,255,0.3), rgba(239,83,80,0.15))',
|
||||
paper: 'linear-gradient(135deg, rgba(77,208,225,0.3), rgba(77,182,172,0.15))',
|
||||
tool: 'linear-gradient(135deg, rgba(255,213,79,0.3), rgba(239,83,80,0.1))',
|
||||
company: 'linear-gradient(135deg, rgba(77,182,172,0.3), rgba(179,136,255,0.15))'
|
||||
}[c] || 'linear-gradient(135deg, rgba(179,136,255,0.3), rgba(77,208,225,0.15))'
|
||||
})
|
||||
|
||||
const heroEmoji = computed(() => {
|
||||
return { product: '🚀', paper: '📄', tool: '🔧', company: '🏢' }[news.value.category || 'product'] || '📰'
|
||||
})
|
||||
|
||||
const categoryLabel = computed(() => {
|
||||
const c = news.value.category || 'product'
|
||||
return { product: '产品动态', paper: '研究论文', tool: '工具发布', company: '公司动态' }[c] || '资讯'
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '—'
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
if (isNaN(d)) return dateStr
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
} catch (e) {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
const newsBody = computed(() => {
|
||||
const content = news.value.body || ''
|
||||
if (!content) {
|
||||
return `<p>${news.value.summary || ''}</p><p style="margin-top:16px;color:rgba(255,255,255,0.35)">完整内容暂未提供,将持续更新...</p>`
|
||||
}
|
||||
return content
|
||||
})
|
||||
|
||||
const relatedTags = computed(() => {
|
||||
return news.value.tags || []
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
const id = uni.getStorageSync('__news_id__')
|
||||
loading.value = false
|
||||
// Try API detail first, fallback to local data from trend page
|
||||
if (id) {
|
||||
try {
|
||||
const data = await trendApi.detail(id)
|
||||
news.value = data
|
||||
isCollected.value = data.collected || false
|
||||
return
|
||||
} catch (e) {
|
||||
// fall through to local
|
||||
}
|
||||
}
|
||||
// Fallback: read from trend page's stored item
|
||||
try {
|
||||
const raw = uni.getStorageSync('__news_item__')
|
||||
if (raw) {
|
||||
news.value = JSON.parse(raw)
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function onFavorite() {
|
||||
isCollected.value = !isCollected.value
|
||||
uni.showToast({ title: isCollected.value ? '已收藏' : '已取消收藏', icon: 'none' })
|
||||
}
|
||||
|
||||
function onShare() {
|
||||
uni.setClipboardData({
|
||||
data: news.value.link || `https://ai-dimension.yuzhiran.com.cn/news/${uni.getStorageSync('__news_id__')}`,
|
||||
success: () => uni.showToast({ title: '链接已复制', icon: 'none' })
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-news-detail {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
padding: 0 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-share { font-size: 22px; color: rgba(255,255,255,0.5); }
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
// Hero
|
||||
.news-hero {
|
||||
height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0 0 24px 24px;
|
||||
}
|
||||
|
||||
.hero-emoji { font-size: 72px; }
|
||||
|
||||
// Category
|
||||
.category-tag {
|
||||
margin: 24px 20px 0;
|
||||
}
|
||||
|
||||
.tag-text {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--dim1-color);
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// Title
|
||||
.news-title {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
color: var(--text-primary);
|
||||
margin: 12px 20px 16px;
|
||||
}
|
||||
|
||||
// Meta
|
||||
.meta-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin: 0 20px 20px;
|
||||
padding: 16px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.35);
|
||||
}
|
||||
|
||||
.meta-label.hot { font-size: 13px; }
|
||||
|
||||
.meta-value { font-size: 12px; color: rgba(255,255,255,0.7); }
|
||||
|
||||
// Summary
|
||||
.summary-card {
|
||||
margin: 0 20px 20px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, rgba(179,136,255,0.08), rgba(77,208,225,0.05));
|
||||
border: 1px solid rgba(179,136,255,0.12);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--dim1-color);
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.summary-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: rgba(255,255,255,0.85);
|
||||
}
|
||||
|
||||
// Body
|
||||
.news-body {
|
||||
margin: 0 20px 20px;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
// Related tags
|
||||
.related-tags {
|
||||
margin: 0 20px 20px;
|
||||
}
|
||||
|
||||
.related-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tags-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
.tag-item {
|
||||
padding: 6px 12px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.tag-text { font-size: 12px; color: rgba(255,255,255,0.6); }
|
||||
|
||||
// Action bar
|
||||
.action-bar {
|
||||
margin: 0 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.action-icon { font-size: 18px; }
|
||||
.action-text { font-size: 13px; color: rgba(255,255,255,0.7); }
|
||||
|
||||
.footer-spacer { height: 60px; }
|
||||
</style>
|
||||
@@ -0,0 +1,418 @@
|
||||
<template>
|
||||
<view class="page-payment">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 导航 -->
|
||||
<view class="nav-bar">
|
||||
<view class="nav-back" @click="goBack">‹</view>
|
||||
<text class="nav-title">订阅升级</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
|
||||
<!-- 头部 -->
|
||||
<view class="payment-header">
|
||||
<text class="header-title">解锁 AI 维度 Pro</text>
|
||||
<text class="header-desc">无限 AI 问答 · 全量知识解锁 · 每日趋势优先推送</text>
|
||||
</view>
|
||||
|
||||
<!-- 方案选择 -->
|
||||
<view class="plan-selector">
|
||||
<view
|
||||
class="plan-tag"
|
||||
:class="{ active: selectedPlan === 'pro' }"
|
||||
@click="selectedPlan = 'pro'"
|
||||
>
|
||||
<text class="plan-name">Pro</text>
|
||||
<text class="plan-price">¥19.9<span class="unit">/月</span></text>
|
||||
<text class="plan-badge">推荐</text>
|
||||
</view>
|
||||
<view
|
||||
class="plan-tag vip"
|
||||
:class="{ active: selectedPlan === 'vip' }"
|
||||
@click="selectedPlan = 'vip'"
|
||||
>
|
||||
<text class="plan-name">VIP</text>
|
||||
<text class="plan-price">¥39.9<span class="unit">/月</span></text>
|
||||
<text class="plan-desc">含 Pro 全部权益</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 权益对比 -->
|
||||
<view class="benefits">
|
||||
<view class="benefit-item" v-for="(item, idx) in benefits" :key="idx">
|
||||
<text class="b-icon">{{ item.icon }}</text>
|
||||
<text class="b-text">{{ item.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 套餐选择 -->
|
||||
<view class="duration-section">
|
||||
<text class="duration-title">选择套餐</text>
|
||||
<view class="duration-list">
|
||||
<view
|
||||
class="duration-item"
|
||||
:class="{ active: selectedDuration === item.key }"
|
||||
v-for="item in durations"
|
||||
:key="item.key"
|
||||
@click="selectedDuration = item.key"
|
||||
>
|
||||
<text class="d-label">{{ item.label }}</text>
|
||||
<text class="d-price">¥{{ item.price }}</text>
|
||||
<text class="d-save" v-if="item.save">省 {{ item.save }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 支付按钮 -->
|
||||
<view class="pay-section">
|
||||
<button class="pay-btn" :loading="loading" @click="handlePay">
|
||||
立即支付 ¥{{ currentPrice }}
|
||||
</button>
|
||||
<text class="pay-agreement">
|
||||
点击支付即同意《用户协议》和《隐私政策》
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 底部 -->
|
||||
<text class="footer-text">如有问题请联系客服 · 支持 7 天无理由退款</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { paymentApi } from '@/utils/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 方案
|
||||
const selectedPlan = ref('pro')
|
||||
|
||||
// 套餐时长
|
||||
const durations = ref([
|
||||
{ key: 'pro-monthly', label: 'Pro 月付', price: 19.9 },
|
||||
{ key: 'pro-quarterly', label: 'Pro 季付', price: 49.9, save: '省 ¥10' },
|
||||
{ key: 'vip-monthly', label: 'VIP 月付', price: 39.9 },
|
||||
{ key: 'vip-quarterly', label: 'VIP 季付', price: 99.9, save: '省 ¥20' }
|
||||
])
|
||||
|
||||
const selectedDuration = ref('pro-monthly')
|
||||
|
||||
// 权益
|
||||
const benefits = ref([
|
||||
{ icon: '🤖', text: 'AI 问答无限次' },
|
||||
{ icon: '📚', text: '全量知识内容解锁' },
|
||||
{ icon: '📡', text: '每日趋势优先推送' },
|
||||
{ icon: '⭐', text: '收藏夹无限容量' },
|
||||
{ icon: '🎁', text: '专属会员标识' },
|
||||
{ icon: '💬', text: '专属客服支持' }
|
||||
])
|
||||
|
||||
// 当前价格
|
||||
const currentPrice = computed(() => {
|
||||
const item = durations.value.find(d => d.key === selectedDuration.value)
|
||||
return item ? item.price : 19.9
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
async function handlePay() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
// 找到对应的 productId
|
||||
const productMap = {
|
||||
'pro-monthly': 'pro-monthly',
|
||||
'pro-quarterly': 'pro-quarterly',
|
||||
'vip-monthly': 'vip-monthly',
|
||||
'vip-quarterly': 'vip-quarterly'
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 调用后端创建订单
|
||||
const data = await paymentApi.createOrder(productMap[selectedDuration.value], userStore.token)
|
||||
|
||||
// 获取微信支付参数
|
||||
const payParam = data.payParams || data.wechatParam || data.paymentData
|
||||
|
||||
if (payParam) {
|
||||
// 调起微信支付
|
||||
uni.requestPayment({
|
||||
provider: 'wxpay',
|
||||
timeStamp: payParam.timeStamp || payParam.timestamp,
|
||||
nonceStr: payParam.nonceStr,
|
||||
package: payParam.package || payParam.packAge,
|
||||
signType: payParam.signType || 'MD5',
|
||||
paySign: payParam.paySign,
|
||||
success: () => {
|
||||
uni.showToast({ title: '支付成功!', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
}, 1500)
|
||||
},
|
||||
fail: (err) => {
|
||||
if (err.errMsg.includes('cancel')) {
|
||||
uni.showToast({ title: '已取消支付', icon: 'none' })
|
||||
} else {
|
||||
uni.showToast({ title: '支付失败,请重试', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
uni.showToast({ title: '获取支付参数失败', icon: 'none' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('创建订单失败:', err)
|
||||
uni.showToast({ title: err.message || '创建订单失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-payment {
|
||||
min-height: 100vh;
|
||||
padding: 0 20px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
// 导航
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-placeholder {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
// 头部
|
||||
.payment-header {
|
||||
text-align: center;
|
||||
padding: 10px 0 20px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 方案选择
|
||||
.plan-selector {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.plan-tag {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.plan-tag.active {
|
||||
background: linear-gradient(135deg, rgba(124, 77, 255, 0.15), rgba(0, 0, 0, 0.2));
|
||||
border-color: rgba(124, 77, 255, 0.3);
|
||||
}
|
||||
|
||||
.plan-tag.vip.active {
|
||||
background: linear-gradient(135deg, rgba(255, 152, 0, 0.15), rgba(0, 0, 0, 0.2));
|
||||
border-color: rgba(255, 152, 0, 0.3);
|
||||
}
|
||||
|
||||
.plan-name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.plan-price {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--dim1-color);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plan-tag.vip .plan-price {
|
||||
color: #ffa726;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.plan-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: 12px;
|
||||
background: linear-gradient(135deg, #ff9800, #ff5722);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.plan-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 权益
|
||||
.benefits {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.benefit-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.benefit-item:not(:last-child) {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.b-icon { font-size: 16px; }
|
||||
|
||||
.b-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
// 套餐时长
|
||||
.duration-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.duration-title {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.duration-list {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.duration-item {
|
||||
flex: 1;
|
||||
padding: 12px 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.duration-item.active {
|
||||
background: rgba(124, 77, 255, 0.12);
|
||||
border-color: rgba(124, 77, 255, 0.3);
|
||||
}
|
||||
|
||||
.d-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.d-price {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--dim1-color);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.d-save {
|
||||
font-size: 10px;
|
||||
color: #4db6ac;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// 支付
|
||||
.pay-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pay-btn {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
background: linear-gradient(135deg, var(--dim1-color), var(--dim3-color));
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 16px rgba(179, 136, 255, 0.3);
|
||||
}
|
||||
|
||||
.pay-agreement {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,514 @@
|
||||
<template>
|
||||
<view class="page-profile">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<view class="profile-card">
|
||||
<view class="profile-avatar">
|
||||
<image
|
||||
v-if="userStore.userInfo?.avatarUrl"
|
||||
:src="userStore.userInfo.avatarUrl"
|
||||
class="avatar-img"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<text v-else class="avatar-text">
|
||||
{{ (userStore.userInfo?.nickName || '用').charAt(0) }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="profile-name">{{ userStore.userInfo?.nickName || '游客' }}</text>
|
||||
<text class="profile-level">
|
||||
{{ subscriptionBadge }} · 已收藏 {{ collectedCount }} 篇
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 订阅卡片 -->
|
||||
<view class="plan-card" @click="goPayment">
|
||||
<view class="plan-info">
|
||||
<text class="plan-title">{{ planTitle }}</text>
|
||||
<text class="plan-desc">{{ planDesc }}</text>
|
||||
<!-- 进度条(订阅到期倒计时) -->
|
||||
<view class="plan-progress" v-if="userStore.subInfo">
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" :style="{ width: planPercent + '%' }"></view>
|
||||
</view>
|
||||
<text class="progress-text">
|
||||
{{ planExpiryText }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="plan-btn" :class="{ disabled: userStore.isSubscribed }">
|
||||
<text>{{ planBtnText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- AI 问答配额 -->
|
||||
<view class="quota-card" @click="goChat" v-if="userStore.isLoggedIn">
|
||||
<text class="quota-card-title">🤖 AI 问答配额</text>
|
||||
<view class="quota-bar">
|
||||
<view class="quota-fill" :style="{ width: quotaPercent + '%' }"></view>
|
||||
</view>
|
||||
<text class="quota-text">
|
||||
{{ quotaUsed }} / {{ quotaTotal }}
|
||||
<text class="quota-note">今日使用</text>
|
||||
</text>
|
||||
<text class="quota-share" @click.stop="goShare">🎁 分享得次数</text>
|
||||
</view>
|
||||
|
||||
<!-- 菜单列表 -->
|
||||
<view class="menu-section">
|
||||
<view class="menu-item" @click="goFavorites">
|
||||
<view class="m-icon" style="background:rgba(179,136,255,0.1);color:#b388ff">⭐</view>
|
||||
<text class="m-label">我的收藏</text>
|
||||
<text class="m-badge">{{ collectedCount }}</text>
|
||||
<text class="m-arrow">→</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goProgress">
|
||||
<view class="m-icon" style="background:rgba(77,208,225,0.1);color:#4dd0e1">📊</view>
|
||||
<text class="m-label">学习进度</text>
|
||||
<text class="m-arrow">→</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goAchievements">
|
||||
<view class="m-icon" style="background:rgba(77,182,172,0.1);color:#4db6ac">🏆</view>
|
||||
<text class="m-label">成就</text>
|
||||
<text class="m-arrow">→</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goLeaderboard">
|
||||
<view class="m-icon" style="background:rgba(255,167,38,0.1);color:#ffa726">🥇</view>
|
||||
<text class="m-label">排行榜</text>
|
||||
<text class="m-arrow">→</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goFeedback">
|
||||
<view class="m-icon" style="background:rgba(102,187,106,0.1);color:#66bb6a">💬</view>
|
||||
<text class="m-label">意见反馈</text>
|
||||
<text class="m-arrow">→</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goAbout">
|
||||
<view class="m-icon" style="background:rgba(255,213,79,0.1);color:#ffd54f">ℹ️</view>
|
||||
<text class="m-label">关于 AI 维度</text>
|
||||
<text class="m-arrow">→</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goSettings">
|
||||
<view class="m-icon" style="background:rgba(239,83,80,0.1);color:#ef5350">⚙️</view>
|
||||
<text class="m-label">设置</text>
|
||||
<text class="m-arrow">→</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 登录/退出 -->
|
||||
<view class="action-btn" v-if="!userStore.isLoggedIn" @click="goLogin">
|
||||
<text>微信一键登录</text>
|
||||
</view>
|
||||
<view class="action-btn logout" v-else @click="handleLogout">
|
||||
<text>退出登录</text>
|
||||
</view>
|
||||
|
||||
<text class="version">宇之然 AI 维度 v1.0.0</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { knowledgeApi, aiChatApi } from '@/utils/api'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 已收藏数量
|
||||
const collectedCount = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
if (userStore.isLoggedIn) {
|
||||
loadUserInfo()
|
||||
loadCollected()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadUserInfo() {
|
||||
await userStore.fetchMe()
|
||||
if (userStore.token) {
|
||||
await userStore.fetchQuota(userStore.token)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCollected() {
|
||||
if (!userStore.token) return
|
||||
try {
|
||||
const list = await knowledgeApi.collected(userStore.token)
|
||||
collectedCount.value = list.length
|
||||
} catch (e) {
|
||||
collectedCount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅信息
|
||||
const planTitle = computed(() => {
|
||||
if (userStore.isSubscribed) {
|
||||
return `✦ ${userStore.subscriptionLevel.toUpperCase()} 会员`
|
||||
}
|
||||
return '✦ 免费版'
|
||||
})
|
||||
|
||||
const planDesc = computed(() => {
|
||||
if (userStore.isSubscribed) {
|
||||
return '全量知识解锁 · AI 问答无限次'
|
||||
}
|
||||
return '每日 5 次免费 AI 问答 · 升级解锁全部知识'
|
||||
})
|
||||
|
||||
const planBtnText = computed(() => {
|
||||
if (userStore.isLoggedIn && userStore.isSubscribed) return '已订阅'
|
||||
if (userStore.isLoggedIn) return '升级 Pro'
|
||||
return '登录后升级'
|
||||
})
|
||||
|
||||
const planExpiryText = computed(() => {
|
||||
if (!userStore.subInfo?.expiresAt) return ''
|
||||
const expires = new Date(userStore.subInfo.expiresAt)
|
||||
const now = new Date()
|
||||
const diffDays = Math.ceil((expires - now) / (1000 * 60 * 60 * 24))
|
||||
if (diffDays <= 0) return '已过期'
|
||||
return `${diffDays} 天后到期`
|
||||
})
|
||||
|
||||
const planPercent = computed(() => {
|
||||
if (!userStore.subInfo?.expiresAt || !userStore.subInfo?.startDate) return 0
|
||||
const start = new Date(userStore.subInfo.startDate).getTime()
|
||||
const expires = new Date(userStore.subInfo.expiresAt).getTime()
|
||||
const now = Date.now()
|
||||
const total = expires - start
|
||||
const elapsed = now - start
|
||||
return Math.min(100, Math.max(0, (elapsed / total) * 100))
|
||||
})
|
||||
|
||||
// 配额
|
||||
const quotaPercent = computed(() => {
|
||||
const q = userStore.quota
|
||||
if (!q) return 0
|
||||
const remaining = q.remainingQuota || 0
|
||||
const used = q.usedQuota || q.dailyUsed || 0
|
||||
const total = q.freeQuota || remaining + used
|
||||
return total > 0 ? Math.round((used / total) * 100) : 0
|
||||
})
|
||||
|
||||
const quotaUsed = computed(() => userStore.quota?.dailyUsed || userStore.quota?.usedQuota || 0)
|
||||
const quotaTotal = computed(() => {
|
||||
const q = userStore.quota
|
||||
return (q?.freeQuota || 5) + (q?.sharedQuota || 0)
|
||||
})
|
||||
|
||||
const subscriptionBadge = computed(() => {
|
||||
if (userStore.isSubscribed) {
|
||||
return userStore.subscriptionLevel.toUpperCase()
|
||||
}
|
||||
return '免费用户'
|
||||
})
|
||||
|
||||
function goLogin() {
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
uni.showModal({
|
||||
title: '退出登录',
|
||||
content: '确定退出当前账号吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
userStore.logout()
|
||||
collectedCount.value = 0
|
||||
uni.showToast({ title: '已退出', icon: 'success' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goPayment() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
goLogin()
|
||||
return
|
||||
}
|
||||
if (userStore.isSubscribed) return
|
||||
uni.navigateTo({ url: '/pages/payment/payment' })
|
||||
}
|
||||
|
||||
function goChat() {
|
||||
uni.switchTab({ url: '/pages/chat/chat' })
|
||||
}
|
||||
|
||||
function goShare() {
|
||||
aiChatApi.shareGain(userStore.token).then(() => {
|
||||
uni.showToast({ title: '已记录分享', icon: 'success' })
|
||||
if (userStore.token) userStore.fetchQuota(userStore.token)
|
||||
}).catch(() => {
|
||||
uni.showToast({ title: '已记录分享', icon: 'success' })
|
||||
})
|
||||
}
|
||||
|
||||
function goFavorites() {
|
||||
uni.navigateTo({ url: '/pages/favorites/favorites' })
|
||||
}
|
||||
|
||||
function goProgress() {
|
||||
uni.navigateTo({ url: '/pages/progress/progress' })
|
||||
}
|
||||
|
||||
function goAchievements() {
|
||||
uni.navigateTo({ url: '/pages/achievements/achievements' })
|
||||
}
|
||||
|
||||
function goLeaderboard() {
|
||||
uni.navigateTo({ url: '/pages/leaderboard/leaderboard' })
|
||||
}
|
||||
|
||||
function goFeedback() {
|
||||
uni.navigateTo({ url: '/pages/feedback/feedback' })
|
||||
}
|
||||
|
||||
function goAbout() {
|
||||
uni.showModal({
|
||||
title: '关于',
|
||||
content: '宇之然 AI 维度\n从 5 个维度系统理解人工智能\n© 2026 宇之然',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
function goSettings() {
|
||||
uni.navigateTo({ url: '/pages/settings/settings' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-profile {
|
||||
min-height: 100vh;
|
||||
padding: 0 20px 40px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
// 用户卡片
|
||||
.profile-card {
|
||||
text-align: center;
|
||||
padding: 20px 0 24px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(124, 77, 255, 0.3), rgba(0, 188, 212, 0.15));
|
||||
border: 2px solid rgba(124, 77, 255, 0.2);
|
||||
margin: 0 auto 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 40px rgba(124, 77, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-level {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 订阅卡片
|
||||
.plan-card {
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, rgba(124, 77, 255, 0.08), rgba(0, 0, 0, 0.2));
|
||||
border: 1px solid rgba(124, 77, 255, 0.12);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-info { flex: 1; }
|
||||
|
||||
.plan-title {
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plan-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plan-progress {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--dim1-color), var(--dim3-color));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plan-btn {
|
||||
background: linear-gradient(135deg, #7c4dff, #b388ff);
|
||||
padding: 10px 22px;
|
||||
border-radius: 20px;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 0 20px rgba(124, 77, 255, 0.2);
|
||||
}
|
||||
|
||||
.plan-btn.disabled {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
// 配额卡片
|
||||
.quota-card {
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.quota-card-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.quota-bar {
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.quota-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4dd0e1, #b388ff);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.quota-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.quota-note {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.quota-share {
|
||||
font-size: 11px;
|
||||
color: var(--dim1-color);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 菜单
|
||||
.menu-section {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.m-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.m-label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.m-arrow {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.m-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(179, 136, 255, 0.12);
|
||||
color: var(--dim1-color);
|
||||
}
|
||||
|
||||
// 按钮
|
||||
.action-btn {
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
color: var(--dim1-color);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.action-btn.logout {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.version {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.1);
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,484 @@
|
||||
<template>
|
||||
<view class="progress-page">
|
||||
<!-- 顶部导航 -->
|
||||
<view class="nav-bar">
|
||||
<view class="nav-back" @click="goBack">‹</view>
|
||||
<text class="nav-title">学习进度</text>
|
||||
<view class="nav-spacer"></view>
|
||||
</view>
|
||||
|
||||
<!-- 用户等级卡片 -->
|
||||
<view class="stats-header">
|
||||
<view class="level-badge">
|
||||
<text class="level-num">{{ stats?.level || '--' }}</text>
|
||||
<text class="level-name">{{ stats?.levelName || '探索者' }}</text>
|
||||
</view>
|
||||
<view class="stats-row">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ stats?.totalPoints || 0 }}</text>
|
||||
<text class="stat-label">累计积分</text>
|
||||
</view>
|
||||
<view class="stat-divider"></view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ studyHours }}</text>
|
||||
<text class="stat-label">学习时长</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 维度进度列表 -->
|
||||
<view class="section-title">
|
||||
<text>维度进度</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="progress-list" scroll-y>
|
||||
<view
|
||||
class="dim-card"
|
||||
v-for="dimData in dimProgress"
|
||||
:key="dimData.id"
|
||||
:class="{ expanded: dimData.expanded }"
|
||||
@click="toggleExpand(dimData.id)"
|
||||
>
|
||||
<!-- 卡片头部(可点击展开) -->
|
||||
<view class="dim-card-head">
|
||||
<view class="dim-info">
|
||||
<view
|
||||
class="dim-icon-circle"
|
||||
:style="{ background: dimData.bg, color: dimData.color }"
|
||||
>
|
||||
<text>{{ dimData.icon }}</text>
|
||||
</view>
|
||||
<view class="dim-text">
|
||||
<text class="dim-name">{{ dimData.name }}</text>
|
||||
<text class="dim-desc">{{ dimData.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 环形进度 -->
|
||||
<view class="dim-ring" v-if="dimData.total > 0">
|
||||
<view
|
||||
class="ring"
|
||||
:style="{
|
||||
background: `conic-gradient(${dimData.color} 0% ${dimData.percent}%, rgba(255,255,255,0.08) ${dimData.percent}% 100%)`
|
||||
}"
|
||||
>
|
||||
<view class="ring-inner">
|
||||
<text class="ring-text">{{ dimData.percent }}%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="dim-ring" v-else>
|
||||
<view class="ring ring-empty">
|
||||
<view class="ring-inner">
|
||||
<text class="ring-text">--</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 展开内容 -->
|
||||
<view class="dim-card-body" v-if="dimData.expanded">
|
||||
<view class="progress-detail">
|
||||
<view class="detail-item">
|
||||
<text class="detail-label">已完成</text>
|
||||
<text class="detail-value" :style="{ color: dimData.color }">
|
||||
{{ dimData.done }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<text class="detail-label">总知识</text>
|
||||
<text class="detail-value">{{ dimData.total }}</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<text class="detail-label">进度</text>
|
||||
<text class="detail-value">
|
||||
{{ dimData.done }}/{{ dimData.total }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<view class="progress-bar-bg">
|
||||
<view
|
||||
class="progress-bar-fill"
|
||||
:style="{
|
||||
width: `${dimData.percent}%`,
|
||||
background: `linear-gradient(90deg, ${dimData.color}, ${dimData.color}dd)`
|
||||
}"
|
||||
></view>
|
||||
</view>
|
||||
<text class="progress-bar-text">
|
||||
{{ dimData.done }} / {{ dimData.total }} 已探索
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部间距 -->
|
||||
<view class="list-spacer"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { knowledgeApi, userApi } from '@/utils/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { DIMENSIONS } from '@/utils/dimensions'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const stats = ref(null)
|
||||
const dimProgress = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
// 学习时长(小时)
|
||||
const studyHours = computed(() => {
|
||||
const hours = stats.value?.studyHours || 0
|
||||
if (hours < 1) return `${Math.round(hours * 60)}分`
|
||||
return hours.toFixed(1) + 'h'
|
||||
})
|
||||
|
||||
// 展开/收起维度
|
||||
function toggleExpand(id) {
|
||||
const dim = dimProgress.value.find(d => d.id === id)
|
||||
if (dim) dim.expanded = !dim.expanded
|
||||
}
|
||||
|
||||
// 初始化维度数据
|
||||
function initDimData() {
|
||||
const exploreData = stats.value?.exploreData || {}
|
||||
return DIMENSIONS.map(dim => ({
|
||||
id: dim.id,
|
||||
name: dim.name,
|
||||
desc: dim.desc,
|
||||
icon: dim.icon,
|
||||
color: dim.color,
|
||||
bg: dim.bg,
|
||||
total: 0,
|
||||
done: exploreData[dim.id] || 0,
|
||||
percent: 0,
|
||||
expanded: false
|
||||
}))
|
||||
}
|
||||
|
||||
// 获取维度知识总数
|
||||
async function fetchDimTotals() {
|
||||
for (const dim of dimProgress.value) {
|
||||
try {
|
||||
const data = await knowledgeApi.list({ dim: dim.id, limit: 1 })
|
||||
dim.total = data?.total || data?.length || (Array.isArray(data) ? data.length : 0)
|
||||
// 如果 list 返回的是数组而不是带 total 的对象,直接用数组长度
|
||||
if (Array.isArray(data) && dim.total === 0) {
|
||||
dim.total = data.length
|
||||
}
|
||||
// 计算百分比
|
||||
if (dim.total > 0) {
|
||||
dim.percent = Math.round((dim.done / dim.total) * 100)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[progress] 获取维度 ${dim.id} 数据失败:`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
// 未登录也展示基础信息,但用户相关数据为空
|
||||
stats.value = { exploreData: {} }
|
||||
dimProgress.value = initDimData()
|
||||
await fetchDimTotals()
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取用户统计和进度
|
||||
const [statsData, progressData] = await Promise.all([
|
||||
userApi.stats(userStore.token).catch(() => ({ exploreData: {} })),
|
||||
userApi.progress(userStore.token).catch(() => ({ exploreData: {} }))
|
||||
])
|
||||
|
||||
// 合并 exploreData
|
||||
stats.value = {
|
||||
...statsData,
|
||||
exploreData: { ...statsData?.exploreData, ...progressData?.exploreData }
|
||||
}
|
||||
|
||||
dimProgress.value = initDimData()
|
||||
await fetchDimTotals()
|
||||
} catch (err) {
|
||||
console.error('[progress] 加载失败:', err)
|
||||
uni.showToast({ title: '加载失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.progress-page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
// 顶部导航
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 50px 20px 12px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.nav-back {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-spacer { width: 30px; }
|
||||
}
|
||||
|
||||
// 用户等级卡片
|
||||
.stats-header {
|
||||
margin: 0 20px 16px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, rgba(179, 136, 255, 0.1), rgba(77, 182, 172, 0.08));
|
||||
border: 1px solid rgba(179, 136, 255, 0.15);
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.level-badge {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.level-num {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--dim1-color), var(--dim2-color));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.level-name {
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 30px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
// 章节标题
|
||||
.section-title {
|
||||
padding: 10px 20px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 进度列表
|
||||
.progress-list {
|
||||
flex: 1;
|
||||
padding: 8px 20px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.list-spacer {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
// 维度卡片
|
||||
.dim-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.dim-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.dim-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dim-icon-circle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.dim-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dim-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dim-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// 环形进度
|
||||
.dim-ring {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.ring {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
padding: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
.ring-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.ring-text {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ring-empty {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
// 展开内容
|
||||
.dim-card-body {
|
||||
padding: 0 16px 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.progress-detail {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-top: 14px;
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.detail-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.detail-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 进度条
|
||||
.progress-bar-bg {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
box-shadow: 0 0 8px rgba(179, 136, 255, 0.4);
|
||||
}
|
||||
|
||||
.progress-bar-text {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 6px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,386 @@
|
||||
<template>
|
||||
<view class="search-page">
|
||||
<!-- 顶部导航 -->
|
||||
<view class="nav-bar">
|
||||
<view class="nav-back" @click="goBack">‹</view>
|
||||
<text class="nav-title">搜索</text>
|
||||
<view class="nav-spacer"></view>
|
||||
</view>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<view class="search-box">
|
||||
<input
|
||||
class="search-input"
|
||||
type="text"
|
||||
:value="searchText"
|
||||
:placeholder="placeholderText"
|
||||
placeholder-style="color: var(--text-secondary); font-size: 14px;"
|
||||
@input="onInput"
|
||||
@confirm="doSearch"
|
||||
confirm-type="search"
|
||||
/>
|
||||
<view class="search-icon">🔍</view>
|
||||
<view v-if="searchText" class="clear-btn" @click="clearSearch">✕</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<view class="loading-state" v-if="loading">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">搜索中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 空搜索提示 -->
|
||||
<view class="empty-state" v-else-if="!hasSearched">
|
||||
<text class="empty-icon">🔍</text>
|
||||
<text class="empty-text">输入关键词搜索 AI 知识</text>
|
||||
<text class="empty-hint">支持按标题、摘要、标签搜索</text>
|
||||
</view>
|
||||
|
||||
<!-- 无结果提示 -->
|
||||
<view class="empty-state" v-else-if="results.length === 0 && !loading">
|
||||
<text class="empty-icon">📭</text>
|
||||
<text class="empty-text">没有找到相关内容</text>
|
||||
<text class="empty-hint">试试换个关键词搜索</text>
|
||||
</view>
|
||||
|
||||
<!-- 搜索结果列表 -->
|
||||
<scroll-view class="results-scroll" scroll-y v-else>
|
||||
<view
|
||||
class="result-card"
|
||||
v-for="item in results"
|
||||
:key="item._id || item.id"
|
||||
@click="goDetail(item._id || item.id)"
|
||||
>
|
||||
<!-- 维度标签 -->
|
||||
<view
|
||||
class="dim-badge"
|
||||
:style="{ background: dimColor(item.dim) }"
|
||||
>
|
||||
<text class="dim-name">D{{ item.dim }}</text>
|
||||
</view>
|
||||
|
||||
<text class="result-title">{{ item.title }}</text>
|
||||
|
||||
<!-- 摘要 -->
|
||||
<text class="result-summary" v-if="item.summary">
|
||||
{{ item.summary }}
|
||||
</text>
|
||||
|
||||
<!-- 标签与分类 -->
|
||||
<view class="result-meta">
|
||||
<view class="meta-tags" v-if="item.tags?.length">
|
||||
<text class="meta-tag" v-for="tag in item.tags" :key="tag">
|
||||
#{{ tag }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="result-category" v-if="item.category">
|
||||
{{ categoryLabel(item.category) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { knowledgeApi } from '@/utils/api'
|
||||
import { DIMENSIONS } from '@/utils/dimensions'
|
||||
|
||||
const searchText = ref('')
|
||||
const results = ref([])
|
||||
const loading = ref(false)
|
||||
const hasSearched = ref(false)
|
||||
let debounceTimer = null
|
||||
let page = 1
|
||||
|
||||
// 维度颜色
|
||||
function dimColor(dim) {
|
||||
if (!dim) return 'var(--dim1-color)'
|
||||
const dimInfo = DIMENSIONS.find(d => d.id === dim)
|
||||
return dimInfo ? dimInfo.color : 'var(--dim1-color)'
|
||||
}
|
||||
|
||||
// 分类标签
|
||||
function categoryLabel(cat) {
|
||||
const map = {
|
||||
concept: '📚 概念',
|
||||
history: '📜 历史',
|
||||
application: '🛠️ 应用',
|
||||
question: '❓ 问答'
|
||||
}
|
||||
return map[cat] || cat
|
||||
}
|
||||
|
||||
// 占位符提示
|
||||
const placeholderText = computed(() =>
|
||||
searchText.value ? '' : '输入关键词搜索 AI 知识'
|
||||
)
|
||||
|
||||
// 输入处理(debounced)
|
||||
function onInput(e) {
|
||||
const val = e.detail?.value || e.target?.value || ''
|
||||
searchText.value = val
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (val.trim()) {
|
||||
doSearch()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 执行搜索
|
||||
async function doSearch() {
|
||||
const q = searchText.value.trim()
|
||||
if (!q) return
|
||||
|
||||
loading.value = true
|
||||
hasSearched.value = true
|
||||
try {
|
||||
const data = await knowledgeApi.search(q, { page, limit: 20 })
|
||||
results.value = data.list || data.items || data || []
|
||||
} catch (err) {
|
||||
console.error('[search] 搜索失败:', err)
|
||||
uni.showToast({ title: '搜索失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 清空搜索
|
||||
function clearSearch() {
|
||||
searchText.value = ''
|
||||
results.value = []
|
||||
hasSearched.value = false
|
||||
page = 1
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到详情
|
||||
function goDetail(id) {
|
||||
if (!id) return
|
||||
uni.navigateTo({ url: `/pages/detail/detail?id=${id}` })
|
||||
}
|
||||
|
||||
// 返回
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 检查是否有路由传入的搜索词
|
||||
const pages = getCurrentPages()
|
||||
const currentPage = pages[pages.length - 1]
|
||||
const query = currentPage.$page?.query || {}
|
||||
if (query.q) {
|
||||
searchText.value = query.q
|
||||
doSearch()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
// 顶部导航
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 50px 20px 12px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.nav-back {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-spacer { width: 30px; }
|
||||
}
|
||||
|
||||
// 搜索框
|
||||
.search-box {
|
||||
position: relative;
|
||||
margin: 0 20px 16px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
padding: 10px 44px 10px 14px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
outline: none;
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid rgba(179, 136, 255, 0.2);
|
||||
border-top-color: var(--dim1-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
|
||||
.empty-icon { font-size: 48px; opacity: 0.5; }
|
||||
.empty-text { font-size: 16px; color: var(--text-secondary); }
|
||||
.empty-hint { font-size: 13px; color: var(--text-muted); }
|
||||
}
|
||||
|
||||
// 结果列表
|
||||
.results-scroll {
|
||||
flex: 1;
|
||||
padding: 0 20px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.dim-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.result-summary {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.meta-tag {
|
||||
font-size: 11px;
|
||||
color: var(--dim1-color);
|
||||
background: rgba(179, 136, 255, 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.result-category {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<view class="page-settings">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
<text class="page-title">设置</text>
|
||||
|
||||
<!-- 偏好设置 -->
|
||||
<view class="section-title">偏好设置</view>
|
||||
<view class="card">
|
||||
<view class="setting-item" @click="togglePush">
|
||||
<view class="s-icon" style="background:rgba(255,213,79,0.1);color:#ffd54f">🔔</view>
|
||||
<view class="s-text">
|
||||
<text class="s-label">推送通知</text>
|
||||
<text class="s-desc">接收每日 AI 趋势推送</text>
|
||||
</view>
|
||||
<view class="s-switch" :class="{ active: pushOn }">
|
||||
<view class="s-switch-knob" :class="{ on: pushOn }"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="setting-item" @click="toggleDarkMode">
|
||||
<view class="s-icon" style="background:rgba(77,208,225,0.1);color:#4dd0e1">🌙</view>
|
||||
<view class="s-text">
|
||||
<text class="s-label">暗色模式</text>
|
||||
<text class="s-desc">{{ darkMode ? '已开启暗色风格' : '当前为亮色风格' }}</text>
|
||||
</view>
|
||||
<view class="s-switch" :class="{ active: darkMode }">
|
||||
<view class="s-switch-knob" :class="{ on: darkMode }"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 其他 -->
|
||||
<view class="section-title">其他</view>
|
||||
<view class="card">
|
||||
<view class="setting-item" @click="showAbout">
|
||||
<view class="s-icon" style="background:rgba(179,136,255,0.1);color:#b388ff">ℹ️</view>
|
||||
<view class="s-text">
|
||||
<text class="s-label">关于「宇之然AI维度」</text>
|
||||
</view>
|
||||
<text class="s-arrow">→</text>
|
||||
</view>
|
||||
<view class="setting-item" @click="showContact">
|
||||
<view class="s-icon" style="background:rgba(102,187,106,0.1);color:#66bb6a">📧</view>
|
||||
<view class="s-text">
|
||||
<text class="s-label">联系方式</text>
|
||||
<text class="s-desc">客服邮箱:support@yuzhiran.com</text>
|
||||
</view>
|
||||
<text class="s-arrow">→</text>
|
||||
</view>
|
||||
<view class="setting-item" @click="showAgreement">
|
||||
<view class="s-icon" style="background:rgba(239,83,80,0.1);color:#ef5350">📄</view>
|
||||
<view class="s-text">
|
||||
<text class="s-label">用户协议 / 隐私政策</text>
|
||||
</view>
|
||||
<text class="s-arrow">→</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="version">宇之然 AI 维度 v1.0.0</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const pushOn = ref(true)
|
||||
const darkMode = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
// 读取当前主题
|
||||
darkMode.value = uni.getStorageSync('theme') === 'dark'
|
||||
})
|
||||
|
||||
function togglePush() {
|
||||
pushOn.value = !pushOn.value
|
||||
uni.showToast({
|
||||
title: pushOn.value ? '推送已开启' : '推送已关闭',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
darkMode.value = !darkMode.value
|
||||
const theme = darkMode.value ? 'dark' : 'light'
|
||||
uni.setStorageSync('theme', theme)
|
||||
applyTheme(theme)
|
||||
uni.showToast({
|
||||
title: darkMode.value ? '已切换为暗色模式' : '已切换为亮色模式',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
try {
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('theme-dark')
|
||||
root.classList.remove('theme-light')
|
||||
} else {
|
||||
root.classList.remove('theme-dark')
|
||||
root.classList.add('theme-light')
|
||||
}
|
||||
} catch (e) {}
|
||||
// tabBar 同步
|
||||
applyTabBarTheme(theme)
|
||||
}
|
||||
|
||||
function applyTabBarTheme(theme) {
|
||||
try {
|
||||
if (theme === 'dark') {
|
||||
uni.setTabBarStyle({ color: 'rgba(255,255,255,0.25)', selectedColor: '#b388ff', backgroundColor: '#08080f', borderStyle: 'black' })
|
||||
} else {
|
||||
uni.setTabBarStyle({ color: 'rgba(0,0,0,0.35)', selectedColor: '#7c4dff', backgroundColor: '#ffffff', borderStyle: 'white' })
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function showAbout() {
|
||||
uni.showModal({
|
||||
title: '关于「宇之然AI维度」',
|
||||
content: '宇之然 AI 维度 v1.0.0\n从 5 个维度系统理解人工智能\n© 2026 宇之然',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
function showContact() {
|
||||
uni.showModal({
|
||||
title: '联系方式',
|
||||
content: '客服邮箱:support@yuzhiran.com\n工作时间:周一至周五 9:00-18:00',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
function showAgreement() {
|
||||
uni.showModal({
|
||||
title: '用户协议 / 隐私政策',
|
||||
content: '暂无内容',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-settings {
|
||||
min-height: 100vh;
|
||||
padding: 0 20px 40px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin: 6px 0 22px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 18px 0 10px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
padding: 4px 16px;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
.setting-item:last-child { border-bottom: none; }
|
||||
|
||||
.s-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.s-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.s-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.s-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.s-arrow {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
// 开关
|
||||
.s-switch {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
.s-switch.active {
|
||||
background: rgba(124, 77, 255, 0.4);
|
||||
}
|
||||
.s-switch-knob {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: left 0.3s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
.s-switch-knob.on { left: 22px; }
|
||||
|
||||
.version {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 30px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<view class="page-trend">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 顶部 -->
|
||||
<view class="trend-header">
|
||||
<text class="header-title">AI 趋势</text>
|
||||
<text class="header-desc">每天精选 AI 行业最新动态</text>
|
||||
</view>
|
||||
|
||||
<!-- 分类筛选 -->
|
||||
<view class="filter-bar">
|
||||
<view
|
||||
class="filter-item"
|
||||
:class="{ active: activeFilter === idx }"
|
||||
v-for="(tag, idx) in filters"
|
||||
:key="idx"
|
||||
@click="activeFilter = idx"
|
||||
>
|
||||
<text>{{ tag }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 新闻列表 -->
|
||||
<view class="trend-list">
|
||||
<view v-for="(item, idx) in displayItems" :key="idx" class="trend-card" @click="goDetail(item)">
|
||||
<!-- 头部标签 -->
|
||||
<view class="card-header">
|
||||
<text class="card-tag" :class="getTagClass(item)">
|
||||
{{ getTagText(item) }}
|
||||
</text>
|
||||
<text class="card-source">{{ item.source }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 标题 -->
|
||||
<text class="card-title">{{ item.title }}</text>
|
||||
|
||||
<!-- 摘要 -->
|
||||
<text class="card-summary" v-if="item.summary">
|
||||
{{ item.summary }}
|
||||
</text>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<view class="card-footer">
|
||||
<text class="card-date">{{ formatDate(item.newsDate) }}</text>
|
||||
<view class="card-stats">
|
||||
<text class="stat-item" v-if="item.hot">🔥 {{ item.hotCount }}</text>
|
||||
<text class="stat-item">👁️ {{ item.viewCount || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="displayItems.length === 0" class="empty-state">
|
||||
<text class="empty-icon">📡</text>
|
||||
<text class="empty-text">暂无趋势资讯</text>
|
||||
<text class="empty-sub">稍后再来看最新 AI 动态</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { trendApi } from '@/utils/api'
|
||||
|
||||
const filters = ref(['全部', '🚀 产品', '📄 论文', '🔧 工具', '🏢 公司'])
|
||||
const activeFilter = ref(0)
|
||||
const items = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
fetchTrends()
|
||||
})
|
||||
|
||||
const displayItems = computed(() => {
|
||||
if (activeFilter.value === 0) return items.value
|
||||
const categoryMap = { 1: 'product', 2: 'paper', 3: 'tool', 4: 'company' }
|
||||
const category = categoryMap[activeFilter.value]
|
||||
if (!category) return items.value
|
||||
return items.value.filter(item => item.category === category)
|
||||
})
|
||||
|
||||
async function fetchTrends() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await trendApi.list({ limit: 20 })
|
||||
items.value = data.list || data.items || []
|
||||
} catch (err) {
|
||||
console.error('获取趋势失败:', err)
|
||||
// 降级:本地示例
|
||||
items.value = getSampleData()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getTagText(item) {
|
||||
if (item.hot) return '🔥 热门'
|
||||
if (item.picked) return '📌 精选'
|
||||
if (item.category === 'product') return '🚀 产品'
|
||||
if (item.category === 'paper') return '📄 论文'
|
||||
if (item.category === 'tool') return '🔧 工具'
|
||||
if (item.category === 'company') return '🏢 公司'
|
||||
return '📰 资讯'
|
||||
}
|
||||
|
||||
function getTagClass(item) {
|
||||
if (item.hot) return 'hot'
|
||||
if (item.picked) return 'pick'
|
||||
if (item.category === 'product') return 'product'
|
||||
if (item.category === 'paper') return 'paper'
|
||||
if (item.category === 'tool') return 'tool'
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
if (hours < 1) return '刚刚'
|
||||
if (hours < 24) return `${hours} 小时前`
|
||||
return `${Math.floor(hours / 24)} 天前`
|
||||
}
|
||||
|
||||
function goDetail(item) {
|
||||
// 如果有原文链接,跳转外部
|
||||
if (item.link) {
|
||||
// 微信小程序不支持直接打开外部链接,复制链接提示
|
||||
uni.setClipboardData({
|
||||
data: item.link,
|
||||
success: () => {
|
||||
uni.showModal({
|
||||
title: '打开链接',
|
||||
content: '链接已复制,请在浏览器中打开',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
uni.setStorageSync('__news_item__', JSON.stringify(item))
|
||||
uni.navigateTo({ url: '/pages/news/news' })
|
||||
}
|
||||
|
||||
function getSampleData() {
|
||||
return [
|
||||
{
|
||||
title: 'DeepSeek 发布 R1 推理模型',
|
||||
summary: 'DeepSeek R1 在多项基准测试中超越 GPT-4o,引发全球关注',
|
||||
source: '量子位',
|
||||
newsDate: new Date(Date.now() - 2 * 3600000).toISOString(),
|
||||
category: 'product',
|
||||
hot: true,
|
||||
hotCount: 2000,
|
||||
viewCount: 15000
|
||||
},
|
||||
{
|
||||
title: 'Google Gemini 1.5 Pro 发布',
|
||||
summary: '支持 200 万 token 上下文窗口,多模态能力大幅提升',
|
||||
source: '机器之心',
|
||||
newsDate: new Date(Date.now() - 6 * 3600000).toISOString(),
|
||||
category: 'product',
|
||||
viewCount: 8000
|
||||
},
|
||||
{
|
||||
title: 'Transformer 论文作者新方向',
|
||||
summary: '从注意力机制到状态空间模型,作者分享最新研究进展',
|
||||
source: 'PaperWeekly',
|
||||
newsDate: new Date(Date.now() - 12 * 3600000).toISOString(),
|
||||
category: 'paper',
|
||||
picked: true,
|
||||
viewCount: 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-trend {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
// 头部
|
||||
.trend-header {
|
||||
padding: 10px 20px 16px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 筛选栏
|
||||
.filter-bar {
|
||||
padding: 0 20px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
margin-right: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.filter-item.active {
|
||||
background: rgba(124, 77, 255, 0.15);
|
||||
border-color: rgba(124, 77, 255, 0.3);
|
||||
color: var(--dim1-color);
|
||||
}
|
||||
|
||||
// 列表
|
||||
.trend-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.trend-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-tag.hot {
|
||||
background: rgba(239, 83, 80, 0.12);
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
.card-tag.pick {
|
||||
background: rgba(124, 77, 255, 0.12);
|
||||
color: #b388ff;
|
||||
}
|
||||
|
||||
.card-tag.product {
|
||||
background: rgba(0, 188, 212, 0.12);
|
||||
color: #4dd0e1;
|
||||
}
|
||||
|
||||
.card-tag.paper {
|
||||
background: rgba(179, 136, 255, 0.12);
|
||||
color: #b388ff;
|
||||
}
|
||||
|
||||
.card-tag.tool {
|
||||
background: rgba(77, 182, 172, 0.12);
|
||||
color: #4db6ac;
|
||||
}
|
||||
|
||||
.card-source {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
// 加载/空状态
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.loading-state text {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 15px;
|
||||
color: var(--text-tertiary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<rect x="6" y="6" width="16" height="16" rx="3" fill="#b388ff"/>
|
||||
<rect x="26" y="6" width="16" height="16" rx="3" fill="#b388ff"/>
|
||||
<rect x="6" y="26" width="16" height="16" rx="3" fill="#b388ff"/>
|
||||
<rect x="26" y="26" width="16" height="16" rx="3" fill="#b388ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 351 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<rect x="6" y="6" width="16" height="16" rx="3" fill="#444"/>
|
||||
<rect x="26" y="6" width="16" height="16" rx="3" fill="#444"/>
|
||||
<rect x="6" y="26" width="16" height="16" rx="3" fill="#444"/>
|
||||
<rect x="26" y="26" width="16" height="16" rx="3" fill="#444"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 339 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<path d="M24 4L28 16L40 18L30 26L34 40L24 32L14 40L18 26L8 18L20 16L24 4Z" fill="#b388ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 173 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<path d="M24 4L28 16L40 18L30 26L34 40L24 32L14 40L18 26L8 18L20 16L24 4Z" fill="#444"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 170 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="16" r="8" fill="#b388ff"/>
|
||||
<path d="M10 44C10 32 16 26 24 26C32 26 38 32 38 44H10Z" fill="#b388ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 204 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="16" r="8" fill="#444"/>
|
||||
<path d="M10 44C10 32 16 26 24 26C32 26 38 32 38 44H10Z" fill="#444"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 198 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<path d="M8 36L24 8L40 36H8Z" fill="#b388ff"/>
|
||||
<rect x="22" y="28" width="4" height="8" rx="1" fill="#7c4dff"/>
|
||||
<circle cx="24" cy="22" r="2" fill="#7c4dff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 244 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<path d="M8 36L24 8L40 36H8Z" fill="#444"/>
|
||||
<rect x="22" y="28" width="4" height="8" rx="1" fill="#222"/>
|
||||
<circle cx="24" cy="22" r="2" fill="#222"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 235 B |
@@ -0,0 +1,39 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { aiChatApi } from '@/utils/api'
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const messages = ref([])
|
||||
const quota = ref({ remaining: 0, total: 0 })
|
||||
const loading = ref(false)
|
||||
|
||||
async function ask(question, dimension = 'general', token) {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await aiChatApi.ask(question, dimension, token)
|
||||
messages.value.push({ role: 'user', content: question })
|
||||
messages.value.push({ role: 'assistant', content: data.answer })
|
||||
quota.value.remaining = data.remainingQuota
|
||||
return data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchQuota(token) {
|
||||
try {
|
||||
const data = await aiChatApi.quota(token)
|
||||
quota.value = data
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('[chatStore] fetchQuota failed:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
return { messages, quota, loading, ask, fetchQuota, clearMessages }
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useUserStore } from './user'
|
||||
export { useKnowledgeStore } from './knowledge'
|
||||
export { useChatStore } from './chat'
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { knowledgeApi } from '@/utils/api'
|
||||
|
||||
export const useKnowledgeStore = defineStore('knowledge', () => {
|
||||
const list = ref([])
|
||||
const currentDetail = ref(null)
|
||||
const loading = ref(false)
|
||||
const collected = ref([])
|
||||
|
||||
async function fetchList(params = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await knowledgeApi.list(params)
|
||||
list.value = data.list || data.items || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDetail(id) {
|
||||
try {
|
||||
currentDetail.value = await knowledgeApi.detail(id)
|
||||
return currentDetail.value
|
||||
} catch (err) {
|
||||
console.error('[knowledgeStore] fetchDetail failed:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCollected(token) {
|
||||
try {
|
||||
collected.value = await knowledgeApi.collected(token)
|
||||
return collected.value
|
||||
} catch (err) {
|
||||
console.error('[knowledgeStore] fetchCollected failed:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCollect(id, token) {
|
||||
try {
|
||||
const item = collected.value.find(i => i.id === id)
|
||||
if (item) {
|
||||
await knowledgeApi.uncollect(id, token)
|
||||
collected.value = collected.value.filter(i => i.id !== id)
|
||||
} else {
|
||||
await knowledgeApi.collect(id, token)
|
||||
collected.value.push({ id })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[knowledgeStore] toggleCollect failed:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
return { list, currentDetail, loading, collected, fetchList, fetchDetail, fetchCollected, toggleCollect }
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { authApi } from '@/utils/api'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref(uni.getStorageSync('token') || '')
|
||||
const userInfo = ref(null)
|
||||
const subInfo = ref(null) // 订阅信息
|
||||
const quota = ref(null) // AI 问答配额
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const isSubscribed = computed(() => {
|
||||
if (!subInfo.value) return false
|
||||
const expires = new Date(subInfo.value.expiresAt)
|
||||
return expires > new Date()
|
||||
})
|
||||
const subscriptionLevel = computed(() => {
|
||||
if (!subInfo.value) return 'free'
|
||||
const expires = new Date(subInfo.value.expiresAt)
|
||||
if (expires <= new Date()) return 'free'
|
||||
return subInfo.value.plan || 'pro'
|
||||
})
|
||||
|
||||
async function login(code, userInfoData) {
|
||||
const data = await authApi.login(code, userInfoData)
|
||||
token.value = data.token
|
||||
userInfo.value = data.userInfo
|
||||
uni.setStorageSync('token', data.token)
|
||||
return data
|
||||
}
|
||||
|
||||
async function h5Login(username, password) {
|
||||
const data = await authApi.h5Login(username, password)
|
||||
token.value = data.token
|
||||
userInfo.value = data.userInfo
|
||||
uni.setStorageSync('token', data.token)
|
||||
return data
|
||||
}
|
||||
|
||||
async function h5Register(username, password, nickName) {
|
||||
const data = await authApi.register(username, password, nickName)
|
||||
token.value = data.token
|
||||
userInfo.value = data.userInfo
|
||||
uni.setStorageSync('token', data.token)
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
try {
|
||||
const data = await authApi.me(token.value)
|
||||
userInfo.value = data
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('[userStore] fetchMe failed:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSubInfo(tokenVal) {
|
||||
// 从后端获取订阅信息 - 复用 knowledgeApi 或专门接口
|
||||
// 当前后端无单独订阅接口,从 /api/user/progress 中获取
|
||||
const { userApi } = await import('@/utils/api')
|
||||
try {
|
||||
const progress = await userApi.progress(tokenVal)
|
||||
if (progress && progress.subscription) {
|
||||
subInfo.value = progress.subscription
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[userStore] fetchSubInfo failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchQuota(tokenVal) {
|
||||
const { aiChatApi } = await import('@/utils/api')
|
||||
try {
|
||||
const data = await aiChatApi.quota(tokenVal)
|
||||
quota.value = data
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('[userStore] fetchQuota failed:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
subInfo.value = null
|
||||
quota.value = null
|
||||
uni.removeStorageSync('token')
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
userInfo,
|
||||
subInfo,
|
||||
quota,
|
||||
isLoggedIn,
|
||||
isSubscribed,
|
||||
subscriptionLevel,
|
||||
login,
|
||||
h5Login,
|
||||
h5Register,
|
||||
fetchMe,
|
||||
fetchSubInfo,
|
||||
fetchQuota,
|
||||
logout
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
# 宇之然AI维度 — 主题系统指南
|
||||
|
||||
## 架构
|
||||
|
||||
所有主题变量定义在 `src/styles/theme.scss` 中,通过 CSS 自定义属性 (CSS Custom Properties) 实现。
|
||||
|
||||
## 核心变量层级
|
||||
|
||||
```
|
||||
CSS 自定义属性 (theme.scss)
|
||||
├── 基础色 (bg-primary, text-primary 等)
|
||||
├── 玻璃拟态 (glass-bg, glass-border 等)
|
||||
├── 品牌色 (color-brand, color-brand-light 等)
|
||||
├── 维度色 (dim1~dim5: color, bg, icon-bg, border, glow)
|
||||
├── 功能色 (free, pro, hot, new, pick)
|
||||
└── 布局 (radius, spacing, blur, transition)
|
||||
```
|
||||
|
||||
## 一键换肤
|
||||
|
||||
### 添加新主题
|
||||
|
||||
在 `theme.scss` 底部添加:
|
||||
|
||||
```scss
|
||||
.theme-xxx {
|
||||
// 只覆盖需要变化的变量
|
||||
--color-brand: #...;
|
||||
--color-brand-light: #...;
|
||||
--bg-primary: #...;
|
||||
// 维度颜色也可以单独覆盖
|
||||
--dim1-color: #...;
|
||||
}
|
||||
```
|
||||
|
||||
### 切换主题
|
||||
|
||||
```js
|
||||
// 在 App.vue 或任意页面中:
|
||||
document.documentElement.className = 'theme-xxx'
|
||||
```
|
||||
|
||||
### 维度卡颜色映射
|
||||
|
||||
每个维度通过 `utils/dimensions.js` 中的 `DIMENSIONS` 数组配置,引用 CSS 变量:
|
||||
|
||||
```js
|
||||
color: 'var(--dim1-color)',
|
||||
bg: 'var(--dim1-bg)',
|
||||
iconBg: 'var(--dim1-icon-bg)',
|
||||
```
|
||||
|
||||
## 现有主题
|
||||
|
||||
| 主题类名 | 说明 |
|
||||
|---------|------|
|
||||
| (默认) | 暗色宇宙深空 |
|
||||
| `.theme-light` | 浅色模式(预留) |
|
||||
| `.theme-christmas` | 圣诞主题(示例) |
|
||||
|
||||
## 修改指南
|
||||
|
||||
### 只改颜色
|
||||
编辑 `theme.scss` 中的 CSS 变量即可,所有组件自动生效。
|
||||
|
||||
### 改维度配置
|
||||
编辑 `utils/dimensions.js` 中的 `DIMENSIONS` 数组。
|
||||
|
||||
### 改布局间距
|
||||
编辑 `theme.scss` 中的 `--spacing-*` 和 `--radius-*` 变量。
|
||||
@@ -0,0 +1,68 @@
|
||||
/* ============================================
|
||||
宇之然AI维度 — 主题系统 v3
|
||||
亮色默认 + CSS 自定义属性切换
|
||||
============================================ */
|
||||
|
||||
/*
|
||||
* 注意: 此文件通过 uni.scss 注入到每个组件
|
||||
* 但全局 CSS 变量定义在 App.vue 的非 scoped <style> 中
|
||||
* 此文件只保留 SCSS 变量 + 工具类,防止重复
|
||||
*/
|
||||
|
||||
/* ---- SCSS 变量(方便组件内使用) ---- */
|
||||
$color-brand: #7c4dff;
|
||||
$color-brand-light: #b388ff;
|
||||
$color-bg-primary: #f5f5f7;
|
||||
$color-bg-dark: #14141e;
|
||||
|
||||
$dim1-color: #7c4dff;
|
||||
$dim2-color: #0ea5e9;
|
||||
$dim3-color: #10b981;
|
||||
$dim4-color: #f59e0b;
|
||||
$dim5-color: #ef4444;
|
||||
|
||||
/* 全局排版 */
|
||||
page {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* 卡片通用 */
|
||||
.card {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
backdrop-filter: blur(var(--blur-bg));
|
||||
-webkit-backdrop-filter: blur(var(--blur-bg));
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.card:active {
|
||||
background: var(--glass-hover);
|
||||
border-color: var(--glass-hover-border);
|
||||
}
|
||||
|
||||
/* 品牌渐变 */
|
||||
.gradient-brand {
|
||||
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, var(--color-brand-light), var(--color-brand));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* 维度色工具类 */
|
||||
.dim-color-1 { color: var(--dim1-color); }
|
||||
.dim-color-2 { color: var(--dim2-color); }
|
||||
.dim-color-3 { color: var(--dim3-color); }
|
||||
.dim-color-4 { color: var(--dim4-color); }
|
||||
.dim-color-5 { color: var(--dim5-color); }
|
||||
|
||||
.dim-bg-1 { background: var(--dim1-bg); }
|
||||
.dim-bg-2 { background: var(--dim2-bg); }
|
||||
.dim-bg-3 { background: var(--dim3-bg); }
|
||||
.dim-bg-4 { background: var(--dim4-bg); }
|
||||
.dim-bg-5 { background: var(--dim5-bg); }
|
||||
@@ -0,0 +1,2 @@
|
||||
/* 宇之然AI维度 — 全局 SCSS 变量 (uni-app 自动注入) */
|
||||
@import "@/styles/theme.scss";
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* 宇之然AI维度 — API 工具
|
||||
* 后端地址:wdkj.yuzhiran.com.cn (生产) / 127.0.0.1:3001 (开发)
|
||||
*/
|
||||
|
||||
const BASE_URL = 'https://wdkj.yuzhiran.com.cn'
|
||||
const DEV_URL = 'http://127.0.0.1:3001'
|
||||
|
||||
// 生产环境使用域名(SSL 已续期)
|
||||
const API_BASE = 'https://wdkj.yuzhiran.com.cn'
|
||||
|
||||
/**
|
||||
* 通用请求封装
|
||||
*/
|
||||
async function request(method, path, data = null, token = null) {
|
||||
const url = `${API_BASE}${path}`
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
header: headers,
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
const body = res.data
|
||||
if (body.success) {
|
||||
return body.data
|
||||
}
|
||||
throw new Error(body.error || '请求失败')
|
||||
} catch (err) {
|
||||
console.error(`[API] ${method} ${path} failed:`, err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证相关
|
||||
*/
|
||||
export const authApi = {
|
||||
/** 微信小程序登录 */
|
||||
login: (code, userInfo) =>
|
||||
request('POST', '/api/auth/login', { code, userInfo }),
|
||||
|
||||
/** H5 密码登录 */
|
||||
h5Login: (username, password) =>
|
||||
request('POST', '/api/auth/user/login', { username, password }),
|
||||
|
||||
/** H5 注册 */
|
||||
register: (username, password, nickName) =>
|
||||
request('POST', '/api/auth/user/register', { username, password, nickName }),
|
||||
|
||||
/** 获取当前用户 */
|
||||
me: (token) =>
|
||||
request('GET', '/api/auth/me', null, token),
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识库
|
||||
*/
|
||||
export const knowledgeApi = {
|
||||
/** 获取知识列表 */
|
||||
list: (params = {}) => {
|
||||
const query = Object.entries(params)
|
||||
.filter(([_, v]) => v !== undefined && v !== null)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('&')
|
||||
return request('GET', `/api/knowledge${query ? '?' + query : ''}`)
|
||||
},
|
||||
|
||||
/** 获取知识详情 */
|
||||
detail: (id) =>
|
||||
request('GET', `/api/knowledge/${id}`),
|
||||
|
||||
/** 收藏知识 */
|
||||
collect: (id, token) =>
|
||||
request('POST', `/api/knowledge/${id}/collect`, null, token),
|
||||
|
||||
/** 取消收藏 */
|
||||
uncollect: (id, token) =>
|
||||
request('DELETE', `/api/knowledge/collect/${id}`, null, token),
|
||||
|
||||
/** 获取收藏列表 */
|
||||
collected: (token) =>
|
||||
request('GET', '/api/knowledge/collected', null, token),
|
||||
|
||||
/** 获取维度问答 */
|
||||
questions: (dim) =>
|
||||
request('GET', `/api/knowledge/${dim}/questions`),
|
||||
|
||||
/** 搜索知识 */
|
||||
search: (q, options = {}) => {
|
||||
const params = { q, ...options }
|
||||
const query = Object.entries(params)
|
||||
.filter(([_, v]) => v !== undefined && v !== null)
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
||||
.join('&')
|
||||
return request('GET', `/api/knowledge/search${query ? '?' + query : ''}`)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 问答
|
||||
*/
|
||||
export const aiChatApi = {
|
||||
/** 获取配额 */
|
||||
quota: (token) =>
|
||||
request('GET', '/api/ai-chat/quota', null, token),
|
||||
|
||||
/** 提问 */
|
||||
ask: (question, dimension = 'general', token) =>
|
||||
request('POST', '/api/ai-chat/ask', { question, dimension }, token),
|
||||
|
||||
/** 分享获得次数 */
|
||||
shareGain: (token) =>
|
||||
request('POST', '/api/ai-chat/share-gain', null, token),
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户相关
|
||||
*/
|
||||
export const userApi = {
|
||||
/** 获取用户进度 */
|
||||
progress: (token) =>
|
||||
request('GET', '/api/user/progress', null, token),
|
||||
|
||||
/** 保存进度 */
|
||||
saveProgress: (data, token) =>
|
||||
request('POST', '/api/user/progress', data, token),
|
||||
|
||||
/** 排行榜 */
|
||||
leaderboard: (dim = null) =>
|
||||
request('GET', `/api/user/leaderboard${dim ? '?dim=' + dim : ''}`),
|
||||
|
||||
/** 获取成就 */
|
||||
achievements: (token) =>
|
||||
request('GET', '/api/user/achievements', null, token),
|
||||
|
||||
/** 用户统计 */
|
||||
stats: (token) =>
|
||||
request('GET', '/api/user/stats', null, token),
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付相关
|
||||
*/
|
||||
export const paymentApi = {
|
||||
/** 获取商品列表 */
|
||||
products: () =>
|
||||
request('GET', '/api/payment/products'),
|
||||
|
||||
/** 创建订单 */
|
||||
createOrder: (productId, token) =>
|
||||
request('POST', '/api/payment/create-order', { productId }, token),
|
||||
}
|
||||
|
||||
/**
|
||||
* 趋势资讯
|
||||
*/
|
||||
export const trendApi = {
|
||||
/** 获取趋势列表 */
|
||||
list: (params = {}) => {
|
||||
const query = Object.entries(params)
|
||||
.filter(([_, v]) => v !== undefined && v !== null)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('&')
|
||||
return request('GET', `/api/trend${query ? '?' + query : ''}`)
|
||||
},
|
||||
|
||||
/** 获取趋势详情 */
|
||||
detail: (id) =>
|
||||
request('GET', `/api/trend/${id}`),
|
||||
}
|
||||
|
||||
/**
|
||||
* 意见反馈
|
||||
*/
|
||||
export const feedbackApi = {
|
||||
/** 提交反馈 */
|
||||
create: (data, token) =>
|
||||
request('POST', '/api/feedback', data, token),
|
||||
/** 我的反馈列表 */
|
||||
my: (token) =>
|
||||
request('GET', '/api/feedback/my', null, token),
|
||||
}
|
||||
|
||||
export default {
|
||||
auth: authApi,
|
||||
knowledge: knowledgeApi,
|
||||
aiChat: aiChatApi,
|
||||
user: userApi,
|
||||
payment: paymentApi,
|
||||
trend: trendApi,
|
||||
feedback: feedbackApi
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 维度配置 — 5 个 AI 维度的元数据
|
||||
* 修改这里可以调整维度名称、颜色、图标等
|
||||
*/
|
||||
|
||||
export const DIMENSIONS = [
|
||||
{
|
||||
id: 1,
|
||||
key: 'origin',
|
||||
name: 'AI 起源',
|
||||
icon: '🌅',
|
||||
desc: '人工智能的诞生——从图灵测试到第一代探索者',
|
||||
color: 'var(--dim1-color)',
|
||||
bg: 'var(--dim1-bg)',
|
||||
iconBg: 'var(--dim1-icon-bg)',
|
||||
border: 'var(--dim1-border)',
|
||||
glow: 'var(--dim1-glow)',
|
||||
articleCount: 12,
|
||||
quizCount: 4
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
key: 'development',
|
||||
name: 'AI 发展',
|
||||
icon: '📈',
|
||||
desc: '技术跃迁之路——深度学习、Transformer 和前沿突破',
|
||||
color: 'var(--dim2-color)',
|
||||
bg: 'var(--dim2-bg)',
|
||||
iconBg: 'var(--dim2-icon-bg)',
|
||||
border: 'var(--dim2-border)',
|
||||
glow: 'var(--dim2-glow)',
|
||||
articleCount: 18,
|
||||
quizCount: 6
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
key: 'current',
|
||||
name: 'AI 当前',
|
||||
icon: '🌐',
|
||||
desc: '2026 年 AI 世界——大模型、Agent、人形机器人',
|
||||
color: 'var(--dim3-color)',
|
||||
bg: 'var(--dim3-bg)',
|
||||
iconBg: 'var(--dim3-icon-bg)',
|
||||
border: 'var(--dim3-border)',
|
||||
glow: 'var(--dim3-glow)',
|
||||
articleCount: 15,
|
||||
quizCount: 5
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
key: 'learning',
|
||||
name: 'AI 学习',
|
||||
icon: '🛠',
|
||||
desc: '动手学 AI——提示词、微调、RAG 和实用工具',
|
||||
color: 'var(--dim4-color)',
|
||||
bg: 'var(--dim4-bg)',
|
||||
iconBg: 'var(--dim4-icon-bg)',
|
||||
border: 'var(--dim4-border)',
|
||||
glow: 'var(--dim4-glow)',
|
||||
articleCount: 20,
|
||||
quizCount: 8
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
key: 'trend',
|
||||
name: 'AI 趋势',
|
||||
icon: '🔥',
|
||||
desc: '前沿动态——每日 AI 新闻、产品和趋势洞察',
|
||||
color: 'var(--dim5-color)',
|
||||
bg: 'var(--dim5-bg)',
|
||||
iconBg: 'var(--dim5-icon-bg)',
|
||||
border: 'var(--dim5-border)',
|
||||
glow: 'var(--dim5-glow)',
|
||||
articleCount: 0,
|
||||
quizCount: 0,
|
||||
isFull: true // 跨列
|
||||
}
|
||||
]
|
||||
|
||||
/** 按维度 ID 查找 */
|
||||
export function getDimension(id) {
|
||||
return DIMENSIONS.find(d => d.id === id)
|
||||
}
|
||||
|
||||
/** 按维度 key 查找 */
|
||||
export function getDimensionByKey(key) {
|
||||
return DIMENSIONS.find(d => d.key === key)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 全局错误处理
|
||||
* 在 main.js 中调用 initErrorHandler()
|
||||
*/
|
||||
|
||||
export function initErrorHandler() {
|
||||
// 捕获未处理的 Promise rejection
|
||||
uni.onUnhandledRejection((res) => {
|
||||
console.error('[Global Error]', res.reason)
|
||||
uni.showToast({ title: '操作失败,请重试', icon: 'none' })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 网络请求拦截器
|
||||
* 增强版 request 函数,带全局错误处理
|
||||
*/
|
||||
async function request(method, path, data = null, token = null, options = {}) {
|
||||
const BASE_URL = 'https://wdkj.yuzhiran.com.cn'
|
||||
const DEV_URL = 'http://127.0.0.1:3001'
|
||||
const isDev = import.meta.env.DEV
|
||||
const API_BASE = isDev ? DEV_URL : BASE_URL
|
||||
|
||||
const url = `${API_BASE}${path}`
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
header: headers,
|
||||
timeout: options.timeout || 15000
|
||||
})
|
||||
|
||||
// 统一错误处理
|
||||
if (res.statusCode >= 400) {
|
||||
throw new Error(res.data?.error || `请求失败 (${res.statusCode})`)
|
||||
}
|
||||
|
||||
const body = res.data
|
||||
if (body && body.success) {
|
||||
return body.data
|
||||
}
|
||||
throw new Error(body?.error || '请求失败')
|
||||
} catch (err) {
|
||||
// 网络错误
|
||||
if (err.errMsg && err.errMsg.includes('timeout')) {
|
||||
throw new Error('网络超时,请检查网络')
|
||||
}
|
||||
if (err.errMsg && err.errMsg.includes('fail')) {
|
||||
throw new Error('网络连接失败')
|
||||
}
|
||||
console.error(`[API Error] ${method} ${path}:`, err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export default request
|
||||