feat: 完善uni-app前端功能
- 新增登录页: 微信一键登录 + H5 密码登录/注册
- 新增知识详情页: 支持问答交互、收藏、AI助手入口
- 完善AI问答页: 配额显示、分享获得次数、建议问题
- 完善个人中心: 订阅卡片、配额进度、菜单系统
- 新增订阅支付页: Pro/VIP 方案选择、微信支付对接
- 完善趋势资讯页: 分类筛选、卡片式新闻列表
- 完善 User Store: 订阅信息、配额管理
- 补充 Knowledge/Chat Store
- 全局错误处理工具
- 注册新页面到 pages.json
构建验证: ✅ mp-weixin + h5 双平台构建通过
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
{"path": "pages/index/index", "style": {"navigationBarTitleText": "AI 维度", "navigationStyle": "custom"}},
|
{"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/dimension/dimension", "style": {"navigationBarTitleText": "", "navigationStyle": "custom"}},
|
{"path": "pages/dimension/dimension", "style": {"navigationBarTitleText": "", "navigationStyle": "custom"}},
|
||||||
{"path": "pages/chat/chat", "style": {"navigationBarTitleText": "AI 问答", "navigationStyle": "custom"}},
|
{"path": "pages/chat/chat", "style": {"navigationBarTitleText": "AI 问答", "navigationStyle": "custom"}},
|
||||||
{"path": "pages/trend/trend", "style": {"navigationBarTitleText": "AI 趋势", "navigationStyle": "custom"}},
|
{"path": "pages/trend/trend", "style": {"navigationBarTitleText": "AI 趋势", "navigationStyle": "custom"}},
|
||||||
@@ -29,4 +32,4 @@
|
|||||||
{"pagePath": "pages/profile/profile", "text": "我的"}
|
{"pagePath": "pages/profile/profile", "text": "我的"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,40 +2,64 @@
|
|||||||
<view class="page-chat">
|
<view class="page-chat">
|
||||||
<view class="status-bar-placeholder"></view>
|
<view class="status-bar-placeholder"></view>
|
||||||
|
|
||||||
|
<!-- 聊天头部 -->
|
||||||
<view class="chat-header">
|
<view class="chat-header">
|
||||||
<view class="chat-avatar">∞</view>
|
<view class="chat-avatar">
|
||||||
|
<text class="avatar-text">∞</text>
|
||||||
|
</view>
|
||||||
<view class="chat-info">
|
<view class="chat-info">
|
||||||
<text class="chat-name">AI 助手</text>
|
<text class="chat-name">AI 助手</text>
|
||||||
<text class="chat-status">DeepSeek V4 · 在线</text>
|
<text class="chat-status">多模型路由 · 在线</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="chat-quota">剩余 {{ quota }} 次</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>
|
</view>
|
||||||
|
|
||||||
<!-- 消息列表 -->
|
<!-- 消息列表 -->
|
||||||
<scroll-view class="msg-list" scroll-y :scroll-into-view="scrollTo">
|
<scroll-view class="msg-list" scroll-y :scroll-into-view="scrollTo" v-else>
|
||||||
<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="welcome-card">
|
||||||
<view class="msg-content">
|
<text class="welcome-icon">💡</text>
|
||||||
<view class="bubble">{{ msg.content }}</view>
|
<text class="welcome-text">试试问我:</text>
|
||||||
<text class="msg-time">{{ msg.role === 'assistant' ? 'AI 助手' : '我' }}</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>
|
</view>
|
||||||
|
|
||||||
<!-- 加载中 -->
|
<view v-for="(msg, idx) in messages" :key="idx" :id="'msg-' + idx" class="chat-msg" :class="msg.role">
|
||||||
<view v-if="loading" class="chat-msg ai">
|
<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="mini-avatar">∞</view>
|
||||||
<view class="msg-content">
|
<view class="msg-content">
|
||||||
<view class="bubble loading-dots">
|
<view class="bubble loading-dots">
|
||||||
<text class="dot">.</text><text class="dot">.</text><text class="dot">.</text>
|
<text class="dot"></text>
|
||||||
|
<text class="dot"></text>
|
||||||
|
<text class="dot"></text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<!-- 输入区 -->
|
<!-- 输入区 -->
|
||||||
<view class="chat-input-area">
|
<view class="chat-input-area" v-if="userStore.isLoggedIn">
|
||||||
<view class="chat-input">
|
<view class="chat-input">
|
||||||
<text class="add-btn">➕</text>
|
|
||||||
<input
|
<input
|
||||||
v-model="question"
|
v-model="question"
|
||||||
class="input-field"
|
class="input-field"
|
||||||
@@ -44,7 +68,7 @@
|
|||||||
@confirm="sendMessage"
|
@confirm="sendMessage"
|
||||||
confirm-type="send"
|
confirm-type="send"
|
||||||
/>
|
/>
|
||||||
<view class="send-btn" @tap="sendMessage">
|
<view class="send-btn" @click="sendMessage">
|
||||||
<text>➤</text>
|
<text>➤</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -55,63 +79,115 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { aiChatApi } from '@/utils/api'
|
import { aiChatApi } from '@/utils/api'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
const question = ref('')
|
const question = ref('')
|
||||||
const messages = ref([
|
const messages = ref([])
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content: '你好!我是 AI 维度助手,你可以问我任何关于人工智能的问题,比如:\n\n💡 "Transformer 的核心原理是什么?"\n💡 "RAG 和微调有什么区别?"\n💡 "2026 年最值得关注的 AI 趋势?"'
|
|
||||||
}
|
|
||||||
])
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const quota = ref(5)
|
|
||||||
const scrollTo = ref('')
|
const scrollTo = ref('')
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const token = computed(() => uni.getStorageSync('token'))
|
// 建议问题
|
||||||
|
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() {
|
async function sendMessage() {
|
||||||
const q = question.value.trim()
|
const q = question.value.trim()
|
||||||
if (!q || loading.value) return
|
if (!q || loading.value) return
|
||||||
|
|
||||||
// 先显示用户消息
|
messages.value.push({ role: 'user', content: escapeHtml(q) })
|
||||||
messages.value.push({ role: 'user', content: q })
|
|
||||||
question.value = ''
|
question.value = ''
|
||||||
scrollToLast()
|
scrollToLast()
|
||||||
|
|
||||||
// 检查登录
|
|
||||||
if (!token.value) {
|
|
||||||
messages.value.push({
|
|
||||||
role: 'assistant',
|
|
||||||
content: '请先登录后使用 AI 问答功能'
|
|
||||||
})
|
|
||||||
scrollToLast()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调 AI 接口
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await aiChatApi.ask(q, 'general', token.value)
|
const data = await aiChatApi.ask(q, 'general', userStore.token)
|
||||||
messages.value.push({ role: 'assistant', content: data.answer })
|
messages.value.push({ role: 'assistant', content: escapeHtml(data.answer) })
|
||||||
quota.value = data.remainingQuota
|
await userStore.fetchQuota(userStore.token)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 降级:本地模拟回复
|
console.error('AI 问答失败:', e)
|
||||||
|
const errorMsg = e.message || 'AI 服务暂时不可用,请稍后再试'
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: `关于「${q}」的问题,这是一个很好的切入点!建议你从对应的 AI 维度开始系统学习。\n\n💡 试试搜索知识库中的相关文章。`
|
content: escapeHtml(`抱歉,${errorMsg}`)
|
||||||
})
|
})
|
||||||
if (quota.value > 0) quota.value--
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
scrollToLast()
|
scrollToLast()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendSuggest(text) {
|
||||||
|
question.value = text
|
||||||
|
sendMessage()
|
||||||
|
}
|
||||||
|
|
||||||
function scrollToLast() {
|
function scrollToLast() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollTo.value = 'msg-' + (messages.value.length - 1)
|
scrollTo.value = 'msg-' + (messages.value.length - 1)
|
||||||
}, 100)
|
}, 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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -129,49 +205,131 @@ function scrollToLast() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 0 20px;
|
padding: 10px 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-avatar, .mini-avatar {
|
.chat-avatar,
|
||||||
width: 42px; height: 42px;
|
.mini-avatar {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(0,188,212,0.2));
|
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);
|
border: 1px solid rgba(124, 77, 255, 0.15);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #fff;
|
|
||||||
font-size: 18px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-text {
|
||||||
|
font-size: 20px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.mini-avatar {
|
.mini-avatar {
|
||||||
width: 32px; height: 32px;
|
width: 32px;
|
||||||
font-size: 14px;
|
height: 32px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-info { flex: 1; }
|
.chat-info { flex: 1; }
|
||||||
|
|
||||||
.chat-name {
|
.chat-name {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #fff;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-status {
|
.chat-status {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: rgba(255,255,255,0.3);
|
color: rgba(255, 255, 255, 0.3);
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-quota {
|
// 配额徽章
|
||||||
background: rgba(124,77,255,0.1);
|
.quota-badge {
|
||||||
border: 1px solid rgba(124,77,255,0.15);
|
display: flex;
|
||||||
border-radius: 20px;
|
align-items: center;
|
||||||
padding: 6px 14px;
|
gap: 4px;
|
||||||
color: #b388ff;
|
padding: 6px 12px;
|
||||||
font-size: 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: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: rgba(255, 255, 255, 0.4);
|
||||||
|
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: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息列表
|
||||||
.msg-list {
|
.msg-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -181,12 +339,17 @@ function scrollToLast() {
|
|||||||
.chat-msg {
|
.chat-msg {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg.user {
|
.chat-msg.user {
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-msg.assistant {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
.msg-content {
|
.msg-content {
|
||||||
max-width: 82%;
|
max-width: 82%;
|
||||||
}
|
}
|
||||||
@@ -194,84 +357,92 @@ function scrollToLast() {
|
|||||||
.bubble {
|
.bubble {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
.chat-msg.ai .bubble {
|
|
||||||
background: rgba(255,255,255,0.04);
|
.chat-msg.assistant .bubble {
|
||||||
border: 1px solid rgba(255,255,255,0.06);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
color: rgba(255,255,255,0.8);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
border-bottom-left-radius: 4px;
|
border-bottom-left-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg.user .bubble {
|
.chat-msg.user .bubble {
|
||||||
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(124,77,255,0.15));
|
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);
|
border: 1px solid rgba(124, 77, 255, 0.15);
|
||||||
color: #fff;
|
color: white;
|
||||||
border-bottom-right-radius: 4px;
|
border-bottom-right-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg-time {
|
// 加载动画
|
||||||
font-size: 10px;
|
.loading-dots {
|
||||||
color: rgba(255,255,255,0.15);
|
display: flex;
|
||||||
padding-top: 4px;
|
gap: 4px;
|
||||||
display: block;
|
padding: 14px 18px;
|
||||||
}
|
}
|
||||||
.chat-msg.ai .msg-time { padding-left: 0; }
|
|
||||||
.chat-msg.user .msg-time { text-align: right; }
|
|
||||||
|
|
||||||
.loading-dots { display: flex; gap: 2px; }
|
|
||||||
.dot {
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
animation: blink 1.4s infinite;
|
animation: blink 1.4s infinite;
|
||||||
font-size: 24px;
|
|
||||||
line-height: 0.5;
|
|
||||||
color: rgba(255,255,255,0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot:nth-child(2) { animation-delay: 0.2s; }
|
.dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
.dot:nth-child(3) { animation-delay: 0.4s; }
|
.dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
0%, 20% { opacity: 0; }
|
0%, 20% { opacity: 0; transform: scale(0.6); }
|
||||||
50% { opacity: 1; }
|
50% { opacity: 1; transform: scale(1); }
|
||||||
100% { opacity: 0; }
|
100% { opacity: 0; transform: scale(0.6); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 输入区
|
||||||
.chat-input-area {
|
.chat-input-area {
|
||||||
padding: 12px 0 30px;
|
padding: 12px 0 30px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input {
|
.chat-input {
|
||||||
background: rgba(255,255,255,0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
border: 1px solid rgba(255,255,255,0.06);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-btn { color: rgba(255,255,255,0.2); font-size: 18px; }
|
|
||||||
|
|
||||||
.input-field {
|
.input-field {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgba(255,255,255,0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-field::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.send-btn {
|
.send-btn {
|
||||||
width: 36px; height: 36px;
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #7c4dff, #b388ff);
|
background: linear-gradient(135deg, #7c4dff, #b388ff);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #fff;
|
color: white;
|
||||||
box-shadow: 0 0 20px rgba(124,77,255,0.2);
|
box-shadow: 0 0 20px rgba(124, 77, 255, 0.2);
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
.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>
|
||||||
@@ -161,7 +161,7 @@ function goBack() {
|
|||||||
|
|
||||||
function goDetail(item) {
|
function goDetail(item) {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/dimension/detail?id=${item._id}`
|
url: `/pages/detail/detail?id=${item._id}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 微信小程序登录 -->
|
||||||
|
<view class="login-method" v-if="isMiniApp">
|
||||||
|
<button class="wechat-btn" @click="wechatLogin">
|
||||||
|
<image class="wechat-icon" src="/static/icons/wechat.svg" mode="aspectFit" />
|
||||||
|
微信一键登录
|
||||||
|
</button>
|
||||||
|
<text class="login-tip">登录后即可开始 AI 知识探索</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- H5 登录/注册 -->
|
||||||
|
<view class="h5-login" v-else>
|
||||||
|
<!-- 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>
|
||||||
|
<button class="submit-btn" :loading="loading" @click="handleLogin">登 录</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 注册表单 -->
|
||||||
|
<view class="form-card" v-else>
|
||||||
|
<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>
|
||||||
|
<button class="submit-btn" :loading="loading" @click="handleRegister">注 册</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部信息 -->
|
||||||
|
<view class="footer-info">
|
||||||
|
<text>© 2026 宇之然 · AI 维度</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useUserStore } from '@/stores/index'
|
||||||
|
|
||||||
|
const isMiniApp = ref(false)
|
||||||
|
// 检测是否为微信小程序环境
|
||||||
|
try {
|
||||||
|
isMiniApp.value = !!uni.getSystemInfoSync().brand &&
|
||||||
|
(uni.getSystemInfoSync().platform === 'devtools' ||
|
||||||
|
(typeof wx !== 'undefined' && wx.getSystemInfoSync))
|
||||||
|
} 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: '' })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小程序登录
|
||||||
|
* 调用 wx.login 获取 code → 后端 code2Session → 获取 token
|
||||||
|
*/
|
||||||
|
const wechatLogin = async () => {
|
||||||
|
try {
|
||||||
|
// 获取微信登录 code
|
||||||
|
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%;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
// 背景装饰光球
|
||||||
|
.bg-orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(60px);
|
||||||
|
opacity: 0.4;
|
||||||
|
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%;
|
||||||
|
padding: 0 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logo
|
||||||
|
.logo-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.logo-ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: linear-gradient(135deg, var(--dim1-color), var(--dim3-color));
|
||||||
|
box-shadow: 0 8px 32px rgba(179, 136, 255, 0.3);
|
||||||
|
}
|
||||||
|
.logo-text {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.app-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 微信登录按钮
|
||||||
|
.wechat-btn {
|
||||||
|
width: 80%;
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: linear-gradient(135deg, #07c160, #06ad56);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 4px 16px rgba(7, 193, 96, 0.3);
|
||||||
|
|
||||||
|
.wechat-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.login-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// H5 登录
|
||||||
|
.h5-login {
|
||||||
|
width: 80%;
|
||||||
|
max-width: 320px;
|
||||||
|
|
||||||
|
.tab-switch {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 15px;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--dim1-color);
|
||||||
|
border-bottom-color: var(--dim1-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px 20px;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
background: linear-gradient(135deg, var(--dim1-color), var(--dim3-color));
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 8px;
|
||||||
|
box-shadow: 0 4px 16px rgba(179, 136, 255, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 40px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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: white;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
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: white;
|
||||||
|
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: rgba(255, 255, 255, 0.4);
|
||||||
|
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: rgba(255, 255, 255, 0.3);
|
||||||
|
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: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 套餐时长
|
||||||
|
.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: rgba(255, 255, 255, 0.3);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,116 +5,257 @@
|
|||||||
<!-- 用户信息 -->
|
<!-- 用户信息 -->
|
||||||
<view class="profile-card">
|
<view class="profile-card">
|
||||||
<view class="profile-avatar">
|
<view class="profile-avatar">
|
||||||
<text>{{ userInfo?.avatarUrl ? '🖼' : '🧑' }}</text>
|
<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>
|
</view>
|
||||||
<text class="profile-name">{{ userInfo?.nickName || '星辰旅者' }}</text>
|
<text class="profile-name">{{ userStore.userInfo?.nickName || '游客' }}</text>
|
||||||
<text class="profile-level">Lv.5 · 维度学徒 · 已探索 12 篇文章</text>
|
<text class="profile-level">
|
||||||
|
{{ subscriptionBadge }} · 已收藏 {{ collectedCount }} 篇
|
||||||
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 订阅卡片 -->
|
<!-- 订阅卡片 -->
|
||||||
<view class="plan-card" @tap="upgradePlan">
|
<view class="plan-card" @click="goPayment">
|
||||||
<view class="plan-info">
|
<view class="plan-info">
|
||||||
<text class="plan-title">✦ 免费版</text>
|
<text class="plan-title">{{ planTitle }}</text>
|
||||||
<text class="plan-desc">{{ isLoggedIn ? '每日 5 次 AI 问答 · 解锁全部知识' : '登录后开始你的 AI 探索之旅' }}</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>
|
||||||
<view class="plan-btn">
|
<view class="plan-btn" :class="{ disabled: userStore.isSubscribed }">
|
||||||
<text>{{ isLoggedIn ? '升级 Pro' : '登录/注册' }}</text>
|
<text>{{ planBtnText }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 菜单 -->
|
<!-- AI 问答配额 -->
|
||||||
<view class="menu-item" @tap="goFavorites">
|
<view class="quota-card" @click="goChat" v-if="userStore.isLoggedIn">
|
||||||
<view class="m-icon" :style="{ background: 'rgba(124,77,255,0.1)', color: '#b388ff' }">📚</view>
|
<text class="quota-card-title">🤖 AI 问答配额</text>
|
||||||
<text class="m-label">我的收藏</text>
|
<view class="quota-bar">
|
||||||
<text class="m-arrow">→</text>
|
<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>
|
||||||
|
|
||||||
<view class="menu-item" @tap="goProgress">
|
<!-- 菜单列表 -->
|
||||||
<view class="m-icon" :style="{ background: 'rgba(0,188,212,0.1)', color: '#4dd0e1' }">📊</view>
|
<view class="menu-section">
|
||||||
<text class="m-label">学习进度</text>
|
<view class="menu-item" @click="goFavorites">
|
||||||
<text class="m-arrow">→</text>
|
<view class="m-icon" style="background:rgba(179,136,255,0.1);color:#b388ff">⭐</view>
|
||||||
</view>
|
<text class="m-label">我的收藏</text>
|
||||||
|
<text class="m-badge">{{ collectedCount }}</text>
|
||||||
<view class="menu-item" @tap="goAchievements">
|
<text class="m-arrow">→</text>
|
||||||
<view class="m-icon" :style="{ background: 'rgba(0,200,150,0.1)', color: '#4db6ac' }">🏆</view>
|
</view>
|
||||||
<text class="m-label">成就</text>
|
<view class="menu-item" @click="goProgress">
|
||||||
<text class="m-badge">3 项未解锁</text>
|
<view class="m-icon" style="background:rgba(77,208,225,0.1);color:#4dd0e1">📊</view>
|
||||||
<text class="m-arrow">→</text>
|
<text class="m-label">学习进度</text>
|
||||||
</view>
|
<text class="m-arrow">→</text>
|
||||||
|
</view>
|
||||||
<view class="menu-item" @tap="checkin">
|
<view class="menu-item" @click="goAchievements">
|
||||||
<view class="m-icon" :style="{ background: 'rgba(255,193,7,0.1)', color: '#ffd54f' }">📅</view>
|
<view class="m-icon" style="background:rgba(77,182,172,0.1);color:#4db6ac">🏆</view>
|
||||||
<text class="m-label">每日签到</text>
|
<text class="m-label">成就</text>
|
||||||
<text class="m-arrow">→</text>
|
<text class="m-arrow">→</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="menu-item" @click="goAbout">
|
||||||
<view class="menu-item" @tap="goSettings">
|
<view class="m-icon" style="background:rgba(255,213,79,0.1);color:#ffd54f">ℹ️</view>
|
||||||
<view class="m-icon" :style="{ background: 'rgba(239,83,80,0.1)', color: '#ef5350' }">⚙️</view>
|
<text class="m-label">关于 AI 维度</text>
|
||||||
<text class="m-label">设置</text>
|
<text class="m-arrow">→</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>
|
||||||
|
|
||||||
<!-- 登录/退出 -->
|
<!-- 登录/退出 -->
|
||||||
<view v-if="isLoggedIn" class="logout-btn" @tap="handleLogout">
|
<view class="action-btn" v-if="!userStore.isLoggedIn" @click="goLogin">
|
||||||
<text>退出登录</text>
|
|
||||||
</view>
|
|
||||||
<view v-else class="logout-btn" @tap="handleLogin">
|
|
||||||
<text>微信一键登录</text>
|
<text>微信一键登录</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="action-btn logout" v-else @click="handleLogout">
|
||||||
|
<text>退出登录</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="version">宇之然AI维度 v1.0.0</view>
|
<text class="version">宇之然 AI 维度 v1.0.0</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { knowledgeApi, aiChatApi } from '@/utils/api'
|
||||||
|
|
||||||
const userInfo = ref(null)
|
const userStore = useUserStore()
|
||||||
const isLoggedIn = computed(() => !!uni.getStorageSync('token'))
|
|
||||||
|
|
||||||
function upgradePlan() {
|
// 已收藏数量
|
||||||
if (!isLoggedIn.value) {
|
const collectedCount = ref(0)
|
||||||
handleLogin()
|
|
||||||
return
|
onMounted(() => {
|
||||||
|
if (userStore.isLoggedIn) {
|
||||||
|
loadUserInfo()
|
||||||
|
loadCollected()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadUserInfo() {
|
||||||
|
await userStore.fetchMe()
|
||||||
|
if (userStore.token) {
|
||||||
|
await userStore.fetchQuota(userStore.token)
|
||||||
}
|
}
|
||||||
uni.showToast({ title: '订阅功能开发中', icon: 'none' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogin() {
|
async function loadCollected() {
|
||||||
uni.login({
|
if (!userStore.token) return
|
||||||
provider: 'weixin',
|
try {
|
||||||
success: (loginRes) => {
|
const list = await knowledgeApi.collected(userStore.token)
|
||||||
const code = loginRes.code
|
collectedCount.value = list.length
|
||||||
uni.getUserProfile({
|
} catch (e) {
|
||||||
desc: '用于完善用户资料',
|
collectedCount.value = 0
|
||||||
success: (profileRes) => {
|
}
|
||||||
const userInfo = profileRes.userInfo
|
}
|
||||||
// 调用登录 API
|
|
||||||
console.log('登录 code:', code, 'userInfo:', userInfo)
|
// 订阅信息
|
||||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
const planTitle = computed(() => {
|
||||||
},
|
if (userStore.isSubscribed) {
|
||||||
fail: () => {
|
return `✦ ${userStore.subscriptionLevel.toUpperCase()} 会员`
|
||||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
}
|
||||||
}
|
return '✦ 免费版'
|
||||||
})
|
})
|
||||||
},
|
|
||||||
fail: () => {
|
const planDesc = computed(() => {
|
||||||
uni.showToast({ title: '微信登录失败', icon: 'none' })
|
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 handleLogout() {
|
function goPayment() {
|
||||||
uni.removeStorageSync('token')
|
if (!userStore.isLoggedIn) {
|
||||||
userInfo.value = null
|
goLogin()
|
||||||
uni.showToast({ title: '已退出', icon: 'success' })
|
return
|
||||||
|
}
|
||||||
|
if (userStore.isSubscribed) return
|
||||||
|
uni.navigateTo({ url: '/pages/payment/payment' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function goFavorites() { uni.showToast({ title: '收藏功能开发中', icon: 'none' }) }
|
function goChat() {
|
||||||
function goProgress() { uni.showToast({ title: '学习进度开发中', icon: 'none' }) }
|
uni.switchTab({ url: '/pages/chat/chat' })
|
||||||
function goAchievements() { uni.showToast({ title: '成就系统开发中', icon: 'none' }) }
|
}
|
||||||
function checkin() { uni.showToast({ title: '签到功能开发中', icon: 'none' }) }
|
|
||||||
function goSettings() { uni.showToast({ title: '设置功能开发中', icon: 'none' }) }
|
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.showToast({ title: '收藏页面开发中', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goProgress() {
|
||||||
|
uni.showToast({ title: '学习进度开发中', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goAchievements() {
|
||||||
|
uni.showToast({ title: '成就系统开发中', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goAbout() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '关于',
|
||||||
|
content: '宇之然 AI 维度\n从 5 个维度系统理解人工智能\n© 2026 宇之然',
|
||||||
|
showCancel: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function goSettings() {
|
||||||
|
uni.showToast({ title: '设置功能开发中', icon: 'none' })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -126,72 +267,172 @@ function goSettings() { uni.showToast({ title: '设置功能开发中', icon: 'n
|
|||||||
|
|
||||||
.status-bar-placeholder { height: 44px; }
|
.status-bar-placeholder { height: 44px; }
|
||||||
|
|
||||||
|
// 用户卡片
|
||||||
.profile-card {
|
.profile-card {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px 0 24px;
|
padding: 20px 0 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-avatar {
|
.profile-avatar {
|
||||||
width: 72px; height: 72px;
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(0,188,212,0.15));
|
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);
|
border: 2px solid rgba(124, 77, 255, 0.2);
|
||||||
margin: 0 auto 14px;
|
margin: 0 auto 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 32px;
|
box-shadow: 0 0 40px rgba(124, 77, 255, 0.1);
|
||||||
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 {
|
.profile-name {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #fff;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-level {
|
.profile-level {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255,255,255,0.3);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 订阅卡片
|
||||||
.plan-card {
|
.plan-card {
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: linear-gradient(135deg, rgba(124,77,255,0.08), rgba(0,0,0,0.2));
|
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);
|
border: 1px solid rgba(124, 77, 255, 0.12);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-info { flex: 1; }
|
.plan-info { flex: 1; }
|
||||||
|
|
||||||
.plan-title {
|
.plan-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #fff;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-desc {
|
.plan-desc {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255,255,255,0.3);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
display: block;
|
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: rgba(255, 255, 255, 0.3);
|
||||||
|
margin-top: 6px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.plan-btn {
|
.plan-btn {
|
||||||
background: linear-gradient(135deg, #7c4dff, #b388ff);
|
background: linear-gradient(135deg, #7c4dff, #b388ff);
|
||||||
padding: 10px 22px;
|
padding: 10px 22px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
color: #fff;
|
color: white;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
box-shadow: 0 0 20px rgba(124,77,255,0.2);
|
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: rgba(255, 255, 255, 0.7);
|
||||||
|
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: rgba(255, 255, 255, 0.4);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-note {
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.menu-item {
|
||||||
@@ -199,52 +440,57 @@ function goSettings() { uni.showToast({ title: '设置功能开发中', icon: 'n
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 14px 0;
|
padding: 14px 0;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-icon {
|
.m-icon {
|
||||||
width: 34px; height: 34px;
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
border: 1px solid rgba(255,255,255,0.06);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-label {
|
.m-label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgba(255,255,255,0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-arrow {
|
.m-arrow {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgba(255,255,255,0.15);
|
color: rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-badge {
|
.m-badge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(239,83,80,0.12);
|
background: rgba(179, 136, 255, 0.12);
|
||||||
color: #ef5350;
|
color: var(--dim1-color);
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn {
|
// 按钮
|
||||||
|
.action-btn {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 18px 0;
|
padding: 16px 0;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgba(255,255,255,0.3);
|
color: var(--dim1-color);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.logout {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.version {
|
.version {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: rgba(255,255,255,0.1);
|
color: rgba(255, 255, 255, 0.1);
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,53 +2,83 @@
|
|||||||
<view class="page-trend">
|
<view class="page-trend">
|
||||||
<view class="status-bar-placeholder"></view>
|
<view class="status-bar-placeholder"></view>
|
||||||
|
|
||||||
<view class="section-header" style="margin-bottom: 4px;">
|
<!-- 顶部 -->
|
||||||
<text class="section-title">🔥 AI 趋势</text>
|
<view class="trend-header">
|
||||||
<text class="section-more">订阅</text>
|
<text class="header-title">AI 趋势</text>
|
||||||
|
<text class="header-desc">每日追踪人工智能前沿动态</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="trend-desc">每日追踪 AI 前沿动态,不错过每一个重要时刻</text>
|
|
||||||
|
|
||||||
<!-- 分类筛选 -->
|
<!-- 分类筛选 -->
|
||||||
<scroll-view class="filter-bar" scroll-x>
|
<scroll-view class="filter-bar" scroll-x :show-scrollbar="false">
|
||||||
<view
|
<view
|
||||||
v-for="(tag, idx) in tags"
|
class="filter-item"
|
||||||
|
:class="{ active: activeFilter === idx }"
|
||||||
|
v-for="(tag, idx) in filters"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
class="filter-tag"
|
@click="activeFilter = idx"
|
||||||
:class="{ active: currentTag === idx }"
|
|
||||||
@tap="currentTag = idx"
|
|
||||||
>
|
>
|
||||||
<text>{{ tag }}</text>
|
<text>{{ tag }}</text>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<!-- 趋势卡片 -->
|
<!-- 新闻列表 -->
|
||||||
<view
|
<scroll-view class="trend-list" scroll-y>
|
||||||
v-for="(item, idx) in filteredTrends"
|
<view v-for="(item, idx) in displayItems" :key="idx" class="trend-card" @click="goDetail(item)">
|
||||||
:key="idx"
|
<!-- 头部标签 -->
|
||||||
class="trend-card"
|
<view class="card-header">
|
||||||
:style="{ borderColor: item.borderColor }"
|
<text class="card-tag" :class="getTagClass(item)">
|
||||||
>
|
{{ getTagText(item) }}
|
||||||
<view class="trend-marker" :style="{ background: item.markerColor }"></view>
|
</text>
|
||||||
<view class="trend-content">
|
<text class="card-source">{{ item.source }}</text>
|
||||||
<text class="trend-tag" :style="{ background: item.tagBg, color: item.tagColor }">{{ item.tag }}</text>
|
</view>
|
||||||
<text class="trend-title">{{ item.title }}</text>
|
|
||||||
<text class="trend-source">{{ item.source }} · {{ item.date }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="bottom-spacer"></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>
|
||||||
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { trendApi } from '@/utils/api'
|
import { trendApi } from '@/utils/api'
|
||||||
|
|
||||||
const tags = ['全部', '🚀 产品', '📄 论文', '🔧 工具', '🏢 公司']
|
const filters = ref(['全部', '🚀 产品', '📄 论文', '🔧 工具', '🏢 公司'])
|
||||||
const currentTag = ref(0)
|
const activeFilter = ref(0)
|
||||||
|
const items = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
const trends = ref([])
|
const displayItems = computed(() => {
|
||||||
const loading = ref(true)
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchTrends()
|
fetchTrends()
|
||||||
@@ -57,168 +87,277 @@ onMounted(() => {
|
|||||||
async function fetchTrends() {
|
async function fetchTrends() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await trendApi.list({ limit: 50 })
|
const data = await trendApi.list({ limit: 20 })
|
||||||
trends.value = data.list.map(item => ({
|
items.value = data.list || data.items || []
|
||||||
tag: item.hot ? '🔥 热门' : item.picked ? '📌 精选' : getCategoryTag(item.category),
|
} catch (err) {
|
||||||
tagBg: getTagBg(item.category),
|
console.error('获取趋势失败:', err)
|
||||||
tagColor: getTagColor(item.category),
|
// 降级:本地示例
|
||||||
borderColor: `${getTagBg(item.category).replace('0.12', '0.08')}`,
|
items.value = getSampleData()
|
||||||
markerColor: getMarkerGradient(item.category),
|
|
||||||
title: item.title,
|
|
||||||
source: item.source,
|
|
||||||
date: formatDate(item.newsDate),
|
|
||||||
category: item.category,
|
|
||||||
_id: item._id
|
|
||||||
}))
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback data
|
|
||||||
trends.value = getFallbackData()
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCategoryTag(cat) {
|
function getTagText(item) {
|
||||||
const map = { '产品': '🚀 产品', '论文': '📄 论文', '工具': '🔧 工具', '公司': '🏢 公司', '政策': '🏛 政策' }
|
if (item.hot) return '🔥 热门'
|
||||||
return map[cat] || '📌 其他'
|
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 '📰 资讯'
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagStyleMap = {
|
function getTagClass(item) {
|
||||||
'产品': { bg: 'rgba(0,188,212,0.12)', color: '#4dd0e1', gradient: 'linear-gradient(180deg, #4dd0e1, #80deea)' },
|
if (item.hot) return 'hot'
|
||||||
'论文': { bg: 'rgba(0,200,150,0.12)', color: '#4db6ac', gradient: 'linear-gradient(180deg, #4db6ac, #80cbc4)' },
|
if (item.picked) return 'pick'
|
||||||
'工具': { bg: 'rgba(255,193,7,0.12)', color: '#ffd54f', gradient: 'linear-gradient(180deg, #ffd54f, #ffe082)' },
|
if (item.category === 'product') return 'product'
|
||||||
'公司': { bg: 'rgba(239,83,80,0.12)', color: '#ef5350', gradient: 'linear-gradient(180deg, #ef5350, #ff8a80)' },
|
if (item.category === 'paper') return 'paper'
|
||||||
'其他': { bg: 'rgba(124,77,255,0.12)', color: '#b388ff', gradient: 'linear-gradient(180deg, #b388ff, #d1c4e9)' }
|
if (item.category === 'tool') return 'tool'
|
||||||
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTagBg(cat) { return tagStyleMap[cat]?.bg || tagStyleMap['其他'].bg }
|
|
||||||
function getTagColor(cat) { return tagStyleMap[cat]?.color || tagStyleMap['其他'].color }
|
|
||||||
function getMarkerGradient(cat) { return tagStyleMap[cat]?.gradient || tagStyleMap['其他'].gradient }
|
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
return new Date(dateStr).toISOString().split('T')[0]
|
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 getFallbackData() {
|
function goDetail(item) {
|
||||||
|
// 如果有原文链接,跳转外部
|
||||||
|
if (item.link) {
|
||||||
|
// 微信小程序不支持直接打开外部链接,复制链接提示
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: item.link,
|
||||||
|
success: () => {
|
||||||
|
uni.showModal({
|
||||||
|
title: '打开链接',
|
||||||
|
content: '链接已复制,请在浏览器中打开',
|
||||||
|
showCancel: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.showToast({ title: '详情页面开发中', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSampleData() {
|
||||||
return [
|
return [
|
||||||
{ tag: '🔥 热门', tagBg: 'rgba(239,83,80,0.12)', tagColor: '#ef5350', borderColor: 'rgba(239,83,80,0.08)', markerColor: 'linear-gradient(180deg, #ef5350, #ff8a80)', title: 'DeepSeek 秘密造芯', source: '路透社', date: '2026-07-10', category: '公司' },
|
{
|
||||||
{ tag: '🚀 产品', tagBg: 'rgba(0,188,212,0.12)', tagColor: '#4dd0e1', borderColor: 'rgba(0,188,212,0.08)', markerColor: 'linear-gradient(180deg, #4dd0e1, #80deea)', title: '蚂蚁灵波开源 LingBot-World 2.0', source: '量子位', date: '2026-07-09', category: '产品' },
|
title: 'DeepSeek 发布 R1 推理模型',
|
||||||
{ tag: '📄 论文', tagBg: 'rgba(0,200,150,0.12)', tagColor: '#4db6ac', borderColor: 'rgba(0,200,150,0.08)', markerColor: 'linear-gradient(180deg, #4db6ac, #80cbc4)', title: '阿里获 ACL 2026 最佳资源论文奖', source: '机器之心', date: '2026-07-09', category: '论文' },
|
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
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredTrends = computed(() => {
|
|
||||||
if (currentTag.value === 0) return trends.value
|
|
||||||
const category = tags[currentTag.value].replace(/^[^\s]+\s/, '')
|
|
||||||
return trends.value.filter(t => t.category === category)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.page-trend {
|
.page-trend {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
padding: 0 20px 40px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar-placeholder { height: 44px; }
|
.status-bar-placeholder { height: 44px; }
|
||||||
|
|
||||||
.section-header {
|
// 头部
|
||||||
display: flex;
|
.trend-header {
|
||||||
justify-content: space-between;
|
padding: 10px 20px 16px;
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.section-title {
|
|
||||||
font-size: 17px;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
.section-more {
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255,255,255,0.25);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-desc {
|
.header-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255,255,255,0.3);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
margin-top: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选栏
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex;
|
padding: 0 20px 12px;
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow-x: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-tag {
|
.filter-item {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 6px 16px;
|
padding: 6px 14px;
|
||||||
border-radius: 20px;
|
margin-right: 8px;
|
||||||
font-size: 12px;
|
background: rgba(255, 255, 255, 0.04);
|
||||||
border: 1px solid rgba(255,255,255,0.06);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
.filter-tag.active {
|
|
||||||
background: rgba(124,77,255,0.2);
|
.filter-item.active {
|
||||||
color: #b388ff;
|
background: rgba(124, 77, 255, 0.15);
|
||||||
border-color: rgba(124,77,255,0.2);
|
border-color: rgba(124, 77, 255, 0.3);
|
||||||
|
color: var(--dim1-color);
|
||||||
}
|
}
|
||||||
.filter-tag:not(.active) {
|
|
||||||
background: rgba(255,255,255,0.03);
|
// 新闻列表
|
||||||
color: rgba(255,255,255,0.3);
|
.trend-list {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-card {
|
.trend-card {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 14px 16px;
|
padding: 16px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 12px;
|
||||||
background: rgba(255,255,255,0.03);
|
}
|
||||||
border: 1px solid rgba(255,255,255,0.05);
|
|
||||||
position: relative;
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-marker {
|
.card-tag {
|
||||||
position: absolute;
|
font-size: 11px;
|
||||||
left: 0;
|
|
||||||
top: 14px;
|
|
||||||
bottom: 14px;
|
|
||||||
width: 3px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trend-content {
|
|
||||||
padding-left: 14px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trend-tag {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 10px;
|
||||||
margin-bottom: 8px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-title {
|
.card-tag.hot {
|
||||||
font-size: 13px;
|
background: rgba(239, 83, 80, 0.12);
|
||||||
color: rgba(255,255,255,0.85);
|
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: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 8px;
|
||||||
font-weight: 500;
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-source {
|
.card-summary {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: rgba(255,255,255,0.2);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 10px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-spacer { height: 40px; }
|
.card-footer {
|
||||||
</style>
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载/空状态
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state text {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 15px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
margin-top: 6px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -1,72 +1,3 @@
|
|||||||
import { defineStore } from 'pinia'
|
export { useUserStore } from './user'
|
||||||
import { ref, computed } from 'vue'
|
export { useKnowledgeStore } from './knowledge'
|
||||||
import { authApi, knowledgeApi, aiChatApi } from '@/utils/api'
|
export { useChatStore } from './chat'
|
||||||
|
|
||||||
export const useUserStore = defineStore('user', () => {
|
|
||||||
const token = ref(uni.getStorageSync('token') || '')
|
|
||||||
const userInfo = ref(null)
|
|
||||||
const isLoggedIn = computed(() => !!token.value)
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
token.value = ''
|
|
||||||
userInfo.value = null
|
|
||||||
uni.removeStorageSync('token')
|
|
||||||
}
|
|
||||||
|
|
||||||
return { token, userInfo, isLoggedIn, login, logout }
|
|
||||||
})
|
|
||||||
|
|
||||||
export const useKnowledgeStore = defineStore('knowledge', () => {
|
|
||||||
const list = ref([])
|
|
||||||
const currentDetail = ref(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
async function fetchList(params = {}) {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
list.value = await knowledgeApi.list(params)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchDetail(id) {
|
|
||||||
currentDetail.value = await knowledgeApi.detail(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { list, currentDetail, loading, fetchList, fetchDetail }
|
|
||||||
})
|
|
||||||
|
|
||||||
export const useChatStore = defineStore('chat', () => {
|
|
||||||
const messages = ref([])
|
|
||||||
const quota = ref({ remaining: 0, total: 0 })
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
async function ask(question, token) {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await aiChatApi.ask(question, 'general', 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) {
|
|
||||||
const data = await aiChatApi.quota(token)
|
|
||||||
quota.value = data
|
|
||||||
}
|
|
||||||
|
|
||||||
return { messages, quota, loading, ask, fetchQuota }
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
Reference in New Issue
Block a user