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 导师"维度精灵"人格化
@@ -8,39 +8,44 @@
|
||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||
|
||||
onLaunch(() => {
|
||||
console.log('[AI维度] App launched')
|
||||
initTheme()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
console.log('[AI维度] App shown')
|
||||
})
|
||||
|
||||
onHide(() => {
|
||||
console.log('[AI维度] App hidden')
|
||||
})
|
||||
onShow(() => {})
|
||||
onHide(() => {})
|
||||
|
||||
// 主题:'auto'(默认,跟随系统)| 'light' | 'dark'
|
||||
function initTheme() {
|
||||
const theme = uni.getStorageSync('theme') || 'light'
|
||||
const theme = uni.getStorageSync('theme') || 'auto'
|
||||
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) {
|
||||
const resolved = resolveTheme(theme)
|
||||
try {
|
||||
// H5 环境:操作 document class
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('theme-dark')
|
||||
root.classList.remove('theme-light')
|
||||
} else {
|
||||
root.classList.remove('theme-dark')
|
||||
root.classList.add('theme-light')
|
||||
}
|
||||
root.classList.remove('theme-dark', 'theme-light')
|
||||
root.classList.add(resolved === 'dark' ? 'theme-dark' : 'theme-light')
|
||||
} catch (e) {
|
||||
// 小程序环境:document 不存在
|
||||
// 小程序环境:document 不存在,深色由 manifest darkmode + CSS 媒体查询接管
|
||||
}
|
||||
// 小程序 tabBar 原生组件同步切换
|
||||
applyTabBarTheme(theme)
|
||||
applyTabBarTheme(resolved)
|
||||
}
|
||||
|
||||
function applyTabBarTheme(theme) {
|
||||
@@ -48,202 +53,34 @@ function applyTabBarTheme(theme) {
|
||||
if (theme === 'dark') {
|
||||
uni.setTabBarStyle({
|
||||
color: 'rgba(255,255,255,0.35)',
|
||||
selectedColor: '#a78bfa',
|
||||
backgroundColor: '#0d0d1a',
|
||||
selectedColor: '#a2b2ff',
|
||||
backgroundColor: '#181828',
|
||||
borderStyle: 'black'
|
||||
})
|
||||
} else {
|
||||
uni.setTabBarStyle({
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
selectedColor: '#6366f1',
|
||||
selectedColor: '#5b5ff8',
|
||||
backgroundColor: '#ffffff',
|
||||
borderStyle: 'white'
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// H5 环境 setTabBarStyle 可能不支持
|
||||
// 非 tabBar 页面或不支持时忽略
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style lang="scss">
|
||||
/* ============================================
|
||||
宇之然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 {
|
||||
@include theme-light-vars;
|
||||
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@@ -252,6 +89,19 @@ page {
|
||||
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 {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -269,7 +119,6 @@ view {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* 页面副标题(统一) */
|
||||
.page-subtitle {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
@@ -282,10 +131,8 @@ view {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
backdrop-filter: blur(var(--blur-bg));
|
||||
-webkit-backdrop-filter: blur(var(--blur-bg));
|
||||
box-shadow: var(--card-shadow, none);
|
||||
transition: all var(--transition-normal);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.gradient-brand {
|
||||
@@ -298,4 +145,25 @@ view {
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
</style>
|
||||
|
||||
/* 区块标题(统一) */
|
||||
.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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -17,6 +17,9 @@
|
||||
},
|
||||
"usingComponents": true,
|
||||
"permission": {},
|
||||
"requiredPrivateInfos": []
|
||||
}
|
||||
"requiredPrivateInfos": [],
|
||||
"darkmode": true,
|
||||
"themeLocation": "theme.json"
|
||||
},
|
||||
"app-plus": {}
|
||||
}
|
||||
@@ -124,25 +124,13 @@
|
||||
"navigationBarTitleText": "协议",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/map/map",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI 知识地图"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/map/quiz",
|
||||
"style": {
|
||||
"navigationBarTitleText": "知识闯关"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTextStyle": "@navTxt",
|
||||
"navigationBarTitleText": "宇之然AI维度",
|
||||
"navigationBarBackgroundColor": "#f5f5f7",
|
||||
"backgroundColor": "#f5f5f7",
|
||||
"navigationBarBackgroundColor": "@navBg",
|
||||
"backgroundColor": "@bg",
|
||||
"backgroundColorTop": "#f5f5f7",
|
||||
"backgroundColorBottom": "#f5f5f7",
|
||||
"app-plus": {
|
||||
@@ -150,10 +138,10 @@
|
||||
}
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "rgba(0,0,0,0.3)",
|
||||
"selectedColor": "#6366f1",
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderStyle": "white",
|
||||
"color": "@tabTxt",
|
||||
"selectedColor": "@tabSel",
|
||||
"backgroundColor": "@tabBg",
|
||||
"borderStyle": "@tabBorder",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/index/index",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<view class="header">
|
||||
<text class="title">🏆 成就</text>
|
||||
<text class="title">成就</text>
|
||||
<text class="unlock-count">{{ unlockedCount }}/{{ totalCount }}</text>
|
||||
</view>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
<!-- 空状态 -->
|
||||
<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-sub">继续学习,解锁属于你的成就吧</text>
|
||||
</view>
|
||||
@@ -88,9 +88,9 @@ const loading = ref(true)
|
||||
|
||||
// 成就分类的默认配置
|
||||
const CATEGORY_CONFIG = [
|
||||
{ name: '知识探索', icon: '📖', color: 'var(--dim1-color)' },
|
||||
{ name: '收藏成就', icon: '⭐', color: 'var(--dim2-color)' },
|
||||
{ name: 'AI 问答', icon: '🤖', color: 'var(--dim3-color)' },
|
||||
{ name: '知识探索', icon: '星', color: 'var(--dim1-color)' },
|
||||
{ name: '收藏成就', icon: '藏', color: 'var(--dim2-color)' },
|
||||
{ name: 'AI 问答', icon: '问', color: 'var(--dim3-color)' },
|
||||
{ name: '社交成就', icon: '👥', color: 'var(--dim4-color)' }
|
||||
]
|
||||
|
||||
@@ -141,7 +141,7 @@ async function loadAchievements() {
|
||||
|
||||
function catIcon(name) {
|
||||
const config = CATEGORY_CONFIG.find((c) => c.name === name)
|
||||
return config ? config.icon : '🏆'
|
||||
return config ? config.icon : '成'
|
||||
}
|
||||
|
||||
function achColor(icon) {
|
||||
|
||||
@@ -2,28 +2,29 @@
|
||||
<view class="page-chat page-layout">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 聊天头部 -->
|
||||
<!-- 聊天头部:维度精灵 -->
|
||||
<view class="chat-header">
|
||||
<view class="chat-avatar">
|
||||
<text class="avatar-text">∞</text>
|
||||
<AppIcon name="planet-white" :size="22" />
|
||||
</view>
|
||||
<view class="chat-info">
|
||||
<text class="chat-name">AI 助手</text>
|
||||
<text class="chat-status">随时为你解答</text>
|
||||
<text class="chat-name">维度精灵</text>
|
||||
<text class="chat-status">你的 AI 知识探索向导</text>
|
||||
</view>
|
||||
<!-- 配额徽章(登录后显示) -->
|
||||
<view class="quota-badge" v-if="userStore.isLoggedIn" @click="goShare">
|
||||
<text class="quota-text">{{ quotaDisplay }}</text>
|
||||
<text class="quota-icon">🎁</text>
|
||||
<view class="quota-badge" @click="onQuotaTap">
|
||||
<AppIcon :name="isGuest ? 'gift' : 'gift-gray'" :size="13" />
|
||||
<text class="quota-text">{{ quotaText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息列表(始终可见) -->
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view class="msg-list" scroll-y :scroll-into-view="scrollTo">
|
||||
<!-- 欢迎消息 -->
|
||||
<!-- 欢迎 -->
|
||||
<view class="welcome-card">
|
||||
<text class="welcome-icon">💡</text>
|
||||
<text class="welcome-text">试试问我这些问题:</text>
|
||||
<view class="welcome-head">
|
||||
<AppIcon name="sparkle" :size="16" />
|
||||
<text class="welcome-text">{{ welcomeText }}</text>
|
||||
</view>
|
||||
<view class="suggest-list">
|
||||
<view class="suggest-item" @click="sendSuggest(item)" v-for="(item, idx) in suggestions" :key="idx">
|
||||
<text>{{ item }}</text>
|
||||
@@ -32,7 +33,9 @@
|
||||
</view>
|
||||
|
||||
<view v-for="(msg, idx) in messages" :key="idx" :id="'msg-' + idx" class="chat-msg" :class="msg.role">
|
||||
<view v-if="msg.role === 'assistant'" class="mini-avatar">∞</view>
|
||||
<view v-if="msg.role === 'assistant'" class="mini-avatar">
|
||||
<AppIcon name="planet-white" :size="14" />
|
||||
</view>
|
||||
<view class="msg-content">
|
||||
<view class="bubble" v-html="msg.content"></view>
|
||||
</view>
|
||||
@@ -40,7 +43,9 @@
|
||||
|
||||
<!-- 加载动画 -->
|
||||
<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="bubble loading-dots">
|
||||
<text class="dot"></text>
|
||||
@@ -49,40 +54,42 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="list-bottom"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 输入区(始终可见,未登录时提示登录) -->
|
||||
<!-- 输入区:游客与登录用户一致,始终可见 -->
|
||||
<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
|
||||
v-model="question"
|
||||
class="input-field"
|
||||
placeholder="输入你对 AI 的疑问..."
|
||||
:placeholder="inputPlaceholder"
|
||||
:disabled="loading"
|
||||
@confirm="sendMessage"
|
||||
confirm-type="send"
|
||||
/>
|
||||
<view class="send-btn" @click="sendMessage">
|
||||
<text>➤</text>
|
||||
</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 class="send-btn" :class="{ disabled: loading || !question.trim() }" @click="sendMessage">
|
||||
<AppIcon name="send-white" :size="16" />
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="isGuest && !guestExhausted" class="guest-hint">游客模式 · 每日 3 次体验</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { aiChatApi } from '@/utils/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
|
||||
const question = ref('')
|
||||
const messages = ref([])
|
||||
@@ -90,35 +97,44 @@ const loading = ref(false)
|
||||
const scrollTo = ref('')
|
||||
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([
|
||||
'"Transformer 的核心原理是什么?"',
|
||||
'"RAG 和微调有什么区别?"',
|
||||
'"2026 年最值得关注的 AI 趋势?"'
|
||||
'用大白话解释 Transformer 是什么',
|
||||
'RAG 和微调有什么区别?',
|
||||
'2026 年最值得关注的 AI 趋势?'
|
||||
])
|
||||
|
||||
// 配额显示
|
||||
const quotaDisplay = computed(() => {
|
||||
const q = userStore.quota
|
||||
if (!q) return '?次'
|
||||
const used = q.usedQuota || q.dailyUsed || 0
|
||||
const remaining = q.remainingQuota || 0
|
||||
const total = q.freeQuota || remaining + used
|
||||
return `${remaining}/${total}`
|
||||
const isGuest = computed(() => !userStore.isLoggedIn)
|
||||
const welcomeText = computed(() => (isGuest.value ? WELCOME_GUEST : WELCOME_LOGGED))
|
||||
const inputPlaceholder = computed(() => (isGuest.value ? INPUT_GUEST : INPUT_LOGGED))
|
||||
const guestExhaustedText = GUEST_EXHAUSTED
|
||||
|
||||
const quotaText = computed(() => {
|
||||
const q = quotaInfo.value
|
||||
if (!q) return '...'
|
||||
return `${q.remainingQuota} 次`
|
||||
})
|
||||
|
||||
// 监听登录状态变化,加载配额
|
||||
const isLoggedIn = computed(() => userStore.isLoggedIn)
|
||||
import { watch } from 'vue'
|
||||
watch(isLoggedIn, (val) => {
|
||||
if (val) {
|
||||
loadQuota()
|
||||
}
|
||||
guestExhausted.value = false
|
||||
loadQuota()
|
||||
}, { immediate: true })
|
||||
|
||||
async function loadQuota() {
|
||||
if (userStore.token) {
|
||||
await userStore.fetchQuota(userStore.token)
|
||||
try {
|
||||
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()
|
||||
if (!q || loading.value) return
|
||||
|
||||
// 未登录 → 跳登录
|
||||
if (!userStore.isLoggedIn) {
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
|
||||
messages.value.push({ role: 'user', content: escapeHtml(q) })
|
||||
question.value = ''
|
||||
scrollToLast()
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await aiChatApi.ask(q, 'general', userStore.token)
|
||||
const data = await aiChatApi.ask(q, 'general', userStore.isLoggedIn ? userStore.token : undefined)
|
||||
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) {
|
||||
console.error('AI 问答失败:', e)
|
||||
const errorMsg = e.message || 'AI 服务暂时不可用,请稍后再试'
|
||||
messages.value.push({
|
||||
role: 'assistant',
|
||||
content: escapeHtml(`抱歉,${errorMsg}`)
|
||||
})
|
||||
const msg = e.message || 'AI 服务暂时不可用,请稍后再试'
|
||||
if (msg.includes('游客体验已用完')) {
|
||||
guestExhausted.value = true
|
||||
} else {
|
||||
messages.value.push({ role: 'assistant', content: escapeHtml(`抱歉,${msg}`) })
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
scrollToLast()
|
||||
@@ -161,8 +176,9 @@ function sendSuggest(text) {
|
||||
|
||||
function scrollToLast() {
|
||||
setTimeout(() => {
|
||||
scrollTo.value = 'msg-' + (messages.value.length - 1)
|
||||
}, 100)
|
||||
scrollTo.value = ''
|
||||
setTimeout(() => { scrollTo.value = 'msg-' + (messages.value.length - 1) }, 50)
|
||||
}, 80)
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
@@ -177,33 +193,43 @@ function goLogin() {
|
||||
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({
|
||||
title: '获取更多次数',
|
||||
content: '将小程序分享给好友,即可获得 5 次额外问答机会',
|
||||
confirmText: '去分享',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// 微信小程序分享能力
|
||||
if (res.confirm && userStore.token) {
|
||||
uni.showShareMenu({ withShareTicket: true })
|
||||
// 调用分享获得次数接口
|
||||
if (userStore.token) {
|
||||
aiChatApi.shareGain(userStore.token).then(() => {
|
||||
uni.showToast({ title: '已记录分享', icon: 'success' })
|
||||
loadQuota()
|
||||
}).catch(() => {})
|
||||
}
|
||||
aiChatApi.shareGain(userStore.token).then(() => {
|
||||
uni.showToast({ title: '已记录分享', icon: 'success' })
|
||||
loadQuota()
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 微信小程序右上角三点分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 - AI 问答',
|
||||
title: '维度精灵 · 你的 AI 知识探索向导',
|
||||
path: '/pages/chat/chat'
|
||||
}))
|
||||
onShareTimeline(() => ({
|
||||
title: '维度精灵 · 你的 AI 知识探索向导'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
@@ -212,7 +238,7 @@ onShareAppMessage(() => ({
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 20px;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
@@ -222,248 +248,230 @@ onShareAppMessage(() => ({
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0 16px;
|
||||
padding: 10px 0 14px;
|
||||
}
|
||||
|
||||
.chat-avatar,
|
||||
.mini-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, rgba(124, 77, 255, 0.3), rgba(0, 188, 212, 0.2));
|
||||
border: 1px solid rgba(124, 77, 255, 0.15);
|
||||
background: linear-gradient(135deg, var(--color-brand), #8b5cf6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 14px var(--color-brand-glow);
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mini-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.chat-avatar { width: 42px; height: 42px; }
|
||||
.mini-avatar { width: 28px; height: 28px; border-radius: 9px; }
|
||||
|
||||
.chat-info { flex: 1; }
|
||||
|
||||
.chat-name {
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.chat-status {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// 配额徽章
|
||||
.quota-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(124, 77, 255, 0.1);
|
||||
border: 1px solid rgba(124, 77, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-brand-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
|
||||
.quota-text {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.quota-text {
|
||||
font-size: 12px;
|
||||
color: var(--dim1-color);
|
||||
font-weight: 600;
|
||||
.msg-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.quota-icon { font-size: 14px; }
|
||||
.list-bottom { height: 16px; }
|
||||
|
||||
// 未登录输入区占位
|
||||
.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 {
|
||||
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 {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--card-shadow, none);
|
||||
}
|
||||
.welcome-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.welcome-icon {
|
||||
font-size: 20px;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
.welcome-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.suggest-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.suggest-item {
|
||||
padding: 8px 12px;
|
||||
background: rgba(124, 77, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
padding: 11px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--glass-border);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
// 消息列表
|
||||
.msg-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.suggest-item:active { border-color: var(--color-brand); color: var(--color-brand); }
|
||||
|
||||
.chat-msg {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.chat-msg.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
&.assistant .msg-content { align-items: flex-start; }
|
||||
|
||||
.chat-msg.assistant {
|
||||
flex-direction: row;
|
||||
}
|
||||
.msg-content {
|
||||
max-width: 82%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
max-width: 82%;
|
||||
&.user {
|
||||
justify-content: flex-end;
|
||||
|
||||
.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);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--text-primary);
|
||||
border-radius: 16px 16px 16px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.7;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-msg.assistant .bubble {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--text-primary);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-msg.user .bubble {
|
||||
background: linear-gradient(135deg, rgba(124, 77, 255, 0.3), rgba(124, 77, 255, 0.15));
|
||||
border: 1px solid rgba(124, 77, 255, 0.15);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
// 加载动画
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 14px 18px;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding: 16px 14px;
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-brand);
|
||||
animation: dotBounce 1.2s infinite ease-in-out;
|
||||
|
||||
&:nth-child(2) { animation-delay: 0.15s; }
|
||||
&:nth-child(3) { animation-delay: 0.3s; }
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
animation: blink 1.4s infinite;
|
||||
@keyframes dotBounce {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-5px); opacity: 1; }
|
||||
}
|
||||
|
||||
.dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes blink {
|
||||
0%, 20% { opacity: 0; transform: scale(0.6); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(0.6); }
|
||||
}
|
||||
|
||||
// 输入区
|
||||
/* 输入区 */
|
||||
.chat-input-area {
|
||||
padding: 12px 0 30px;
|
||||
background: var(--bg-primary);
|
||||
flex-shrink: 0;
|
||||
padding: 10px 0 12px;
|
||||
}
|
||||
|
||||
.guest-hint {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 6px 6px 6px 18px;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
flex: 1;
|
||||
height: 24px;
|
||||
height: 38px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-field::placeholder {
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #7c4dff, #b388ff);
|
||||
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
box-shadow: 0 0 20px rgba(124, 77, 255, 0.2);
|
||||
flex-shrink: 0;
|
||||
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>
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
<template>
|
||||
<view class="detail-page page-layout">
|
||||
<!-- 状态栏占位 -->
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<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>
|
||||
<view class="nav-collect" @click="toggleCollect">
|
||||
<text v-if="isCollected" class="icon icon-collected">★</text>
|
||||
<text v-else class="icon">☆</text>
|
||||
<AppIcon v-if="isCollected" name="star-fill" :size="20" />
|
||||
<AppIcon v-else name="star-gray" :size="20" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 知识头部 -->
|
||||
<view class="detail-header">
|
||||
<view class="dim-tag" :style="{ background: dimColor }">
|
||||
<text class="dim-name">{{ dimensionName }}</text>
|
||||
<view class="dim-tag" :style="{ background: dimInfo.bg, color: dimInfo.color }">
|
||||
{{ dimInfo.name }}
|
||||
</view>
|
||||
<text class="detail-title">{{ detail?.title || '加载中...' }}</text>
|
||||
<view class="meta-row">
|
||||
<text class="meta-item">📖 {{ viewCountText }}</text>
|
||||
<text class="meta-item">⭐ {{ collectionCount }}</text>
|
||||
<text class="meta-item" v-if="detail?.category">
|
||||
{{ categoryLabel }}
|
||||
</text>
|
||||
<view class="meta-item">
|
||||
<AppIcon name="search-gray" :size="12" />
|
||||
<text>{{ viewCountText }} 次阅读</text>
|
||||
</view>
|
||||
<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>
|
||||
|
||||
@@ -32,85 +38,140 @@
|
||||
<text class="tag" v-for="tag in detail.tags" :key="tag">#{{ tag }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<scroll-view class="content-scroll" scroll-y>
|
||||
<!-- 分段内容(如果存在 sections) -->
|
||||
<view class="content-section" v-if="detail?.sections?.length">
|
||||
<view class="section-block" v-for="(section, idx) in detail.sections" :key="idx">
|
||||
<text class="section-title">{{ section.title }}</text>
|
||||
<text class="section-content">{{ section.content }}</text>
|
||||
<view class="section-images" v-if="section.images?.length">
|
||||
<image
|
||||
class="section-img"
|
||||
v-for="(img, i) in section.images"
|
||||
:key="i"
|
||||
:src="img"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 正文 -->
|
||||
<view class="content-card">
|
||||
<view v-if="!detail" class="loading-block">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 普通内容 -->
|
||||
<view class="content-block" v-else>
|
||||
<text class="content-text">{{ detail?.content || '暂无内容' }}</text>
|
||||
<template v-else-if="paragraphs.length">
|
||||
<text
|
||||
v-for="(p, i) in paragraphs"
|
||||
:key="i"
|
||||
class="para"
|
||||
:class="{ 'para-lead': i === 0 }"
|
||||
>{{ p }}</text>
|
||||
</template>
|
||||
<view v-else class="loading-block">
|
||||
<text>暂无内容</text>
|
||||
</view>
|
||||
|
||||
<!-- 问答区域 -->
|
||||
<view class="qa-section" v-if="detail?.category === 'question' && detail?.question">
|
||||
<view class="qa-question">
|
||||
<text class="qa-label">问题</text>
|
||||
<text class="qa-text">{{ detail.question }}</text>
|
||||
</view>
|
||||
<view class="qa-options" v-if="detail?.options?.length">
|
||||
<view
|
||||
class="qa-option"
|
||||
v-for="(opt, idx) in detail.options"
|
||||
:key="idx"
|
||||
:class="{ correct: selectedOption === idx && opt.isCorrect }"
|
||||
@click="selectedOption = idx"
|
||||
>
|
||||
<text class="option-letter">{{ String.fromCharCode(65 + idx) }}</text>
|
||||
<text class="option-text">{{ opt.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="qa-answer" v-if="selectedOption !== null">
|
||||
<text class="answer-label">
|
||||
{{ detail.options?.[selectedOption]?.isCorrect ? '✅ 正确' : '❌ 错误' }}
|
||||
</text>
|
||||
<text class="answer-text" v-if="detail.answer">{{ detail.answer }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部 AI 问答入口 -->
|
||||
<view class="ai-banner" @click="goToAI">
|
||||
<text class="ai-icon">🤖</text>
|
||||
<text class="ai-text">还有疑问?问问 AI 助手</text>
|
||||
<text class="ai-arrow">→</text>
|
||||
</view>
|
||||
|
||||
<!-- 检测题(读后测,答对点亮星球) -->
|
||||
<view class="quiz-card" v-if="detail?.question && detail?.options?.length">
|
||||
<view class="quiz-head">
|
||||
<AppIcon name="sparkle" :size="16" />
|
||||
<text class="quiz-title">读后检测 · 答对点亮这颗星球</text>
|
||||
</view>
|
||||
<text class="quiz-question">{{ detail.question }}</text>
|
||||
|
||||
<template v-if="!quizResult">
|
||||
<view
|
||||
v-for="(opt, idx) in detail.options"
|
||||
:key="idx"
|
||||
class="quiz-option"
|
||||
:class="{ selected: selectedOption === idx }"
|
||||
@click="selectedOption = idx"
|
||||
>
|
||||
<text class="option-letter">{{ optionLetters[idx] }}</text>
|
||||
<text class="option-text">{{ opt.text }}</text>
|
||||
</view>
|
||||
<view
|
||||
class="quiz-submit"
|
||||
:class="{ disabled: selectedOption === null }"
|
||||
@click="submitQuiz"
|
||||
>
|
||||
<text>{{ selectedOption === null ? '选择你的答案' : '确认作答' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<view class="quiz-result" :class="quizResult.correct ? 'ok' : 'bad'">
|
||||
<view class="result-icon">
|
||||
<AppIcon :name="quizResult.correct ? 'check-green' : 'lock-gray'" :size="18" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { knowledgeApi } from '@/utils/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
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 detail = ref(null)
|
||||
const isCollected = ref(false)
|
||||
const selectedOption = ref(null)
|
||||
const quizResult = ref(null)
|
||||
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(() => {
|
||||
const pages = getCurrentPages()
|
||||
const currentPage = pages[pages.length - 1]
|
||||
const query = currentPage.$page?.query || {}
|
||||
const query = currentPage.$page?.query || currentPage.options || {}
|
||||
detailId.value = query.id || ''
|
||||
|
||||
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() {
|
||||
try {
|
||||
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) {
|
||||
const collected = await knowledgeApi.collected(userStore.token)
|
||||
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() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
// 未登录:弹窗给用户选择,可取消,不强制跳转
|
||||
uni.showModal({
|
||||
title: '登录后收藏',
|
||||
content: '登录后即可收藏喜欢的 AI 知识',
|
||||
@@ -198,44 +261,45 @@ function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function goToAI() {
|
||||
function goChat() {
|
||||
uni.switchTab({ url: '/pages/chat/chat' })
|
||||
}
|
||||
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 - 你的AI知识助手',
|
||||
path: '/pages/index/index'
|
||||
title: (detail.value?.title ? detail.value.title + ' · AI 维度' : 'AI 维度 · 知识星球'),
|
||||
path: `/pages/detail/detail?id=${detailId.value}`
|
||||
}))
|
||||
onShareTimeline(() => ({
|
||||
title: detail.value?.title || 'AI 维度 · 知识星球'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
padding: 0 20px 30px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
// 顶部导航
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 50px 20px 12px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 0 14px;
|
||||
|
||||
.nav-back {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-title {
|
||||
font-size: 16px;
|
||||
@@ -243,226 +307,259 @@ onShareAppMessage(() => ({
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-collect {
|
||||
width: 30px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.icon {
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.icon-collected {
|
||||
color: var(--dim5-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 知识头部
|
||||
.detail-header {
|
||||
padding: 0 20px 16px;
|
||||
flex-shrink: 0;
|
||||
padding: 4px 0 14px;
|
||||
|
||||
.dim-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.9;
|
||||
|
||||
.dim-name { color: white; }
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 20px 16px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 0 14px;
|
||||
|
||||
.tag {
|
||||
font-size: 12px;
|
||||
color: var(--dim1-color);
|
||||
background: rgba(179, 136, 255, 0.1);
|
||||
font-size: 11px;
|
||||
color: var(--color-brand);
|
||||
background: var(--color-brand-bg);
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
}
|
||||
|
||||
// 内容滚动区
|
||||
.content-scroll {
|
||||
flex: 1;
|
||||
padding: 0 20px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 80px;
|
||||
.content-card {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 18px 16px;
|
||||
margin-bottom: 14px;
|
||||
box-shadow: var(--card-shadow, none);
|
||||
}
|
||||
|
||||
.content-section,
|
||||
.content-block,
|
||||
.qa-section {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
.loading-block {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.para {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.9;
|
||||
margin-bottom: 14px;
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.quiz-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.quiz-question {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.quiz-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--bg-primary);
|
||||
border: 1.5px solid var(--glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.selected {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-brand-bg);
|
||||
}
|
||||
|
||||
.section-block {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child { margin-bottom: 0; }
|
||||
|
||||
.section-title {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--dim1-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.section-content {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.8;
|
||||
}
|
||||
.section-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
.section-img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
.option-letter {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
.option-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// 问答区域
|
||||
.qa-section {
|
||||
.qa-question {
|
||||
.qa-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.qa-text {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
.qa-options {
|
||||
.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;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.qa-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.correct {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
.option-letter {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.option-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.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 入口 */
|
||||
.ai-banner {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(135deg, var(--dim1-color), var(--dim3-color));
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px rgba(179, 136, 255, 0.3);
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, var(--color-brand-dark), var(--color-brand));
|
||||
border-radius: var(--radius-md);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px var(--color-brand-glow);
|
||||
|
||||
.ai-icon { font-size: 18px; }
|
||||
.ai-text { font-size: 14px; font-weight: 600; }
|
||||
.ai-arrow { font-size: 14px; }
|
||||
.ai-text { font-size: 14px; font-weight: 600; color: #fff; }
|
||||
}
|
||||
|
||||
.bottom-spacer { height: 24px; }
|
||||
</style>
|
||||
|
||||
@@ -2,72 +2,96 @@
|
||||
<view class="page-dim page-layout">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<view class="back-btn" @tap="goBack">
|
||||
<text>← 返回</text>
|
||||
<view class="nav-row">
|
||||
<view class="back-btn" @tap="goBack">
|
||||
<AppIcon name="back-gray" :size="16" />
|
||||
<text>返回</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 维度横幅 -->
|
||||
<!-- 星域横幅 -->
|
||||
<view class="dim-banner" :style="{ borderColor: currentDim?.border }">
|
||||
<text class="dim-banner-icon">{{ currentDim?.icon }}</text>
|
||||
<text class="dim-banner-title">{{ currentDim?.name }}</text>
|
||||
<text class="dim-banner-desc">{{ bannerDesc }}</text>
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" :style="{ width: progress + '%' }"></view>
|
||||
<view 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-desc">{{ currentDim?.tagline }}</text>
|
||||
</view>
|
||||
<ProgressRing
|
||||
:size="46"
|
||||
:num-size="10"
|
||||
:percent="litPercent"
|
||||
:color="currentDim?.color"
|
||||
/>
|
||||
</view>
|
||||
<text class="progress-label">探索进度 {{ progress }}% · 已完成 {{ readCount }}/{{ totalCount }} 篇</text>
|
||||
<text class="progress-label">{{ litCount }}/{{ totalCount }} 颗星球已点亮 · {{ litPercent }}%</text>
|
||||
</view>
|
||||
|
||||
<!-- 知识列表 -->
|
||||
<view v-for="item in knowledgeList" :key="item._id" class="knowledge-item" @tap="goDetail(item)">
|
||||
<view class="k-icon" :style="getItemStyle(item)">
|
||||
<text>{{ item.icon || '📄' }}</text>
|
||||
<!-- 星球列表 -->
|
||||
<view
|
||||
v-for="item in knowledgeList"
|
||||
: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 class="k-info">
|
||||
<text class="k-title">{{ item.title }}</text>
|
||||
<text class="k-desc">{{ item.desc }}</text>
|
||||
<view class="k-meta">
|
||||
<text>📖 {{ item.readTime || 5 }} 分钟</text>
|
||||
<text v-if="item.collected">⭐ 已收藏</text>
|
||||
<text v-else>🔖 {{ item.collectCount || 0 }} 人收藏</text>
|
||||
<text class="k-badge" :class="item.isPremium ? 'pro' : 'free'">
|
||||
{{ item.isPremium ? 'Pro' : '免费' }}
|
||||
</text>
|
||||
<view class="p-info">
|
||||
<text class="p-title">{{ item.title }}</text>
|
||||
<text class="p-desc">{{ item.desc }}</text>
|
||||
<view class="p-meta">
|
||||
<view class="p-lit" v-if="item.isLit" :style="{ color: currentDim?.color, background: currentDim?.bg }">已点亮</view>
|
||||
<text class="p-read">{{ item.readTime }} 分钟</text>
|
||||
<text class="p-quiz" v-if="item.hasQuiz">含检测题</text>
|
||||
</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 v-if="loading" class="loading-state">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="bottom-spacer"></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getDimension } from '@/utils/dimensions'
|
||||
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 currentDim = computed(() => getDimension(dimId.value))
|
||||
const knowledgeList = ref([])
|
||||
const loading = ref(false)
|
||||
const progress = ref(35)
|
||||
const readCount = ref(4)
|
||||
const totalCount = ref(12)
|
||||
const litIds = ref([])
|
||||
|
||||
const bannerDesc = computed(() => {
|
||||
const descs = {
|
||||
1: '从 1950 年图灵测试到 2026 年通用人工智能,追溯 70 年演进之路',
|
||||
2: '从深度学习到 Transformer,从 MoE 到多模态,理解每一次技术跃迁',
|
||||
3: '大模型格局、Agent 生态、具身智能——2026 年 AI 世界全景图',
|
||||
4: '提示词工程、RAG 系统、模型微调、工具链——从入门到上手',
|
||||
5: '每日追踪 AI 前沿动态,不错过每一个重要时刻'
|
||||
}
|
||||
return descs[dimId.value] || '探索人工智能的精彩世界'
|
||||
})
|
||||
const totalCount = computed(() => knowledgeList.value.length)
|
||||
const litCount = computed(() => knowledgeList.value.filter(i => i.isLit).length)
|
||||
const litPercent = computed(() =>
|
||||
totalCount.value ? Math.round((litCount.value / totalCount.value) * 100) : 0
|
||||
)
|
||||
|
||||
const litStyle = computed(() => ({
|
||||
background: currentDim.value?.iconBg,
|
||||
borderColor: currentDim.value?.border
|
||||
}))
|
||||
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages()
|
||||
@@ -81,97 +105,42 @@ onMounted(() => {
|
||||
async function loadKnowledge() {
|
||||
loading.value = true
|
||||
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 => ({
|
||||
...item,
|
||||
icon: getDimIcon(item.dim),
|
||||
desc: item.content?.substring(0, 40) + '...' || '',
|
||||
readTime: Math.ceil((item.content?.length || 300) / 200),
|
||||
isPremium: false,
|
||||
collected: false,
|
||||
collectCount: item.collectionCount
|
||||
desc: item.content ? item.content.substring(0, 42) + '...' : '',
|
||||
readTime: Math.max(1, Math.ceil((item.content?.length || 300) / 300)),
|
||||
hasQuiz: !!(item.question && (item.options || []).length),
|
||||
isLit: litIds.value.includes(String(item._id))
|
||||
}))
|
||||
} catch (e) {
|
||||
// 用示例数据填充
|
||||
knowledgeList.value = getSampleData(dimId.value)
|
||||
knowledgeList.value = []
|
||||
uni.showToast({ title: '加载失败,请下拉重试', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getItemStyle(item) {
|
||||
const colors = [
|
||||
{ bg: 'rgba(124,77,255,0.1)', color: '#b388ff' },
|
||||
{ bg: 'rgba(0,188,212,0.1)', color: '#4dd0e1' },
|
||||
{ bg: 'rgba(0,200,150,0.1)', color: '#4db6ac' },
|
||||
{ bg: 'rgba(255,193,7,0.1)', color: '#ffd54f' },
|
||||
{ bg: 'rgba(239,83,80,0.1)', color: '#ef5350' }
|
||||
]
|
||||
const c = colors[(item.dim || 1) - 1] || colors[0]
|
||||
return {
|
||||
background: c.bg,
|
||||
color: c.color,
|
||||
borderColor: c.bg
|
||||
}
|
||||
}
|
||||
|
||||
function getDimIcon(dim) {
|
||||
const icons = ['📜', '🏛', '♟', '🧠', '⚡']
|
||||
return icons[(dim || 1) - 1]
|
||||
}
|
||||
|
||||
function getSampleData(dim) {
|
||||
const samples = {
|
||||
1: [
|
||||
{ title: '图灵测试:人工智能的哲学起点', content: '1950 年,艾伦·图灵提出"机器能思考吗?"' },
|
||||
{ title: '达特茅斯会议:AI 的诞生时刻', content: '1956 年,一群科学家正式命名了"人工智能"' },
|
||||
{ title: '深蓝 vs 卡斯帕罗夫', content: '1997 年,IBM 深蓝首次击败国际象棋世界冠军' },
|
||||
],
|
||||
2: [
|
||||
{ title: '深度学习革命', content: '从神经网络到深度学习的演进之路' },
|
||||
{ title: 'Transformer 架构详解', content: '2017 年 Google 提出的革命性架构' },
|
||||
],
|
||||
3: [
|
||||
{ title: '2026 年大模型格局', content: 'GPT、Claude、Gemini、DeepSeek 全面对比' },
|
||||
{ title: 'Agent 智能体生态', content: '从 Chatbot 到自主 Agent 的进化' },
|
||||
],
|
||||
4: [
|
||||
{ title: '提示词工程入门', content: '如何写出高质量的 prompt' },
|
||||
{ title: 'RAG 系统搭建指南', content: '检索增强生成从零到一' },
|
||||
],
|
||||
5: [
|
||||
{ title: 'DeepSeek 自研推理芯片', content: '降低对英伟达依赖的重要一步' },
|
||||
{ title: 'WAIC 2026 即将开幕', content: '7 月 17 日上海,1100+ 企业参展' },
|
||||
]
|
||||
}
|
||||
const data = samples[dim] || samples[1]
|
||||
return data.map((item, i) => ({
|
||||
...item,
|
||||
_id: `sample-${dim}-${i}`,
|
||||
dim,
|
||||
icon: getDimIcon(dim),
|
||||
readTime: 5 + i * 2,
|
||||
isPremium: i >= 3,
|
||||
collected: i === 0,
|
||||
collectionCount: 10 - i * 2
|
||||
}))
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function goDetail(item) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/detail/detail?id=${item._id}`
|
||||
})
|
||||
uni.navigateTo({ url: `/pages/detail/detail?id=${item._id}` })
|
||||
}
|
||||
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 - 你的AI知识助手',
|
||||
path: '/pages/index/index'
|
||||
title: (currentDim.value?.name || 'AI 维度') + '星域 · 每颗星球都是一则 AI 知识',
|
||||
path: `/pages/dimension/dimension?id=${dimId.value}`
|
||||
}))
|
||||
onShareTimeline(() => ({
|
||||
title: (currentDim.value?.name || 'AI 维度') + '星域 · AI 维度'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
@@ -185,107 +154,148 @@ onShareAppMessage(() => ({
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
.nav-row { padding-bottom: 12px; }
|
||||
.back-btn {
|
||||
color: rgba(255,255,255,0.4);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 0 14px;
|
||||
gap: 4px;
|
||||
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 {
|
||||
border-radius: 18px;
|
||||
padding: 24px 20px;
|
||||
margin-bottom: 20px;
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.1), rgba(0,0,0,0.3));
|
||||
border: 1px solid rgba(124,77,255,0.12);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dim-banner-icon { font-size: 28px; display: block; margin-bottom: 8px; }
|
||||
.dim-banner-title { font-size: 22px; color: var(--text-primary); display: block; margin-bottom: 6px; }
|
||||
.dim-banner-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 18px 16px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--glass-bg);
|
||||
border: 1.5px solid var(--glass-border);
|
||||
box-shadow: var(--card-shadow, none);
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
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);
|
||||
.banner-top {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.k-icon {
|
||||
.dim-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
border: 1px solid var(--glass-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.k-info { flex: 1; }
|
||||
.k-title {
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.85);
|
||||
font-weight: 500;
|
||||
.banner-info { flex: 1; min-width: 0; }
|
||||
.dim-banner-title {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.k-desc {
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
.dim-banner-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
.k-meta {
|
||||
.progress-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.planet-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.15);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.k-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
.p-lit {
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.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 {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: rgba(255,255,255,0.2);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
.bottom-spacer { height: 20px; }
|
||||
</style>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<!-- 空状态 -->
|
||||
<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-else>登录后可收藏喜欢的 AI 知识</text>
|
||||
<text class="empty-sub" v-if="userStore.isLoggedIn">去首页发现有趣的 AI 知识吧</text>
|
||||
|
||||
@@ -112,7 +112,7 @@ const userStore = useUserStore()
|
||||
// 反馈类型
|
||||
const feedbackTypes = [
|
||||
{ value: 'bug', name: '问题报告', icon: '🐛' },
|
||||
{ value: 'feature', name: '功能建议', icon: '💡' },
|
||||
{ value: 'feature', name: '功能建议', icon: '★' },
|
||||
{ value: 'content', name: '内容纠错', icon: '📝' },
|
||||
{ value: 'other', name: '其他', icon: '💬' }
|
||||
]
|
||||
@@ -147,7 +147,7 @@ function statusLabel(status) {
|
||||
function typeLabel(type) {
|
||||
const map = {
|
||||
bug: '🐛 问题报告',
|
||||
feature: '💡 功能建议',
|
||||
feature: '功能建议',
|
||||
content: '📝 内容纠错',
|
||||
other: '💬 其他'
|
||||
}
|
||||
|
||||
@@ -1,75 +1,93 @@
|
||||
<template>
|
||||
<view class="page-home page-layout">
|
||||
<!-- 状态栏占位 -->
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<view class="header">
|
||||
<view class="brand">
|
||||
<view class="brand-icon">✦</view>
|
||||
<view class="brand-icon">
|
||||
<AppIcon name="planet-white" :size="20" />
|
||||
</view>
|
||||
<view class="brand-text">
|
||||
<text class="title">AI 维度</text>
|
||||
<text class="subtitle">宇之然 · 探索</text>
|
||||
<text class="subtitle">宇之然 · 知识星图</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="header-actions">
|
||||
<view class="icon-btn" @tap="onNotify">
|
||||
<text>🔔</text>
|
||||
</view>
|
||||
<view class="icon-btn" @tap="goProfile">
|
||||
<text>👤</text>
|
||||
<AppIcon name="user-gray" :size="18" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 问候语 -->
|
||||
<view class="greeting">
|
||||
<text class="greeting-title">探索 </text>
|
||||
<text class="greeting-highlight">人工智能</text>
|
||||
<text class="greeting-desc">从 5 个维度,系统理解 AI 的过去、现在与未来</text>
|
||||
<!-- 连续点亮 streak -->
|
||||
<view class="streak-bar" v-if="streak > 0" @tap="goFirstUnlit">
|
||||
<AppIcon name="flame" :size="16" />
|
||||
<text class="streak-text">已连续点亮 {{ streak }} 天</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 class="search-bar" @tap="onSearch">
|
||||
<text class="search-icon">🔍</text>
|
||||
<AppIcon name="search-gray" :size="16" />
|
||||
<text class="search-placeholder">搜搜你想了解的 AI 知识...</text>
|
||||
</view>
|
||||
|
||||
<!-- 五维度网格 -->
|
||||
<!-- 五大星域 -->
|
||||
<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 class="dim-grid">
|
||||
<view
|
||||
v-for="dim in dimensions"
|
||||
:key="dim.id"
|
||||
class="dim-card"
|
||||
:class="{ 'dim-full': dim.isFull }"
|
||||
:style="{ background: dim.bg }"
|
||||
@tap="goDimension(dim)"
|
||||
>
|
||||
<view class="dim-icon" :style="{ background: dim.iconBg, color: dim.color }">
|
||||
<text>{{ dim.icon }}</text>
|
||||
<view class="dim-top">
|
||||
<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>
|
||||
<text class="dim-name" :style="{ color: dim.color }">{{ dim.name }}</text>
|
||||
<text class="dim-desc">{{ dim.desc }}</text>
|
||||
<text class="dim-count">
|
||||
{{ dim.isFull ? '今日已更新 8 条' : `${dim.articleCount} 篇 · ${dim.quizCount} 个问答` }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 今日快讯 -->
|
||||
<view class="section-header">
|
||||
<text class="section-title">📡 今日快讯</text>
|
||||
<text class="section-more" @tap="goTrend">查看全部 →</text>
|
||||
<text class="section-title">今日快讯</text>
|
||||
<text class="section-more" @tap="goTrend">查看全部</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(item, idx) in newsList"
|
||||
:key="idx"
|
||||
class="news-card"
|
||||
@tap="goNewsDetail(item)"
|
||||
>
|
||||
<view v-for="(item, idx) in newsList" :key="idx" class="news-card" @tap="goNewsDetail(item)">
|
||||
<view class="news-tag" :class="item.tagClass">{{ item.tag }}</view>
|
||||
<text class="news-title">{{ item.title }}</text>
|
||||
<view class="news-meta">
|
||||
@@ -83,34 +101,56 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onPullDownRefresh, onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { ref, computed } from 'vue'
|
||||
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 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 () => {
|
||||
try {
|
||||
const data = await trendApi.list({ limit: 5 })
|
||||
// 页面已有响应式引用会自动更新(保持既有逻辑)
|
||||
} catch (e) {}
|
||||
await Promise.all([loadStats(), loadNews()])
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchNews()
|
||||
})
|
||||
|
||||
async function fetchNews() {
|
||||
async function loadStats() {
|
||||
try {
|
||||
const data = await trendApi.list({ limit: 5 })
|
||||
newsList.value = data.list.map(item => ({
|
||||
tag: item.hot ? '🔥 热门' : item.picked ? '📌 精选' : '🚀 最新',
|
||||
const data = await knowledgeApi.stats(userStore.isLoggedIn ? userStore.token : '')
|
||||
byDim.value = data.byDim || {}
|
||||
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',
|
||||
title: item.title,
|
||||
source: item.source,
|
||||
@@ -118,17 +158,27 @@ async function fetchNews() {
|
||||
_id: item._id
|
||||
}))
|
||||
} catch (e) {
|
||||
// 降级:本地数据
|
||||
newsList.value = [
|
||||
{ tag: '🔥 热门', tagClass: 'hot', title: 'DeepSeek 被曝自研推理芯片', source: '量子位', time: '2 小时前' },
|
||||
{ tag: '🚀 最新', tagClass: 'new', title: '蚂蚁灵波开源 LingBot-World 2.0', source: '量子位', time: '4 小时前' },
|
||||
{ tag: '📌 精选', tagClass: 'pick', title: '阿里获 ACL 2026 最佳资源论文奖', source: '机器之心', time: '6 小时前' }
|
||||
]
|
||||
} finally {
|
||||
loading.value = false
|
||||
newsList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
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}` })
|
||||
}
|
||||
|
||||
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() {
|
||||
uni.switchTab({ url: '/pages/trend/trend' })
|
||||
}
|
||||
@@ -153,21 +213,19 @@ function onSearch() {
|
||||
uni.navigateTo({ url: '/pages/search/search' })
|
||||
}
|
||||
|
||||
function onNotify() {
|
||||
uni.showToast({ title: '暂无新消息', icon: 'none' })
|
||||
}
|
||||
|
||||
function goNewsDetail(item) {
|
||||
uni.setStorageSync('__news_item__', JSON.stringify(item))
|
||||
uni.navigateTo({ url: '/pages/news/news' })
|
||||
}
|
||||
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 - 你的AI知识助手',
|
||||
title: 'AI 维度 · 5 分钟点亮一颗 AI 知识星球',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
onShareTimeline(() => ({
|
||||
title: 'AI 维度 · 5 分钟点亮一颗 AI 知识星球'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
@@ -178,15 +236,13 @@ onShareAppMessage(() => ({
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder {
|
||||
height: 44px;
|
||||
}
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0 20px;
|
||||
padding: 6px 0 18px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -196,38 +252,32 @@ onShareAppMessage(() => ({
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(0,188,212,0.2));
|
||||
border: 1px solid rgba(124,77,255,0.2);
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--color-brand), #8b5cf6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 0 20px rgba(124,77,255,0.1);
|
||||
box-shadow: 0 4px 14px var(--color-brand-glow);
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.brand-text { display: flex; flex-direction: column; }
|
||||
.brand-text .title {
|
||||
font-size: 20px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.brand-text .subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 1.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
.icon-btn {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50%;
|
||||
@@ -236,167 +286,185 @@ onShareAppMessage(() => ({
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
margin-bottom: 20px;
|
||||
padding: 24px 20px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, rgba(99,102,241,0.08), rgba(139,92,246,0.04));
|
||||
border: 1px solid rgba(99,102,241,0.1);
|
||||
/* streak */
|
||||
.streak-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
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 {
|
||||
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;
|
||||
.streak-text {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--dim5-color);
|
||||
}
|
||||
.streak-sub {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
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);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
padding: 12px 16px;
|
||||
box-shadow: var(--card-shadow, none);
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.search-icon { color: var(--text-muted); }
|
||||
.search-placeholder { color: var(--text-muted); font-size: 14px; }
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
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;
|
||||
.search-placeholder {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 星域网格 */
|
||||
.dim-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.dim-card {
|
||||
border-radius: 18px;
|
||||
padding: 20px 16px;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
.dim-full {
|
||||
grid-column: 1 / -1;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px;
|
||||
box-shadow: var(--card-shadow, none);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.dim-card:active { transform: scale(0.97); }
|
||||
|
||||
.dim-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.dim-icon {
|
||||
width: 42px; height: 42px;
|
||||
border-radius: 12px;
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.dim-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.dim-desc {
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
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 {
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 快讯 */
|
||||
.news-card {
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: var(--card-shadow, none);
|
||||
}
|
||||
|
||||
.news-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.news-tag.hot {
|
||||
background: rgba(239,83,80,0.12);
|
||||
color: #ef5350;
|
||||
}
|
||||
.news-tag.new {
|
||||
background: rgba(0,188,212,0.12);
|
||||
color: #4dd0e1;
|
||||
}
|
||||
.news-tag.pick {
|
||||
background: rgba(124,77,255,0.12);
|
||||
color: #b388ff;
|
||||
}
|
||||
|
||||
.news-tag.hot { background: var(--color-hot-bg); color: var(--color-hot); }
|
||||
.news-tag.new { background: var(--color-new-bg); color: var(--color-new); }
|
||||
.news-tag.pick { background: var(--color-pick-bg); color: var(--color-pick); }
|
||||
.news-title {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.news-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.bottom-spacer {
|
||||
height: 40px;
|
||||
}
|
||||
</style>
|
||||
.bottom-spacer { height: 30px; }
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -39,7 +39,7 @@
|
||||
<text class="meta-value">{{ news.viewCount || 0 }} 次</text>
|
||||
</view>
|
||||
<view class="meta-item" v-if="news.hot">
|
||||
<text class="meta-label hot">🔥</text>
|
||||
<text class="meta-label hot">热</text>
|
||||
<text class="meta-value">{{ news.hotCount || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -110,7 +110,8 @@ const heroBg = 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(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<view class="page-profile page-layout">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<!-- 学习者档案头 -->
|
||||
<view class="profile-card">
|
||||
<view class="profile-avatar">
|
||||
<image
|
||||
@@ -11,14 +11,48 @@
|
||||
class="avatar-img"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<text v-else class="avatar-text">
|
||||
{{ (userStore.userInfo?.nickName || '用').charAt(0) }}
|
||||
</text>
|
||||
<view v-else class="avatar-placeholder">
|
||||
<AppIcon name="planet-white" :size="26" />
|
||||
</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>
|
||||
<text class="profile-name">{{ userStore.userInfo?.nickName || '游客' }}</text>
|
||||
<text class="profile-level">
|
||||
{{ subscriptionBadge }} · 已收藏 {{ collectedCount }} 篇
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 订阅卡片 -->
|
||||
@@ -26,14 +60,11 @@
|
||||
<view class="plan-info">
|
||||
<text class="plan-title">{{ planTitle }}</text>
|
||||
<text class="plan-desc">{{ planDesc }}</text>
|
||||
<!-- 进度条(订阅到期倒计时) -->
|
||||
<view class="plan-progress" v-if="userStore.subInfo">
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" :style="{ width: planPercent + '%' }"></view>
|
||||
</view>
|
||||
<text class="progress-text">
|
||||
{{ planExpiryText }}
|
||||
</text>
|
||||
<text class="progress-text">{{ planExpiryText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="plan-btn" :class="{ disabled: userStore.isSubscribed }">
|
||||
@@ -42,55 +73,52 @@
|
||||
</view>
|
||||
|
||||
<!-- AI 问答配额 -->
|
||||
<view class="quota-card" @click="goChat" v-if="userStore.isLoggedIn">
|
||||
<text class="quota-card-title">🤖 AI 问答配额</text>
|
||||
<view class="quota-card" @click="goChat" v-if="userStore.isLoggedIn && quotaInfo">
|
||||
<view class="quota-head">
|
||||
<AppIcon name="chat" :size="15" />
|
||||
<text class="quota-card-title">AI 问答 · 今日</text>
|
||||
</view>
|
||||
<view class="quota-bar">
|
||||
<view class="quota-fill" :style="{ width: quotaPercent + '%' }"></view>
|
||||
</view>
|
||||
<text class="quota-text">
|
||||
{{ quotaUsed }} / {{ quotaTotal }}
|
||||
<text class="quota-note">今日使用</text>
|
||||
</text>
|
||||
<text class="quota-share" @click.stop="goShare">🎁 分享得次数</text>
|
||||
<view class="quota-foot">
|
||||
<text class="quota-text">剩余 {{ quotaInfo.remainingQuota }} 次</text>
|
||||
<text class="quota-share" @click.stop="goShare">分享 +5 次</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 菜单列表 -->
|
||||
<view class="menu-section">
|
||||
<view class="menu-item" @click="goFavorites">
|
||||
<view class="m-icon" style="background:rgba(179,136,255,0.1);color:#b388ff">⭐</view>
|
||||
<view class="m-icon" style="background:var(--dim1-bg)"><AppIcon name="star" :size="16" /></view>
|
||||
<text class="m-label">我的收藏</text>
|
||||
<text class="m-badge">{{ collectedCount }}</text>
|
||||
<text class="m-arrow">→</text>
|
||||
<AppIcon name="arrow-gray" :size="12" />
|
||||
</view>
|
||||
<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-arrow">→</text>
|
||||
<AppIcon name="arrow-gray" :size="12" />
|
||||
</view>
|
||||
<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-arrow">→</text>
|
||||
<AppIcon name="arrow-gray" :size="12" />
|
||||
</view>
|
||||
<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-arrow">→</text>
|
||||
<AppIcon name="arrow-gray" :size="12" />
|
||||
</view>
|
||||
<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-arrow">→</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goAbout">
|
||||
<view class="m-icon" style="background:rgba(255,213,79,0.1);color:#ffd54f">ℹ️</view>
|
||||
<text class="m-label">关于 AI 维度</text>
|
||||
<text class="m-arrow">→</text>
|
||||
<AppIcon name="arrow-gray" :size="12" />
|
||||
</view>
|
||||
<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-arrow">→</text>
|
||||
<AppIcon name="arrow-gray" :size="12" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -99,42 +127,98 @@
|
||||
<view class="action-btn primary" @click="goLogin">
|
||||
<text>微信一键登录</text>
|
||||
</view>
|
||||
<!-- 未登录:提供显著有效的「拒绝/暂不登录」入口,避免强制登录 -->
|
||||
<view class="action-btn ghost" @click="goBrowse">
|
||||
<text>暂不登录,先体验</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="action-btn logout" v-else @click="handleLogout">
|
||||
<text>退出登录</text>
|
||||
<text>退出登录</text>
|
||||
</view>
|
||||
|
||||
<text class="version">宇之然 AI 维度 v1.0.0</text>
|
||||
<text class="version">宇之然 AI 维度 v{{ versionText }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
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 dimensions = DIMENSIONS
|
||||
|
||||
// 已收藏数量
|
||||
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(() => {
|
||||
loadStats()
|
||||
if (userStore.isLoggedIn) {
|
||||
loadUserInfo()
|
||||
userStore.fetchMe()
|
||||
loadCollected()
|
||||
loadQuota()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadUserInfo() {
|
||||
await userStore.fetchMe()
|
||||
if (userStore.token) {
|
||||
await userStore.fetchQuota(userStore.token)
|
||||
async function loadStats() {
|
||||
try {
|
||||
const data = await knowledgeApi.stats(userStore.isLoggedIn ? userStore.token : '')
|
||||
stats.value = data.user
|
||||
byDim.value = data.byDim || {}
|
||||
} catch (e) {
|
||||
// 游客或失败:不显示进度区
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,422 +232,393 @@ async function loadCollected() {
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅信息
|
||||
const planTitle = computed(() => {
|
||||
if (userStore.isSubscribed) {
|
||||
return `✦ ${userStore.subscriptionLevel.toUpperCase()} 会员`
|
||||
}
|
||||
return '✦ 免费版'
|
||||
})
|
||||
|
||||
const planDesc = computed(() => {
|
||||
if (userStore.isSubscribed) {
|
||||
return '全量知识解锁 · AI 问答无限次'
|
||||
}
|
||||
return '每日 5 次免费 AI 问答 · 升级解锁全部知识'
|
||||
})
|
||||
|
||||
const planBtnText = computed(() => {
|
||||
if (userStore.isLoggedIn && userStore.isSubscribed) return '已订阅'
|
||||
if (userStore.isLoggedIn) return '升级 Pro'
|
||||
return '登录后升级'
|
||||
})
|
||||
|
||||
const planExpiryText = computed(() => {
|
||||
if (!userStore.subInfo?.expiresAt) return ''
|
||||
const expires = new Date(userStore.subInfo.expiresAt)
|
||||
const now = new Date()
|
||||
const diffDays = Math.ceil((expires - now) / (1000 * 60 * 60 * 24))
|
||||
if (diffDays <= 0) return '已过期'
|
||||
return `${diffDays} 天后到期`
|
||||
})
|
||||
|
||||
const planPercent = computed(() => {
|
||||
if (!userStore.subInfo?.expiresAt || !userStore.subInfo?.startDate) return 0
|
||||
const start = new Date(userStore.subInfo.startDate).getTime()
|
||||
const expires = new Date(userStore.subInfo.expiresAt).getTime()
|
||||
const now = Date.now()
|
||||
const total = expires - start
|
||||
const elapsed = now - start
|
||||
return Math.min(100, Math.max(0, (elapsed / total) * 100))
|
||||
})
|
||||
|
||||
// 配额
|
||||
const quotaPercent = computed(() => {
|
||||
const q = userStore.quota
|
||||
if (!q) return 0
|
||||
const remaining = q.remainingQuota || 0
|
||||
const used = q.usedQuota || q.dailyUsed || 0
|
||||
const total = q.freeQuota || remaining + used
|
||||
return total > 0 ? Math.round((used / total) * 100) : 0
|
||||
})
|
||||
|
||||
const quotaUsed = computed(() => userStore.quota?.dailyUsed || userStore.quota?.usedQuota || 0)
|
||||
const quotaTotal = computed(() => {
|
||||
const q = userStore.quota
|
||||
return (q?.freeQuota || 5) + (q?.sharedQuota || 0)
|
||||
})
|
||||
|
||||
const subscriptionBadge = computed(() => {
|
||||
if (userStore.isSubscribed) {
|
||||
return userStore.subscriptionLevel.toUpperCase()
|
||||
}
|
||||
return '免费用户'
|
||||
})
|
||||
|
||||
function goLogin() {
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
async function loadQuota() {
|
||||
try {
|
||||
quotaInfo.value = await aiChatApi.quota(userStore.token)
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
/** 仅浏览:未登录用户可跳过登录直接使用首页 */
|
||||
function goBrowse() {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
function dimPercent(dimId) {
|
||||
const total = byDim.value[dimId] || 0
|
||||
if (!total) return 0
|
||||
return Math.round(((stats.value?.litByDim?.[dimId] || 0) / total) * 100)
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
uni.showModal({
|
||||
title: '退出登录',
|
||||
content: '确定退出当前账号吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
userStore.logout()
|
||||
collectedCount.value = 0
|
||||
uni.showToast({ title: '已退出', icon: 'success' })
|
||||
}
|
||||
}
|
||||
})
|
||||
function goDim(id) {
|
||||
uni.navigateTo({ url: `/pages/dimension/dimension?id=${id}` })
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
const res = await uni.showModal({ title: '退出登录', content: '确定退出当前账号?' })
|
||||
if (res.confirm) {
|
||||
userStore.logout()
|
||||
stats.value = null
|
||||
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() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
goLogin()
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
if (userStore.isSubscribed) return
|
||||
uni.navigateTo({ url: '/pages/payment/payment' })
|
||||
}
|
||||
|
||||
function goChat() {
|
||||
uni.switchTab({ url: '/pages/chat/chat' })
|
||||
}
|
||||
|
||||
function goShare() {
|
||||
if (!userStore.token) return
|
||||
uni.showShareMenu({ withShareTicket: true })
|
||||
aiChatApi.shareGain(userStore.token).then(() => {
|
||||
uni.showToast({ title: '已记录分享', icon: 'success' })
|
||||
if (userStore.token) userStore.fetchQuota(userStore.token)
|
||||
}).catch(() => {
|
||||
uni.showToast({ title: '已记录分享', icon: 'success' })
|
||||
})
|
||||
uni.showToast({ title: '已记录分享 +5 次', icon: 'success' })
|
||||
loadQuota()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
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
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 - 你的AI知识助手',
|
||||
title: '我在 AI 维度点亮了知识星球,来一起探索',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
onShareTimeline(() => ({
|
||||
title: 'AI 维度 · 点亮你的知识星球'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-profile {
|
||||
min-height: 100vh;
|
||||
padding: 0 20px 40px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
// 用户卡片
|
||||
.profile-card {
|
||||
text-align: center;
|
||||
padding: 20px 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
width: 62px; height: 62px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(124, 77, 255, 0.3), rgba(0, 188, 212, 0.15));
|
||||
border: 2px solid rgba(124, 77, 255, 0.2);
|
||||
margin: 0 auto 14px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 40px rgba(124, 77, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
|
||||
.avatar-img { width: 100%; height: 100%; }
|
||||
.avatar-placeholder {
|
||||
width: 100%; height: 100%;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--color-brand), #8b5cf6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-main { flex: 1; min-width: 0; }
|
||||
.profile-name {
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.profile-level {
|
||||
.profile-sub {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// 订阅卡片
|
||||
.plan-card {
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
.streak-chip {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, rgba(124, 77, 255, 0.08), rgba(0, 0, 0, 0.2));
|
||||
border: 1px solid rgba(124, 77, 255, 0.12);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-info { flex: 1; }
|
||||
|
||||
.plan-title {
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plan-desc {
|
||||
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;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
color: var(--dim5-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plan-progress {
|
||||
margin-top: 10px;
|
||||
/* 学习数据 */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 3px;
|
||||
background: var(--glass-bg);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plan-progress .progress-track {
|
||||
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;
|
||||
.stat-tile {
|
||||
flex: 1;
|
||||
background: var(--glass-bg);
|
||||
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;
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.quota-bar {
|
||||
height: 4px;
|
||||
/* 五星域进度 */
|
||||
.dims-progress {
|
||||
background: var(--glass-bg);
|
||||
border-radius: 2px;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
.quota-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4dd0e1, #b388ff);
|
||||
border-radius: 2px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, var(--color-brand), var(--color-brand-light));
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.quota-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
.quota-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
padding: 4px 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--card-shadow, none);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 15px 16px;
|
||||
gap: 12px;
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.menu-item:last-child {
|
||||
border-bottom: none;
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.m-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.m-label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.m-arrow {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.m-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(179, 136, 255, 0.12);
|
||||
color: var(--dim1-color);
|
||||
}
|
||||
|
||||
// 按钮
|
||||
.action-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;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 操作 */
|
||||
.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);
|
||||
border: 1.5px solid var(--glass-border);
|
||||
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 {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.1);
|
||||
margin-top: 12px;
|
||||
color: var(--text-muted);
|
||||
padding: 8px 0 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
@confirm="doSearch"
|
||||
confirm-type="search"
|
||||
/>
|
||||
<view class="search-icon">🔍</view>
|
||||
<view class="search-icon"></view>
|
||||
<view v-if="searchText" class="clear-btn" @click="clearSearch">✕</view>
|
||||
</view>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<!-- 空搜索提示 -->
|
||||
<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-hint">支持按标题、摘要、标签搜索</text>
|
||||
</view>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<view class="section-title">偏好设置</view>
|
||||
<view class="card">
|
||||
<view class="setting-item" @click="togglePush">
|
||||
<view class="s-icon" style="background:rgba(255,213,79,0.1);color:#ffd54f">🔔</view>
|
||||
<view class="s-icon" style="background:rgba(91,95,248,0.1)">铃</view>
|
||||
<view class="s-text">
|
||||
<text class="s-label">推送通知</text>
|
||||
<text class="s-desc">接收每日 AI 趋势推送</text>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<view class="card-footer">
|
||||
<text class="card-date">{{ formatDate(item.newsDate) }}</text>
|
||||
<view class="card-stats">
|
||||
<text class="stat-item" v-if="item.hot">🔥 {{ item.hotCount }}</text>
|
||||
<text class="stat-item" v-if="item.hot">{{ item.hotCount }} 人觉得热</text>
|
||||
<text class="stat-item">👁️ {{ item.viewCount || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -56,7 +56,7 @@
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
<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-sub">稍后再来看最新 AI 动态</text>
|
||||
</view>
|
||||
@@ -72,7 +72,7 @@ import { trendApi } from '@/utils/api'
|
||||
|
||||
const filters = ref([
|
||||
{ icon: '📰', text: '全部' },
|
||||
{ icon: '🚀', text: '产品' },
|
||||
{ icon: 'rocket', text: '产品' },
|
||||
{ icon: '📄', text: '论文' },
|
||||
{ icon: '🔧', text: '工具' },
|
||||
{ icon: '🏢', text: '公司' }
|
||||
@@ -116,9 +116,9 @@ async function fetchTrends() {
|
||||
}
|
||||
|
||||
function getTagText(item) {
|
||||
if (item.hot) return '🔥 热门'
|
||||
if (item.picked) return '📌 精选'
|
||||
if (item.category === '产品') return '🚀 产品'
|
||||
if (item.hot) return '热门'
|
||||
if (item.picked) 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 {
|
||||
background: rgba(124, 77, 255, 0.12);
|
||||
color: #b388ff;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.card-tag.product {
|
||||
@@ -321,7 +321,7 @@ onShareAppMessage(() => ({
|
||||
|
||||
.card-tag.paper {
|
||||
background: rgba(179, 136, 255, 0.12);
|
||||
color: #b388ff;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.card-tag.tool {
|
||||
|
||||
|
After Width: | Height: | Size: 989 B |
|
After Width: | Height: | Size: 496 B |
|
After Width: | Height: | Size: 1007 B |
|
After Width: | Height: | Size: 859 B |
|
After Width: | Height: | Size: 448 B |
|
After Width: | Height: | Size: 874 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 934 B |
|
Before Width: | Height: | Size: 297 B After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 660 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 984 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 737 B |
|
After Width: | Height: | Size: 1012 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -1,25 +1,164 @@
|
||||
/* ============================================
|
||||
宇之然AI维度 — 主题系统 v3
|
||||
亮色默认 + CSS 自定义属性切换
|
||||
宇之然AI维度 — 主题系统 v4
|
||||
亮色默认 + CSS 自定义属性切换(mixin 化,防重复)
|
||||
============================================ */
|
||||
|
||||
/*
|
||||
* 注意: 此文件通过 uni.scss 注入到每个组件
|
||||
* 但全局 CSS 变量定义在 App.vue 的非 scoped <style> 中
|
||||
* 此文件只保留 SCSS 变量 + 工具类,防止重复
|
||||
*/
|
||||
/* ---- 主题变量 Mixin(唯一出处,App.vue 引用) ---- */
|
||||
@mixin theme-light-vars {
|
||||
/* 基础色 */
|
||||
--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 变量(方便组件内使用) ---- */
|
||||
$color-brand: #6366f1;
|
||||
$color-brand-light: #818cf8;
|
||||
$color-brand: #5b5ff8;
|
||||
$color-brand-light: #7b87fd;
|
||||
$color-bg-primary: #f5f5f7;
|
||||
$color-bg-dark: #14141e;
|
||||
|
||||
$dim1-color: #6366f1;
|
||||
$dim2-color: #0891b2;
|
||||
$dim3-color: #059669;
|
||||
$dim4-color: #d97706;
|
||||
$dim5-color: #dc2626;
|
||||
/* ---- SCSS 变量(方便组件内使用) ---- */
|
||||
|
||||
/* 全局排版 */
|
||||
page {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
const BASE_URL = 'https://wdkj.yuzhiran.com.cn'
|
||||
const DEV_URL = 'http://127.0.0.1:3001'
|
||||
|
||||
// 开发环境走本地后端,生产环境使用线上域名
|
||||
const API_BASE = import.meta.env.DEV ? DEV_URL : BASE_URL
|
||||
// 构建时可用 VITE_API_BASE 覆盖(本地 docker 验证等场景)
|
||||
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) =>
|
||||
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) =>
|
||||
request('POST', `/api/knowledge/${id}/collect`, null, token),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 维度配置 — 5 个 AI 维度的元数据
|
||||
* 修改这里可以调整维度名称、颜色、图标等
|
||||
* 星域配置 — 5 个 AI 知识星域的元数据
|
||||
* 修改这里可以调整星域名称、颜色、图标等
|
||||
* 真实计数由 /api/knowledge/stats 下发,本地不再硬编码
|
||||
*/
|
||||
|
||||
export const DIMENSIONS = [
|
||||
@@ -8,72 +9,66 @@ export const DIMENSIONS = [
|
||||
id: 1,
|
||||
key: 'origin',
|
||||
name: 'AI 起源',
|
||||
icon: '🌅',
|
||||
icon: 'book',
|
||||
tagline: '从图灵测试到深度学习革命',
|
||||
desc: '人工智能的诞生——从图灵测试到第一代探索者',
|
||||
color: 'var(--dim1-color)',
|
||||
bg: 'var(--dim1-bg)',
|
||||
iconBg: 'var(--dim1-icon-bg)',
|
||||
border: 'var(--dim1-border)',
|
||||
glow: 'var(--dim1-glow)',
|
||||
articleCount: 12,
|
||||
quizCount: 4
|
||||
glow: 'var(--dim1-glow)'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
key: 'development',
|
||||
name: 'AI 发展',
|
||||
icon: '📈',
|
||||
icon: 'rocket',
|
||||
tagline: 'Transformer、大模型与 Agent',
|
||||
desc: '技术跃迁之路——深度学习、Transformer 和前沿突破',
|
||||
color: 'var(--dim2-color)',
|
||||
bg: 'var(--dim2-bg)',
|
||||
iconBg: 'var(--dim2-icon-bg)',
|
||||
border: 'var(--dim2-border)',
|
||||
glow: 'var(--dim2-glow)',
|
||||
articleCount: 18,
|
||||
quizCount: 6
|
||||
glow: 'var(--dim2-glow)'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
key: 'current',
|
||||
name: 'AI 当前',
|
||||
icon: '🌐',
|
||||
icon: 'planet',
|
||||
tagline: '2026 行业全景与具身智能',
|
||||
desc: '2026 年 AI 世界——大模型、Agent、人形机器人',
|
||||
color: 'var(--dim3-color)',
|
||||
bg: 'var(--dim3-bg)',
|
||||
iconBg: 'var(--dim3-icon-bg)',
|
||||
border: 'var(--dim3-border)',
|
||||
glow: 'var(--dim3-glow)',
|
||||
articleCount: 15,
|
||||
quizCount: 5
|
||||
glow: 'var(--dim3-glow)'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
key: 'learning',
|
||||
name: 'AI 学习',
|
||||
icon: '🛠',
|
||||
icon: 'bulb',
|
||||
tagline: '提示词、RAG 与实用工具',
|
||||
desc: '动手学 AI——提示词、微调、RAG 和实用工具',
|
||||
color: 'var(--dim4-color)',
|
||||
bg: 'var(--dim4-bg)',
|
||||
iconBg: 'var(--dim4-icon-bg)',
|
||||
border: 'var(--dim4-border)',
|
||||
glow: 'var(--dim4-glow)',
|
||||
articleCount: 20,
|
||||
quizCount: 8
|
||||
glow: 'var(--dim4-glow)'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
key: 'trend',
|
||||
name: 'AI 趋势',
|
||||
icon: '🔥',
|
||||
icon: 'sparkle',
|
||||
tagline: '每日前沿动态速递',
|
||||
desc: '前沿动态——每日 AI 新闻、产品和趋势洞察',
|
||||
color: 'var(--dim5-color)',
|
||||
bg: 'var(--dim5-bg)',
|
||||
iconBg: 'var(--dim5-icon-bg)',
|
||||
border: 'var(--dim5-border)',
|
||||
glow: 'var(--dim5-glow)',
|
||||
articleCount: 0,
|
||||
quizCount: 0,
|
||||
isFull: true // 跨列
|
||||
glow: 'var(--dim5-glow)'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -85,4 +80,4 @@ export function getDimension(id) {
|
||||
/** 按维度 key 查找 */
|
||||
export function getDimensionByKey(key) {
|
||||
return DIMENSIONS.find(d => d.key === key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
# 安装 CJK 字体 + 系统依赖
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fonts-wqy-microhei ca-certificates \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production PORT=3001 TZ=Asia/Shanghai
|
||||
|
||||
# dimension server 是 CJS(require),直接复制源码和依赖
|
||||
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/package.json server/package-lock.json ./
|
||||
|
||||
COPY server/src ./server/src
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
|
||||
COPY server/src ./src
|
||||
COPY server/.env* ./
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["node", "server/src/index.js"]
|
||||
CMD ["node", "src/index.js"]
|
||||
|
||||
@@ -16,6 +16,8 @@ services:
|
||||
context: .
|
||||
dockerfile: deploy/server.Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:13001:3001"
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- NODE_ENV=production
|
||||
|
||||
@@ -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}`)
|
||||
@@ -15,6 +15,9 @@ const logger = require('./utils/logger')
|
||||
// 创建 Express 应用
|
||||
const app = express()
|
||||
|
||||
// 信任反向代理(nginx/宝塔),使 req.ip 取到真实客户端 IP
|
||||
app.set('trust proxy', 1)
|
||||
|
||||
// 数据库连接
|
||||
connectDB()
|
||||
|
||||
@@ -22,11 +25,14 @@ connectDB()
|
||||
app.use('/', seoRoutes)
|
||||
|
||||
// 中间件配置
|
||||
app.use(helmet()) // 安全头部
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false, // H5 与小程序客户端各自管理 CSP;API 服务无需开启
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' }
|
||||
})) // 安全头部
|
||||
app.use(cors()) // 跨域支持
|
||||
app.use(compression()) // Gzip 压缩
|
||||
app.use(express.json({ limit: '10mb' })) // JSON 解析
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' })) // URL 编码解析
|
||||
app.use(express.json({ limit: '1mb' })) // JSON 解析(普通接口无需 10mb 大包)
|
||||
app.use(express.urlencoded({ extended: true, limit: '1mb' })) // URL 编码解析
|
||||
|
||||
// 日志中间件
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
@@ -67,16 +73,20 @@ app.use((req, res) => {
|
||||
})
|
||||
})
|
||||
|
||||
// 全局错误处理
|
||||
// 全局错误处理(不向客户端泄露内部错误细节)
|
||||
app.use((err, req, res, next) => {
|
||||
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({
|
||||
success: false,
|
||||
error: process.env.NODE_ENV === 'production'
|
||||
? '服务器内部错误'
|
||||
: err.message,
|
||||
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
|
||||
error: process.env.NODE_ENV === 'production'
|
||||
? '服务器内部错误'
|
||||
: err.message
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -66,9 +66,16 @@ AIChatQuotaSchema.methods.getTotalQuota = function() {
|
||||
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() {
|
||||
return this.getTotalQuota() - this.usedQuota
|
||||
const todayUsed = this.isUsedToday() ? this.dailyUsed : 0
|
||||
return Math.max(0, this.getTotalQuota() - todayUsed)
|
||||
}
|
||||
|
||||
// 检查是否有可用次数
|
||||
|
||||
@@ -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)
|
||||
@@ -1,6 +1,7 @@
|
||||
const User = require('./User')
|
||||
const Order = require('./Order')
|
||||
const Knowledge = require('./Knowledge')
|
||||
const KnowledgeLit = require('./KnowledgeLit')
|
||||
const ShopItem = require('./ShopItem')
|
||||
const Admin = require('./Admin')
|
||||
const AdminLog = require('./AdminLog')
|
||||
@@ -15,6 +16,7 @@ module.exports = {
|
||||
User,
|
||||
Order,
|
||||
Knowledge,
|
||||
KnowledgeLit,
|
||||
ShopItem,
|
||||
Admin,
|
||||
AdminLog,
|
||||
|
||||