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
+254 -186
View File
@@ -1,75 +1,93 @@
<template>
<view class="page-home page-layout">
<!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view>
<!-- 顶部导航 -->
<view class="header">
<view class="brand">
<view class="brand-icon">✦</view>
<view class="brand-icon">
<AppIcon name="planet-white" :size="20" />
</view>
<view class="brand-text">
<text class="title">AI 维度</text>
<text class="subtitle">宇之然 · 探索</text>
<text class="subtitle">宇之然 · 知识星图</text>
</view>
</view>
<view class="header-actions">
<view class="icon-btn" @tap="onNotify">
<text>🔔</text>
</view>
<view class="icon-btn" @tap="goProfile">
<text>👤</text>
<AppIcon name="user-gray" :size="18" />
</view>
</view>
</view>
<!-- 问候语 -->
<view class="greeting">
<text class="greeting-title">探索 </text>
<text class="greeting-highlight">人工智能</text>
<text class="greeting-desc">从 5 个维度,系统理解 AI 的过去、现在与未来</text>
<!-- 连续点亮 streak -->
<view class="streak-bar" v-if="streak > 0" @tap="goFirstUnlit">
<AppIcon name="flame" :size="16" />
<text class="streak-text">已连续点亮 {{ streak }} 天</text>
<text class="streak-sub">{{ todayLit ? '今日已点亮,继续保持' : '今日还没点亮,去逛逛' }}</text>
<view class="streak-go">
<AppIcon name="arrow-white" :size="12" />
</view>
</view>
<!-- 继续探索 -->
<view class="resume-card" v-if="lastRead" @tap="resumeRead">
<view class="resume-icon">
<AppIcon name="clock" :size="18" />
</view>
<view class="resume-info">
<text class="resume-label">继续探索</text>
<text class="resume-title">{{ lastRead.title }}</text>
</view>
<AppIcon name="arrow-gray" :size="14" />
</view>
<!-- 搜索栏 -->
<view class="search-bar" @tap="onSearch">
<text class="search-icon">🔍</text>
<AppIcon name="search-gray" :size="16" />
<text class="search-placeholder">搜搜你想了解的 AI 知识...</text>
</view>
<!-- 五维度网格 -->
<!-- 五大星域 -->
<view class="section-header">
<text class="section-title">✦ 五个维度</text>
<text class="section-title">五大星域</text>
<text class="section-more" v-if="userStats">{{ userStats.totalLit }}/{{ statsTotal }} 颗已点亮</text>
</view>
<view class="dim-grid">
<view
v-for="dim in dimensions"
:key="dim.id"
class="dim-card"
:class="{ 'dim-full': dim.isFull }"
:style="{ background: dim.bg }"
@tap="goDimension(dim)"
>
<view class="dim-icon" :style="{ background: dim.iconBg, color: dim.color }">
<text>{{ dim.icon }}</text>
<view class="dim-top">
<view class="dim-icon" :style="{ background: dim.iconBg }">
<AppIcon :name="dim.icon" :size="18" />
</view>
<ProgressRing
:size="34"
:num-size="9"
:percent="dimPercent(dim.id)"
:color="dim.color"
/>
</view>
<text class="dim-name">{{ dim.name }}</text>
<text class="dim-desc">{{ dim.tagline }}</text>
<view class="dim-foot">
<view class="dim-lit" :style="{ background: dim.bg, color: dim.color }">
{{ dimLitText(dim.id) }}
</view>
<text class="dim-count">{{ dimCountText(dim.id) }}</text>
</view>
<text class="dim-name" :style="{ color: dim.color }">{{ dim.name }}</text>
<text class="dim-desc">{{ dim.desc }}</text>
<text class="dim-count">
{{ dim.isFull ? '今日已更新 8 条' : `${dim.articleCount} 篇 · ${dim.quizCount} 个问答` }}
</text>
</view>
</view>
<!-- 今日快讯 -->
<view class="section-header">
<text class="section-title">📡 今日快讯</text>
<text class="section-more" @tap="goTrend">查看全部 →</text>
<text class="section-title">今日快讯</text>
<text class="section-more" @tap="goTrend">查看全部</text>
</view>
<view
v-for="(item, idx) in newsList"
:key="idx"
class="news-card"
@tap="goNewsDetail(item)"
>
<view v-for="(item, idx) in newsList" :key="idx" class="news-card" @tap="goNewsDetail(item)">
<view class="news-tag" :class="item.tagClass">{{ item.tag }}</view>
<text class="news-title">{{ item.title }}</text>
<view class="news-meta">
@@ -83,34 +101,56 @@
</template>
<script setup>
import { onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, onMounted } from 'vue'
import { onPullDownRefresh, onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'
import { DIMENSIONS } from '@/utils/dimensions'
import { trendApi } from '@/utils/api'
import { knowledgeApi, trendApi } from '@/utils/api'
import { useUserStore } from '@/stores/user'
import AppIcon from '@/components/AppIcon.vue'
import ProgressRing from '@/components/ProgressRing.vue'
const userStore = useUserStore()
const dimensions = ref(DIMENSIONS)
const newsList = ref([])
const loading = ref(true)
const byDim = ref({}) // 各星域知识总数
const userStats = ref(null) // { totalLit, litByDim, streak }
const lastRead = ref(null)
const TAG_HOT = '热门'
const TAG_PICK = '精选'
const TAG_NEW = '最新'
const statsTotal = computed(() => Object.values(byDim.value).reduce((a, b) => a + b, 0))
const streak = computed(() => userStats.value?.streak || 0)
const todayLit = computed(() => !!uni.getStorageSync('lit_today'))
onShow(() => {
loadStats()
loadNews()
lastRead.value = uni.getStorageSync('last_read') || null
})
onPullDownRefresh(async () => {
try {
const data = await trendApi.list({ limit: 5 })
// 页面已有响应式引用会自动更新(保持既有逻辑)
} catch (e) {}
await Promise.all([loadStats(), loadNews()])
uni.stopPullDownRefresh()
})
onMounted(() => {
fetchNews()
})
async function fetchNews() {
async function loadStats() {
try {
const data = await trendApi.list({ limit: 5 })
newsList.value = data.list.map(item => ({
tag: item.hot ? '🔥 热门' : item.picked ? '📌 精选' : '🚀 最新',
const data = await knowledgeApi.stats(userStore.isLoggedIn ? userStore.token : '')
byDim.value = data.byDim || {}
userStats.value = data.user || null
} catch (e) {
// 静默降级:环形显示 0%
}
}
async function loadNews() {
try {
const data = await trendApi.list({ limit: 3 })
newsList.value = (data.list || []).slice(0, 3).map(item => ({
tag: item.hot ? TAG_HOT : item.picked ? TAG_PICK : TAG_NEW,
tagClass: item.hot ? 'hot' : item.picked ? 'pick' : 'new',
title: item.title,
source: item.source,
@@ -118,17 +158,27 @@ async function fetchNews() {
_id: item._id
}))
} catch (e) {
// 降级:本地数据
newsList.value = [
{ tag: '🔥 热门', tagClass: 'hot', title: 'DeepSeek 被曝自研推理芯片', source: '量子位', time: '2 小时前' },
{ tag: '🚀 最新', tagClass: 'new', title: '蚂蚁灵波开源 LingBot-World 2.0', source: '量子位', time: '4 小时前' },
{ tag: '📌 精选', tagClass: 'pick', title: '阿里获 ACL 2026 最佳资源论文奖', source: '机器之心', time: '6 小时前' }
]
} finally {
loading.value = false
newsList.value = []
}
}
function dimPercent(dimId) {
const total = byDim.value[dimId] || 0
if (!total) return 0
const lit = userStats.value?.litByDim?.[dimId] || 0
return Math.round((lit / total) * 100)
}
function dimLitText(dimId) {
const lit = userStats.value?.litByDim?.[dimId] || 0
return `${lit} 颗已点亮`
}
function dimCountText(dimId) {
const total = byDim.value[dimId] || 0
return `共 ${total} 颗星球`
}
function formatTime(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime()
const hours = Math.floor(diff / 3600000)
@@ -141,6 +191,16 @@ function goDimension(dim) {
uni.navigateTo({ url: `/pages/dimension/dimension?id=${dim.id}&name=${dim.name}` })
}
function goFirstUnlit() {
uni.navigateTo({ url: '/pages/dimension/dimension?id=1' })
}
function resumeRead() {
if (lastRead.value?.id) {
uni.navigateTo({ url: `/pages/detail/detail?id=${lastRead.value.id}` })
}
}
function goTrend() {
uni.switchTab({ url: '/pages/trend/trend' })
}
@@ -153,21 +213,19 @@ function onSearch() {
uni.navigateTo({ url: '/pages/search/search' })
}
function onNotify() {
uni.showToast({ title: '暂无新消息', icon: 'none' })
}
function goNewsDetail(item) {
uni.setStorageSync('__news_item__', JSON.stringify(item))
uni.navigateTo({ url: '/pages/news/news' })
}
// 微信小程序分享
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手',
title: 'AI 维度 · 5 分钟点亮一颗 AI 知识星球',
path: '/pages/index/index'
}))
onShareTimeline(() => ({
title: 'AI 维度 · 5 分钟点亮一颗 AI 知识星球'
}))
// #endif
</script>
@@ -178,15 +236,13 @@ onShareAppMessage(() => ({
background: var(--bg-primary);
}
.status-bar-placeholder {
height: 44px;
}
.status-bar-placeholder { height: 44px; }
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0 20px;
padding: 6px 0 18px;
}
.brand {
@@ -196,38 +252,32 @@ onShareAppMessage(() => ({
}
.brand-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(0,188,212,0.2));
border: 1px solid rgba(124,77,255,0.2);
width: 38px;
height: 38px;
border-radius: 12px;
background: linear-gradient(135deg, var(--color-brand), #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 0 20px rgba(124,77,255,0.1);
box-shadow: 0 4px 14px var(--color-brand-glow);
}
.brand-text {
display: flex;
flex-direction: column;
}
.brand-text { display: flex; flex-direction: column; }
.brand-text .title {
font-size: 20px;
color: var(--text-primary);
font-weight: 700;
letter-spacing: 1px;
line-height: 1.2;
}
.brand-text .subtitle {
font-size: 11px;
color: var(--text-muted);
letter-spacing: 2px;
color: var(--text-tertiary);
letter-spacing: 1.5px;
margin-top: 2px;
}
.header-actions {
display: flex;
gap: 10px;
}
.header-actions { display: flex; gap: 10px; }
.icon-btn {
width: 36px; height: 36px;
border-radius: 50%;
@@ -236,167 +286,185 @@ onShareAppMessage(() => ({
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
}
.greeting {
margin-bottom: 20px;
padding: 24px 20px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(99,102,241,0.08), rgba(139,92,246,0.04));
border: 1px solid rgba(99,102,241,0.1);
/* streak */
.streak-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, rgba(229, 28, 28, 0.08), rgba(223, 119, 0, 0.06));
border: 1px solid rgba(229, 28, 28, 0.15);
margin-bottom: 12px;
}
.greeting-title {
font-size: 26px;
color: var(--text-primary);
font-weight: 700;
}
.greeting-highlight {
font-size: 26px;
font-weight: 700;
background: linear-gradient(135deg, #b388ff, #7c4dff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.greeting-desc {
display: block;
.streak-text {
font-size: 13px;
font-weight: 700;
color: var(--dim5-color);
}
.streak-sub {
flex: 1;
font-size: 11px;
color: var(--text-tertiary);
margin-top: 4px;
}
.streak-go {
width: 22px; height: 22px;
border-radius: 50%;
background: var(--dim5-color);
display: flex;
align-items: center;
justify-content: center;
}
.search-bar {
/* 继续探索 */
.resume-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: var(--radius-lg);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 14px;
padding: 12px 16px;
box-shadow: var(--card-shadow, none);
margin-bottom: 12px;
}
.resume-icon {
width: 38px; height: 38px;
border-radius: 12px;
background: var(--color-brand-bg);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.resume-info { flex: 1; min-width: 0; }
.resume-label {
font-size: 11px;
color: var(--text-tertiary);
display: block;
margin-bottom: 2px;
}
.resume-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
/* 搜索 */
.search-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 24px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
padding: 12px 16px;
border-radius: var(--radius-full);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
margin-bottom: 6px;
}
.search-icon { color: var(--text-muted); }
.search-placeholder { color: var(--text-muted); font-size: 14px; }
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
padding: 8px 12px;
border-radius: 10px;
background: rgba(99,102,241,0.04);
}
.section-title {
font-size: 17px;
color: var(--text-primary);
font-weight: 600;
letter-spacing: 0.5px;
}
.section-more {
font-size: 12px;
.search-placeholder {
font-size: 13px;
color: var(--text-muted);
}
/* 星域网格 */
.dim-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 28px;
}
.dim-card {
border-radius: 18px;
padding: 20px 16px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.dim-full {
grid-column: 1 / -1;
border-radius: var(--radius-lg);
padding: 14px;
box-shadow: var(--card-shadow, none);
transition: transform 0.2s ease;
}
.dim-card:active { transform: scale(0.97); }
.dim-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.dim-icon {
width: 42px; height: 42px;
border-radius: 12px;
width: 34px; height: 34px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
margin-bottom: 14px;
}
.dim-name {
font-size: 15px;
font-weight: 600;
margin-bottom: 4px;
letter-spacing: 0.5px;
display: block;
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 3px;
}
.dim-desc {
font-size: 12px;
display: block;
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.5;
display: block;
margin-bottom: 10px;
}
.dim-foot {
display: flex;
align-items: center;
justify-content: space-between;
}
.dim-lit {
font-size: 10px;
font-weight: 600;
padding: 3px 8px;
border-radius: var(--radius-full);
}
.dim-count {
font-size: 11px;
font-size: 10px;
color: var(--text-muted);
margin-top: 10px;
display: block;
}
/* 快讯 */
.news-card {
border-radius: 14px;
padding: 14px 16px;
margin-bottom: 10px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: var(--radius-md);
padding: 14px 16px;
margin-bottom: 10px;
box-shadow: var(--card-shadow, none);
}
.news-tag {
display: inline-block;
font-size: 10px;
padding: 2px 10px;
border-radius: 6px;
font-weight: 600;
padding: 2px 8px;
border-radius: var(--radius-full);
margin-bottom: 8px;
font-weight: 500;
}
.news-tag.hot {
background: rgba(239,83,80,0.12);
color: #ef5350;
}
.news-tag.new {
background: rgba(0,188,212,0.12);
color: #4dd0e1;
}
.news-tag.pick {
background: rgba(124,77,255,0.12);
color: #b388ff;
}
.news-tag.hot { background: var(--color-hot-bg); color: var(--color-hot); }
.news-tag.new { background: var(--color-new-bg); color: var(--color-new); }
.news-tag.pick { background: var(--color-pick-bg); color: var(--color-pick); }
.news-title {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.5;
margin-bottom: 8px;
font-weight: 500;
display: block;
}
.news-meta {
display: flex;
gap: 16px;
justify-content: space-between;
font-size: 11px;
color: var(--text-muted);
}
.bottom-spacer {
height: 40px;
}
</style>
.bottom-spacer { height: 30px; }
</style>