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

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

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

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

服务端加固:
- trust proxy 真实 IP、错误信息脱敏、ask 独立限流、无效 ObjectId 404
- seo.js ESM→CJS(修复容器启动)、AI 导师"维度精灵"人格化
This commit is contained in:
2026-09-12 23:39:05 +08:00
parent cbc1a587f2
commit 2a111d21c0
104 changed files with 2776 additions and 2109 deletions
+196 -186
View File
@@ -2,72 +2,96 @@
<view class="page-dim page-layout">
<view class="status-bar-placeholder"></view>
<view class="back-btn" @tap="goBack">
<text>← 返回</text>
<view class="nav-row">
<view class="back-btn" @tap="goBack">
<AppIcon name="back-gray" :size="16" />
<text>返回</text>
</view>
</view>
<!-- 维度横幅 -->
<!-- 星域横幅 -->
<view class="dim-banner" :style="{ borderColor: currentDim?.border }">
<text class="dim-banner-icon">{{ currentDim?.icon }}</text>
<text class="dim-banner-title">{{ currentDim?.name }}</text>
<text class="dim-banner-desc">{{ bannerDesc }}</text>
<view class="progress-track">
<view class="progress-fill" :style="{ width: progress + '%' }"></view>
<view class="banner-top">
<view class="dim-icon" :style="{ background: currentDim?.iconBg }">
<AppIcon :name="currentDim?.icon || 'planet'" :size="20" />
</view>
<view class="banner-info">
<text class="dim-banner-title">{{ currentDim?.name }}</text>
<text class="dim-banner-desc">{{ currentDim?.tagline }}</text>
</view>
<ProgressRing
:size="46"
:num-size="10"
:percent="litPercent"
:color="currentDim?.color"
/>
</view>
<text class="progress-label">探索进度 {{ progress }}% · 已完成 {{ readCount }}/{{ totalCount }} 篇</text>
<text class="progress-label">{{ litCount }}/{{ totalCount }} 颗星球已点亮 · {{ litPercent }}%</text>
</view>
<!-- 知识列表 -->
<view v-for="item in knowledgeList" :key="item._id" class="knowledge-item" @tap="goDetail(item)">
<view class="k-icon" :style="getItemStyle(item)">
<text>{{ item.icon || '📄' }}</text>
<!-- 星球列表 -->
<view
v-for="item in knowledgeList"
:key="item._id"
class="planet-item"
@tap="goDetail(item)"
>
<view class="p-icon" :class="{ lit: item.isLit }" :style="item.isLit ? litStyle : null">
<AppIcon :name="item.isLit ? 'star-fill-white' : 'star-gray'" :size="16" />
</view>
<view class="k-info">
<text class="k-title">{{ item.title }}</text>
<text class="k-desc">{{ item.desc }}</text>
<view class="k-meta">
<text>📖 {{ item.readTime || 5 }} 分钟</text>
<text v-if="item.collected">⭐ 已收藏</text>
<text v-else>🔖 {{ item.collectCount || 0 }} 人收藏</text>
<text class="k-badge" :class="item.isPremium ? 'pro' : 'free'">
{{ item.isPremium ? 'Pro' : '免费' }}
</text>
<view class="p-info">
<text class="p-title">{{ item.title }}</text>
<text class="p-desc">{{ item.desc }}</text>
<view class="p-meta">
<view class="p-lit" v-if="item.isLit" :style="{ color: currentDim?.color, background: currentDim?.bg }">已点亮</view>
<text class="p-read">{{ item.readTime }} 分钟</text>
<text class="p-quiz" v-if="item.hasQuiz">含检测题</text>
</view>
</view>
<AppIcon name="arrow-gray" :size="13" />
</view>
<!-- 空态 -->
<view v-if="!loading && !knowledgeList.length" class="empty-state">
<AppIcon name="planet-gray" :size="40" />
<text class="empty-title">这片星域还没有星球</text>
<text class="empty-desc">内容正在扩充中,先去别的星域逛逛</text>
</view>
<!-- 加载状态 -->
<view v-if="loading" class="loading-state">
<text>加载中...</text>
</view>
<view class="bottom-spacer"></view>
</view>
</template>
<script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, onMounted } from 'vue'
import { getDimension } from '@/utils/dimensions'
import { knowledgeApi } from '@/utils/api'
import { useUserStore } from '@/stores/user'
import AppIcon from '@/components/AppIcon.vue'
import ProgressRing from '@/components/ProgressRing.vue'
const userStore = useUserStore()
const dimId = ref(1)
const currentDim = computed(() => getDimension(dimId.value))
const knowledgeList = ref([])
const loading = ref(false)
const progress = ref(35)
const readCount = ref(4)
const totalCount = ref(12)
const litIds = ref([])
const bannerDesc = computed(() => {
const descs = {
1: '从 1950 年图灵测试到 2026 年通用人工智能,追溯 70 年演进之路',
2: '从深度学习到 Transformer,从 MoE 到多模态,理解每一次技术跃迁',
3: '大模型格局、Agent 生态、具身智能——2026 年 AI 世界全景图',
4: '提示词工程、RAG 系统、模型微调、工具链——从入门到上手',
5: '每日追踪 AI 前沿动态,不错过每一个重要时刻'
}
return descs[dimId.value] || '探索人工智能的精彩世界'
})
const totalCount = computed(() => knowledgeList.value.length)
const litCount = computed(() => knowledgeList.value.filter(i => i.isLit).length)
const litPercent = computed(() =>
totalCount.value ? Math.round((litCount.value / totalCount.value) * 100) : 0
)
const litStyle = computed(() => ({
background: currentDim.value?.iconBg,
borderColor: currentDim.value?.border
}))
onMounted(() => {
const pages = getCurrentPages()
@@ -81,97 +105,42 @@ onMounted(() => {
async function loadKnowledge() {
loading.value = true
try {
const data = await knowledgeApi.list({ dim: dimId.value })
const [data, stats] = await Promise.all([
knowledgeApi.list({ dim: dimId.value, limit: 100 }),
knowledgeApi.stats(userStore.isLoggedIn ? userStore.token : '').catch(() => null)
])
litIds.value = stats?.user?.litIds || []
knowledgeList.value = (data.items || []).map(item => ({
...item,
icon: getDimIcon(item.dim),
desc: item.content?.substring(0, 40) + '...' || '',
readTime: Math.ceil((item.content?.length || 300) / 200),
isPremium: false,
collected: false,
collectCount: item.collectionCount
desc: item.content ? item.content.substring(0, 42) + '...' : '',
readTime: Math.max(1, Math.ceil((item.content?.length || 300) / 300)),
hasQuiz: !!(item.question && (item.options || []).length),
isLit: litIds.value.includes(String(item._id))
}))
} catch (e) {
// 用示例数据填充
knowledgeList.value = getSampleData(dimId.value)
knowledgeList.value = []
uni.showToast({ title: '加载失败,请下拉重试', icon: 'none' })
} finally {
loading.value = false
}
}
function getItemStyle(item) {
const colors = [
{ bg: 'rgba(124,77,255,0.1)', color: '#b388ff' },
{ bg: 'rgba(0,188,212,0.1)', color: '#4dd0e1' },
{ bg: 'rgba(0,200,150,0.1)', color: '#4db6ac' },
{ bg: 'rgba(255,193,7,0.1)', color: '#ffd54f' },
{ bg: 'rgba(239,83,80,0.1)', color: '#ef5350' }
]
const c = colors[(item.dim || 1) - 1] || colors[0]
return {
background: c.bg,
color: c.color,
borderColor: c.bg
}
}
function getDimIcon(dim) {
const icons = ['📜', '🏛', '♟', '🧠', '⚡']
return icons[(dim || 1) - 1]
}
function getSampleData(dim) {
const samples = {
1: [
{ title: '图灵测试:人工智能的哲学起点', content: '1950 年,艾伦·图灵提出"机器能思考吗?"' },
{ title: '达特茅斯会议:AI 的诞生时刻', content: '1956 年,一群科学家正式命名了"人工智能"' },
{ title: '深蓝 vs 卡斯帕罗夫', content: '1997 年,IBM 深蓝首次击败国际象棋世界冠军' },
],
2: [
{ title: '深度学习革命', content: '从神经网络到深度学习的演进之路' },
{ title: 'Transformer 架构详解', content: '2017 年 Google 提出的革命性架构' },
],
3: [
{ title: '2026 年大模型格局', content: 'GPT、Claude、Gemini、DeepSeek 全面对比' },
{ title: 'Agent 智能体生态', content: '从 Chatbot 到自主 Agent 的进化' },
],
4: [
{ title: '提示词工程入门', content: '如何写出高质量的 prompt' },
{ title: 'RAG 系统搭建指南', content: '检索增强生成从零到一' },
],
5: [
{ title: 'DeepSeek 自研推理芯片', content: '降低对英伟达依赖的重要一步' },
{ title: 'WAIC 2026 即将开幕', content: '7 月 17 日上海,1100+ 企业参展' },
]
}
const data = samples[dim] || samples[1]
return data.map((item, i) => ({
...item,
_id: `sample-${dim}-${i}`,
dim,
icon: getDimIcon(dim),
readTime: 5 + i * 2,
isPremium: i >= 3,
collected: i === 0,
collectionCount: 10 - i * 2
}))
}
function goBack() {
uni.navigateBack()
}
function goDetail(item) {
uni.navigateTo({
url: `/pages/detail/detail?id=${item._id}`
})
uni.navigateTo({ url: `/pages/detail/detail?id=${item._id}` })
}
// 微信小程序分享
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手',
path: '/pages/index/index'
title: (currentDim.value?.name || 'AI 维度') + '星域 · 每颗星球都是一则 AI 知识',
path: `/pages/dimension/dimension?id=${dimId.value}`
}))
onShareTimeline(() => ({
title: (currentDim.value?.name || 'AI 维度') + '星域 · AI 维度'
}))
// #endif
</script>
@@ -185,107 +154,148 @@ onShareAppMessage(() => ({
.status-bar-placeholder { height: 44px; }
.nav-row { padding-bottom: 12px; }
.back-btn {
color: rgba(255,255,255,0.4);
font-size: 14px;
display: flex;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 0 14px;
gap: 4px;
padding: 8px 14px 8px 10px;
border-radius: var(--radius-full);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
font-size: 13px;
color: var(--text-secondary);
}
.dim-banner {
border-radius: 18px;
padding: 24px 20px;
margin-bottom: 20px;
background: linear-gradient(135deg, rgba(124,77,255,0.1), rgba(0,0,0,0.3));
border: 1px solid rgba(124,77,255,0.12);
position: relative;
overflow: hidden;
}
.dim-banner-icon { font-size: 28px; display: block; margin-bottom: 8px; }
.dim-banner-title { font-size: 22px; color: var(--text-primary); display: block; margin-bottom: 6px; }
.dim-banner-desc {
font-size: 12px;
color: rgba(255,255,255,0.4);
line-height: 1.6;
display: block;
border-radius: var(--radius-lg);
padding: 18px 16px;
margin-bottom: 16px;
background: var(--glass-bg);
border: 1.5px solid var(--glass-border);
box-shadow: var(--card-shadow, none);
}
.progress-track {
height: 3px;
background: var(--glass-bg);
border-radius: 2px;
margin-top: 16px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #b388ff, #7c4dff);
border-radius: 2px;
}
.progress-label {
font-size: 11px;
color: rgba(255,255,255,0.2);
margin-top: 8px;
display: block;
}
.knowledge-item {
border-radius: 14px;
padding: 16px;
margin-bottom: 10px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
.banner-top {
display: flex;
gap: 14px;
align-items: flex-start;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.k-icon {
.dim-icon {
width: 44px; height: 44px;
border-radius: 12px;
flex-shrink: 0;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
border: 1px solid var(--glass-border);
flex-shrink: 0;
}
.k-info { flex: 1; }
.k-title {
font-size: 14px;
color: rgba(255,255,255,0.85);
font-weight: 500;
.banner-info { flex: 1; min-width: 0; }
.dim-banner-title {
display: block;
margin-bottom: 4px;
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 3px;
}
.k-desc {
font-size: 11px;
color: rgba(255,255,255,0.3);
.dim-banner-desc {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
margin-bottom: 8px;
display: block;
}
.k-meta {
.progress-label {
font-size: 11px;
color: var(--text-tertiary);
}
.planet-item {
display: flex;
align-items: center;
gap: 12px;
border-radius: var(--radius-lg);
padding: 14px;
margin-bottom: 10px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
box-shadow: var(--card-shadow, none);
transition: transform 0.2s ease;
}
.planet-item:active { transform: scale(0.98); }
.p-icon {
width: 40px; height: 40px;
border-radius: 13px;
border: 1.5px dashed var(--glass-border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.lit { border-style: solid; }
}
.p-info { flex: 1; min-width: 0; }
.p-title {
display: block;
font-size: 14px;
color: var(--text-primary);
font-weight: 600;
line-height: 1.5;
margin-bottom: 3px;
}
.p-desc {
display: block;
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.5;
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.p-meta {
display: flex;
gap: 8px;
align-items: center;
font-size: 10px;
color: rgba(255,255,255,0.15);
color: var(--text-muted);
}
.k-badge {
font-size: 10px;
padding: 2px 10px;
border-radius: 6px;
font-weight: 500;
.p-lit {
font-weight: 600;
padding: 2px 8px;
border-radius: var(--radius-full);
}
.p-quiz {
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--bg-tertiary);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 50px 0 30px;
gap: 8px;
.empty-title {
font-size: 15px;
font-weight: 600;
color: var(--text-secondary);
margin-top: 8px;
}
.empty-desc {
font-size: 12px;
color: var(--text-muted);
}
}
.k-badge.free { background: rgba(76,175,80,0.12); color: #66bb6a; }
.k-badge.pro { background: rgba(255,152,0,0.12); color: #ffa726; }
.loading-state {
text-align: center;
padding: 40px 0;
color: rgba(255,255,255,0.2);
color: var(--text-muted);
font-size: 13px;
}
</style>
.bottom-spacer { height: 20px; }
</style>