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