Files
ai-dimension/client/src/pages/dimension/dimension.vue
T
txai-dev 2a111d21c0 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 导师"维度精灵"人格化
2026-09-12 23:39:05 +08:00

302 lines
7.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page-dim page-layout">
<view class="status-bar-placeholder"></view>
<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 }">
<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">{{ litCount }}/{{ totalCount }} 颗星球已点亮 · {{ litPercent }}%</text>
</view>
<!-- 星球列表 -->
<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="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 litIds = ref([])
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()
const page = pages[pages.length - 1]
if (page.options?.id) {
dimId.value = parseInt(page.options.id)
}
loadKnowledge()
})
async function loadKnowledge() {
loading.value = true
try {
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,
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 = []
uni.showToast({ title: '加载失败,请下拉重试', icon: 'none' })
} finally {
loading.value = false
}
}
function goBack() {
uni.navigateBack()
}
function goDetail(item) {
uni.navigateTo({ url: `/pages/detail/detail?id=${item._id}` })
}
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: (currentDim.value?.name || 'AI 维度') + '星域 · 每颗星球都是一则 AI 知识',
path: `/pages/dimension/dimension?id=${dimId.value}`
}))
onShareTimeline(() => ({
title: (currentDim.value?.name || 'AI 维度') + '星域 · AI 维度'
}))
// #endif
</script>
<style lang="scss" scoped>
.page-dim {
min-height: 100vh;
padding: 0 20px 40px;
background: var(--bg-primary);
}
.status-bar-placeholder { height: 44px; }
.nav-row { padding-bottom: 12px; }
.back-btn {
display: inline-flex;
align-items: center;
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: 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);
}
.banner-top {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.dim-icon {
width: 44px; height: 44px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.banner-info { flex: 1; min-width: 0; }
.dim-banner-title {
display: block;
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 3px;
}
.dim-banner-desc {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
}
.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: var(--text-muted);
}
.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);
}
}
.loading-state {
text-align: center;
padding: 40px 0;
color: var(--text-muted);
font-size: 13px;
}
.bottom-spacer { height: 20px; }
</style>