feat: v1.3.0 差异化重构——星图点亮体系

P0 修复:
- 详情页正文渲染(content 分段),71 条知识不再空白
- 问答页去登录墙:游客每日 3 次(IP 限流),登录后每日 5 次
- 修正问答配额为真正的每日重置语义

点亮闭环:
- KnowledgeLit 模型 + 服务端判题(POST /:id/quiz,答案不下发)
- /api/knowledge/stats 星域统计 + 点亮进度 + streak + litIds
- AI 批量生成 71 条读后检测题(scripts/generate-quiz.js)

设计系统 2.0:
- 70 个统一线性图标替换全部 emoji 图标
- 主题 token mixin 化,修复 H5 暗色级联 bug,MP 跟随系统(darkmode)
- 首页星域导航(进度环)、我的页学习者档案、星域列表页重写
- 硬编码色清理,误植 map 页移除

服务端加固:
- trust proxy 真实 IP、错误信息脱敏、ask 独立限流、无效 ObjectId 404
- seo.js ESM→CJS(修复容器启动)、AI 导师"维度精灵"人格化
This commit is contained in:
2026-09-12 23:39:05 +08:00
parent cbc1a587f2
commit 2a111d21c0
104 changed files with 2776 additions and 2109 deletions
+68 -200
View File
@@ -8,39 +8,44 @@
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app' import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
onLaunch(() => { onLaunch(() => {
console.log('[AI维度] App launched')
initTheme() initTheme()
}) })
onShow(() => { onShow(() => {})
console.log('[AI维度] App shown') onHide(() => {})
})
onHide(() => {
console.log('[AI维度] App hidden')
})
// 主题:'auto'(默认,跟随系统)| 'light' | 'dark'
function initTheme() { function initTheme() {
const theme = uni.getStorageSync('theme') || 'light' const theme = uni.getStorageSync('theme') || 'auto'
applyTheme(theme) applyTheme(theme)
// auto 模式下监听系统切换(H5)
if (theme === 'auto' && typeof window !== 'undefined' && window.matchMedia) {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onChange = () => applyTheme('auto')
if (mq.addEventListener) mq.addEventListener('change', onChange)
else if (mq.addListener) mq.addListener(onChange)
}
}
function resolveTheme(theme) {
if (theme !== 'auto') return theme
if (typeof window !== 'undefined' && window.matchMedia) {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
return 'light'
} }
function applyTheme(theme) { function applyTheme(theme) {
const resolved = resolveTheme(theme)
try { try {
// H5 环境:操作 document class // H5 环境:操作 document class
const root = document.documentElement const root = document.documentElement
if (theme === 'dark') { root.classList.remove('theme-dark', 'theme-light')
root.classList.add('theme-dark') root.classList.add(resolved === 'dark' ? 'theme-dark' : 'theme-light')
root.classList.remove('theme-light')
} else {
root.classList.remove('theme-dark')
root.classList.add('theme-light')
}
} catch (e) { } catch (e) {
// 小程序环境:document 不存在 // 小程序环境:document 不存在,深色由 manifest darkmode + CSS 媒体查询接管
} }
// 小程序 tabBar 原生组件同步切换 applyTabBarTheme(resolved)
applyTabBarTheme(theme)
} }
function applyTabBarTheme(theme) { function applyTabBarTheme(theme) {
@@ -48,202 +53,34 @@ function applyTabBarTheme(theme) {
if (theme === 'dark') { if (theme === 'dark') {
uni.setTabBarStyle({ uni.setTabBarStyle({
color: 'rgba(255,255,255,0.35)', color: 'rgba(255,255,255,0.35)',
selectedColor: '#a78bfa', selectedColor: '#a2b2ff',
backgroundColor: '#0d0d1a', backgroundColor: '#181828',
borderStyle: 'black' borderStyle: 'black'
}) })
} else { } else {
uni.setTabBarStyle({ uni.setTabBarStyle({
color: 'rgba(0,0,0,0.3)', color: 'rgba(0,0,0,0.3)',
selectedColor: '#6366f1', selectedColor: '#5b5ff8',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
borderStyle: 'white' borderStyle: 'white'
}) })
} }
} catch (e) { } catch (e) {
// H5 环境 setTabBarStyle 可能不支持 // 非 tabBar 页面或不支持时忽略
} }
} }
</script> </script>
<style> <style lang="scss">
/* ============================================ /* ============================================
宇之然AI维度 — 全局主题 宇之然AI维度 — 全局主题
默认:亮色模式(蓝紫主题) | .theme-dark:暗色模式 变量 mixin 唯一出处:styles/theme.scss
============================================ */ ============================================ */
/* ---- 亮色模式(默认:浅底+色彩点缀,现代丰富) ---- */ /* 小程序基础:浅色默认 */
page, .theme-light {
/* 基础色 — 干净浅底 */
--bg-primary: #f5f5f7;
--bg-secondary: #ffffff;
--bg-tertiary: #e8e8ed;
/* 卡片 — 纯白微阴影 */
--glass-bg: #ffffff;
--glass-border: rgba(0, 0, 0, 0.08);
--glass-hover: #f8f8ff;
--glass-hover-border: rgba(99, 102, 241, 0.15);
--glass-strong: #ffffff;
--glass-dim-bg: rgba(99, 102, 241, 0.04);
/* 文字 — 清晰深色 */
--text-primary: #0d0d1a;
--text-secondary: rgba(13, 13, 26, 0.68);
--text-tertiary: rgba(13, 13, 26, 0.48);
--text-muted: rgba(13, 13, 26, 0.25);
--text-disabled: rgba(13, 13, 26, 0.15);
/* 品牌色 — Indigo 高饱和 */
--color-brand: #5b5ff8;
--color-brand-light: #7b87fd;
--color-brand-dark: #4f46e5;
--color-brand-bg: rgba(91, 95, 248, 0.08);
--color-brand-glow: rgba(91, 95, 248, 0.1);
/* 维度色 — 高饱和度,在浅色底上鲜艳 */
--dim1-color: #5b5ff8;
--dim1-bg: rgba(91, 95, 248, 0.2);
--dim1-icon-bg: rgba(91, 95, 248, 0.25);
--dim1-border: rgba(91, 95, 248, 0.3);
--dim1-glow: rgba(91, 95, 248, 0.08);
--dim2-color: #0095ba;
--dim2-bg: rgba(0, 149, 186, 0.2);
--dim2-icon-bg: rgba(0, 149, 186, 0.25);
--dim2-border: rgba(0, 149, 186, 0.3);
--dim2-glow: rgba(0, 149, 186, 0.08);
--dim3-color: #009b6a;
--dim3-bg: rgba(0, 155, 106, 0.2);
--dim3-icon-bg: rgba(0, 155, 106, 0.25);
--dim3-border: rgba(0, 155, 106, 0.3);
--dim3-glow: rgba(0, 155, 106, 0.08);
--dim4-color: #df7700;
--dim4-bg: rgba(223, 119, 0, 0.2);
--dim4-icon-bg: rgba(223, 119, 0, 0.25);
--dim4-border: rgba(223, 119, 0, 0.3);
--dim4-glow: rgba(223, 119, 0, 0.08);
--dim5-color: #e51c1c;
--dim5-bg: rgba(229, 28, 28, 0.2);
--dim5-icon-bg: rgba(229, 28, 28, 0.25);
--dim5-border: rgba(229, 28, 28, 0.3);
--dim5-glow: rgba(229, 28, 28, 0.08);
/* 功能色 */
--color-free: #009b6a;
--color-free-bg: rgba(0, 155, 106, 0.1);
--color-pro: #df7700;
--color-pro-bg: rgba(223, 119, 0, 0.1);
--color-vip: #e51c1c;
--color-vip-bg: rgba(229, 28, 28, 0.1);
--color-hot: #e51c1c;
--color-hot-bg: rgba(229, 28, 28, 0.12);
--color-new: #0095ba;
--color-new-bg: rgba(0, 149, 186, 0.12);
--color-pick: #5b5ff8;
--color-pick-bg: rgba(91, 95, 248, 0.12);
/* 圆角/间距/过渡 */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-full: 9999px;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 20px;
--spacing-2xl: 24px;
--blur-bg: 20px;
--transition-fast: 0.2s ease;
--transition-normal: 0.3s ease;
--glow-brand: 0 0 20px rgba(99, 102, 241, 0.08);
--glow-brand-strong: 0 0 30px rgba(99, 102, 241, 0.12);
/* 亮色专属:卡片阴影 */
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02);
--card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* ---- 暗色模式 ---- */
.theme-dark {
/* 基础色 */
--bg-primary: #14141e;
--bg-secondary: #181828;
--bg-tertiary: #1c1a34;
--glass-bg: rgba(255, 255, 255, 0.06);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-hover: rgba(255, 255, 255, 0.1);
--glass-hover-border: rgba(255, 255, 255, 0.15);
--glass-strong: rgba(255, 255, 255, 0.12);
--glass-dim-bg: rgba(99, 102, 241, 0.06);
--text-primary: rgba(255, 255, 255, 0.95);
--text-secondary: rgba(255, 255, 255, 0.7);
--text-tertiary: rgba(255, 255, 255, 0.5);
--text-muted: rgba(255, 255, 255, 0.3);
--text-disabled: rgba(255, 255, 255, 0.15);
--color-brand: #7b87fd;
--color-brand-light: #a2b2ff;
--color-brand-dark: #5b5ff8;
--color-brand-bg: rgba(91, 95, 248, 0.12);
--color-brand-glow: rgba(91, 95, 248, 0.15);
--dim1-color: #a485ff;
--dim1-bg: rgba(164, 133, 255, 0.12);
--dim1-icon-bg: rgba(164, 133, 255, 0.2);
--dim1-border: rgba(164, 133, 255, 0.18);
--dim1-glow: rgba(164, 133, 255, 0.08);
--dim2-color: #5ba4ff;
--dim2-bg: rgba(91, 164, 255, 0.12);
--dim2-icon-bg: rgba(91, 164, 255, 0.2);
--dim2-border: rgba(91, 164, 255, 0.18);
--dim2-glow: rgba(91, 164, 255, 0.08);
--dim3-color: #2cda9b;
--dim3-bg: rgba(44, 218, 155, 0.12);
--dim3-icon-bg: rgba(44, 218, 155, 0.2);
--dim3-border: rgba(44, 218, 155, 0.18);
--dim3-glow: rgba(44, 218, 155, 0.08);
--dim4-color: #ffc020;
--dim4-bg: rgba(255, 192, 32, 0.12);
--dim4-icon-bg: rgba(255, 192, 32, 0.2);
--dim4-border: rgba(255, 192, 32, 0.18);
--dim4-glow: rgba(255, 192, 32, 0.08);
--dim5-color: #ff6c82;
--dim5-bg: rgba(255, 108, 130, 0.12);
--dim5-icon-bg: rgba(255, 108, 130, 0.2);
--dim5-border: rgba(255, 108, 130, 0.18);
--dim5-glow: rgba(255, 108, 130, 0.08);
--color-free: #2cda9b;
--color-free-bg: rgba(44, 218, 155, 0.12);
--color-pro: #ffc020;
--color-pro-bg: rgba(255, 192, 32, 0.12);
--color-vip: #ff6c82;
--color-vip-bg: rgba(255, 108, 130, 0.12);
--color-hot: #ff6c82;
--color-hot-bg: rgba(255, 108, 130, 0.15);
--color-new: #5ba4ff;
--color-new-bg: rgba(91, 164, 255, 0.15);
--color-pick: #a485ff;
--color-pick-bg: rgba(164, 133, 255, 0.15);
--glow-brand: 0 0 20px rgba(99, 102, 241, 0.1);
--glow-brand-strong: 0 0 30px rgba(99, 102, 241, 0.15);
}
/* 全局排版 */
page { page {
@include theme-light-vars;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
@@ -252,6 +89,19 @@ page {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
} }
/* H5:html 类切换主题(需覆盖 page 上的浅色定义,特异性更高) */
/* #ifdef H5 */
html.theme-light page { @include theme-light-vars; }
html.theme-dark, html.theme-dark page { @include theme-dark-vars; }
/* #endif */
/* 小程序:跟随系统深色(需 manifest darkmode: true) */
/* #ifdef MP-WEIXIN */
@media (prefers-color-scheme: dark) {
page { @include theme-dark-vars; }
}
/* #endif */
view { view {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -269,7 +119,6 @@ view {
background: var(--bg-primary); background: var(--bg-primary);
} }
/* 页面副标题(统一) */
.page-subtitle { .page-subtitle {
display: block; display: block;
font-size: 13px; font-size: 13px;
@@ -282,10 +131,8 @@ view {
background: var(--glass-bg); background: var(--glass-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
backdrop-filter: blur(var(--blur-bg));
-webkit-backdrop-filter: blur(var(--blur-bg));
box-shadow: var(--card-shadow, none); box-shadow: var(--card-shadow, none);
transition: all var(--transition-normal); transition: all 0.3s ease;
} }
.gradient-brand { .gradient-brand {
@@ -298,4 +145,25 @@ view {
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
} }
/* 区块标题(统一) */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 24px 0 12px;
.section-title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 6px;
}
.section-more {
font-size: 12px;
color: var(--text-tertiary);
}
}
</style> </style>
+30
View File
@@ -0,0 +1,30 @@
<template>
<image
class="app-icon"
:class="{ dim: dim }"
:src="`/static/icons/${name}.png`"
:style="{ width: size + 'px', height: size + 'px' }"
mode="aspectFit"
/>
</template>
<script setup>
defineProps({
/** 图标名(static/icons/ 下的文件名,不含扩展名) */
name: { type: String, required: true },
/** 显示尺寸 px */
size: { type: Number, default: 20 },
/** 透明度弱化(次要操作) */
dim: { type: Boolean, default: false }
})
</script>
<style scoped>
.app-icon {
flex-shrink: 0;
vertical-align: middle;
}
.app-icon.dim {
opacity: 0.55;
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<template>
<view class="ring-wrap" :style="{ width: size + 'px', height: size + 'px' }">
<view class="ring-bg"></view>
<view
class="ring-fill"
:style="{
background: `conic-gradient(${color} ${percent * 3.6}deg, transparent ${percent * 3.6}deg)`,
'-webkit-mask': maskStyle,
mask: maskStyle
}"
></view>
<view class="ring-center">
<slot>
<text class="ring-num" :style="{ fontSize: numSize + 'px' }">{{ percent }}%</text>
</slot>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
/** 0-100 */
percent: { type: Number, default: 0 },
size: { type: Number, default: 56 },
color: { type: String, default: 'var(--color-brand)' },
numSize: { type: Number, default: 12 }
})
// 用径向遮罩挖出圆环(兼容小程序与 H5,无需 SVG)
const maskStyle = computed(() => {
const inner = Math.round(props.size * 0.22)
return `radial-gradient(circle ${inner}px at center, transparent 98%, #000 100%)`
})
</script>
<style scoped>
.ring-wrap {
position: relative;
flex-shrink: 0;
}
.ring-bg,
.ring-fill {
position: absolute;
inset: 0;
border-radius: 50%;
}
.ring-bg {
background: var(--glass-border);
}
.ring-center {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.ring-num {
font-weight: 700;
color: var(--text-primary);
}
</style>
+5 -2
View File
@@ -17,6 +17,9 @@
}, },
"usingComponents": true, "usingComponents": true,
"permission": {}, "permission": {},
"requiredPrivateInfos": [] "requiredPrivateInfos": [],
} "darkmode": true,
"themeLocation": "theme.json"
},
"app-plus": {}
} }
+7 -19
View File
@@ -124,25 +124,13 @@
"navigationBarTitleText": "协议", "navigationBarTitleText": "协议",
"navigationStyle": "custom" "navigationStyle": "custom"
} }
},
{
"path": "pages/map/map",
"style": {
"navigationBarTitleText": "AI 知识地图"
}
},
{
"path": "pages/map/quiz",
"style": {
"navigationBarTitleText": "知识闯关"
}
} }
], ],
"globalStyle": { "globalStyle": {
"navigationBarTextStyle": "black", "navigationBarTextStyle": "@navTxt",
"navigationBarTitleText": "宇之然AI维度", "navigationBarTitleText": "宇之然AI维度",
"navigationBarBackgroundColor": "#f5f5f7", "navigationBarBackgroundColor": "@navBg",
"backgroundColor": "#f5f5f7", "backgroundColor": "@bg",
"backgroundColorTop": "#f5f5f7", "backgroundColorTop": "#f5f5f7",
"backgroundColorBottom": "#f5f5f7", "backgroundColorBottom": "#f5f5f7",
"app-plus": { "app-plus": {
@@ -150,10 +138,10 @@
} }
}, },
"tabBar": { "tabBar": {
"color": "rgba(0,0,0,0.3)", "color": "@tabTxt",
"selectedColor": "#6366f1", "selectedColor": "@tabSel",
"backgroundColor": "#ffffff", "backgroundColor": "@tabBg",
"borderStyle": "white", "borderStyle": "@tabBorder",
"list": [ "list": [
{ {
"pagePath": "pages/index/index", "pagePath": "pages/index/index",
@@ -4,7 +4,7 @@
<!-- 顶部导航 --> <!-- 顶部导航 -->
<view class="header"> <view class="header">
<text class="title">🏆 成就</text> <text class="title">成就</text>
<text class="unlock-count">{{ unlockedCount }}/{{ totalCount }}</text> <text class="unlock-count">{{ unlockedCount }}/{{ totalCount }}</text>
</view> </view>
@@ -62,7 +62,7 @@
<!-- 空状态 --> <!-- 空状态 -->
<view class="empty-state" v-else-if="!loading"> <view class="empty-state" v-else-if="!loading">
<text class="empty-icon">🏆</text> <text class="empty-icon">暂无成就</text>
<text class="empty-text">还没有获得任何成就</text> <text class="empty-text">还没有获得任何成就</text>
<text class="empty-sub">继续学习,解锁属于你的成就吧</text> <text class="empty-sub">继续学习,解锁属于你的成就吧</text>
</view> </view>
@@ -88,9 +88,9 @@ const loading = ref(true)
// 成就分类的默认配置 // 成就分类的默认配置
const CATEGORY_CONFIG = [ const CATEGORY_CONFIG = [
{ name: '知识探索', icon: '📖', color: 'var(--dim1-color)' }, { name: '知识探索', icon: '星', color: 'var(--dim1-color)' },
{ name: '收藏成就', icon: '⭐', color: 'var(--dim2-color)' }, { name: '收藏成就', icon: '藏', color: 'var(--dim2-color)' },
{ name: 'AI 问答', icon: '🤖', color: 'var(--dim3-color)' }, { name: 'AI 问答', icon: '问', color: 'var(--dim3-color)' },
{ name: '社交成就', icon: '👥', color: 'var(--dim4-color)' } { name: '社交成就', icon: '👥', color: 'var(--dim4-color)' }
] ]
@@ -141,7 +141,7 @@ async function loadAchievements() {
function catIcon(name) { function catIcon(name) {
const config = CATEGORY_CONFIG.find((c) => c.name === name) const config = CATEGORY_CONFIG.find((c) => c.name === name)
return config ? config.icon : '🏆' return config ? config.icon : '成'
} }
function achColor(icon) { function achColor(icon) {
+229 -221
View File
@@ -2,28 +2,29 @@
<view class="page-chat page-layout"> <view class="page-chat page-layout">
<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 class="chat-avatar">
<text class="avatar-text">∞</text> <AppIcon name="planet-white" :size="22" />
</view> </view>
<view class="chat-info"> <view class="chat-info">
<text class="chat-name">AI 助手</text> <text class="chat-name">维度精灵</text>
<text class="chat-status">随时为你解答</text> <text class="chat-status">你的 AI 知识探索向导</text>
</view> </view>
<!-- 配额徽章(登录后显示) --> <view class="quota-badge" @click="onQuotaTap">
<view class="quota-badge" v-if="userStore.isLoggedIn" @click="goShare"> <AppIcon :name="isGuest ? 'gift' : 'gift-gray'" :size="13" />
<text class="quota-text">{{ quotaDisplay }}</text> <text class="quota-text">{{ quotaText }}</text>
<text class="quota-icon">🎁</text>
</view> </view>
</view> </view>
<!-- 消息列表(始终可见) --> <!-- 消息列表 -->
<scroll-view class="msg-list" scroll-y :scroll-into-view="scrollTo"> <scroll-view class="msg-list" scroll-y :scroll-into-view="scrollTo">
<!-- 欢迎消息 --> <!-- 欢迎 -->
<view class="welcome-card"> <view class="welcome-card">
<text class="welcome-icon">💡</text> <view class="welcome-head">
<text class="welcome-text">试试问我这些问题:</text> <AppIcon name="sparkle" :size="16" />
<text class="welcome-text">{{ welcomeText }}</text>
</view>
<view class="suggest-list"> <view class="suggest-list">
<view class="suggest-item" @click="sendSuggest(item)" v-for="(item, idx) in suggestions" :key="idx"> <view class="suggest-item" @click="sendSuggest(item)" v-for="(item, idx) in suggestions" :key="idx">
<text>{{ item }}</text> <text>{{ item }}</text>
@@ -32,7 +33,9 @@
</view> </view>
<view v-for="(msg, idx) in messages" :key="idx" :id="'msg-' + idx" class="chat-msg" :class="msg.role"> <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 v-if="msg.role === 'assistant'" class="mini-avatar">
<AppIcon name="planet-white" :size="14" />
</view>
<view class="msg-content"> <view class="msg-content">
<view class="bubble" v-html="msg.content"></view> <view class="bubble" v-html="msg.content"></view>
</view> </view>
@@ -40,7 +43,9 @@
<!-- 加载动画 --> <!-- 加载动画 -->
<view v-if="loading" class="chat-msg assistant"> <view v-if="loading" class="chat-msg assistant">
<view class="mini-avatar">∞</view> <view class="mini-avatar">
<AppIcon name="planet-white" :size="14" />
</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>
@@ -49,40 +54,42 @@
</view> </view>
</view> </view>
</view> </view>
<view class="list-bottom"></view>
</scroll-view> </scroll-view>
<!-- 输入区(始终可见,未登录时提示登录) --> <!-- 输入区:游客与登录用户一致,始终可见 -->
<view class="chat-input-area"> <view class="chat-input-area">
<view class="chat-input" v-if="userStore.isLoggedIn"> <view v-if="guestExhausted" class="login-guide" @click="goLogin">
<text class="guide-text">{{ guestExhaustedText }}</text>
<view class="guide-btn">
<text>登录后每天 5 次</text>
</view>
</view>
<view class="chat-input" v-else>
<input <input
v-model="question" v-model="question"
class="input-field" class="input-field"
placeholder="输入你对 AI 的疑问..." :placeholder="inputPlaceholder"
:disabled="loading" :disabled="loading"
@confirm="sendMessage" @confirm="sendMessage"
confirm-type="send" confirm-type="send"
/> />
<view class="send-btn" @click="sendMessage"> <view class="send-btn" :class="{ disabled: loading || !question.trim() }" @click="sendMessage">
<text>➤</text> <AppIcon name="send-white" :size="16" />
</view>
</view>
<!-- 未登录:显示登录引导 -->
<view class="login-input-placeholder" v-else @click="goLogin">
<text class="login-input-text">登录后即可体验 AI 问答</text>
<view class="login-input-btn">
<text>去登录 →</text>
</view> </view>
</view> </view>
<text v-if="isGuest && !guestExhausted" class="guest-hint">游客模式 · 每日 3 次体验</text>
</view> </view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, watch } from 'vue'
import { ref, computed } from 'vue'
import { aiChatApi } from '@/utils/api' import { aiChatApi } from '@/utils/api'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import AppIcon from '@/components/AppIcon.vue'
const question = ref('') const question = ref('')
const messages = ref([]) const messages = ref([])
@@ -90,35 +97,44 @@ const loading = ref(false)
const scrollTo = ref('') const scrollTo = ref('')
const userStore = useUserStore() const userStore = useUserStore()
// 建议问题 const quotaInfo = ref(null) // { remainingQuota, freeQuota, isGuest }
const guestExhausted = ref(false)
const WELCOME_LOGGED = '你好呀,我是维度精灵。任何 AI 概念都能问我,我用大白话讲明白。'
const WELCOME_GUEST = '嗨,我是维度精灵。游客身份也能提问,试试下面的问题。'
const GUEST_EXHAUSTED = '今日 3 次游客体验已用完,登录后每天可问 5 次,还能点亮知识星球。'
const INPUT_LOGGED = '输入你对 AI 的疑问...'
const INPUT_GUEST = '游客模式,直接提问试试...'
const suggestions = ref([ const suggestions = ref([
'"Transformer 的核心原理是什么?"', '用大白话解释 Transformer 是什么',
'"RAG 和微调有什么区别?"', 'RAG 和微调有什么区别?',
'"2026 年最值得关注的 AI 趋势?"' '2026 年最值得关注的 AI 趋势?'
]) ])
// 配额显示 const isGuest = computed(() => !userStore.isLoggedIn)
const quotaDisplay = computed(() => { const welcomeText = computed(() => (isGuest.value ? WELCOME_GUEST : WELCOME_LOGGED))
const q = userStore.quota const inputPlaceholder = computed(() => (isGuest.value ? INPUT_GUEST : INPUT_LOGGED))
if (!q) return '?次' const guestExhaustedText = GUEST_EXHAUSTED
const used = q.usedQuota || q.dailyUsed || 0
const remaining = q.remainingQuota || 0 const quotaText = computed(() => {
const total = q.freeQuota || remaining + used const q = quotaInfo.value
return `${remaining}/${total}` if (!q) return '...'
return `${q.remainingQuota} 次`
}) })
// 监听登录状态变化,加载配额
const isLoggedIn = computed(() => userStore.isLoggedIn) const isLoggedIn = computed(() => userStore.isLoggedIn)
import { watch } from 'vue'
watch(isLoggedIn, (val) => { watch(isLoggedIn, (val) => {
if (val) { guestExhausted.value = false
loadQuota() loadQuota()
}
}, { immediate: true }) }, { immediate: true })
async function loadQuota() { async function loadQuota() {
if (userStore.token) { try {
await userStore.fetchQuota(userStore.token) const data = await aiChatApi.quota(userStore.isLoggedIn ? userStore.token : undefined)
quotaInfo.value = data
} catch (e) {
quotaInfo.value = null
} }
} }
@@ -126,28 +142,27 @@ async function sendMessage() {
const q = question.value.trim() const q = question.value.trim()
if (!q || loading.value) return if (!q || loading.value) return
// 未登录 → 跳登录
if (!userStore.isLoggedIn) {
uni.navigateTo({ url: '/pages/login/login' })
return
}
messages.value.push({ role: 'user', content: escapeHtml(q) }) messages.value.push({ role: 'user', content: escapeHtml(q) })
question.value = '' question.value = ''
scrollToLast() scrollToLast()
loading.value = true loading.value = true
try { try {
const data = await aiChatApi.ask(q, 'general', userStore.token) const data = await aiChatApi.ask(q, 'general', userStore.isLoggedIn ? userStore.token : undefined)
messages.value.push({ role: 'assistant', content: escapeHtml(data.answer) }) messages.value.push({ role: 'assistant', content: escapeHtml(data.answer) })
await userStore.fetchQuota(userStore.token) if (quotaInfo.value) {
quotaInfo.value.remainingQuota = data.remainingQuota
}
if (isGuest.value && data.remainingQuota <= 0) {
guestExhausted.value = true
}
} catch (e) { } catch (e) {
console.error('AI 问答失败:', e) const msg = e.message || 'AI 服务暂时不可用,请稍后再试'
const errorMsg = e.message || 'AI 服务暂时不可用,请稍后再试' if (msg.includes('游客体验已用完')) {
messages.value.push({ guestExhausted.value = true
role: 'assistant', } else {
content: escapeHtml(`抱歉,${errorMsg}`) messages.value.push({ role: 'assistant', content: escapeHtml(`抱歉,${msg}`) })
}) }
} finally { } finally {
loading.value = false loading.value = false
scrollToLast() scrollToLast()
@@ -161,8 +176,9 @@ function sendSuggest(text) {
function scrollToLast() { function scrollToLast() {
setTimeout(() => { setTimeout(() => {
scrollTo.value = 'msg-' + (messages.value.length - 1) scrollTo.value = ''
}, 100) setTimeout(() => { scrollTo.value = 'msg-' + (messages.value.length - 1) }, 50)
}, 80)
} }
function escapeHtml(text) { function escapeHtml(text) {
@@ -177,33 +193,43 @@ function goLogin() {
uni.navigateTo({ url: '/pages/login/login' }) uni.navigateTo({ url: '/pages/login/login' })
} }
function goShare() { function onQuotaTap() {
if (isGuest.value) {
uni.showModal({
title: '游客体验',
content: '每天可免费提问 3 次;登录后每天 5 次,答对检测题还能点亮知识星球',
confirmText: '去登录',
cancelText: '继续体验',
success: (res) => {
if (res.confirm) goLogin()
}
})
return
}
uni.showModal({ uni.showModal({
title: '获取更多次数', title: '获取更多次数',
content: '将小程序分享给好友,即可获得 5 次额外问答机会', content: '将小程序分享给好友,即可获得 5 次额外问答机会',
confirmText: '去分享', confirmText: '去分享',
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm && userStore.token) {
// 微信小程序分享能力
uni.showShareMenu({ withShareTicket: true }) uni.showShareMenu({ withShareTicket: true })
// 调用分享获得次数接口
if (userStore.token) {
aiChatApi.shareGain(userStore.token).then(() => { aiChatApi.shareGain(userStore.token).then(() => {
uni.showToast({ title: '已记录分享', icon: 'success' }) uni.showToast({ title: '已记录分享', icon: 'success' })
loadQuota() loadQuota()
}).catch(() => {}) }).catch(() => {})
} }
} }
}
}) })
} }
// 微信小程序右上角三点分享
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
onShareAppMessage(() => ({ onShareAppMessage(() => ({
title: 'AI 维度 - AI 问答', title: '维度精灵 · 你的 AI 知识探索向导',
path: '/pages/chat/chat' path: '/pages/chat/chat'
})) }))
onShareTimeline(() => ({
title: '维度精灵 · 你的 AI 知识探索向导'
}))
// #endif // #endif
</script> </script>
@@ -212,7 +238,7 @@ onShareAppMessage(() => ({
height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0 20px; padding: 0 16px;
background: var(--bg-primary); background: var(--bg-primary);
} }
@@ -222,248 +248,230 @@ onShareAppMessage(() => ({
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 10px 0 16px; padding: 10px 0 14px;
} }
.chat-avatar, .chat-avatar,
.mini-avatar { .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, var(--color-brand), #8b5cf6);
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;
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 4px 14px var(--color-brand-glow);
} }
.chat-avatar { width: 42px; height: 42px; }
.avatar-text { .mini-avatar { width: 28px; height: 28px; border-radius: 9px; }
font-size: 20px;
color: white;
}
.mini-avatar {
width: 32px;
height: 32px;
border-radius: 10px;
font-size: 14px;
}
.chat-info { flex: 1; } .chat-info { flex: 1; }
.chat-name { .chat-name {
font-size: 16px;
color: var(--text-primary);
font-weight: 600;
display: block; display: block;
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
} }
.chat-status { .chat-status {
display: block;
font-size: 11px; font-size: 11px;
color: var(--text-tertiary); color: var(--text-tertiary);
display: block; margin-top: 2px;
} }
// 配额徽章
.quota-badge { .quota-badge {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 5px;
padding: 6px 12px; padding: 6px 12px;
background: rgba(124, 77, 255, 0.1); border-radius: var(--radius-full);
border: 1px solid rgba(124, 77, 255, 0.2); background: var(--color-brand-bg);
border-radius: 16px; border: 1px solid var(--glass-border);
}
.quota-text { .quota-text {
font-size: 12px; font-size: 12px;
color: var(--dim1-color); font-weight: 700;
font-weight: 600; color: var(--color-brand);
}
} }
.quota-icon { font-size: 14px; } .msg-list {
flex: 1;
// 未登录输入区占位 min-height: 0;
.login-input-placeholder {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background: var(--glass-bg);
border: 1px dashed var(--glass-border);
border-radius: 14px;
margin-bottom: 30px;
cursor: pointer;
} }
.login-input-text { .list-bottom { height: 16px; }
font-size: 14px;
color: var(--text-tertiary);
}
.login-input-btn {
padding: 6px 16px;
background: var(--color-brand);
border-radius: 8px;
}
.login-input-btn text {
font-size: 13px;
color: white;
font-weight: 600;
}
// 欢迎卡片
.welcome-card { .welcome-card {
background: var(--glass-bg); background: var(--glass-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: 14px; border-radius: var(--radius-lg);
padding: 16px; padding: 16px;
margin-bottom: 16px; margin-bottom: 16px;
box-shadow: var(--card-shadow, none);
} }
.welcome-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
.welcome-icon { .welcome-text {
font-size: 20px;
display: block;
margin-bottom: 6px;
}
.welcome-text {
font-size: 13px; font-size: 13px;
color: var(--text-tertiary); font-weight: 600;
display: block; color: var(--text-primary);
margin-bottom: 10px; line-height: 1.5;
}
} }
.suggest-list { .suggest-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 8px;
} }
.suggest-item { .suggest-item {
padding: 8px 12px; padding: 11px 14px;
background: rgba(124, 77, 255, 0.08); border-radius: var(--radius-md);
border-radius: 8px; background: var(--bg-primary);
font-size: 12px; border: 1px solid var(--glass-border);
font-size: 13px;
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.5;
transition: all 0.2s ease;
} }
.suggest-item:active { border-color: var(--color-brand); color: var(--color-brand); }
// 消息列表
.msg-list {
flex: 1;
overflow-y: auto;
padding-bottom: 10px;
}
.chat-msg { .chat-msg {
display: flex; display: flex;
gap: 10px; gap: 8px;
margin-bottom: 14px; margin-bottom: 14px;
}
.chat-msg.user { &.assistant .msg-content { align-items: flex-start; }
flex-direction: row-reverse;
}
.chat-msg.assistant { .msg-content {
flex-direction: row;
}
.msg-content {
max-width: 82%; max-width: 82%;
} display: flex;
flex-direction: column;
}
.bubble { &.user {
padding: 12px 16px; justify-content: flex-end;
border-radius: 16px;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.chat-msg.assistant .bubble { .msg-content { align-items: flex-end; }
.bubble {
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
color: #fff;
border-radius: 16px 16px 4px 16px;
}
}
&.assistant .bubble {
background: var(--glass-bg); background: var(--glass-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
color: var(--text-primary); color: var(--text-primary);
border-bottom-left-radius: 4px; border-radius: 16px 16px 16px 4px;
}
} }
.chat-msg.user .bubble { .bubble {
background: linear-gradient(135deg, rgba(124, 77, 255, 0.3), rgba(124, 77, 255, 0.15)); padding: 12px 14px;
border: 1px solid rgba(124, 77, 255, 0.15); font-size: 14px;
color: white; line-height: 1.7;
border-bottom-right-radius: 4px; word-break: break-word;
} }
// 加载动画
.loading-dots { .loading-dots {
display: flex; display: flex;
gap: 4px; gap: 5px;
padding: 14px 18px; align-items: center;
} padding: 16px 14px;
.dot { .dot {
width: 6px; width: 7px;
height: 6px; height: 7px;
border-radius: 50%; border-radius: 50%;
background: rgba(255, 255, 255, 0.4); background: var(--color-brand);
animation: blink 1.4s infinite; animation: dotBounce 1.2s infinite ease-in-out;
&:nth-child(2) { animation-delay: 0.15s; }
&:nth-child(3) { animation-delay: 0.3s; }
}
} }
.dot:nth-child(2) { animation-delay: 0.2s; } @keyframes dotBounce {
.dot:nth-child(3) { animation-delay: 0.4s; } 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-5px); opacity: 1; }
@keyframes blink {
0%, 20% { opacity: 0; transform: scale(0.6); }
50% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(0.6); }
} }
// 输入区 /* 输入区 */
.chat-input-area { .chat-input-area {
padding: 12px 0 30px; flex-shrink: 0;
background: var(--bg-primary); padding: 10px 0 12px;
}
.guest-hint {
display: block;
text-align: center;
font-size: 11px;
color: var(--text-muted);
margin-top: 8px;
} }
.chat-input { .chat-input {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 14px;
padding: 10px 14px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-full);
padding: 6px 6px 6px 18px;
} }
.input-field { .input-field {
flex: 1; flex: 1;
height: 24px; height: 38px;
font-size: 14px; font-size: 14px;
color: var(--text-primary); color: var(--text-primary);
background: none; background: transparent;
border: none;
outline: none;
}
.input-field::placeholder {
color: var(--text-muted);
} }
.send-btn { .send-btn {
width: 36px; width: 38px;
height: 36px; height: 38px;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, #7c4dff, #b388ff); background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: white; flex-shrink: 0;
box-shadow: 0 0 20px rgba(124, 77, 255, 0.2); transition: opacity 0.2s ease;
&.disabled { opacity: 0.45; }
} }
.send-btn text { /* 用完额度引导 */
font-size: 16px; .login-guide {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-radius: var(--radius-lg);
background: linear-gradient(135deg, var(--color-brand-bg), rgba(139, 92, 246, 0.06));
border: 1px solid var(--glass-hover-border);
.guide-text {
flex: 1;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.6;
}
.guide-btn {
padding: 8px 14px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
color: #fff;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
} }
</style> </style>
+336 -239
View File
@@ -1,29 +1,35 @@
<template> <template>
<view class="detail-page page-layout"> <view class="detail-page page-layout">
<!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view> <view class="status-bar-placeholder"></view>
<!-- 顶部导航 --> <!-- 顶部导航 -->
<view class="nav-bar"> <view class="nav-bar">
<view class="nav-back" @click="goBack">‹</view> <view class="nav-back" @click="goBack">
<AppIcon name="back-gray" :size="18" />
</view>
<text class="nav-title">AI 知识</text> <text class="nav-title">AI 知识</text>
<view class="nav-collect" @click="toggleCollect"> <view class="nav-collect" @click="toggleCollect">
<text v-if="isCollected" class="icon icon-collected">★</text> <AppIcon v-if="isCollected" name="star-fill" :size="20" />
<text v-else class="icon">☆</text> <AppIcon v-else name="star-gray" :size="20" />
</view> </view>
</view> </view>
<!-- 知识头部 --> <!-- 知识头部 -->
<view class="detail-header"> <view class="detail-header">
<view class="dim-tag" :style="{ background: dimColor }"> <view class="dim-tag" :style="{ background: dimInfo.bg, color: dimInfo.color }">
<text class="dim-name">{{ dimensionName }}</text> {{ dimInfo.name }}
</view> </view>
<text class="detail-title">{{ detail?.title || '加载中...' }}</text> <text class="detail-title">{{ detail?.title || '加载中...' }}</text>
<view class="meta-row"> <view class="meta-row">
<text class="meta-item">📖 {{ viewCountText }}</text> <view class="meta-item">
<text class="meta-item">⭐ {{ collectionCount }}</text> <AppIcon name="search-gray" :size="12" />
<text class="meta-item" v-if="detail?.category"> <text>{{ viewCountText }} 次阅读</text>
{{ categoryLabel }} </view>
</text> <view class="meta-item">
<AppIcon name="star-gray" :size="12" />
<text>{{ collectionCount }} 人收藏</text>
</view>
<text class="meta-item" v-if="readMinutes">{{ readMinutes }} 分钟读完</text>
</view> </view>
</view> </view>
@@ -32,85 +38,140 @@
<text class="tag" v-for="tag in detail.tags" :key="tag">#{{ tag }}</text> <text class="tag" v-for="tag in detail.tags" :key="tag">#{{ tag }}</text>
</view> </view>
<!-- 内容区域 --> <!-- 正文 -->
<scroll-view class="content-scroll" scroll-y> <view class="content-card">
<!-- 分段内容(如果存在 sections) --> <view v-if="!detail" class="loading-block">
<view class="content-section" v-if="detail?.sections?.length"> <text>加载中...</text>
<view class="section-block" v-for="(section, idx) in detail.sections" :key="idx"> </view>
<text class="section-title">{{ section.title }}</text> <template v-else-if="paragraphs.length">
<text class="section-content">{{ section.content }}</text> <text
<view class="section-images" v-if="section.images?.length"> v-for="(p, i) in paragraphs"
<image
class="section-img"
v-for="(img, i) in section.images"
:key="i" :key="i"
:src="img" class="para"
mode="aspectFill" :class="{ 'para-lead': i === 0 }"
/> >{{ p }}</text>
</view> </template>
<view v-else class="loading-block">
<text>暂无内容</text>
</view> </view>
</view> </view>
<!-- 普通内容 --> <!-- 检测题(读后测,答对点亮星球) -->
<view class="content-block" v-else> <view class="quiz-card" v-if="detail?.question && detail?.options?.length">
<text class="content-text">{{ detail?.content || '暂无内容' }}</text> <view class="quiz-head">
<AppIcon name="sparkle" :size="16" />
<text class="quiz-title">读后检测 · 答对点亮这颗星球</text>
</view> </view>
<text class="quiz-question">{{ detail.question }}</text>
<!-- 问答区域 --> <template v-if="!quizResult">
<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 <view
class="qa-option"
v-for="(opt, idx) in detail.options" v-for="(opt, idx) in detail.options"
:key="idx" :key="idx"
:class="{ correct: selectedOption === idx && opt.isCorrect }" class="quiz-option"
:class="{ selected: selectedOption === idx }"
@click="selectedOption = idx" @click="selectedOption = idx"
> >
<text class="option-letter">{{ String.fromCharCode(65 + idx) }}</text> <text class="option-letter">{{ optionLetters[idx] }}</text>
<text class="option-text">{{ opt.text }}</text> <text class="option-text">{{ opt.text }}</text>
</view> </view>
<view
class="quiz-submit"
:class="{ disabled: selectedOption === null }"
@click="submitQuiz"
>
<text>{{ selectedOption === null ? '选择你的答案' : '确认作答' }}</text>
</view> </view>
<view class="qa-answer" v-if="selectedOption !== null"> </template>
<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 问答入口 --> <template v-else>
<view class="ai-banner" @click="goToAI"> <view class="quiz-result" :class="quizResult.correct ? 'ok' : 'bad'">
<text class="ai-icon">🤖</text> <view class="result-icon">
<text class="ai-text">还有疑问?问问 AI 助手</text> <AppIcon :name="quizResult.correct ? 'check-green' : 'lock-gray'" :size="18" />
<text class="ai-arrow">→</text>
</view> </view>
<view class="result-text">
<text class="result-title">{{ quizResult.correct ? resultOkTitle : resultBadTitle }}</text>
<text class="result-desc" v-if="quizResult.answer">{{ quizResult.answer }}</text>
</view>
</view>
<view class="quiz-actions">
<view v-if="!quizResult.correct" class="quiz-retry" @click="retryQuiz">
<text>再试一次</text>
</view>
<view class="quiz-next" @click="goChat">
<text>还有疑问?问维度精灵</text>
</view>
</view>
</template>
</view>
<!-- 底部 AI 导师入口 -->
<view class="ai-banner" @click="goChat">
<AppIcon name="bulb-white" :size="16" />
<text class="ai-text">读完想深入聊聊?问维度精灵</text>
<AppIcon name="arrow-white" :size="12" />
</view>
<view class="bottom-spacer"></view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { knowledgeApi } from '@/utils/api' import { knowledgeApi } from '@/utils/api'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { DIMENSIONS } from '@/utils/dimensions' import { DIMENSIONS } from '@/utils/dimensions'
import AppIcon from '@/components/AppIcon.vue'
const RESULT_OK_TITLE = '回答正确,星球已点亮'
const RESULT_OK_GUEST = '回答正确!登录后可永久记录点亮'
const RESULT_BAD_TITLE = '差一点点,再想想'
const detailId = ref('') const detailId = ref('')
const detail = ref(null) const detail = ref(null)
const isCollected = ref(false) const isCollected = ref(false)
const selectedOption = ref(null) const selectedOption = ref(null)
const quizResult = ref(null)
const userStore = useUserStore() const userStore = useUserStore()
// 从路由参数获取 ID const optionLetters = ['A', 'B', 'C', 'D', 'E']
// 正文按段落渲染(兼容全部存量数据:content 字符串)
const paragraphs = computed(() => {
const raw = detail.value?.content
if (!raw) return []
return String(raw)
.split(/\n+/)
.map(s => s.trim())
.filter(Boolean)
})
const readMinutes = computed(() => {
const len = detail.value?.content?.length || 0
return len ? Math.max(1, Math.ceil(len / 300)) : 0
})
const dimInfo = computed(() => {
const dim = detail.value?.dim
return DIMENSIONS.find(d => d.id === dim) || DIMENSIONS[0]
})
const viewCountText = computed(() => {
const count = detail.value?.viewCount || 0
return count >= 10000 ? (count / 10000).toFixed(1) + '万' : String(count)
})
const collectionCount = computed(() => detail.value?.collectionCount || 0)
const resultOkTitle = computed(() =>
quizResult.value?.lit ? RESULT_OK_TITLE : RESULT_OK_GUEST
)
const resultBadTitle = RESULT_BAD_TITLE
onMounted(() => { onMounted(() => {
const pages = getCurrentPages() const pages = getCurrentPages()
const currentPage = pages[pages.length - 1] const currentPage = pages[pages.length - 1]
const query = currentPage.$page?.query || {} const query = currentPage.$page?.query || currentPage.options || {}
detailId.value = query.id || '' detailId.value = query.id || ''
if (detailId.value) { if (detailId.value) {
@@ -118,41 +179,19 @@ onMounted(() => {
} }
}) })
// 维度信息
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() { async function fetchDetail() {
try { try {
detail.value = await knowledgeApi.detail(detailId.value) detail.value = await knowledgeApi.detail(detailId.value)
// 检查是否已收藏
// 记录"继续探索"
try {
uni.setStorageSync('last_read', {
id: detailId.value,
title: detail.value.title || '',
dim: detail.value.dim
})
} catch (e) {}
if (userStore.isLoggedIn) { if (userStore.isLoggedIn) {
const collected = await knowledgeApi.collected(userStore.token) const collected = await knowledgeApi.collected(userStore.token)
isCollected.value = collected.some(item => item.id === detailId.value) isCollected.value = collected.some(item => item.id === detailId.value)
@@ -163,9 +202,33 @@ async function fetchDetail() {
} }
} }
async function submitQuiz() {
if (selectedOption.value === null || !detailId.value) return
try {
const res = await knowledgeApi.quiz(
detailId.value,
selectedOption.value,
userStore.isLoggedIn ? userStore.token : ''
)
quizResult.value = res
if (res.correct) {
uni.setStorageSync('lit_today', new Date().toDateString())
if (res.lit) {
uni.showToast({ title: '星球已点亮', icon: 'success' })
}
}
} catch (err) {
uni.showToast({ title: err.message || '提交失败', icon: 'none' })
}
}
function retryQuiz() {
selectedOption.value = null
quizResult.value = null
}
async function toggleCollect() { async function toggleCollect() {
if (!userStore.isLoggedIn) { if (!userStore.isLoggedIn) {
// 未登录:弹窗给用户选择,可取消,不强制跳转
uni.showModal({ uni.showModal({
title: '登录后收藏', title: '登录后收藏',
content: '登录后即可收藏喜欢的 AI 知识', content: '登录后即可收藏喜欢的 AI 知识',
@@ -198,44 +261,45 @@ function goBack() {
uni.navigateBack() uni.navigateBack()
} }
function goToAI() { function goChat() {
uni.switchTab({ url: '/pages/chat/chat' }) uni.switchTab({ url: '/pages/chat/chat' })
} }
// 微信小程序分享
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
onShareAppMessage(() => ({ onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手', title: (detail.value?.title ? detail.value.title + ' · AI 维度' : 'AI 维度 · 知识星球'),
path: '/pages/index/index' path: `/pages/detail/detail?id=${detailId.value}`
}))
onShareTimeline(() => ({
title: detail.value?.title || 'AI 维度 · 知识星球'
})) }))
// #endif // #endif
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.detail-page { .detail-page {
width: 100%; min-height: 100vh;
height: 100vh; padding: 0 20px 30px;
display: flex;
flex-direction: column;
background: var(--bg-primary); background: var(--bg-primary);
} }
// 顶部导航 .status-bar-placeholder { height: 44px; }
.nav-bar { .nav-bar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 50px 20px 12px; padding: 0 0 14px;
flex-shrink: 0;
.nav-back { .nav-back {
width: 30px; width: 32px;
height: 30px; height: 32px;
border-radius: 50%;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 22px;
color: var(--text-primary);
} }
.nav-title { .nav-title {
font-size: 16px; font-size: 16px;
@@ -243,166 +307,140 @@ onShareAppMessage(() => ({
color: var(--text-primary); color: var(--text-primary);
} }
.nav-collect { .nav-collect {
width: 30px; width: 32px;
height: 32px;
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
.icon {
font-size: 18px;
color: var(--text-secondary);
}
.icon-collected {
color: var(--dim5-color);
}
} }
} }
// 知识头部
.detail-header { .detail-header {
padding: 0 20px 16px; padding: 4px 0 14px;
flex-shrink: 0;
.dim-tag { .dim-tag {
display: inline-block; display: inline-block;
padding: 4px 12px; padding: 4px 12px;
border-radius: 12px; border-radius: var(--radius-full);
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 600;
color: white;
margin-bottom: 10px; margin-bottom: 10px;
opacity: 0.9;
.dim-name { color: white; }
} }
.detail-title { .detail-title {
display: block; display: block;
font-size: 20px; font-size: 21px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.4; line-height: 1.45;
margin-bottom: 10px; margin-bottom: 10px;
} }
.meta-row { .meta-row {
display: flex; display: flex;
gap: 16px; gap: 14px;
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-tertiary);
.meta-item { display: flex; align-items: center; } .meta-item {
display: flex;
align-items: center;
gap: 4px;
}
} }
} }
// 标签
.tag-row { .tag-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
padding: 0 20px 16px; padding: 0 0 14px;
flex-shrink: 0;
.tag { .tag {
font-size: 12px; font-size: 11px;
color: var(--dim1-color); color: var(--color-brand);
background: rgba(179, 136, 255, 0.1); background: var(--color-brand-bg);
padding: 4px 10px; padding: 4px 10px;
border-radius: 10px; border-radius: var(--radius-full);
} }
} }
// 内容滚动区 .content-card {
.content-scroll {
flex: 1;
padding: 0 20px;
overflow-y: auto;
padding-bottom: 80px;
.content-section,
.content-block,
.qa-section {
background: var(--glass-bg); background: var(--glass-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: 12px; border-radius: var(--radius-lg);
padding: 16px; padding: 18px 16px;
margin-bottom: 12px; margin-bottom: 14px;
} box-shadow: var(--card-shadow, none);
}
.section-block { .loading-block {
margin-bottom: 16px; text-align: center;
padding: 40px 0;
color: var(--text-muted);
font-size: 13px;
}
&:last-child { margin-bottom: 0; } .para {
.section-title {
display: block; display: block;
font-size: 15px; font-size: 15px;
font-weight: 600;
color: var(--dim1-color);
margin-bottom: 8px;
}
.section-content {
display: block;
font-size: 14px;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.8; line-height: 1.9;
} margin-bottom: 14px;
.section-images { letter-spacing: 0.3px;
&:last-child { margin-bottom: 0; }
}
.para-lead {
color: var(--text-primary);
}
/* 检测题 */
.quiz-card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 16px;
margin-bottom: 14px;
box-shadow: var(--card-shadow, none);
}
.quiz-head {
display: flex; display: flex;
flex-wrap: wrap; align-items: center;
gap: 8px; gap: 6px;
margin-top: 10px; margin-bottom: 10px;
.section-img { .quiz-title {
width: 100%; font-size: 13px;
height: 200px; font-weight: 700;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
}
}
}
.content-text {
font-size: 14px;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.8;
} }
} }
// 问答区域 .quiz-question {
.qa-section {
.qa-question {
.qa-label {
display: block;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.qa-text {
display: block; display: block;
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.6; line-height: 1.6;
} margin-bottom: 12px;
} }
.qa-options { .quiz-option {
display: flex;
flex-direction: column;
gap: 8px;
.qa-option {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 12px; padding: 12px;
background: var(--glass-bg); background: var(--bg-primary);
border: 1px solid var(--glass-border); border: 1.5px solid var(--glass-border);
border-radius: 10px; border-radius: var(--radius-md);
transition: all 0.3s; margin-bottom: 8px;
transition: all 0.2s ease;
&.correct { &.selected {
background: rgba(34, 197, 94, 0.15); border-color: var(--color-brand);
border-color: rgba(34, 197, 94, 0.4); background: var(--color-brand-bg);
} }
.option-letter { .option-letter {
@@ -411,58 +449,117 @@ onShareAppMessage(() => ({
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(255, 255, 255, 0.1); background: var(--bg-tertiary);
border-radius: 50%; border-radius: 50%;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 700;
color: var(--text-primary); color: var(--text-secondary);
flex-shrink: 0;
} }
.option-text { .option-text {
font-size: 14px; font-size: 14px;
color: var(--text-primary); color: var(--text-primary);
} line-height: 1.5;
}
}
.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 底部入口 .quiz-submit {
margin-top: 6px;
height: 44px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 600;
&.disabled {
opacity: 0.5;
}
}
.quiz-result {
display: flex;
gap: 12px;
padding: 14px;
border-radius: var(--radius-md);
&.ok {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
}
&.bad {
background: rgba(229, 28, 28, 0.07);
border: 1px solid rgba(229, 28, 28, 0.2);
}
.result-icon {
flex-shrink: 0;
margin-top: 2px;
}
.result-title {
display: block;
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.result-desc {
display: block;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
}
}
.quiz-actions {
display: flex;
gap: 10px;
margin-top: 12px;
.quiz-retry {
flex: 1;
height: 42px;
border-radius: var(--radius-md);
border: 1.5px solid var(--glass-border);
background: var(--glass-bg);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.quiz-next {
flex: 1.6;
height: 42px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: #fff;
}
}
/* AI 入口 */
.ai-banner { .ai-banner {
position: fixed;
bottom: 20px;
left: 20px;
right: 20px;
height: 48px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
background: linear-gradient(135deg, var(--dim1-color), var(--dim3-color)); height: 48px;
border-radius: 12px; background: linear-gradient(135deg, var(--color-brand-dark), var(--color-brand));
color: white; border-radius: var(--radius-md);
box-shadow: 0 4px 16px rgba(179, 136, 255, 0.3); color: #fff;
box-shadow: 0 4px 16px var(--color-brand-glow);
.ai-icon { font-size: 18px; } .ai-text { font-size: 14px; font-weight: 600; color: #fff; }
.ai-text { font-size: 14px; font-weight: 600; }
.ai-arrow { font-size: 14px; }
} }
.bottom-spacer { height: 24px; }
</style> </style>
+193 -183
View File
@@ -2,72 +2,96 @@
<view class="page-dim page-layout"> <view class="page-dim page-layout">
<view class="status-bar-placeholder"></view> <view class="status-bar-placeholder"></view>
<view class="nav-row">
<view class="back-btn" @tap="goBack"> <view class="back-btn" @tap="goBack">
<text>← 返回</text> <AppIcon name="back-gray" :size="16" />
<text>返回</text>
</view>
</view> </view>
<!-- 维度横幅 --> <!-- 星域横幅 -->
<view class="dim-banner" :style="{ borderColor: currentDim?.border }"> <view class="dim-banner" :style="{ borderColor: currentDim?.border }">
<text class="dim-banner-icon">{{ currentDim?.icon }}</text> <view class="banner-top">
<view class="dim-icon" :style="{ background: currentDim?.iconBg }">
<AppIcon :name="currentDim?.icon || 'planet'" :size="20" />
</view>
<view class="banner-info">
<text class="dim-banner-title">{{ currentDim?.name }}</text> <text class="dim-banner-title">{{ currentDim?.name }}</text>
<text class="dim-banner-desc">{{ bannerDesc }}</text> <text class="dim-banner-desc">{{ currentDim?.tagline }}</text>
<view class="progress-track">
<view class="progress-fill" :style="{ width: progress + '%' }"></view>
</view> </view>
<text class="progress-label">探索进度 {{ progress }}% · 已完成 {{ readCount }}/{{ totalCount }} 篇</text> <ProgressRing
:size="46"
:num-size="10"
:percent="litPercent"
:color="currentDim?.color"
/>
</view>
<text class="progress-label">{{ litCount }}/{{ totalCount }} 颗星球已点亮 · {{ litPercent }}%</text>
</view> </view>
<!-- 知识列表 --> <!-- 星球列表 -->
<view v-for="item in knowledgeList" :key="item._id" class="knowledge-item" @tap="goDetail(item)"> <view
<view class="k-icon" :style="getItemStyle(item)"> v-for="item in knowledgeList"
<text>{{ item.icon || '📄' }}</text> :key="item._id"
class="planet-item"
@tap="goDetail(item)"
>
<view class="p-icon" :class="{ lit: item.isLit }" :style="item.isLit ? litStyle : null">
<AppIcon :name="item.isLit ? 'star-fill-white' : 'star-gray'" :size="16" />
</view> </view>
<view class="k-info"> <view class="p-info">
<text class="k-title">{{ item.title }}</text> <text class="p-title">{{ item.title }}</text>
<text class="k-desc">{{ item.desc }}</text> <text class="p-desc">{{ item.desc }}</text>
<view class="k-meta"> <view class="p-meta">
<text>📖 {{ item.readTime || 5 }} 分钟</text> <view class="p-lit" v-if="item.isLit" :style="{ color: currentDim?.color, background: currentDim?.bg }">已点亮</view>
<text v-if="item.collected">⭐ 已收藏</text> <text class="p-read">{{ item.readTime }} 分钟</text>
<text v-else>🔖 {{ item.collectCount || 0 }} 人收藏</text> <text class="p-quiz" v-if="item.hasQuiz">含检测题</text>
<text class="k-badge" :class="item.isPremium ? 'pro' : 'free'">
{{ item.isPremium ? 'Pro' : '免费' }}
</text>
</view> </view>
</view> </view>
<AppIcon name="arrow-gray" :size="13" />
</view>
<!-- 空态 -->
<view v-if="!loading && !knowledgeList.length" class="empty-state">
<AppIcon name="planet-gray" :size="40" />
<text class="empty-title">这片星域还没有星球</text>
<text class="empty-desc">内容正在扩充中,先去别的星域逛逛</text>
</view> </view>
<!-- 加载状态 -->
<view v-if="loading" class="loading-state"> <view v-if="loading" class="loading-state">
<text>加载中...</text> <text>加载中...</text>
</view> </view>
<view class="bottom-spacer"></view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { getDimension } from '@/utils/dimensions' import { getDimension } from '@/utils/dimensions'
import { knowledgeApi } from '@/utils/api' import { knowledgeApi } from '@/utils/api'
import { useUserStore } from '@/stores/user'
import AppIcon from '@/components/AppIcon.vue'
import ProgressRing from '@/components/ProgressRing.vue'
const userStore = useUserStore()
const dimId = ref(1) const dimId = ref(1)
const currentDim = computed(() => getDimension(dimId.value)) const currentDim = computed(() => getDimension(dimId.value))
const knowledgeList = ref([]) const knowledgeList = ref([])
const loading = ref(false) const loading = ref(false)
const progress = ref(35) const litIds = ref([])
const readCount = ref(4)
const totalCount = ref(12)
const bannerDesc = computed(() => { const totalCount = computed(() => knowledgeList.value.length)
const descs = { const litCount = computed(() => knowledgeList.value.filter(i => i.isLit).length)
1: '从 1950 年图灵测试到 2026 年通用人工智能,追溯 70 年演进之路', const litPercent = computed(() =>
2: '从深度学习到 Transformer,从 MoE 到多模态,理解每一次技术跃迁', totalCount.value ? Math.round((litCount.value / totalCount.value) * 100) : 0
3: '大模型格局、Agent 生态、具身智能——2026 年 AI 世界全景图', )
4: '提示词工程、RAG 系统、模型微调、工具链——从入门到上手',
5: '每日追踪 AI 前沿动态,不错过每一个重要时刻' const litStyle = computed(() => ({
} background: currentDim.value?.iconBg,
return descs[dimId.value] || '探索人工智能的精彩世界' borderColor: currentDim.value?.border
}) }))
onMounted(() => { onMounted(() => {
const pages = getCurrentPages() const pages = getCurrentPages()
@@ -81,97 +105,42 @@ onMounted(() => {
async function loadKnowledge() { async function loadKnowledge() {
loading.value = true loading.value = true
try { try {
const data = await knowledgeApi.list({ dim: dimId.value }) const [data, stats] = await Promise.all([
knowledgeApi.list({ dim: dimId.value, limit: 100 }),
knowledgeApi.stats(userStore.isLoggedIn ? userStore.token : '').catch(() => null)
])
litIds.value = stats?.user?.litIds || []
knowledgeList.value = (data.items || []).map(item => ({ knowledgeList.value = (data.items || []).map(item => ({
...item, ...item,
icon: getDimIcon(item.dim), desc: item.content ? item.content.substring(0, 42) + '...' : '',
desc: item.content?.substring(0, 40) + '...' || '', readTime: Math.max(1, Math.ceil((item.content?.length || 300) / 300)),
readTime: Math.ceil((item.content?.length || 300) / 200), hasQuiz: !!(item.question && (item.options || []).length),
isPremium: false, isLit: litIds.value.includes(String(item._id))
collected: false,
collectCount: item.collectionCount
})) }))
} catch (e) { } catch (e) {
// 用示例数据填充 knowledgeList.value = []
knowledgeList.value = getSampleData(dimId.value) uni.showToast({ title: '加载失败,请下拉重试', icon: 'none' })
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function getItemStyle(item) {
const colors = [
{ bg: 'rgba(124,77,255,0.1)', color: '#b388ff' },
{ bg: 'rgba(0,188,212,0.1)', color: '#4dd0e1' },
{ bg: 'rgba(0,200,150,0.1)', color: '#4db6ac' },
{ bg: 'rgba(255,193,7,0.1)', color: '#ffd54f' },
{ bg: 'rgba(239,83,80,0.1)', color: '#ef5350' }
]
const c = colors[(item.dim || 1) - 1] || colors[0]
return {
background: c.bg,
color: c.color,
borderColor: c.bg
}
}
function getDimIcon(dim) {
const icons = ['📜', '🏛', '♟', '🧠', '⚡']
return icons[(dim || 1) - 1]
}
function getSampleData(dim) {
const samples = {
1: [
{ title: '图灵测试:人工智能的哲学起点', content: '1950 年,艾伦·图灵提出"机器能思考吗?"' },
{ title: '达特茅斯会议:AI 的诞生时刻', content: '1956 年,一群科学家正式命名了"人工智能"' },
{ title: '深蓝 vs 卡斯帕罗夫', content: '1997 年,IBM 深蓝首次击败国际象棋世界冠军' },
],
2: [
{ title: '深度学习革命', content: '从神经网络到深度学习的演进之路' },
{ title: 'Transformer 架构详解', content: '2017 年 Google 提出的革命性架构' },
],
3: [
{ title: '2026 年大模型格局', content: 'GPT、Claude、Gemini、DeepSeek 全面对比' },
{ title: 'Agent 智能体生态', content: '从 Chatbot 到自主 Agent 的进化' },
],
4: [
{ title: '提示词工程入门', content: '如何写出高质量的 prompt' },
{ title: 'RAG 系统搭建指南', content: '检索增强生成从零到一' },
],
5: [
{ title: 'DeepSeek 自研推理芯片', content: '降低对英伟达依赖的重要一步' },
{ title: 'WAIC 2026 即将开幕', content: '7 月 17 日上海,1100+ 企业参展' },
]
}
const data = samples[dim] || samples[1]
return data.map((item, i) => ({
...item,
_id: `sample-${dim}-${i}`,
dim,
icon: getDimIcon(dim),
readTime: 5 + i * 2,
isPremium: i >= 3,
collected: i === 0,
collectionCount: 10 - i * 2
}))
}
function goBack() { function goBack() {
uni.navigateBack() uni.navigateBack()
} }
function goDetail(item) { function goDetail(item) {
uni.navigateTo({ uni.navigateTo({ url: `/pages/detail/detail?id=${item._id}` })
url: `/pages/detail/detail?id=${item._id}`
})
} }
// 微信小程序分享
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
onShareAppMessage(() => ({ onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手', title: (currentDim.value?.name || 'AI 维度') + '星域 · 每颗星球都是一则 AI 知识',
path: '/pages/index/index' path: `/pages/dimension/dimension?id=${dimId.value}`
}))
onShareTimeline(() => ({
title: (currentDim.value?.name || 'AI 维度') + '星域 · AI 维度'
})) }))
// #endif // #endif
</script> </script>
@@ -185,107 +154,148 @@ onShareAppMessage(() => ({
.status-bar-placeholder { height: 44px; } .status-bar-placeholder { height: 44px; }
.nav-row { padding-bottom: 12px; }
.back-btn { .back-btn {
color: rgba(255,255,255,0.4); display: inline-flex;
font-size: 14px;
display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 4px;
padding: 10px 0 14px; padding: 8px 14px 8px 10px;
border-radius: var(--radius-full);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
font-size: 13px;
color: var(--text-secondary);
} }
.dim-banner { .dim-banner {
border-radius: 18px; border-radius: var(--radius-lg);
padding: 24px 20px; padding: 18px 16px;
margin-bottom: 20px; margin-bottom: 16px;
background: linear-gradient(135deg, rgba(124,77,255,0.1), rgba(0,0,0,0.3)); background: var(--glass-bg);
border: 1px solid rgba(124,77,255,0.12); border: 1.5px solid var(--glass-border);
position: relative; box-shadow: var(--card-shadow, none);
overflow: hidden;
}
.dim-banner-icon { font-size: 28px; display: block; margin-bottom: 8px; }
.dim-banner-title { font-size: 22px; color: var(--text-primary); display: block; margin-bottom: 6px; }
.dim-banner-desc {
font-size: 12px;
color: rgba(255,255,255,0.4);
line-height: 1.6;
display: block;
} }
.progress-track { .banner-top {
height: 3px;
background: var(--glass-bg);
border-radius: 2px;
margin-top: 16px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #b388ff, #7c4dff);
border-radius: 2px;
}
.progress-label {
font-size: 11px;
color: rgba(255,255,255,0.2);
margin-top: 8px;
display: block;
}
.knowledge-item {
border-radius: 14px;
padding: 16px;
margin-bottom: 10px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
display: flex; display: flex;
gap: 14px; align-items: center;
align-items: flex-start; gap: 12px;
margin-bottom: 12px;
} }
.dim-icon {
.k-icon {
width: 44px; height: 44px; width: 44px; height: 44px;
border-radius: 12px; border-radius: 14px;
flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 18px; flex-shrink: 0;
border: 1px solid var(--glass-border);
} }
.k-info { flex: 1; } .banner-info { flex: 1; min-width: 0; }
.k-title { .dim-banner-title {
font-size: 14px;
color: rgba(255,255,255,0.85);
font-weight: 500;
display: block; display: block;
margin-bottom: 4px; font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 3px;
} }
.k-desc { .dim-banner-desc {
font-size: 11px; font-size: 12px;
color: rgba(255,255,255,0.3); color: var(--text-tertiary);
line-height: 1.5; line-height: 1.5;
margin-bottom: 8px;
display: block;
} }
.k-meta { .progress-label {
font-size: 11px;
color: var(--text-tertiary);
}
.planet-item {
display: flex; display: flex;
align-items: center;
gap: 12px; gap: 12px;
border-radius: var(--radius-lg);
padding: 14px;
margin-bottom: 10px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
box-shadow: var(--card-shadow, none);
transition: transform 0.2s ease;
}
.planet-item:active { transform: scale(0.98); }
.p-icon {
width: 40px; height: 40px;
border-radius: 13px;
border: 1.5px dashed var(--glass-border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.lit { border-style: solid; }
}
.p-info { flex: 1; min-width: 0; }
.p-title {
display: block;
font-size: 14px;
color: var(--text-primary);
font-weight: 600;
line-height: 1.5;
margin-bottom: 3px;
}
.p-desc {
display: block;
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.5;
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.p-meta {
display: flex;
gap: 8px;
align-items: center; align-items: center;
font-size: 10px; font-size: 10px;
color: rgba(255,255,255,0.15); color: var(--text-muted);
} }
.k-badge { .p-lit {
font-size: 10px; font-weight: 600;
padding: 2px 10px; padding: 2px 8px;
border-radius: 6px; border-radius: var(--radius-full);
font-weight: 500; }
.p-quiz {
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--bg-tertiary);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 50px 0 30px;
gap: 8px;
.empty-title {
font-size: 15px;
font-weight: 600;
color: var(--text-secondary);
margin-top: 8px;
}
.empty-desc {
font-size: 12px;
color: var(--text-muted);
}
} }
.k-badge.free { background: rgba(76,175,80,0.12); color: #66bb6a; }
.k-badge.pro { background: rgba(255,152,0,0.12); color: #ffa726; }
.loading-state { .loading-state {
text-align: center; text-align: center;
padding: 40px 0; padding: 40px 0;
color: rgba(255,255,255,0.2); color: var(--text-muted);
font-size: 13px;
} }
.bottom-spacer { height: 20px; }
</style> </style>
+1 -1
View File
@@ -34,7 +34,7 @@
<!-- 空状态 --> <!-- 空状态 -->
<view class="empty-state" v-else> <view class="empty-state" v-else>
<text class="empty-icon">⭐</text> <text class="empty-icon">还没有收藏</text>
<text class="empty-text" v-if="userStore.isLoggedIn">你还没有收藏任何内容</text> <text class="empty-text" v-if="userStore.isLoggedIn">你还没有收藏任何内容</text>
<text class="empty-text" v-else>登录后可收藏喜欢的 AI 知识</text> <text class="empty-text" v-else>登录后可收藏喜欢的 AI 知识</text>
<text class="empty-sub" v-if="userStore.isLoggedIn">去首页发现有趣的 AI 知识吧</text> <text class="empty-sub" v-if="userStore.isLoggedIn">去首页发现有趣的 AI 知识吧</text>
+2 -2
View File
@@ -112,7 +112,7 @@ const userStore = useUserStore()
// 反馈类型 // 反馈类型
const feedbackTypes = [ const feedbackTypes = [
{ value: 'bug', name: '问题报告', icon: '🐛' }, { value: 'bug', name: '问题报告', icon: '🐛' },
{ value: 'feature', name: '功能建议', icon: '💡' }, { value: 'feature', name: '功能建议', icon: '★' },
{ value: 'content', name: '内容纠错', icon: '📝' }, { value: 'content', name: '内容纠错', icon: '📝' },
{ value: 'other', name: '其他', icon: '💬' } { value: 'other', name: '其他', icon: '💬' }
] ]
@@ -147,7 +147,7 @@ function statusLabel(status) {
function typeLabel(type) { function typeLabel(type) {
const map = { const map = {
bug: '🐛 问题报告', bug: '🐛 问题报告',
feature: '💡 功能建议', feature: '功能建议',
content: '📝 内容纠错', content: '📝 内容纠错',
other: '💬 其他' other: '💬 其他'
} }
+253 -185
View File
@@ -1,75 +1,93 @@
<template> <template>
<view class="page-home page-layout"> <view class="page-home page-layout">
<!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view> <view class="status-bar-placeholder"></view>
<!-- 顶部导航 --> <!-- 顶部导航 -->
<view class="header"> <view class="header">
<view class="brand"> <view class="brand">
<view class="brand-icon">✦</view> <view class="brand-icon">
<AppIcon name="planet-white" :size="20" />
</view>
<view class="brand-text"> <view class="brand-text">
<text class="title">AI 维度</text> <text class="title">AI 维度</text>
<text class="subtitle">宇之然 · 探索</text> <text class="subtitle">宇之然 · 知识星图</text>
</view> </view>
</view> </view>
<view class="header-actions"> <view class="header-actions">
<view class="icon-btn" @tap="onNotify">
<text>🔔</text>
</view>
<view class="icon-btn" @tap="goProfile"> <view class="icon-btn" @tap="goProfile">
<text>👤</text> <AppIcon name="user-gray" :size="18" />
</view> </view>
</view> </view>
</view> </view>
<!-- 问候语 --> <!-- 连续点亮 streak -->
<view class="greeting"> <view class="streak-bar" v-if="streak > 0" @tap="goFirstUnlit">
<text class="greeting-title">探索 </text> <AppIcon name="flame" :size="16" />
<text class="greeting-highlight">人工智能</text> <text class="streak-text">已连续点亮 {{ streak }} 天</text>
<text class="greeting-desc">从 5 个维度,系统理解 AI 的过去、现在与未来</text> <text class="streak-sub">{{ todayLit ? '今日已点亮,继续保持' : '今日还没点亮,去逛逛' }}</text>
<view class="streak-go">
<AppIcon name="arrow-white" :size="12" />
</view>
</view>
<!-- 继续探索 -->
<view class="resume-card" v-if="lastRead" @tap="resumeRead">
<view class="resume-icon">
<AppIcon name="clock" :size="18" />
</view>
<view class="resume-info">
<text class="resume-label">继续探索</text>
<text class="resume-title">{{ lastRead.title }}</text>
</view>
<AppIcon name="arrow-gray" :size="14" />
</view> </view>
<!-- 搜索栏 --> <!-- 搜索栏 -->
<view class="search-bar" @tap="onSearch"> <view class="search-bar" @tap="onSearch">
<text class="search-icon">🔍</text> <AppIcon name="search-gray" :size="16" />
<text class="search-placeholder">搜搜你想了解的 AI 知识...</text> <text class="search-placeholder">搜搜你想了解的 AI 知识...</text>
</view> </view>
<!-- 五维度网格 --> <!-- 五大星域 -->
<view class="section-header"> <view class="section-header">
<text class="section-title">✦ 五个维度</text> <text class="section-title">五大星域</text>
<text class="section-more" v-if="userStats">{{ userStats.totalLit }}/{{ statsTotal }} 颗已点亮</text>
</view> </view>
<view class="dim-grid"> <view class="dim-grid">
<view <view
v-for="dim in dimensions" v-for="dim in dimensions"
:key="dim.id" :key="dim.id"
class="dim-card" class="dim-card"
:class="{ 'dim-full': dim.isFull }"
:style="{ background: dim.bg }"
@tap="goDimension(dim)" @tap="goDimension(dim)"
> >
<view class="dim-icon" :style="{ background: dim.iconBg, color: dim.color }"> <view class="dim-top">
<text>{{ dim.icon }}</text> <view class="dim-icon" :style="{ background: dim.iconBg }">
<AppIcon :name="dim.icon" :size="18" />
</view>
<ProgressRing
:size="34"
:num-size="9"
:percent="dimPercent(dim.id)"
:color="dim.color"
/>
</view>
<text class="dim-name">{{ dim.name }}</text>
<text class="dim-desc">{{ dim.tagline }}</text>
<view class="dim-foot">
<view class="dim-lit" :style="{ background: dim.bg, color: dim.color }">
{{ dimLitText(dim.id) }}
</view>
<text class="dim-count">{{ dimCountText(dim.id) }}</text>
</view> </view>
<text class="dim-name" :style="{ color: dim.color }">{{ dim.name }}</text>
<text class="dim-desc">{{ dim.desc }}</text>
<text class="dim-count">
{{ dim.isFull ? '今日已更新 8 条' : `${dim.articleCount} 篇 · ${dim.quizCount} 个问答` }}
</text>
</view> </view>
</view> </view>
<!-- 今日快讯 --> <!-- 今日快讯 -->
<view class="section-header"> <view class="section-header">
<text class="section-title">📡 今日快讯</text> <text class="section-title">今日快讯</text>
<text class="section-more" @tap="goTrend">查看全部 →</text> <text class="section-more" @tap="goTrend">查看全部</text>
</view> </view>
<view <view v-for="(item, idx) in newsList" :key="idx" class="news-card" @tap="goNewsDetail(item)">
v-for="(item, idx) in newsList"
:key="idx"
class="news-card"
@tap="goNewsDetail(item)"
>
<view class="news-tag" :class="item.tagClass">{{ item.tag }}</view> <view class="news-tag" :class="item.tagClass">{{ item.tag }}</view>
<text class="news-title">{{ item.title }}</text> <text class="news-title">{{ item.title }}</text>
<view class="news-meta"> <view class="news-meta">
@@ -83,34 +101,56 @@
</template> </template>
<script setup> <script setup>
import { onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onPullDownRefresh, onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'
import { ref, onMounted } from 'vue'
import { DIMENSIONS } from '@/utils/dimensions' import { DIMENSIONS } from '@/utils/dimensions'
import { trendApi } from '@/utils/api' import { knowledgeApi, trendApi } from '@/utils/api'
import { useUserStore } from '@/stores/user'
import AppIcon from '@/components/AppIcon.vue'
import ProgressRing from '@/components/ProgressRing.vue'
const userStore = useUserStore()
const dimensions = ref(DIMENSIONS) const dimensions = ref(DIMENSIONS)
const newsList = ref([]) const newsList = ref([])
const loading = ref(true) const byDim = ref({}) // 各星域知识总数
const userStats = ref(null) // { totalLit, litByDim, streak }
const lastRead = ref(null)
const TAG_HOT = '热门'
const TAG_PICK = '精选'
const TAG_NEW = '最新'
const statsTotal = computed(() => Object.values(byDim.value).reduce((a, b) => a + b, 0))
const streak = computed(() => userStats.value?.streak || 0)
const todayLit = computed(() => !!uni.getStorageSync('lit_today'))
onShow(() => {
loadStats()
loadNews()
lastRead.value = uni.getStorageSync('last_read') || null
})
onPullDownRefresh(async () => { onPullDownRefresh(async () => {
try { await Promise.all([loadStats(), loadNews()])
const data = await trendApi.list({ limit: 5 })
// 页面已有响应式引用会自动更新(保持既有逻辑)
} catch (e) {}
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
}) })
onMounted(() => { async function loadStats() {
fetchNews()
})
async function fetchNews() {
try { try {
const data = await trendApi.list({ limit: 5 }) const data = await knowledgeApi.stats(userStore.isLoggedIn ? userStore.token : '')
newsList.value = data.list.map(item => ({ byDim.value = data.byDim || {}
tag: item.hot ? '🔥 热门' : item.picked ? '📌 精选' : '🚀 最新', userStats.value = data.user || null
} catch (e) {
// 静默降级:环形显示 0%
}
}
async function loadNews() {
try {
const data = await trendApi.list({ limit: 3 })
newsList.value = (data.list || []).slice(0, 3).map(item => ({
tag: item.hot ? TAG_HOT : item.picked ? TAG_PICK : TAG_NEW,
tagClass: item.hot ? 'hot' : item.picked ? 'pick' : 'new', tagClass: item.hot ? 'hot' : item.picked ? 'pick' : 'new',
title: item.title, title: item.title,
source: item.source, source: item.source,
@@ -118,17 +158,27 @@ async function fetchNews() {
_id: item._id _id: item._id
})) }))
} catch (e) { } catch (e) {
// 降级:本地数据 newsList.value = []
newsList.value = [
{ tag: '🔥 热门', tagClass: 'hot', title: 'DeepSeek 被曝自研推理芯片', source: '量子位', time: '2 小时前' },
{ tag: '🚀 最新', tagClass: 'new', title: '蚂蚁灵波开源 LingBot-World 2.0', source: '量子位', time: '4 小时前' },
{ tag: '📌 精选', tagClass: 'pick', title: '阿里获 ACL 2026 最佳资源论文奖', source: '机器之心', time: '6 小时前' }
]
} finally {
loading.value = false
} }
} }
function dimPercent(dimId) {
const total = byDim.value[dimId] || 0
if (!total) return 0
const lit = userStats.value?.litByDim?.[dimId] || 0
return Math.round((lit / total) * 100)
}
function dimLitText(dimId) {
const lit = userStats.value?.litByDim?.[dimId] || 0
return `${lit} 颗已点亮`
}
function dimCountText(dimId) {
const total = byDim.value[dimId] || 0
return `共 ${total} 颗星球`
}
function formatTime(dateStr) { function formatTime(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime() const diff = Date.now() - new Date(dateStr).getTime()
const hours = Math.floor(diff / 3600000) const hours = Math.floor(diff / 3600000)
@@ -141,6 +191,16 @@ function goDimension(dim) {
uni.navigateTo({ url: `/pages/dimension/dimension?id=${dim.id}&name=${dim.name}` }) uni.navigateTo({ url: `/pages/dimension/dimension?id=${dim.id}&name=${dim.name}` })
} }
function goFirstUnlit() {
uni.navigateTo({ url: '/pages/dimension/dimension?id=1' })
}
function resumeRead() {
if (lastRead.value?.id) {
uni.navigateTo({ url: `/pages/detail/detail?id=${lastRead.value.id}` })
}
}
function goTrend() { function goTrend() {
uni.switchTab({ url: '/pages/trend/trend' }) uni.switchTab({ url: '/pages/trend/trend' })
} }
@@ -153,21 +213,19 @@ function onSearch() {
uni.navigateTo({ url: '/pages/search/search' }) uni.navigateTo({ url: '/pages/search/search' })
} }
function onNotify() {
uni.showToast({ title: '暂无新消息', icon: 'none' })
}
function goNewsDetail(item) { function goNewsDetail(item) {
uni.setStorageSync('__news_item__', JSON.stringify(item)) uni.setStorageSync('__news_item__', JSON.stringify(item))
uni.navigateTo({ url: '/pages/news/news' }) uni.navigateTo({ url: '/pages/news/news' })
} }
// 微信小程序分享
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
onShareAppMessage(() => ({ onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手', title: 'AI 维度 · 5 分钟点亮一颗 AI 知识星球',
path: '/pages/index/index' path: '/pages/index/index'
})) }))
onShareTimeline(() => ({
title: 'AI 维度 · 5 分钟点亮一颗 AI 知识星球'
}))
// #endif // #endif
</script> </script>
@@ -178,15 +236,13 @@ onShareAppMessage(() => ({
background: var(--bg-primary); background: var(--bg-primary);
} }
.status-bar-placeholder { .status-bar-placeholder { height: 44px; }
height: 44px;
}
.header { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 6px 0 20px; padding: 6px 0 18px;
} }
.brand { .brand {
@@ -196,38 +252,32 @@ onShareAppMessage(() => ({
} }
.brand-icon { .brand-icon {
width: 36px; width: 38px;
height: 36px; height: 38px;
border-radius: 10px; border-radius: 12px;
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(0,188,212,0.2)); background: linear-gradient(135deg, var(--color-brand), #8b5cf6);
border: 1px solid rgba(124,77,255,0.2);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 18px; box-shadow: 0 4px 14px var(--color-brand-glow);
box-shadow: 0 0 20px rgba(124,77,255,0.1);
} }
.brand-text { .brand-text { display: flex; flex-direction: column; }
display: flex;
flex-direction: column;
}
.brand-text .title { .brand-text .title {
font-size: 20px; font-size: 20px;
color: var(--text-primary); color: var(--text-primary);
font-weight: 700; font-weight: 700;
letter-spacing: 1px; letter-spacing: 1px;
line-height: 1.2;
} }
.brand-text .subtitle { .brand-text .subtitle {
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-tertiary);
letter-spacing: 2px; letter-spacing: 1.5px;
margin-top: 2px;
} }
.header-actions { .header-actions { display: flex; gap: 10px; }
display: flex;
gap: 10px;
}
.icon-btn { .icon-btn {
width: 36px; height: 36px; width: 36px; height: 36px;
border-radius: 50%; border-radius: 50%;
@@ -236,167 +286,185 @@ onShareAppMessage(() => ({
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 15px;
} }
.greeting { /* streak */
margin-bottom: 20px; .streak-bar {
padding: 24px 20px; display: flex;
border-radius: 18px; align-items: center;
background: linear-gradient(135deg, rgba(99,102,241,0.08), rgba(139,92,246,0.04)); gap: 8px;
border: 1px solid rgba(99,102,241,0.1); padding: 10px 14px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, rgba(229, 28, 28, 0.08), rgba(223, 119, 0, 0.06));
border: 1px solid rgba(229, 28, 28, 0.15);
margin-bottom: 12px;
} }
.greeting-title { .streak-text {
font-size: 26px;
color: var(--text-primary);
font-weight: 700;
}
.greeting-highlight {
font-size: 26px;
font-weight: 700;
background: linear-gradient(135deg, #b388ff, #7c4dff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.greeting-desc {
display: block;
font-size: 13px; font-size: 13px;
font-weight: 700;
color: var(--dim5-color);
}
.streak-sub {
flex: 1;
font-size: 11px;
color: var(--text-tertiary); color: var(--text-tertiary);
margin-top: 4px; }
.streak-go {
width: 22px; height: 22px;
border-radius: 50%;
background: var(--dim5-color);
display: flex;
align-items: center;
justify-content: center;
} }
.search-bar { /* 继续探索 */
.resume-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: var(--radius-lg);
background: var(--glass-bg); background: var(--glass-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: 14px; box-shadow: var(--card-shadow, none);
padding: 12px 16px; margin-bottom: 12px;
}
.resume-icon {
width: 38px; height: 38px;
border-radius: 12px;
background: var(--color-brand-bg);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.resume-info { flex: 1; min-width: 0; }
.resume-label {
font-size: 11px;
color: var(--text-tertiary);
display: block;
margin-bottom: 2px;
}
.resume-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
/* 搜索 */
.search-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
margin-bottom: 24px; padding: 12px 16px;
backdrop-filter: blur(10px); border-radius: var(--radius-full);
-webkit-backdrop-filter: blur(10px); background: var(--glass-bg);
border: 1px solid var(--glass-border);
margin-bottom: 6px;
} }
.search-icon { color: var(--text-muted); } .search-placeholder {
.search-placeholder { color: var(--text-muted); font-size: 14px; } font-size: 13px;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
padding: 8px 12px;
border-radius: 10px;
background: rgba(99,102,241,0.04);
}
.section-title {
font-size: 17px;
color: var(--text-primary);
font-weight: 600;
letter-spacing: 0.5px;
}
.section-more {
font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
} }
/* 星域网格 */
.dim-grid { .dim-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 12px; gap: 12px;
margin-bottom: 28px;
} }
.dim-card { .dim-card {
border-radius: 18px; background: var(--glass-bg);
padding: 20px 16px;
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
backdrop-filter: blur(10px); border-radius: var(--radius-lg);
-webkit-backdrop-filter: blur(10px); padding: 14px;
} box-shadow: var(--card-shadow, none);
.dim-full { transition: transform 0.2s ease;
grid-column: 1 / -1;
} }
.dim-card:active { transform: scale(0.97); }
.dim-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.dim-icon { .dim-icon {
width: 42px; height: 42px; width: 34px; height: 34px;
border-radius: 12px; border-radius: 11px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 20px;
margin-bottom: 14px;
} }
.dim-name { .dim-name {
font-size: 15px;
font-weight: 600;
margin-bottom: 4px;
letter-spacing: 0.5px;
display: block; display: block;
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 3px;
} }
.dim-desc { .dim-desc {
font-size: 12px; display: block;
font-size: 11px;
color: var(--text-tertiary); color: var(--text-tertiary);
line-height: 1.5; line-height: 1.5;
display: block; margin-bottom: 10px;
}
.dim-foot {
display: flex;
align-items: center;
justify-content: space-between;
}
.dim-lit {
font-size: 10px;
font-weight: 600;
padding: 3px 8px;
border-radius: var(--radius-full);
} }
.dim-count { .dim-count {
font-size: 11px; font-size: 10px;
color: var(--text-muted); color: var(--text-muted);
margin-top: 10px;
display: block;
} }
/* 快讯 */
.news-card { .news-card {
border-radius: 14px;
padding: 14px 16px;
margin-bottom: 10px;
background: var(--glass-bg); background: var(--glass-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
backdrop-filter: blur(10px); border-radius: var(--radius-md);
-webkit-backdrop-filter: blur(10px); padding: 14px 16px;
margin-bottom: 10px;
box-shadow: var(--card-shadow, none);
} }
.news-tag { .news-tag {
display: inline-block; display: inline-block;
font-size: 10px; font-size: 10px;
padding: 2px 10px; font-weight: 600;
border-radius: 6px; padding: 2px 8px;
border-radius: var(--radius-full);
margin-bottom: 8px; margin-bottom: 8px;
font-weight: 500;
} }
.news-tag.hot { .news-tag.hot { background: var(--color-hot-bg); color: var(--color-hot); }
background: rgba(239,83,80,0.12); .news-tag.new { background: var(--color-new-bg); color: var(--color-new); }
color: #ef5350; .news-tag.pick { background: var(--color-pick-bg); color: var(--color-pick); }
}
.news-tag.new {
background: rgba(0,188,212,0.12);
color: #4dd0e1;
}
.news-tag.pick {
background: rgba(124,77,255,0.12);
color: #b388ff;
}
.news-title { .news-title {
display: block;
font-size: 14px; font-size: 14px;
font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.5; line-height: 1.5;
margin-bottom: 8px; margin-bottom: 8px;
font-weight: 500;
display: block;
} }
.news-meta { .news-meta {
display: flex; display: flex;
gap: 16px; justify-content: space-between;
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
} }
.bottom-spacer { .bottom-spacer { height: 30px; }
height: 40px;
}
</style> </style>
-106
View File
@@ -1,106 +0,0 @@
<template>
<view class="map-page">
<view class="status-bar"></view>
<view class="top-bar">
<view class="user-info">
<text class="lv-icon">{{ level.icon }}</text>
<text class="lv-name">{{ level.name }} Lv{{ level.level }}</text>
</view>
<view class="coin-box">
<text class="coin-icon">💎</text>
<text class="coin-num">{{ credits }}</text>
</view>
</view>
<scroll-view scroll-y class="map-scroll">
<view class="map-body" :style="{ height: mapHeight + rpx }">
<view class="map-path-line" :style="{ height: mapHeight + rpx }"></view>
<view v-for="(island, i) in islands" :key="i"
class="island-node"
:class="{ active: island.status === current, done: island.status === done, locked: island.status === locked }"
:style="{ top: island.y + rpx, left: island.x + rpx }"
@tap="goIsland(island)">
<view class="island-icon">{{ island.icon }}</view>
<text class="island-name">{{ island.name }}</text>
<view v-if="island.status === done" class="done-star">⭐</view>
<view v-if="island.status === locked" class="lock-icon">🔒</view>
</view>
</view>
</scroll-view>
<view class="bottom-bar">
<button class="ai-btn" :disabled="!aiUnlocked" @tap="goCreate">✨ AI 生成</button>
<text v-if="!aiUnlocked" class="ai-hint">完成一个知识关后解锁</text>
</view>
</view>
</template>
<script>
export default {
data() {
return {
credits: 0,
level: { level: 1, name: "画童", icon: "🖌️" },
islands: [
{ id: 1, name: "AI 起源", icon: "🏛️", x: 100, y: 180, status: "current" },
{ id: 2, name: "发展脉络", icon: "📈", x: 380, y: 360, status: "locked" },
{ id: 3, name: "行业格局", icon: "🌍", x: 100, y: 540, status: "locked" },
{ id: 4, name: "学习工具", icon: "🛠️", x: 380, y: 720, status: "locked" },
{ id: 5, name: "趋势前沿", icon: "🚀", x: 100, y: 900, status: "locked" },
],
aiUnlocked: false,
mapHeight: 1100,
}
},
onShow() { this.loadUser() },
methods: {
loadUser() {
var token = uni.getStorageSync("dim_admin_token")
if (!token) return
uni.request({
url: "/api/me",
header: { Authorization: "Bearer " + token },
success: function(res) {
if (res.data && res.data.user) {
this.credits = res.data.user.credits || 0
}
}.bind(this)
})
},
goIsland(island) {
if (island.status !== "current") {
uni.showToast({ title: "先完成前面的知识关", icon: "none" })
return
}
uni.navigateTo({ url: "/pages/map/quiz?islandId=" + island.id + "&name=" + encodeURIComponent(island.name) })
},
goCreate() {
uni.switchTab({ url: "/pages/index/index" })
}
}
}
</script>
<style>
.map-page { background: linear-gradient(180deg, #1a1a3e 0%, #2d1b69 50%, #0d0d1a 100%); min-height: 100vh; }
.status-bar { height: 80rpx; }
.top-bar { display: flex; justify-content: space-between; align-items: center; padding: 16rpx 30rpx; }
.user-info { display: flex; align-items: center; gap: 8rpx; }
.lv-icon { font-size: 32rpx; }
.lv-name { color: #fff; font-size: 26rpx; font-weight: 600; }
.coin-box { display: flex; align-items: center; gap: 6rpx; background: rgba(255,255,255,0.1); border-radius: 999rpx; padding: 6rpx 20rpx; }
.coin-icon { font-size: 26rpx; }
.coin-num { color: #ffd700; font-size: 26rpx; font-weight: 700; }
.map-scroll { height: calc(100vh - 80rpx - 70rpx - 120rpx); }
.map-body { position: relative; width: 100%; }
.map-path-line { position: absolute; left: 50%; top: 100rpx; width: 4rpx; height: 900rpx; background: rgba(255,255,255,0.1); transform: translateX(-50%); }
.island-node { position: absolute; width: 180rpx; text-align: center; }
.island-icon { font-size: 56rpx; display: block; margin-bottom: 8rpx; }
.island-name { color: #fff; font-size: 24rpx; display: block; }
.done-star { position: absolute; top: -10rpx; right: 0; font-size: 28rpx; }
.lock-icon { position: absolute; top: -8rpx; right: -8rpx; font-size: 28rpx; }
.island-node.active .island-icon { animation: bounce 2s infinite; }
@keyframes bounce { 0%,100% { transform: translateY(0) } 50% { transform: translateY(-16rpx) } }
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; padding: 20rpx 30rpx calc(20rpx + env(safe-area-inset-bottom)); background: rgba(0,0,0,0.5); display: flex; flex-direction: column; align-items: center; gap: 8rpx; z-index: 100; }
.ai-btn { width: 100%; background: linear-gradient(135deg, #6366f1, #a855f7); color: #fff; font-size: 32rpx; font-weight: 700; border-radius: 999rpx; padding: 20rpx; border: none; }
.ai-btn[disabled] { opacity: 0.4; }
.ai-hint { color: rgba(255,255,255,0.5); font-size: 22rpx; }
</style>
-89
View File
@@ -1,89 +0,0 @@
<template>
<view class="quiz-page">
<view class="quiz-header">
<text class="quiz-title">{{ islandName }} · 知识关</text>
<text class="quiz-progress">{{ current + 1 }} / {{ questions.length }}</text>
</view>
<view class="quiz-body" v-if="currentQ">
<text class="quiz-q">{{ currentQ.q }}</text>
<view v-for="(opt, i) in currentQ.options" :key="i"
class="quiz-opt"
:class="{ selected: selected === i, correct: submitted && i === currentQ.answer, wrong: submitted && selected === i && i !== currentQ.answer }"
@tap="pick(i)">
<text>{{ labels[i] }}. {{ opt }}</text>
</view>
<view v-if="submitted" class="quiz-result">
<text :class="isCorrect ? 'ok' : 'fail'">{{ resultText }}</text>
</view>
</view>
<view class="quiz-footer">
<button v-if="!submitted && currentQ" class="submit-btn" @tap="submit">提交答案</button>
<button v-if="submitted && current < questions.length - 1" class="next-btn" @tap="nextQ">下一题 →</button>
<button v-if="submitted && current >= questions.length - 1" class="next-btn done-btn" @tap="finish">完成知识关</button>
</view>
</view>
</template>
<script>
export default {
data() {
return {
islandName: '',
current: 0,
selected: -1,
submitted: false,
isCorrect: false,
resultText: '',
labels: ['A', 'B', 'C', 'D'],
questions: [
{ q: 'AI 的英文全称是什么?', options: ['Artificial Intelligence', 'Automatic Integration', 'Advanced Internet', 'Applied Innovation'], answer: 0 },
{ q: '深度学习属于 AI 的哪个领域?', options: ['规则推理', '机器学习', '硬件设计', '网络协议'], answer: 1 },
{ q: '以下哪个是当前主流的大语言模型?', options: ['Photoshop', 'GPT', 'Excel', 'Bluetooth'], answer: 1 },
],
}
},
computed: {
currentQ() { return this.questions[this.current] || null },
isLast() { return this.current >= this.questions.length - 1 },
},
onLoad(options) {
if (options.name) {
this.islandName = decodeURIComponent(options.name)
uni.setNavigationBarTitle({ title: this.islandName + ' · 知识关' })
}
},
methods: {
pick(i) {
if (this.submitted) return
this.selected = i
this.submitted = true
this.isCorrect = this.selected === this.questions[this.current].answer
},
nextQ() {
if (this.current < this.questions.length - 1) {
this.current++
this.selected = -1
this.submitted = false
} else {
uni.showToast({ title: '知识关完成!', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
}
},
}
}
</script>
<style>
.quiz-page { background: linear-gradient(180deg, #1a1a3e, #0d0d1a); min-height: 100vh; padding: 30rpx; box-sizing: border-box; }
.quiz-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30rpx; }
.quiz-title { color: #fff; font-size: 32rpx; font-weight: 700; }
.quiz-progress { color: rgba(255,255,255,0.5); font-size: 24rpx; }
.quiz-q { color: #fff; font-size: 32rpx; font-weight: 600; margin-bottom: 30rpx; display: block; }
.quiz-opt { background: rgba(255,255,255,0.08); border: 2rpx solid rgba(255,255,255,0.15); border-radius: 16rpx; padding: 24rpx 28rpx; margin-bottom: 16rpx; color: #fff; font-size: 28rpx; transition: all 0.2s; }
.quiz-opt.selected { border-color: #6366f1; background: rgba(99,102,241,0.15); }
.quiz-opt.correct { border-color: #22c55e; background: rgba(34,197,94,0.15); }
.quiz-opt.wrong { border-color: #ef4444; background: rgba(239,68,68,0.15); }
.quiz-result { text-align: center; margin-top: 20rpx; font-size: 30rpx; }
.quiz-footer { margin-top: 30rpx; }
.submit-btn { width: 100%; background: linear-gradient(135deg, #6366f1, #a855f7); color: #fff; font-size: 30rpx; font-weight: 700; border-radius: 16rpx; padding: 24rpx; border: none; margin-top: 20rpx; }
</style>
+3 -2
View File
@@ -39,7 +39,7 @@
<text class="meta-value">{{ news.viewCount || 0 }} 次</text> <text class="meta-value">{{ news.viewCount || 0 }} 次</text>
</view> </view>
<view class="meta-item" v-if="news.hot"> <view class="meta-item" v-if="news.hot">
<text class="meta-label hot">🔥</text> <text class="meta-label hot">热</text>
<text class="meta-value">{{ news.hotCount || 0 }}</text> <text class="meta-value">{{ news.hotCount || 0 }}</text>
</view> </view>
</view> </view>
@@ -110,7 +110,8 @@ const heroBg = computed(() => {
}) })
const heroEmoji = computed(() => { const heroEmoji = computed(() => {
return { product: '🚀', paper: '📄', tool: '🔧', company: '🏢' }[news.value.category || 'product'] || '📰' const map = { product: '产品', paper: '论文', tool: '工具', company: '公司' }
return map[news.value.category || 'product'] || '资讯'
}) })
const categoryLabel = computed(() => { const categoryLabel = computed(() => {
+413 -358
View File
@@ -2,7 +2,7 @@
<view class="page-profile page-layout"> <view class="page-profile page-layout">
<view class="status-bar-placeholder"></view> <view class="status-bar-placeholder"></view>
<!-- 用户信息 --> <!-- 学习者档案头 -->
<view class="profile-card"> <view class="profile-card">
<view class="profile-avatar"> <view class="profile-avatar">
<image <image
@@ -11,14 +11,48 @@
class="avatar-img" class="avatar-img"
mode="aspectFill" mode="aspectFill"
/> />
<text v-else class="avatar-text"> <view v-else class="avatar-placeholder">
{{ (userStore.userInfo?.nickName || '用').charAt(0) }} <AppIcon name="planet-white" :size="26" />
</text> </view>
</view>
<view class="profile-main">
<text class="profile-name">{{ userStore.userInfo?.nickName || '探索者' }}</text>
<text class="profile-sub">{{ profileSubText }}</text>
</view>
<view class="streak-chip" v-if="stats && stats.streak > 0">
<AppIcon name="flame" :size="13" />
<text>{{ stats.streak }} 天</text>
</view>
</view>
<!-- 学习数据 -->
<view class="stats-row">
<view class="stat-tile" @click="goProgress">
<text class="stat-num">{{ stats?.totalLit || 0 }}</text>
<text class="stat-label">点亮星球</text>
</view>
<view class="stat-tile">
<text class="stat-num">{{ stats?.streak || 0 }}</text>
<text class="stat-label">连续天数</text>
</view>
<view class="stat-tile" @click="goFavorites">
<text class="stat-num">{{ collectedCount }}</text>
<text class="stat-label">收藏知识</text>
</view>
</view>
<!-- 五星域点亮进度 -->
<view class="dims-progress" v-if="stats">
<view v-for="dim in dimensions" :key="dim.id" class="dim-row" @click="goDim(dim.id)">
<view class="dim-dot" :style="{ background: dim.iconBg }">
<AppIcon :name="dim.icon" :size="13" />
</view>
<text class="dim-name">{{ dim.name }}</text>
<view class="dim-bar">
<view class="dim-bar-fill" :style="{ width: dimPercent(dim.id) + '%', background: dim.color }"></view>
</view>
<text class="dim-frac">{{ stats.litByDim?.[dim.id] || 0 }}/{{ byDim[dim.id] || 0 }}</text>
</view> </view>
<text class="profile-name">{{ userStore.userInfo?.nickName || '游客' }}</text>
<text class="profile-level">
{{ subscriptionBadge }} · 已收藏 {{ collectedCount }} 篇
</text>
</view> </view>
<!-- 订阅卡片 --> <!-- 订阅卡片 -->
@@ -26,14 +60,11 @@
<view class="plan-info"> <view class="plan-info">
<text class="plan-title">{{ planTitle }}</text> <text class="plan-title">{{ planTitle }}</text>
<text class="plan-desc">{{ planDesc }}</text> <text class="plan-desc">{{ planDesc }}</text>
<!-- 进度条(订阅到期倒计时) -->
<view class="plan-progress" v-if="userStore.subInfo"> <view class="plan-progress" v-if="userStore.subInfo">
<view class="progress-track"> <view class="progress-track">
<view class="progress-fill" :style="{ width: planPercent + '%' }"></view> <view class="progress-fill" :style="{ width: planPercent + '%' }"></view>
</view> </view>
<text class="progress-text"> <text class="progress-text">{{ planExpiryText }}</text>
{{ planExpiryText }}
</text>
</view> </view>
</view> </view>
<view class="plan-btn" :class="{ disabled: userStore.isSubscribed }"> <view class="plan-btn" :class="{ disabled: userStore.isSubscribed }">
@@ -42,55 +73,52 @@
</view> </view>
<!-- AI 问答配额 --> <!-- AI 问答配额 -->
<view class="quota-card" @click="goChat" v-if="userStore.isLoggedIn"> <view class="quota-card" @click="goChat" v-if="userStore.isLoggedIn && quotaInfo">
<text class="quota-card-title">🤖 AI 问答配额</text> <view class="quota-head">
<AppIcon name="chat" :size="15" />
<text class="quota-card-title">AI 问答 · 今日</text>
</view>
<view class="quota-bar"> <view class="quota-bar">
<view class="quota-fill" :style="{ width: quotaPercent + '%' }"></view> <view class="quota-fill" :style="{ width: quotaPercent + '%' }"></view>
</view> </view>
<text class="quota-text"> <view class="quota-foot">
{{ quotaUsed }} / {{ quotaTotal }} <text class="quota-text">剩余 {{ quotaInfo.remainingQuota }} 次</text>
<text class="quota-note">今日使用</text> <text class="quota-share" @click.stop="goShare">分享 +5 次</text>
</text> </view>
<text class="quota-share" @click.stop="goShare">🎁 分享得次数</text>
</view> </view>
<!-- 菜单列表 --> <!-- 菜单列表 -->
<view class="menu-section"> <view class="menu-section">
<view class="menu-item" @click="goFavorites"> <view class="menu-item" @click="goFavorites">
<view class="m-icon" style="background:rgba(179,136,255,0.1);color:#b388ff">⭐</view> <view class="m-icon" style="background:var(--dim1-bg)"><AppIcon name="star" :size="16" /></view>
<text class="m-label">我的收藏</text> <text class="m-label">我的收藏</text>
<text class="m-badge">{{ collectedCount }}</text> <text class="m-badge">{{ collectedCount }}</text>
<text class="m-arrow">→</text> <AppIcon name="arrow-gray" :size="12" />
</view> </view>
<view class="menu-item" @click="goProgress"> <view class="menu-item" @click="goProgress">
<view class="m-icon" style="background:rgba(77,208,225,0.1);color:#4dd0e1">📊</view> <view class="m-icon" style="background:var(--dim2-bg)"><AppIcon name="rocket" :size="16" /></view>
<text class="m-label">学习进度</text> <text class="m-label">学习进度</text>
<text class="m-arrow">→</text> <AppIcon name="arrow-gray" :size="12" />
</view> </view>
<view class="menu-item" @click="goAchievements"> <view class="menu-item" @click="goAchievements">
<view class="m-icon" style="background:rgba(77,182,172,0.1);color:#4db6ac">🏆</view> <view class="m-icon" style="background:var(--dim4-bg)"><AppIcon name="trophy" :size="16" /></view>
<text class="m-label">成就</text> <text class="m-label">成就</text>
<text class="m-arrow">→</text> <AppIcon name="arrow-gray" :size="12" />
</view> </view>
<view class="menu-item" @click="goLeaderboard"> <view class="menu-item" @click="goLeaderboard">
<view class="m-icon" style="background:rgba(255,167,38,0.1);color:#ffa726">🥇</view> <view class="m-icon" style="background:var(--dim3-bg)"><AppIcon name="sparkle" :size="16" /></view>
<text class="m-label">排行榜</text> <text class="m-label">排行榜</text>
<text class="m-arrow">→</text> <AppIcon name="arrow-gray" :size="12" />
</view> </view>
<view class="menu-item" @click="goFeedback"> <view class="menu-item" @click="goFeedback">
<view class="m-icon" style="background:rgba(102,187,106,0.1);color:#66bb6a">💬</view> <view class="m-icon" style="background:var(--dim5-bg)"><AppIcon name="feedback" :size="16" /></view>
<text class="m-label">意见反馈</text> <text class="m-label">意见反馈</text>
<text class="m-arrow">→</text> <AppIcon name="arrow-gray" :size="12" />
</view>
<view class="menu-item" @click="goAbout">
<view class="m-icon" style="background:rgba(255,213,79,0.1);color:#ffd54f">ℹ️</view>
<text class="m-label">关于 AI 维度</text>
<text class="m-arrow">→</text>
</view> </view>
<view class="menu-item" @click="goSettings"> <view class="menu-item" @click="goSettings">
<view class="m-icon" style="background:rgba(239,83,80,0.1);color:#ef5350">⚙️</view> <view class="m-icon" style="background:var(--bg-tertiary)"><AppIcon name="settings-gray" :size="16" /></view>
<text class="m-label">设置</text> <text class="m-label">设置</text>
<text class="m-arrow">→</text> <AppIcon name="arrow-gray" :size="12" />
</view> </view>
</view> </view>
@@ -99,7 +127,6 @@
<view class="action-btn primary" @click="goLogin"> <view class="action-btn primary" @click="goLogin">
<text>微信一键登录</text> <text>微信一键登录</text>
</view> </view>
<!-- 未登录:提供显著有效的「拒绝/暂不登录」入口,避免强制登录 -->
<view class="action-btn ghost" @click="goBrowse"> <view class="action-btn ghost" @click="goBrowse">
<text>暂不登录,先体验</text> <text>暂不登录,先体验</text>
</view> </view>
@@ -108,33 +135,90 @@
<text>退出登录</text> <text>退出登录</text>
</view> </view>
<text class="version">宇之然 AI 维度 v1.0.0</text> <text class="version">宇之然 AI 维度 v{{ versionText }}</text>
</view> </view>
</template> </template>
<script setup> <script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { knowledgeApi, aiChatApi } from '@/utils/api' import { knowledgeApi, aiChatApi } from '@/utils/api'
import { DIMENSIONS } from '@/utils/dimensions'
import AppIcon from '@/components/AppIcon.vue'
const VERSION = '1.3.0'
const SUB_GUEST = '游客 · 点亮进度保存在本机,登录后同步'
const SUB_USER = '已点亮 {n} 颗星球 · 连续 {d} 天'
const PLAN_FREE = '✦ 免费版'
const PLAN_DESC_FREE = '每日 5 次 AI 问答 · 答对检测题点亮星球'
const userStore = useUserStore() const userStore = useUserStore()
const dimensions = DIMENSIONS
// 已收藏数量
const collectedCount = ref(0) const collectedCount = ref(0)
const stats = ref(null)
const byDim = ref({})
const quotaInfo = ref(null)
const versionText = VERSION
const profileSubText = computed(() => {
if (!userStore.isLoggedIn) return SUB_GUEST
if (!stats.value) return '知识星图探索中'
return `已点亮 ${stats.value.totalLit} 颗星球 · 连续 ${stats.value.streak} 天`
})
const planTitle = computed(() =>
userStore.isSubscribed ? `✦ ${userStore.subscriptionLevel?.toUpperCase() || 'PRO'} 会员` : PLAN_FREE
)
const planDesc = computed(() =>
userStore.isSubscribed ? '全量知识解锁 · AI 问答无限次' : PLAN_DESC_FREE
)
const planBtnText = computed(() => {
if (userStore.isLoggedIn && userStore.isSubscribed) return '已订阅'
if (userStore.isLoggedIn) return '升级 Pro'
return '登录后升级'
})
const planPercent = computed(() => {
const sub = userStore.subInfo
if (!sub?.expiresAt || !sub?.startedAt) return 100
const total = new Date(sub.expiresAt) - new Date(sub.startedAt)
const passed = Date.now() - new Date(sub.startedAt)
if (total <= 0) return 100
return Math.min(100, Math.max(0, Math.round((passed / total) * 100)))
})
const planExpiryText = computed(() => {
if (!userStore.subInfo?.expiresAt) return ''
const diffDays = Math.ceil((new Date(userStore.subInfo.expiresAt) - Date.now()) / 86400000)
return diffDays <= 0 ? '已过期' : `${diffDays} 天后到期`
})
const quotaPercent = computed(() => {
if (!quotaInfo.value) return 0
const total = (quotaInfo.value.freeQuota || 5) + (quotaInfo.value.sharedQuota || 0)
if (!total) return 0
return Math.round((quotaInfo.value.remainingQuota / total) * 100)
})
onMounted(() => { onMounted(() => {
loadStats()
if (userStore.isLoggedIn) { if (userStore.isLoggedIn) {
loadUserInfo() userStore.fetchMe()
loadCollected() loadCollected()
loadQuota()
} }
}) })
async function loadUserInfo() { async function loadStats() {
await userStore.fetchMe() try {
if (userStore.token) { const data = await knowledgeApi.stats(userStore.isLoggedIn ? userStore.token : '')
await userStore.fetchQuota(userStore.token) stats.value = data.user
byDim.value = data.byDim || {}
} catch (e) {
// 游客或失败:不显示进度区
} }
} }
@@ -148,422 +232,393 @@ async function loadCollected() {
} }
} }
// 订阅信息 async function loadQuota() {
const planTitle = computed(() => { try {
if (userStore.isSubscribed) { quotaInfo.value = await aiChatApi.quota(userStore.token)
return `✦ ${userStore.subscriptionLevel.toUpperCase()} 会员` } catch (e) {}
}
return '✦ 免费版'
})
const planDesc = computed(() => {
if (userStore.isSubscribed) {
return '全量知识解锁 · AI 问答无限次'
}
return '每日 5 次免费 AI 问答 · 升级解锁全部知识'
})
const planBtnText = computed(() => {
if (userStore.isLoggedIn && userStore.isSubscribed) return '已订阅'
if (userStore.isLoggedIn) return '升级 Pro'
return '登录后升级'
})
const planExpiryText = computed(() => {
if (!userStore.subInfo?.expiresAt) return ''
const expires = new Date(userStore.subInfo.expiresAt)
const now = new Date()
const diffDays = Math.ceil((expires - now) / (1000 * 60 * 60 * 24))
if (diffDays <= 0) return '已过期'
return `${diffDays} 天后到期`
})
const planPercent = computed(() => {
if (!userStore.subInfo?.expiresAt || !userStore.subInfo?.startDate) return 0
const start = new Date(userStore.subInfo.startDate).getTime()
const expires = new Date(userStore.subInfo.expiresAt).getTime()
const now = Date.now()
const total = expires - start
const elapsed = now - start
return Math.min(100, Math.max(0, (elapsed / total) * 100))
})
// 配额
const quotaPercent = computed(() => {
const q = userStore.quota
if (!q) return 0
const remaining = q.remainingQuota || 0
const used = q.usedQuota || q.dailyUsed || 0
const total = q.freeQuota || remaining + used
return total > 0 ? Math.round((used / total) * 100) : 0
})
const quotaUsed = computed(() => userStore.quota?.dailyUsed || userStore.quota?.usedQuota || 0)
const quotaTotal = computed(() => {
const q = userStore.quota
return (q?.freeQuota || 5) + (q?.sharedQuota || 0)
})
const subscriptionBadge = computed(() => {
if (userStore.isSubscribed) {
return userStore.subscriptionLevel.toUpperCase()
}
return '免费用户'
})
function goLogin() {
uni.navigateTo({ url: '/pages/login/login' })
} }
/** 仅浏览:未登录用户可跳过登录直接使用首页 */ function dimPercent(dimId) {
function goBrowse() { const total = byDim.value[dimId] || 0
uni.switchTab({ url: '/pages/index/index' }) if (!total) return 0
return Math.round(((stats.value?.litByDim?.[dimId] || 0) / total) * 100)
} }
function handleLogout() { function goDim(id) {
uni.showModal({ uni.navigateTo({ url: `/pages/dimension/dimension?id=${id}` })
title: '退出登录', }
content: '确定退出当前账号吗?',
success: (res) => { async function handleLogout() {
const res = await uni.showModal({ title: '退出登录', content: '确定退出当前账号?' })
if (res.confirm) { if (res.confirm) {
userStore.logout() userStore.logout()
collectedCount.value = 0 stats.value = null
uni.showToast({ title: '已退出', icon: 'success' }) uni.showToast({ title: '已退出', icon: 'none' })
} }
}
})
} }
function goLogin() { uni.navigateTo({ url: '/pages/login/login' }) }
function goBrowse() { uni.switchTab({ url: '/pages/index/index' }) }
function goChat() { uni.switchTab({ url: '/pages/chat/chat' }) }
function goFavorites() { uni.navigateTo({ url: '/pages/favorites/favorites' }) }
function goProgress() { uni.navigateTo({ url: '/pages/progress/progress' }) }
function goAchievements() { uni.navigateTo({ url: '/pages/achievements/achievements' }) }
function goLeaderboard() { uni.navigateTo({ url: '/pages/leaderboard/leaderboard' }) }
function goFeedback() { uni.navigateTo({ url: '/pages/feedback/feedback' }) }
function goSettings() { uni.navigateTo({ url: '/pages/settings/settings' }) }
function goPayment() { function goPayment() {
if (!userStore.isLoggedIn) { if (!userStore.isLoggedIn) {
goLogin() uni.navigateTo({ url: '/pages/login/login' })
return return
} }
if (userStore.isSubscribed) return
uni.navigateTo({ url: '/pages/payment/payment' }) uni.navigateTo({ url: '/pages/payment/payment' })
} }
function goChat() {
uni.switchTab({ url: '/pages/chat/chat' })
}
function goShare() { function goShare() {
if (!userStore.token) return
uni.showShareMenu({ withShareTicket: true })
aiChatApi.shareGain(userStore.token).then(() => { aiChatApi.shareGain(userStore.token).then(() => {
uni.showToast({ title: '已记录分享', icon: 'success' }) uni.showToast({ title: '已记录分享 +5 次', icon: 'success' })
if (userStore.token) userStore.fetchQuota(userStore.token) loadQuota()
}).catch(() => { }).catch(() => {})
uni.showToast({ title: '已记录分享', icon: 'success' })
})
} }
function goFavorites() {
uni.navigateTo({ url: '/pages/favorites/favorites' })
}
function goProgress() {
uni.navigateTo({ url: '/pages/progress/progress' })
}
function goAchievements() {
uni.navigateTo({ url: '/pages/achievements/achievements' })
}
function goLeaderboard() {
uni.navigateTo({ url: '/pages/leaderboard/leaderboard' })
}
function goFeedback() {
uni.navigateTo({ url: '/pages/feedback/feedback' })
}
function goAbout() {
uni.showModal({
title: '关于',
content: '宇之然 AI 维度\n从 5 个维度系统理解人工智能\n© 2026 宇之然',
showCancel: false
})
}
function goSettings() {
uni.navigateTo({ url: '/pages/settings/settings' })
}
// 微信小程序分享
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
onShareAppMessage(() => ({ onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手', title: '我在 AI 维度点亮了知识星球,来一起探索',
path: '/pages/index/index' path: '/pages/index/index'
})) }))
onShareTimeline(() => ({
title: 'AI 维度 · 点亮你的知识星球'
}))
// #endif // #endif
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.page-profile { .page-profile {
min-height: 100vh; min-height: 100vh;
padding: 0 20px 40px;
background: var(--bg-primary); background: var(--bg-primary);
} }
.status-bar-placeholder { height: 44px; } .status-bar-placeholder { height: 44px; }
// 用户卡片
.profile-card { .profile-card {
text-align: center; display: flex;
padding: 20px 0 24px; align-items: center;
gap: 14px;
padding: 8px 0 16px;
} }
.profile-avatar { .profile-avatar {
width: 72px; width: 62px; height: 62px;
height: 72px;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, rgba(124, 77, 255, 0.3), rgba(0, 188, 212, 0.15)); flex-shrink: 0;
border: 2px solid rgba(124, 77, 255, 0.2); overflow: hidden;
margin: 0 auto 14px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 0 40px rgba(124, 77, 255, 0.1);
overflow: hidden;
}
.avatar-img { .avatar-img { width: 100%; height: 100%; }
width: 100%; .avatar-placeholder {
height: 100%; width: 100%; height: 100%;
} border-radius: 50%;
background: linear-gradient(135deg, var(--color-brand), #8b5cf6);
.avatar-text {
font-size: 28px;
font-weight: 600;
color: white;
}
.profile-name {
font-size: 18px;
color: var(--text-primary);
font-weight: 600;
display: block;
}
.profile-level {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
display: block;
}
// 订阅卡片
.plan-card {
border-radius: 18px;
padding: 20px;
margin-bottom: 16px;
display: flex; display: flex;
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)); justify-content: center;
border: 1px solid rgba(124, 77, 255, 0.12); }
position: relative;
overflow: hidden;
} }
.plan-info { flex: 1; } .profile-main { flex: 1; min-width: 0; }
.profile-name {
.plan-title {
font-size: 16px;
color: var(--text-primary);
font-weight: 600;
display: block; display: block;
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
} }
.profile-sub {
.plan-desc { display: block;
font-size: 12px; font-size: 12px;
color: var(--text-tertiary); color: var(--text-tertiary);
margin-top: 4px; line-height: 1.5;
display: block;
} }
.plan-progress { .streak-chip {
margin-top: 10px; display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: var(--radius-full);
background: rgba(229, 28, 28, 0.08);
border: 1px solid rgba(229, 28, 28, 0.15);
font-size: 12px;
font-weight: 700;
color: var(--dim5-color);
flex-shrink: 0;
} }
.progress-track { /* 学习数据 */
height: 3px; .stats-row {
background: var(--glass-bg); display: flex;
border-radius: 2px; gap: 10px;
overflow: hidden; margin-bottom: 12px;
} }
.stat-tile {
.plan-progress .progress-track { flex: 1;
margin-top: 10px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--dim1-color), var(--dim3-color));
border-radius: 2px;
}
.progress-text {
font-size: 10px;
color: var(--text-muted);
margin-top: 6px;
display: block;
}
.plan-btn {
background: linear-gradient(135deg, #7c4dff, #b388ff);
padding: 10px 22px;
border-radius: 20px;
color: white;
font-size: 13px;
font-weight: 500;
box-shadow: 0 0 20px rgba(124, 77, 255, 0.2);
}
.plan-btn.disabled {
background: rgba(255, 255, 255, 0.1);
box-shadow: none;
}
// 配额卡片
.quota-card {
border-radius: 16px;
padding: 16px;
margin-bottom: 20px;
background: var(--glass-bg); background: var(--glass-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 14px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
box-shadow: var(--card-shadow, none);
.stat-num {
font-size: 20px;
font-weight: 800;
color: var(--text-primary);
}
.stat-label {
font-size: 11px;
color: var(--text-tertiary);
}
} }
.quota-card-title { /* 五星域进度 */
font-size: 13px; .dims-progress {
color: var(--text-secondary);
display: block;
margin-bottom: 10px;
}
.quota-bar {
height: 4px;
background: var(--glass-bg); background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 6px 16px;
margin-bottom: 12px;
box-shadow: var(--card-shadow, none);
}
.dim-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
}
.dim-dot {
width: 26px; height: 26px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dim-name {
font-size: 12px;
color: var(--text-secondary);
width: 56px;
flex-shrink: 0;
}
.dim-bar {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--bg-tertiary);
overflow: hidden;
}
.dim-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
.dim-frac {
font-size: 11px;
color: var(--text-muted);
width: 40px;
text-align: right;
flex-shrink: 0;
}
/* 订阅 */
.plan-card {
display: flex;
align-items: center;
gap: 12px;
border-radius: var(--radius-lg);
padding: 16px;
margin-bottom: 12px;
background: linear-gradient(135deg, #1a1a3a, #2d2b5e);
border: 1px solid rgba(123, 135, 253, 0.3);
}
.plan-info { flex: 1; min-width: 0; }
.plan-title {
display: block;
font-size: 16px;
font-weight: 700;
color: #fff;
margin-bottom: 4px;
}
.plan-desc {
display: block;
font-size: 11px;
color: rgba(255, 255, 255, 0.55);
line-height: 1.5;
}
.plan-progress {
margin-top: 8px;
.progress-track {
height: 3px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px; border-radius: 2px;
overflow: hidden; overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #7b87fd, #a2b2ff);
border-radius: 2px;
}
.progress-text {
font-size: 10px;
color: rgba(255, 255, 255, 0.4);
margin-top: 4px;
display: block;
}
}
.plan-btn {
padding: 9px 16px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--color-brand), #8b5cf6);
color: #fff;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
&.disabled { opacity: 0.5; }
}
/* 配额 */
.quota-card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 14px 16px;
margin-bottom: 12px;
box-shadow: var(--card-shadow, none);
}
.quota-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
.quota-card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
}
.quota-bar {
height: 6px;
border-radius: 3px;
background: var(--bg-tertiary);
overflow: hidden;
margin-bottom: 8px; margin-bottom: 8px;
} }
.quota-fill { .quota-fill {
height: 100%; height: 100%;
background: linear-gradient(90deg, #4dd0e1, #b388ff); border-radius: 3px;
border-radius: 2px; background: linear-gradient(90deg, var(--color-brand), var(--color-brand-light));
transition: width 0.3s ease;
} }
.quota-foot {
.quota-text {
font-size: 11px;
color: var(--text-tertiary);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
.quota-text {
font-size: 12px;
color: var(--text-secondary);
}
.quota-share {
font-size: 12px;
font-weight: 600;
color: var(--color-brand);
}
} }
.quota-note { /* 菜单 */
color: var(--text-muted);
}
.quota-share {
font-size: 11px;
color: var(--dim1-color);
margin-top: 4px;
display: block;
}
// 菜单
.menu-section { .menu-section {
background: var(--glass-bg); background: var(--glass-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; padding: 4px 16px;
margin-bottom: 20px; margin-bottom: 16px;
box-shadow: var(--card-shadow, none);
} }
.menu-item { .menu-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 12px;
padding: 15px 16px; padding: 13px 0;
border-bottom: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border);
}
.menu-item:last-child { &:last-child { border-bottom: none; }
border-bottom: none;
} }
.m-icon { .m-icon {
width: 34px; width: 32px; height: 32px;
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;
flex-shrink: 0; flex-shrink: 0;
} }
.m-label { .m-label {
flex: 1; flex: 1;
font-size: 14px; font-size: 14px;
color: var(--text-secondary); color: var(--text-primary);
} }
.m-arrow {
font-size: 14px;
color: var(--text-muted);
}
.m-badge { .m-badge {
font-size: 10px; font-size: 11px;
padding: 2px 10px;
border-radius: 10px;
background: rgba(179, 136, 255, 0.12);
color: var(--dim1-color);
}
// 按钮
.action-row {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.action-btn {
text-align: center;
padding: 16px 0;
font-size: 14px;
border-radius: 12px;
}
.action-btn.primary {
color: white;
background: linear-gradient(135deg, #7c4dff, #b388ff);
box-shadow: 0 0 20px rgba(124, 77, 255, 0.2);
font-weight: 600;
}
.action-btn.ghost {
color: var(--text-secondary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
}
.action-btn.logout {
margin-top: 20px;
color: var(--text-muted); color: var(--text-muted);
}
/* 操作 */
.action-row {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
.action-btn {
flex: 1;
height: 46px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
&.primary {
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
color: #fff;
}
&.ghost {
background: var(--glass-bg); background: var(--glass-bg);
border: 1px solid var(--glass-border); border: 1.5px solid var(--glass-border);
border-radius: 12px; color: var(--text-secondary);
}
&.logout {
flex: none;
width: 100%;
background: var(--glass-bg);
border: 1.5px solid var(--glass-border);
color: var(--dim5-color);
margin-bottom: 12px;
}
} }
.version { .version {
display: block;
text-align: center; text-align: center;
font-size: 11px; font-size: 11px;
color: rgba(255, 255, 255, 0.1); color: var(--text-muted);
margin-top: 12px; padding: 8px 0 20px;
} }
</style> </style>
+2 -2
View File
@@ -21,7 +21,7 @@
@confirm="doSearch" @confirm="doSearch"
confirm-type="search" confirm-type="search"
/> />
<view class="search-icon">🔍</view> <view class="search-icon"></view>
<view v-if="searchText" class="clear-btn" @click="clearSearch">✕</view> <view v-if="searchText" class="clear-btn" @click="clearSearch">✕</view>
</view> </view>
@@ -33,7 +33,7 @@
<!-- 空搜索提示 --> <!-- 空搜索提示 -->
<view class="empty-state" v-else-if="!hasSearched"> <view class="empty-state" v-else-if="!hasSearched">
<text class="empty-icon">🔍</text> <text class="empty-icon">没有找到相关知识</text>
<text class="empty-text">输入关键词搜索 AI 知识</text> <text class="empty-text">输入关键词搜索 AI 知识</text>
<text class="empty-hint">支持按标题、摘要、标签搜索</text> <text class="empty-hint">支持按标题、摘要、标签搜索</text>
</view> </view>
+1 -1
View File
@@ -11,7 +11,7 @@
<view class="section-title">偏好设置</view> <view class="section-title">偏好设置</view>
<view class="card"> <view class="card">
<view class="setting-item" @click="togglePush"> <view class="setting-item" @click="togglePush">
<view class="s-icon" style="background:rgba(255,213,79,0.1);color:#ffd54f">🔔</view> <view class="s-icon" style="background:rgba(91,95,248,0.1)">铃</view>
<view class="s-text"> <view class="s-text">
<text class="s-label">推送通知</text> <text class="s-label">推送通知</text>
<text class="s-desc">接收每日 AI 趋势推送</text> <text class="s-desc">接收每日 AI 趋势推送</text>
+8 -8
View File
@@ -45,7 +45,7 @@
<view class="card-footer"> <view class="card-footer">
<text class="card-date">{{ formatDate(item.newsDate) }}</text> <text class="card-date">{{ formatDate(item.newsDate) }}</text>
<view class="card-stats"> <view class="card-stats">
<text class="stat-item" v-if="item.hot">🔥 {{ item.hotCount }}</text> <text class="stat-item" v-if="item.hot">{{ item.hotCount }} 人觉得热</text>
<text class="stat-item">👁️ {{ item.viewCount || 0 }}</text> <text class="stat-item">👁️ {{ item.viewCount || 0 }}</text>
</view> </view>
</view> </view>
@@ -56,7 +56,7 @@
<text>加载中...</text> <text>加载中...</text>
</view> </view>
<view v-else-if="displayItems.length === 0" class="empty-state"> <view v-else-if="displayItems.length === 0" class="empty-state">
<text class="empty-icon">📡</text> <text class="empty-icon">暂时没有快讯</text>
<text class="empty-text">暂无趋势资讯</text> <text class="empty-text">暂无趋势资讯</text>
<text class="empty-sub">稍后再来看最新 AI 动态</text> <text class="empty-sub">稍后再来看最新 AI 动态</text>
</view> </view>
@@ -72,7 +72,7 @@ import { trendApi } from '@/utils/api'
const filters = ref([ const filters = ref([
{ icon: '📰', text: '全部' }, { icon: '📰', text: '全部' },
{ icon: '🚀', text: '产品' }, { icon: 'rocket', text: '产品' },
{ icon: '📄', text: '论文' }, { icon: '📄', text: '论文' },
{ icon: '🔧', text: '工具' }, { icon: '🔧', text: '工具' },
{ icon: '🏢', text: '公司' } { icon: '🏢', text: '公司' }
@@ -116,9 +116,9 @@ async function fetchTrends() {
} }
function getTagText(item) { function getTagText(item) {
if (item.hot) return '🔥 热门' if (item.hot) return '热门'
if (item.picked) return '📌 精选' if (item.picked) return '精选'
if (item.category === '产品') return '🚀 产品' if (item.category === '产品') return '产品'
if (item.category === '论文') return '📄 论文' if (item.category === '论文') return '📄 论文'
if (item.category === '工具') return '🔧 工具' if (item.category === '工具') return '🔧 工具'
if (item.category === '公司') return '🏢 公司' if (item.category === '公司') return '🏢 公司'
@@ -311,7 +311,7 @@ onShareAppMessage(() => ({
.card-tag.pick { .card-tag.pick {
background: rgba(124, 77, 255, 0.12); background: rgba(124, 77, 255, 0.12);
color: #b388ff; color: var(--color-brand);
} }
.card-tag.product { .card-tag.product {
@@ -321,7 +321,7 @@ onShareAppMessage(() => ({
.card-tag.paper { .card-tag.paper {
background: rgba(179, 136, 255, 0.12); background: rgba(179, 136, 255, 0.12);
color: #b388ff; color: var(--color-brand);
} }
.card-tag.tool { .card-tag.tool {
Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1007 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+153 -14
View File
@@ -1,25 +1,164 @@
/* ============================================ /* ============================================
宇之然AI维度 — 主题系统 v3 宇之然AI维度 — 主题系统 v4
亮色默认 + CSS 自定义属性切换 亮色默认 + CSS 自定义属性切换(mixin 化,防重复)
============================================ */ ============================================ */
/* /* ---- 主题变量 Mixin(唯一出处,App.vue 引用) ---- */
* 注意: 此文件通过 uni.scss 注入到每个组件 @mixin theme-light-vars {
* 但全局 CSS 变量定义在 App.vue 的非 scoped <style> 中 /* 基础色 */
* 此文件只保留 SCSS 变量 + 工具类,防止重复 --bg-primary: #f5f5f7;
*/ --bg-secondary: #ffffff;
--bg-tertiary: #e8e8ed;
--glass-bg: #ffffff;
--glass-border: rgba(0, 0, 0, 0.08);
--glass-hover: #f8f8ff;
--glass-hover-border: rgba(99, 102, 241, 0.15);
--glass-strong: #ffffff;
--glass-dim-bg: rgba(99, 102, 241, 0.04);
--text-primary: #0d0d1a;
--text-secondary: rgba(13, 13, 26, 0.68);
--text-tertiary: rgba(13, 13, 26, 0.48);
--text-muted: rgba(13, 13, 26, 0.25);
--text-disabled: rgba(13, 13, 26, 0.15);
--color-brand: #5b5ff8;
--color-brand-light: #7b87fd;
--color-brand-dark: #4f46e5;
--color-brand-bg: rgba(91, 95, 248, 0.08);
--color-brand-glow: rgba(91, 95, 248, 0.1);
--dim1-color: #5b5ff8;
--dim1-bg: rgba(91, 95, 248, 0.12);
--dim1-icon-bg: rgba(91, 95, 248, 0.18);
--dim1-border: rgba(91, 95, 248, 0.3);
--dim1-glow: rgba(91, 95, 248, 0.08);
--dim2-color: #0095ba;
--dim2-bg: rgba(0, 149, 186, 0.12);
--dim2-icon-bg: rgba(0, 149, 186, 0.18);
--dim2-border: rgba(0, 149, 186, 0.3);
--dim2-glow: rgba(0, 149, 186, 0.08);
--dim3-color: #009b6a;
--dim3-bg: rgba(0, 155, 106, 0.12);
--dim3-icon-bg: rgba(0, 155, 106, 0.18);
--dim3-border: rgba(0, 155, 106, 0.3);
--dim3-glow: rgba(0, 155, 106, 0.08);
--dim4-color: #df7700;
--dim4-bg: rgba(223, 119, 0, 0.12);
--dim4-icon-bg: rgba(223, 119, 0, 0.18);
--dim4-border: rgba(223, 119, 0, 0.3);
--dim4-glow: rgba(223, 119, 0, 0.08);
--dim5-color: #e51c1c;
--dim5-bg: rgba(229, 28, 28, 0.12);
--dim5-icon-bg: rgba(229, 28, 28, 0.18);
--dim5-border: rgba(229, 28, 28, 0.3);
--dim5-glow: rgba(229, 28, 28, 0.08);
--color-free: #009b6a;
--color-free-bg: rgba(0, 155, 106, 0.1);
--color-pro: #df7700;
--color-pro-bg: rgba(223, 119, 0, 0.1);
--color-vip: #e51c1c;
--color-vip-bg: rgba(229, 28, 28, 0.1);
--color-hot: #e51c1c;
--color-hot-bg: rgba(229, 28, 28, 0.12);
--color-new: #0095ba;
--color-new-bg: rgba(0, 149, 186, 0.12);
--color-pick: #5b5ff8;
--color-pick-bg: rgba(91, 95, 248, 0.12);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-full: 9999px;
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02);
--card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.08);
}
@mixin theme-dark-vars {
--bg-primary: #14141e;
--bg-secondary: #181828;
--bg-tertiary: #1c1a34;
--glass-bg: #1c1c2e;
--glass-border: rgba(255, 255, 255, 0.09);
--glass-hover: #242438;
--glass-hover-border: rgba(123, 135, 253, 0.25);
--glass-strong: #262640;
--glass-dim-bg: rgba(99, 102, 241, 0.06);
--text-primary: rgba(255, 255, 255, 0.95);
--text-secondary: rgba(255, 255, 255, 0.7);
--text-tertiary: rgba(255, 255, 255, 0.5);
--text-muted: rgba(255, 255, 255, 0.3);
--text-disabled: rgba(255, 255, 255, 0.15);
--color-brand: #7b87fd;
--color-brand-light: #a2b2ff;
--color-brand-dark: #5b5ff8;
--color-brand-bg: rgba(91, 95, 248, 0.12);
--color-brand-glow: rgba(91, 95, 248, 0.15);
--dim1-color: #a485ff;
--dim1-bg: rgba(164, 133, 255, 0.12);
--dim1-icon-bg: rgba(164, 133, 255, 0.2);
--dim1-border: rgba(164, 133, 255, 0.18);
--dim1-glow: rgba(164, 133, 255, 0.08);
--dim2-color: #5ba4ff;
--dim2-bg: rgba(91, 164, 255, 0.12);
--dim2-icon-bg: rgba(91, 164, 255, 0.2);
--dim2-border: rgba(91, 164, 255, 0.18);
--dim2-glow: rgba(91, 164, 255, 0.08);
--dim3-color: #2cda9b;
--dim3-bg: rgba(44, 218, 155, 0.12);
--dim3-icon-bg: rgba(44, 218, 155, 0.2);
--dim3-border: rgba(44, 218, 155, 0.18);
--dim3-glow: rgba(44, 218, 155, 0.08);
--dim4-color: #ffc020;
--dim4-bg: rgba(255, 192, 32, 0.12);
--dim4-icon-bg: rgba(255, 192, 32, 0.2);
--dim4-border: rgba(255, 192, 32, 0.18);
--dim4-glow: rgba(255, 192, 32, 0.08);
--dim5-color: #ff6c82;
--dim5-bg: rgba(255, 108, 130, 0.12);
--dim5-icon-bg: rgba(255, 108, 130, 0.2);
--dim5-border: rgba(255, 108, 130, 0.18);
--dim5-glow: rgba(255, 108, 130, 0.08);
--color-free: #2cda9b;
--color-free-bg: rgba(44, 218, 155, 0.12);
--color-pro: #ffc020;
--color-pro-bg: rgba(255, 192, 32, 0.12);
--color-vip: #ff6c82;
--color-vip-bg: rgba(255, 108, 130, 0.12);
--color-hot: #ff6c82;
--color-hot-bg: rgba(255, 108, 130, 0.15);
--color-new: #5ba4ff;
--color-new-bg: rgba(91, 164, 255, 0.15);
--color-pick: #a485ff;
--color-pick-bg: rgba(164, 133, 255, 0.15);
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.4);
}
/* ---- SCSS 变量(方便组件内使用) ---- */ /* ---- SCSS 变量(方便组件内使用) ---- */
$color-brand: #6366f1; $color-brand: #5b5ff8;
$color-brand-light: #818cf8; $color-brand-light: #7b87fd;
$color-bg-primary: #f5f5f7; $color-bg-primary: #f5f5f7;
$color-bg-dark: #14141e; $color-bg-dark: #14141e;
$dim1-color: #6366f1; /* ---- SCSS 变量(方便组件内使用) ---- */
$dim2-color: #0891b2;
$dim3-color: #059669;
$dim4-color: #d97706;
$dim5-color: #dc2626;
/* 全局排版 */ /* 全局排版 */
page { page {
+20
View File
@@ -0,0 +1,20 @@
{
"light": {
"navBg": "#f5f5f7",
"navTxt": "black",
"bg": "#f5f5f7",
"tabTxt": "#8e93a8",
"tabSel": "#5b5ff8",
"tabBg": "#ffffff",
"tabBorder": "white"
},
"dark": {
"navBg": "#14141e",
"navTxt": "white",
"bg": "#14141e",
"tabTxt": "rgba(255,255,255,0.35)",
"tabSel": "#a2b2ff",
"tabBg": "#181828",
"tabBorder": "black"
}
}
+10 -2
View File
@@ -6,8 +6,8 @@
const BASE_URL = 'https://wdkj.yuzhiran.com.cn' const BASE_URL = 'https://wdkj.yuzhiran.com.cn'
const DEV_URL = 'http://127.0.0.1:3001' const DEV_URL = 'http://127.0.0.1:3001'
// 开发环境走本地后端,生产环境使用线上域名 // 构建时可用 VITE_API_BASE 覆盖(本地 docker 验证等场景)
const API_BASE = import.meta.env.DEV ? DEV_URL : BASE_URL const API_BASE = import.meta.env.VITE_API_BASE || (import.meta.env.DEV ? DEV_URL : BASE_URL)
/** /**
* 通用请求封装 * 通用请求封装
@@ -79,6 +79,14 @@ export const knowledgeApi = {
detail: (id) => detail: (id) =>
request('GET', `/api/knowledge/${id}`), request('GET', `/api/knowledge/${id}`),
/** 星域统计(公共计数 + 登录用户点亮进度) */
stats: (token) =>
request('GET', '/api/knowledge/stats', null, token || undefined),
/** 检测题作答(服务端判题,答对且登录则点亮) */
quiz: (id, optionIndex, token) =>
request('POST', `/api/knowledge/${id}/quiz`, { optionIndex }, token || undefined),
/** 收藏知识 */ /** 收藏知识 */
collect: (id, token) => collect: (id, token) =>
request('POST', `/api/knowledge/${id}/collect`, null, token), request('POST', `/api/knowledge/${id}/collect`, null, token),
+18 -23
View File
@@ -1,6 +1,7 @@
/** /**
* 维度配置 — 5 个 AI 维度的元数据 * 星域配置 — 5 个 AI 知识星域的元数据
* 修改这里可以调整维度名称、颜色、图标等 * 修改这里可以调整星域名称、颜色、图标等
* 真实计数由 /api/knowledge/stats 下发,本地不再硬编码
*/ */
export const DIMENSIONS = [ export const DIMENSIONS = [
@@ -8,72 +9,66 @@ export const DIMENSIONS = [
id: 1, id: 1,
key: 'origin', key: 'origin',
name: 'AI 起源', name: 'AI 起源',
icon: '🌅', icon: 'book',
tagline: '从图灵测试到深度学习革命',
desc: '人工智能的诞生——从图灵测试到第一代探索者', desc: '人工智能的诞生——从图灵测试到第一代探索者',
color: 'var(--dim1-color)', color: 'var(--dim1-color)',
bg: 'var(--dim1-bg)', bg: 'var(--dim1-bg)',
iconBg: 'var(--dim1-icon-bg)', iconBg: 'var(--dim1-icon-bg)',
border: 'var(--dim1-border)', border: 'var(--dim1-border)',
glow: 'var(--dim1-glow)', glow: 'var(--dim1-glow)'
articleCount: 12,
quizCount: 4
}, },
{ {
id: 2, id: 2,
key: 'development', key: 'development',
name: 'AI 发展', name: 'AI 发展',
icon: '📈', icon: 'rocket',
tagline: 'Transformer、大模型与 Agent',
desc: '技术跃迁之路——深度学习、Transformer 和前沿突破', desc: '技术跃迁之路——深度学习、Transformer 和前沿突破',
color: 'var(--dim2-color)', color: 'var(--dim2-color)',
bg: 'var(--dim2-bg)', bg: 'var(--dim2-bg)',
iconBg: 'var(--dim2-icon-bg)', iconBg: 'var(--dim2-icon-bg)',
border: 'var(--dim2-border)', border: 'var(--dim2-border)',
glow: 'var(--dim2-glow)', glow: 'var(--dim2-glow)'
articleCount: 18,
quizCount: 6
}, },
{ {
id: 3, id: 3,
key: 'current', key: 'current',
name: 'AI 当前', name: 'AI 当前',
icon: '🌐', icon: 'planet',
tagline: '2026 行业全景与具身智能',
desc: '2026 年 AI 世界——大模型、Agent、人形机器人', desc: '2026 年 AI 世界——大模型、Agent、人形机器人',
color: 'var(--dim3-color)', color: 'var(--dim3-color)',
bg: 'var(--dim3-bg)', bg: 'var(--dim3-bg)',
iconBg: 'var(--dim3-icon-bg)', iconBg: 'var(--dim3-icon-bg)',
border: 'var(--dim3-border)', border: 'var(--dim3-border)',
glow: 'var(--dim3-glow)', glow: 'var(--dim3-glow)'
articleCount: 15,
quizCount: 5
}, },
{ {
id: 4, id: 4,
key: 'learning', key: 'learning',
name: 'AI 学习', name: 'AI 学习',
icon: '🛠', icon: 'bulb',
tagline: '提示词、RAG 与实用工具',
desc: '动手学 AI——提示词、微调、RAG 和实用工具', desc: '动手学 AI——提示词、微调、RAG 和实用工具',
color: 'var(--dim4-color)', color: 'var(--dim4-color)',
bg: 'var(--dim4-bg)', bg: 'var(--dim4-bg)',
iconBg: 'var(--dim4-icon-bg)', iconBg: 'var(--dim4-icon-bg)',
border: 'var(--dim4-border)', border: 'var(--dim4-border)',
glow: 'var(--dim4-glow)', glow: 'var(--dim4-glow)'
articleCount: 20,
quizCount: 8
}, },
{ {
id: 5, id: 5,
key: 'trend', key: 'trend',
name: 'AI 趋势', name: 'AI 趋势',
icon: '🔥', icon: 'sparkle',
tagline: '每日前沿动态速递',
desc: '前沿动态——每日 AI 新闻、产品和趋势洞察', desc: '前沿动态——每日 AI 新闻、产品和趋势洞察',
color: 'var(--dim5-color)', color: 'var(--dim5-color)',
bg: 'var(--dim5-bg)', bg: 'var(--dim5-bg)',
iconBg: 'var(--dim5-icon-bg)', iconBg: 'var(--dim5-icon-bg)',
border: 'var(--dim5-border)', border: 'var(--dim5-border)',
glow: 'var(--dim5-glow)', glow: 'var(--dim5-glow)'
articleCount: 0,
quizCount: 0,
isFull: true // 跨列
} }
] ]
+6 -8
View File
@@ -1,20 +1,18 @@
FROM node:22-bookworm-slim FROM node:22-bookworm-slim
# 安装 CJK 字体 + 系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-wqy-microhei ca-certificates \ ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production PORT=3001 TZ=Asia/Shanghai ENV NODE_ENV=production PORT=3001 TZ=Asia/Shanghai
# dimension server 是 CJS(require),直接复制源码和依赖 COPY server/package.json server/package-lock.json ./
COPY server/package.json server/package-lock.json* ./server/ 2>/dev/null || true
COPY server/package.json ./
RUN cd server 2>/dev/null; npm install --omit=dev --no-audit --no-fund 2>/dev/null || true
COPY server/src ./server/src RUN npm install --omit=dev --no-audit --no-fund
COPY server/src ./src
COPY server/.env* ./ COPY server/.env* ./
EXPOSE 3001 EXPOSE 3001
CMD ["node", "server/src/index.js"] CMD ["node", "src/index.js"]
+2
View File
@@ -16,6 +16,8 @@ services:
context: . context: .
dockerfile: deploy/server.Dockerfile dockerfile: deploy/server.Dockerfile
restart: unless-stopped restart: unless-stopped
ports:
- "127.0.0.1:13001:3001"
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- NODE_ENV=production - NODE_ENV=production
+212
View File
@@ -0,0 +1,212 @@
/**
* 生成统一线性图标集 → client/src/static/icons/
* 用法: NODE_PATH=<ai-art>/server/node_modules node scripts/gen-icons.mjs
* 规格: 48x48 (显示 22-24px @2x), 2.5px 线宽, 圆角端点
*/
import { createRequire } from 'node:module'
import { mkdirSync, writeFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const CANVAS_PATH = process.env.CANVAS_PATH ||
'/home/dev2-ubuntu/workspace/ai-art/server/node_modules/@napi-rs/canvas/index.js'
const { createCanvas } = createRequire(import.meta.url)(CANVAS_PATH)
const __dirname = dirname(fileURLToPath(import.meta.url))
const OUT = join(__dirname, '../client/src/static/icons')
mkdirSync(OUT, { recursive: true })
const BRAND = '#5b5ff8'
const GRAY = '#8e93a8'
const WHITE = '#ffffff'
const GREEN = '#22c55e'
const S = 96
const LW = 2.6
const SCALE = 2
async function make(draw, color = BRAND) {
const c = createCanvas(S, S)
const ctx = c.getContext('2d')
ctx.scale(SCALE, SCALE)
ctx.strokeStyle = color
ctx.fillStyle = color
ctx.lineWidth = LW
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
draw(ctx)
return await c.encode('png')
}
// —— 图标绘制函数(几何线条,风格统一)——
const shapes = {
// 铃铛
notice: (g) => {
g.beginPath(); g.arc(24, 18, 9, Math.PI, 0); g.stroke()
g.beginPath(); g.moveTo(15, 18); g.lineTo(13, 30); g.lineTo(35, 30); g.lineTo(33, 18); g.stroke()
g.beginPath(); g.arc(24, 34, 3, Math.PI, 0, true); g.stroke()
},
// 用户
user: (g) => {
g.beginPath(); g.arc(24, 17, 7, 0, Math.PI * 2); g.stroke()
g.beginPath(); g.arc(24, 40, 12, Math.PI * 1.15, Math.PI * 1.85); g.stroke()
},
// 搜索
search: (g) => {
g.beginPath(); g.arc(21, 21, 10, 0, Math.PI * 2); g.stroke()
g.beginPath(); g.moveTo(29, 29); g.lineTo(38, 38); g.stroke()
},
// 收藏星(空心/实心两版)
star: (g, fill) => {
const pts = []
for (let i = 0; i < 10; i++) {
const r = i % 2 === 0 ? 14 : 6
const a = -Math.PI / 2 + (i * Math.PI) / 5
pts.push([24 + r * Math.cos(a), 24 + r * Math.sin(a)])
}
g.beginPath(); pts.forEach(([x, y], i) => (i ? g.lineTo(x, y) : g.moveTo(x, y))); g.closePath()
if (fill) { g.save(); g.lineWidth = LW / 2; g.fill(); g.restore() }
g.stroke()
},
// 火焰(streak)
flame: (g) => {
g.beginPath()
g.moveTo(24, 10)
g.bezierCurveTo(30, 17, 34, 22, 34, 29)
g.arc(24, 29, 10, 0, Math.PI, false)
g.bezierCurveTo(14, 22, 18, 17, 24, 10)
g.stroke()
g.beginPath(); g.arc(24, 31, 4, 0, Math.PI * 2); g.stroke()
},
// 星球(品牌)
planet: (g) => {
g.beginPath(); g.arc(22, 22, 11, 0, Math.PI * 2); g.stroke()
g.beginPath(); g.ellipse(24, 24, 17, 6.5, -0.45, 0, Math.PI * 2); g.stroke()
},
// 对话气泡
chat: (g) => {
g.beginPath()
g.moveTo(10, 14); g.lineTo(38, 14); g.quadraticCurveTo(41, 14, 41, 17)
g.lineTo(41, 29); g.quadraticCurveTo(41, 32, 38, 32); g.lineTo(22, 32)
g.lineTo(14, 39); g.lineTo(14, 32); g.quadraticCurveTo(11, 32, 11, 29)
g.lineTo(11, 17); g.quadraticCurveTo(11, 14, 14, 14); g.closePath(); g.stroke()
},
// 分享
share: (g) => {
g.beginPath(); g.arc(34, 12, 5, 0, Math.PI * 2); g.stroke()
g.beginPath(); g.arc(34, 36, 5, 0, Math.PI * 2); g.stroke()
g.beginPath(); g.arc(14, 24, 5, 0, Math.PI * 2); g.stroke()
g.beginPath(); g.moveTo(19.5, 21.5); g.lineTo(28.5, 14.5); g.stroke()
g.beginPath(); g.moveTo(19.5, 26.5); g.lineTo(28.5, 33.5); g.stroke()
},
// 设置齿轮
settings: (g) => {
g.beginPath(); g.arc(24, 24, 6.5, 0, Math.PI * 2); g.stroke()
for (let i = 0; i < 8; i++) {
const a = (i * Math.PI) / 4
g.beginPath()
g.moveTo(24 + 10.5 * Math.cos(a), 24 + 10.5 * Math.sin(a))
g.lineTo(24 + 14.5 * Math.cos(a), 24 + 14.5 * Math.sin(a))
g.stroke()
}
},
// 反馈(对话+点)
feedback: (g) => {
g.beginPath()
g.moveTo(10, 13); g.lineTo(38, 13); g.quadraticCurveTo(41, 13, 41, 16)
g.lineTo(41, 28); g.quadraticCurveTo(41, 31, 38, 31); g.lineTo(20, 31)
g.lineTo(13, 38); g.lineTo(13, 31); g.quadraticCurveTo(10, 31, 10, 28)
g.lineTo(10, 16); g.quadraticCurveTo(10, 13, 13, 13); g.closePath(); g.stroke()
g.beginPath(); g.arc(17, 22, 1.6, 0, Math.PI * 2); g.fill()
g.beginPath(); g.arc(25, 22, 1.6, 0, Math.PI * 2); g.fill()
g.beginPath(); g.arc(33, 22, 1.6, 0, Math.PI * 2); g.fill()
},
// 奖杯
trophy: (g) => {
g.beginPath(); g.moveTo(16, 11); g.lineTo(32, 11); g.lineTo(31, 24); g.quadraticCurveTo(30, 30, 24, 30); g.quadraticCurveTo(18, 30, 17, 24); g.closePath(); g.stroke()
g.beginPath(); g.moveTo(24, 30); g.lineTo(24, 36); g.stroke()
g.beginPath(); g.moveTo(17, 38); g.lineTo(31, 38); g.stroke()
g.beginPath(); g.moveTo(16, 14); g.lineTo(10, 16); g.quadraticCurveTo(10, 25, 16, 26); g.stroke()
g.beginPath(); g.moveTo(32, 14); g.lineTo(38, 16); g.quadraticCurveTo(38, 25, 32, 26); g.stroke()
},
// 火箭(趋势)
rocket: (g) => {
g.beginPath()
g.moveTo(24, 8)
g.bezierCurveTo(31, 13, 33, 20, 33, 27)
g.lineTo(24, 33); g.lineTo(15, 27)
g.bezierCurveTo(15, 20, 17, 13, 24, 8)
g.closePath(); g.stroke()
g.beginPath(); g.arc(24, 20, 3.5, 0, Math.PI * 2); g.stroke()
g.beginPath(); g.moveTo(15, 27); g.lineTo(10, 36); g.lineTo(17, 33); g.stroke()
g.beginPath(); g.moveTo(33, 27); g.lineTo(38, 36); g.lineTo(31, 33); g.stroke()
},
// 书本(知识)
book: (g) => {
g.beginPath(); g.moveTo(24, 13); g.quadraticCurveTo(17, 9, 9, 11); g.lineTo(9, 36); g.quadraticCurveTo(17, 34, 24, 38); g.quadraticCurveTo(31, 34, 39, 36); g.lineTo(39, 11); g.quadraticCurveTo(31, 9, 24, 13); g.closePath(); g.stroke()
g.beginPath(); g.moveTo(24, 13); g.lineTo(24, 38); g.stroke()
},
// 灯泡(提示/AI)
bulb: (g) => {
g.beginPath(); g.arc(24, 19, 9.5, Math.PI * 0.95, Math.PI * 0.05, false); g.stroke()
g.beginPath(); g.moveTo(19, 27); g.lineTo(19, 33); g.lineTo(29, 33); g.lineTo(29, 27); g.stroke()
g.beginPath(); g.moveTo(20, 37); g.lineTo(28, 37); g.stroke()
},
// 勾
check: (g) => {
g.beginPath(); g.moveTo(13, 25); g.lineTo(21, 33); g.lineTo(35, 16); g.stroke()
},
// 锁
lock: (g) => {
g.beginPath(); g.moveTo(14, 23); g.lineTo(14, 37); g.lineTo(34, 37); g.lineTo(34, 23); g.closePath(); g.stroke()
g.beginPath(); g.arc(24, 23, 6.5, Math.PI, 0); g.stroke()
},
// 返回箭头
back: (g) => {
g.beginPath(); g.moveTo(27, 12); g.lineTo(18, 24); g.lineTo(27, 36); g.stroke()
},
// 右箭头
arrow: (g) => {
g.beginPath(); g.moveTo(20, 12); g.lineTo(30, 24); g.lineTo(20, 36); g.stroke()
},
// 发送
send: (g) => {
g.beginPath(); g.moveTo(10, 24); g.lineTo(38, 12); g.lineTo(30, 36); g.lineTo(24, 27); g.closePath(); g.stroke()
g.beginPath(); g.moveTo(24, 27); g.lineTo(38, 12); g.stroke()
},
// 礼物
gift: (g) => {
g.beginPath(); g.moveTo(10, 18); g.lineTo(38, 18); g.lineTo(38, 38); g.lineTo(10, 38); g.closePath(); g.stroke()
g.beginPath(); g.moveTo(24, 18); g.lineTo(24, 38); g.stroke()
g.beginPath(); g.moveTo(10, 24); g.lineTo(38, 24); g.stroke()
g.beginPath(); g.arc(19.5, 13, 4.5, Math.PI * 0.6, Math.PI * 1.7); g.stroke()
g.beginPath(); g.arc(28.5, 13, 4.5, Math.PI * 1.3, Math.PI * 0.4); g.stroke()
},
// 时钟(继续探索)
clock: (g) => {
g.beginPath(); g.arc(24, 24, 12, 0, Math.PI * 2); g.stroke()
g.beginPath(); g.moveTo(24, 16); g.lineTo(24, 24); g.lineTo(30, 28); g.stroke()
},
// 闪烁(点亮/正确反馈)
sparkle: (g) => {
g.beginPath()
g.moveTo(24, 8); g.quadraticCurveTo(26, 20, 38, 24); g.quadraticCurveTo(26, 28, 24, 40); g.quadraticCurveTo(22, 28, 10, 24); g.quadraticCurveTo(22, 20, 24, 8)
g.closePath(); g.stroke()
g.beginPath(); g.arc(37, 11, 1.8, 0, Math.PI * 2); g.fill()
g.beginPath(); g.arc(11, 35, 1.4, 0, Math.PI * 2); g.fill()
},
}
const jobs = []
for (const [name, draw] of Object.entries(shapes)) {
jobs.push([name, await make(draw, BRAND)])
jobs.push([`${name}-gray`, await make(draw, GRAY)])
jobs.push([`${name}-white`, await make(draw, WHITE)])
}
// 特例:实心星(品牌色 + 灰)
jobs.push(['star-fill', await make((g) => shapes.star(g, true), BRAND)])
jobs.push(['star-fill-gray', await make((g) => shapes.star(g, true), GRAY)])
jobs.push(['star-fill-white', await make((g) => shapes.star(g, true), WHITE)])
jobs.push(['check-green', await make(shapes.check, GREEN)])
for (const [name, buf] of jobs) writeFileSync(join(OUT, `${name}.png`), buf)
console.log(`生成 ${jobs.length} 个图标 → ${OUT}`)
+16 -6
View File
@@ -15,6 +15,9 @@ const logger = require('./utils/logger')
// 创建 Express 应用 // 创建 Express 应用
const app = express() const app = express()
// 信任反向代理(nginx/宝塔),使 req.ip 取到真实客户端 IP
app.set('trust proxy', 1)
// 数据库连接 // 数据库连接
connectDB() connectDB()
@@ -22,11 +25,14 @@ connectDB()
app.use('/', seoRoutes) app.use('/', seoRoutes)
// 中间件配置 // 中间件配置
app.use(helmet()) // 安全头部 app.use(helmet({
contentSecurityPolicy: false, // H5 与小程序客户端各自管理 CSP;API 服务无需开启
crossOriginResourcePolicy: { policy: 'cross-origin' }
})) // 安全头部
app.use(cors()) // 跨域支持 app.use(cors()) // 跨域支持
app.use(compression()) // Gzip 压缩 app.use(compression()) // Gzip 压缩
app.use(express.json({ limit: '10mb' })) // JSON 解析 app.use(express.json({ limit: '1mb' })) // JSON 解析(普通接口无需 10mb 大包)
app.use(express.urlencoded({ extended: true, limit: '10mb' })) // URL 编码解析 app.use(express.urlencoded({ extended: true, limit: '1mb' })) // URL 编码解析
// 日志中间件 // 日志中间件
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
@@ -67,16 +73,20 @@ app.use((req, res) => {
}) })
}) })
// 全局错误处理 // 全局错误处理(不向客户端泄露内部错误细节)
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
logger.error('服务器错误:', err) logger.error('服务器错误:', err)
// JSON 解析错误等客户端问题返回 400
if (err.type === 'entity.parse.failed' || err.type === 'entity.too.large') {
return res.status(400).json({ success: false, error: '请求体格式错误' })
}
res.status(err.status || 500).json({ res.status(err.status || 500).json({
success: false, success: false,
error: process.env.NODE_ENV === 'production' error: process.env.NODE_ENV === 'production'
? '服务器内部错误' ? '服务器内部错误'
: err.message, : err.message
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
}) })
}) })
+9 -2
View File
@@ -66,9 +66,16 @@ AIChatQuotaSchema.methods.getTotalQuota = function() {
return this.freeQuota + this.sharedQuota return this.freeQuota + this.sharedQuota
} }
// 获取剩余可用次数 // 判断最后使用日期是否是今天
AIChatQuotaSchema.methods.isUsedToday = function() {
if (!this.lastUsedDate) return false
return new Date(this.lastUsedDate).toDateString() === new Date().toDateString()
}
// 获取剩余可用次数(每日重置:总额度 - 今日已用)
AIChatQuotaSchema.methods.getRemainingQuota = function() { AIChatQuotaSchema.methods.getRemainingQuota = function() {
return this.getTotalQuota() - this.usedQuota const todayUsed = this.isUsedToday() ? this.dailyUsed : 0
return Math.max(0, this.getTotalQuota() - todayUsed)
} }
// 检查是否有可用次数 // 检查是否有可用次数
+38
View File
@@ -0,0 +1,38 @@
const mongoose = require('mongoose')
/**
* 知识点亮记录 —— 读完 + 答对检测题后"点亮一颗星球"
*/
const KnowledgeLitSchema = new mongoose.Schema({
openid: {
type: String,
required: true,
index: true
},
knowledgeId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Knowledge',
required: true
},
dim: {
type: Number,
enum: [1, 2, 3, 4, 5],
index: true
},
quizPassed: {
type: Boolean,
default: true
},
litAt: {
type: Date,
default: Date.now,
index: true
}
}, {
timestamps: true
})
// 一个用户对一条知识只点亮一次
KnowledgeLitSchema.index({ openid: 1, knowledgeId: 1 }, { unique: true })
module.exports = mongoose.model('KnowledgeLit', KnowledgeLitSchema)
+2
View File
@@ -1,6 +1,7 @@
const User = require('./User') const User = require('./User')
const Order = require('./Order') const Order = require('./Order')
const Knowledge = require('./Knowledge') const Knowledge = require('./Knowledge')
const KnowledgeLit = require('./KnowledgeLit')
const ShopItem = require('./ShopItem') const ShopItem = require('./ShopItem')
const Admin = require('./Admin') const Admin = require('./Admin')
const AdminLog = require('./AdminLog') const AdminLog = require('./AdminLog')
@@ -15,6 +16,7 @@ module.exports = {
User, User,
Order, Order,
Knowledge, Knowledge,
KnowledgeLit,
ShopItem, ShopItem,
Admin, Admin,
AdminLog, AdminLog,

Some files were not shown because too many files have changed in this diff Show More