feat: 微信分享钩子全页面接入 + 订阅信息结构化映射 + JWT 错误处理加强
- client: 全部页面接入 onShareAppMessage/onShareTimeline 分享钩子 - client: login 页增强、profile 订阅展示优化 - client/stores: 订阅字段扁平结构映射为 subInfo(plan/expiresAt/startDate) - server: auth 路由 JWT 无效/过期统一返回 401 未授权响应 - server: models/tests 同步更新
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@parcel/watcher': set this to true or false
|
'@parcel/watcher': true
|
||||||
core-js: set this to true or false
|
core-js: true
|
||||||
esbuild: set this to true or false
|
esbuild: true
|
||||||
vue-demi: set this to true or false
|
vue-demi: true
|
||||||
|
|||||||
@@ -76,6 +76,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { userApi } from '@/utils/api'
|
import { userApi } from '@/utils/api'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
@@ -113,8 +115,7 @@ const progressPercent = computed(() => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!userStore.isLoggedIn) {
|
if (!userStore.isLoggedIn) {
|
||||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
loading.value = false
|
||||||
uni.navigateTo({ url: '/pages/login/login' })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadAchievements()
|
loadAchievements()
|
||||||
@@ -158,10 +159,12 @@ function formatDate(dateStr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 - 你的AI知识助手',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -90,6 +90,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
const type = ref('terms')
|
const type = ref('terms')
|
||||||
@@ -114,10 +116,12 @@ function onLoad(fn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 - 你的AI知识助手',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -78,6 +78,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, computed } 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'
|
||||||
@@ -197,10 +199,12 @@ function goShare() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序右上角三点分享
|
// 微信小程序右上角三点分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - AI 问答',
|
title: 'AI 维度 - AI 问答',
|
||||||
path: '/pages/chat/chat'
|
path: '/pages/chat/chat'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -93,6 +93,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
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'
|
||||||
@@ -163,7 +165,18 @@ async function fetchDetail() {
|
|||||||
|
|
||||||
async function toggleCollect() {
|
async function toggleCollect() {
|
||||||
if (!userStore.isLoggedIn) {
|
if (!userStore.isLoggedIn) {
|
||||||
uni.navigateTo({ url: '/pages/login/login' })
|
// 未登录:弹窗给用户选择,可取消,不强制跳转
|
||||||
|
uni.showModal({
|
||||||
|
title: '登录后收藏',
|
||||||
|
content: '登录后即可收藏喜欢的 AI 知识',
|
||||||
|
confirmText: '去登录',
|
||||||
|
cancelText: '暂不',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.navigateTo({ url: '/pages/login/login' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -190,10 +203,12 @@ function goToAI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 - 你的AI知识助手',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -44,6 +44,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
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'
|
||||||
@@ -80,12 +82,12 @@ async function loadKnowledge() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await knowledgeApi.list({ dim: dimId.value })
|
const data = await knowledgeApi.list({ dim: dimId.value })
|
||||||
knowledgeList.value = data.map(item => ({
|
knowledgeList.value = (data.items || []).map(item => ({
|
||||||
...item,
|
...item,
|
||||||
icon: getDimIcon(item.dim),
|
icon: getDimIcon(item.dim),
|
||||||
desc: item.content?.substring(0, 40) + '...' || '',
|
desc: item.content?.substring(0, 40) + '...' || '',
|
||||||
readTime: Math.ceil((item.content?.length || 300) / 200),
|
readTime: Math.ceil((item.content?.length || 300) / 200),
|
||||||
isPremium: item.isPremium,
|
isPremium: false,
|
||||||
collected: false,
|
collected: false,
|
||||||
collectCount: item.collectionCount
|
collectCount: item.collectionCount
|
||||||
}))
|
}))
|
||||||
@@ -166,10 +168,12 @@ function goDetail(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 - 你的AI知识助手',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
<!-- 维度标签 -->
|
<!-- 维度标签 -->
|
||||||
<view
|
<view
|
||||||
class="dim-tag"
|
class="dim-tag"
|
||||||
:style="{ color: dimColor(item.dimensionId) }"
|
:style="{ color: dimColor(item.dimension) }"
|
||||||
>
|
>
|
||||||
{{ dimName(item.dimensionId) }}
|
{{ dimName(item.dimension) }}
|
||||||
</view>
|
</view>
|
||||||
<!-- 标题 -->
|
<!-- 标题 -->
|
||||||
<text class="fav-title">{{ item.title }}</text>
|
<text class="fav-title">{{ item.title }}</text>
|
||||||
@@ -35,8 +35,9 @@
|
|||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<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">你还没有收藏任何内容</text>
|
<text class="empty-text" v-if="userStore.isLoggedIn">你还没有收藏任何内容</text>
|
||||||
<text class="empty-sub">去首页发现有趣的 AI 知识吧</text>
|
<text class="empty-text" v-else>登录后可收藏喜欢的 AI 知识</text>
|
||||||
|
<text class="empty-sub" v-if="userStore.isLoggedIn">去首页发现有趣的 AI 知识吧</text>
|
||||||
<view class="empty-btn" @tap="goHome">
|
<view class="empty-btn" @tap="goHome">
|
||||||
<text>开始探索</text>
|
<text>开始探索</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -45,6 +46,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { knowledgeApi } from '@/utils/api'
|
import { knowledgeApi } from '@/utils/api'
|
||||||
import { getDimension } from '@/utils/dimensions'
|
import { getDimension } from '@/utils/dimensions'
|
||||||
@@ -56,8 +59,8 @@ const loading = ref(true)
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!userStore.isLoggedIn) {
|
if (!userStore.isLoggedIn) {
|
||||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
// 未登录时显示空状态提示,不强制跳转登录
|
||||||
uni.navigateTo({ url: '/pages/login/login' })
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
@@ -114,10 +117,12 @@ function goHome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 - 你的AI知识助手',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -101,6 +101,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { feedbackApi } from '@/utils/api'
|
import { feedbackApi } from '@/utils/api'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
@@ -228,10 +230,12 @@ function goBack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 - 你的AI知识助手',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -83,6 +83,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { DIMENSIONS } from '@/utils/dimensions'
|
import { DIMENSIONS } from '@/utils/dimensions'
|
||||||
import { trendApi } from '@/utils/api'
|
import { trendApi } from '@/utils/api'
|
||||||
@@ -153,10 +155,12 @@ function goNewsDetail(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 - 你的AI知识助手',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -37,20 +37,20 @@
|
|||||||
<!-- 头像 -->
|
<!-- 头像 -->
|
||||||
<view class="avatar">
|
<view class="avatar">
|
||||||
<image
|
<image
|
||||||
v-if="item.avatarUrl"
|
v-if="item.avatar"
|
||||||
:src="item.avatarUrl"
|
:src="item.avatar"
|
||||||
class="avatar-img"
|
class="avatar-img"
|
||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
/>
|
/>
|
||||||
<text v-else class="avatar-text">
|
<text v-else class="avatar-text">
|
||||||
{{ (item.nickName || item.username || '用').charAt(0) }}
|
{{ (item.nickname || item.username || '用').charAt(0) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 信息 -->
|
<!-- 信息 -->
|
||||||
<view class="user-info">
|
<view class="user-info">
|
||||||
<text class="user-name">{{ item.nickName || item.username || '用户' }}</text>
|
<text class="user-name">{{ item.nickname || item.username || '用户' }}</text>
|
||||||
<text class="user-score">{{ item.score || 0 }} 分</text>
|
<text class="user-score">{{ item.totalScore || 0 }} 分</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 已解锁维度 -->
|
<!-- 已解锁维度 -->
|
||||||
@@ -81,6 +81,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { userApi } from '@/utils/api'
|
import { userApi } from '@/utils/api'
|
||||||
import { getDimension } from '@/utils/dimensions'
|
import { getDimension } from '@/utils/dimensions'
|
||||||
@@ -102,8 +104,7 @@ const dimTabs = [
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!userStore.isLoggedIn) {
|
if (!userStore.isLoggedIn) {
|
||||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
loading.value = false
|
||||||
uni.navigateTo({ url: '/pages/login/login' })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadBoard()
|
loadBoard()
|
||||||
@@ -116,10 +117,10 @@ async function loadBoard() {
|
|||||||
const data = await userApi.leaderboard(dimParam)
|
const data = await userApi.leaderboard(dimParam)
|
||||||
const list = Array.isArray(data) ? data : data.list || []
|
const list = Array.isArray(data) ? data : data.list || []
|
||||||
// 标记当前用户
|
// 标记当前用户
|
||||||
const myId = userStore.userInfo?.id || userStore.userInfo?.userId || ''
|
const myId = userStore.userInfo?.openid || ''
|
||||||
board.value = list.map((item) => ({
|
board.value = list.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
isCurrentUser: String(item.userId || item.id || '') === String(myId)
|
isCurrentUser: item.openid === myId
|
||||||
}))
|
}))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[leaderboard] load failed:', e)
|
console.error('[leaderboard] load failed:', e)
|
||||||
@@ -135,10 +136,12 @@ function dimDotColor(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 - 你的AI知识助手',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
<view class="bg-orb orb-2"></view>
|
<view class="bg-orb orb-2"></view>
|
||||||
<view class="bg-orb orb-3"></view>
|
<view class="bg-orb orb-3"></view>
|
||||||
|
|
||||||
|
<!-- 顶部返回 -->
|
||||||
|
<view class="nav-bar">
|
||||||
|
<view class="nav-back" @click="goBack">
|
||||||
|
<text class="nav-back-icon">←</text>
|
||||||
|
<text class="nav-back-text">返回</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="login-container">
|
<view class="login-container">
|
||||||
<view class="logo-wrap">
|
<view class="logo-wrap">
|
||||||
<view class="logo-ring"></view>
|
<view class="logo-ring"></view>
|
||||||
@@ -28,6 +36,11 @@
|
|||||||
<input class="form-input" v-model="loginForm.password" type="password" placeholder="请输入密码" />
|
<input class="form-input" v-model="loginForm.password" type="password" placeholder="请输入密码" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 忘记密码 -->
|
||||||
|
<view class="forgot-row">
|
||||||
|
<text class="forgot-link" @click="goForgotPassword">忘记密码?</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 登录协议勾选 -->
|
<!-- 登录协议勾选 -->
|
||||||
<view class="agreement-row">
|
<view class="agreement-row">
|
||||||
<view class="agreement-checkbox" :class="{ checked: loginAgreed }" @click="loginAgreed = !loginAgreed">
|
<view class="agreement-checkbox" :class="{ checked: loginAgreed }" @click="loginAgreed = !loginAgreed">
|
||||||
@@ -45,12 +58,14 @@
|
|||||||
<text>{{ loading ? '登录中...' : '登录' }}</text>
|
<text>{{ loading ? '登录中...' : '登录' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<!-- 微信小程序一键登录 -->
|
<!-- 微信小程序一键登录 -->
|
||||||
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
<view class="wx-login-row" v-if="isMiniApp">
|
<view class="wx-login-row" v-if="isMiniApp">
|
||||||
<view class="wx-login-btn" @click="wechatLogin">
|
<view class="wx-login-btn" @click="wechatLogin">
|
||||||
<text class="wx-icon">💬</text>
|
<text class="wx-icon">💬</text>
|
||||||
<text>微信一键登录</text>
|
<text>微信一键登录</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<!-- #endif -->
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 注册表单 -->
|
<!-- 注册表单 -->
|
||||||
@@ -59,6 +74,10 @@
|
|||||||
<text class="form-label">邮箱 <text style="color:var(--color-vip);font-size:11px;">*</text></text>
|
<text class="form-label">邮箱 <text style="color:var(--color-vip);font-size:11px;">*</text></text>
|
||||||
<input class="form-input" v-model="registerForm.email" type="email" placeholder="请输入邮箱地址" @input="registerForm.email = $event.detail.value.trim(); verifyCodeSent = false" />
|
<input class="form-input" v-model="registerForm.email" type="email" placeholder="请输入邮箱地址" @input="registerForm.email = $event.detail.value.trim(); verifyCodeSent = false" />
|
||||||
</view>
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="form-label">用户名 <text style="color:var(--color-vip);font-size:11px;">*</text></text>
|
||||||
|
<input class="form-input" v-model="registerForm.username" placeholder="请输入用户名(用于登录)" @input="registerForm.username = $event.detail.value.trim()" />
|
||||||
|
</view>
|
||||||
<view class="form-item">
|
<view class="form-item">
|
||||||
<text class="form-label">昵称 <text style="color:var(--text-muted);font-size:11px;">(选填)</text></text>
|
<text class="form-label">昵称 <text style="color:var(--text-muted);font-size:11px;">(选填)</text></text>
|
||||||
<input class="form-input" v-model="registerForm.nickName" placeholder="显示名称,留空自动生成" />
|
<input class="form-input" v-model="registerForm.nickName" placeholder="显示名称,留空自动生成" />
|
||||||
@@ -100,21 +119,30 @@
|
|||||||
<view class="footer-info">
|
<view class="footer-info">
|
||||||
<text>© 2026 宇之然 · AI 维度</text>
|
<text>© 2026 宇之然 · AI 维度</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 显著有效的「拒绝/暂不登录」入口,避免强制登录 -->
|
||||||
|
<view class="skip-login" @click="goBrowse">
|
||||||
|
<text>暂不登录,先体验</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useUserStore } from '@/stores/index'
|
import { useUserStore } from '@/stores/index'
|
||||||
|
|
||||||
const isMiniApp = ref(false)
|
const isMiniApp = ref(false)
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
try {
|
try {
|
||||||
const sysInfo = uni.getSystemInfoSync()
|
const sysInfo = uni.getSystemInfoSync()
|
||||||
if (sysInfo && sysInfo.version && sysInfo.platform) {
|
if (sysInfo && sysInfo.version && sysInfo.platform) {
|
||||||
isMiniApp.value = true
|
isMiniApp.value = true
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
// #endif
|
||||||
|
|
||||||
const activeTab = ref('login')
|
const activeTab = ref('login')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -127,7 +155,7 @@ let countdownTimer = null
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const loginForm = ref({ email: '', password: '' })
|
const loginForm = ref({ email: '', password: '' })
|
||||||
const registerForm = ref({ email: '', password: '', nickName: '', verifyCode: '' })
|
const registerForm = ref({ email: '', username: '', password: '', nickName: '', verifyCode: '' })
|
||||||
|
|
||||||
const BASE_URL = 'https://wdkj.yuzhiran.com.cn'
|
const BASE_URL = 'https://wdkj.yuzhiran.com.cn'
|
||||||
|
|
||||||
@@ -241,6 +269,10 @@ const handleRegister = async () => {
|
|||||||
uni.showToast({ title: '请输入密码', icon: 'none' })
|
uni.showToast({ title: '请输入密码', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!registerForm.value.username) {
|
||||||
|
uni.showToast({ title: '请输入用户名', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (registerForm.value.password.length < 6) {
|
if (registerForm.value.password.length < 6) {
|
||||||
uni.showToast({ title: '密码至少 6 位', icon: 'none' })
|
uni.showToast({ title: '密码至少 6 位', icon: 'none' })
|
||||||
return
|
return
|
||||||
@@ -255,7 +287,8 @@ const handleRegister = async () => {
|
|||||||
registerForm.value.email,
|
registerForm.value.email,
|
||||||
registerForm.value.password,
|
registerForm.value.password,
|
||||||
registerForm.value.nickName || '',
|
registerForm.value.nickName || '',
|
||||||
registerForm.value.verifyCode
|
registerForm.value.verifyCode,
|
||||||
|
registerForm.value.username
|
||||||
)
|
)
|
||||||
uni.showToast({ title: '注册成功', icon: 'success' })
|
uni.showToast({ title: '注册成功', icon: 'success' })
|
||||||
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
|
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
|
||||||
@@ -274,11 +307,26 @@ function openAgreement(type) {
|
|||||||
uni.navigateTo({ url: pages[type] || pages.terms })
|
uni.navigateTo({ url: pages[type] || pages.terms })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack({ fallback: () => uni.switchTab({ url: '/pages/index/index' }) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拒绝登录:返回首页以游客身份浏览,不强制登录 */
|
||||||
|
function goBrowse() {
|
||||||
|
uni.switchTab({ url: '/pages/index/index' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goForgotPassword() {
|
||||||
|
uni.showToast({ title: '请联系管理员重置密码', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 - 你的AI知识助手',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -303,6 +351,19 @@ onShareAppMessage(() => ({
|
|||||||
.orb-2 { width: 160px; height: 160px; top: 30%; right: -40px; background: var(--dim3-color); }
|
.orb-2 { width: 160px; height: 160px; top: 30%; right: -40px; background: var(--dim3-color); }
|
||||||
.orb-3 { width: 180px; height: 180px; bottom: -60px; left: 30%; background: var(--dim5-color); }
|
.orb-3 { width: 180px; height: 180px; bottom: -60px; left: 30%; background: var(--dim5-color); }
|
||||||
|
|
||||||
|
// 顶部导航栏
|
||||||
|
.nav-bar {
|
||||||
|
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
|
||||||
|
padding: 44px 20px 0;
|
||||||
|
.nav-back {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
padding: 8px 12px; cursor: pointer;
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
.nav-back-icon { font-size: 20px; color: var(--text-primary); }
|
||||||
|
.nav-back-text { font-size: 14px; color: var(--text-secondary); }
|
||||||
|
}
|
||||||
|
|
||||||
.login-container {
|
.login-container {
|
||||||
position: relative; z-index: 1;
|
position: relative; z-index: 1;
|
||||||
width: 100%; max-width: 360px;
|
width: 100%; max-width: 360px;
|
||||||
@@ -354,6 +415,15 @@ onShareAppMessage(() => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 忘记密码
|
||||||
|
.forgot-row {
|
||||||
|
display: flex; justify-content: flex-end; margin: -8px 0 0;
|
||||||
|
.forgot-link {
|
||||||
|
font-size: 12px; color: var(--color-brand); padding: 4px 0;
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 验证码行
|
// 验证码行
|
||||||
.verify-row {
|
.verify-row {
|
||||||
display: flex; gap: 10px;
|
display: flex; gap: 10px;
|
||||||
@@ -417,5 +487,20 @@ onShareAppMessage(() => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer-info { margin-top: 40px; font-size: 11px; color: var(--text-muted); }
|
.footer-info { margin-top: 40px; font-size: 11px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
// 暂不登录入口(拒绝/返回,显著有效)
|
||||||
|
.skip-login {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 14px 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
&:active { opacity: 0.7; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -90,6 +90,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { trendApi, knowledgeApi } from '@/utils/api'
|
import { trendApi, knowledgeApi } from '@/utils/api'
|
||||||
|
|
||||||
@@ -140,9 +142,14 @@ const relatedTags = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function loadDetail() {
|
async function loadDetail() {
|
||||||
const id = uni.getStorageSync('__news_id__')
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
// Try API detail first, fallback to local data from trend page
|
// 列表页通过 __news_item__ 传入条目(含 _id),优先拉取详情以刷新阅读量
|
||||||
|
const raw = uni.getStorageSync('__news_item__')
|
||||||
|
let item = null
|
||||||
|
if (raw) {
|
||||||
|
try { item = JSON.parse(raw) } catch (e) {}
|
||||||
|
}
|
||||||
|
const id = item?._id
|
||||||
if (id) {
|
if (id) {
|
||||||
try {
|
try {
|
||||||
const data = await trendApi.detail(id)
|
const data = await trendApi.detail(id)
|
||||||
@@ -150,17 +157,11 @@ async function loadDetail() {
|
|||||||
isCollected.value = data.collected || false
|
isCollected.value = data.collected || false
|
||||||
return
|
return
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// fall through to local
|
// 回退到本地缓存
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: read from trend page's stored item
|
if (item) {
|
||||||
try {
|
news.value = item
|
||||||
const raw = uni.getStorageSync('__news_item__')
|
|
||||||
if (raw) {
|
|
||||||
news.value = JSON.parse(raw)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +176,7 @@ function onFavorite() {
|
|||||||
|
|
||||||
function onShare() {
|
function onShare() {
|
||||||
uni.setClipboardData({
|
uni.setClipboardData({
|
||||||
data: news.value.link || `https://ai-dimension.yuzhiran.com.cn/news/${uni.getStorageSync('__news_id__')}`,
|
data: news.value.link || `https://ai-dimension.yuzhiran.com.cn/news/${news.value._id || ''}`,
|
||||||
success: () => uni.showToast({ title: '链接已复制', icon: 'none' })
|
success: () => uni.showToast({ title: '链接已复制', icon: 'none' })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -199,10 +200,12 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 - 你的AI知识助手',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -80,10 +80,35 @@
|
|||||||
|
|
||||||
<view class="footer-spacer"></view>
|
<view class="footer-spacer"></view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- H5 支付失败/备选方案 -->
|
||||||
|
<view v-if="showH5Fallback" class="h5-fallback-mask">
|
||||||
|
<view class="h5-fallback-content">
|
||||||
|
<view class="fallback-icon">📱</view>
|
||||||
|
<text class="fallback-title">请使用微信完成支付</text>
|
||||||
|
<text class="fallback-desc">
|
||||||
|
订单号:{{ h5OrderNo }}
|
||||||
|
金额:¥{{ currentPrice }}
|
||||||
|
</text>
|
||||||
|
<view class="fallback-actions">
|
||||||
|
<button class="fallback-btn primary" @click="retryH5Pay">
|
||||||
|
再次跳转微信支付
|
||||||
|
</button>
|
||||||
|
<button class="fallback-btn secondary" @click="copyH5Url">
|
||||||
|
复制链接到微信打开
|
||||||
|
</button>
|
||||||
|
<button class="fallback-btn text-btn" @click="closeH5Fallback">
|
||||||
|
稍后再说
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { paymentApi } from '@/utils/api'
|
import { paymentApi } from '@/utils/api'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
@@ -102,6 +127,21 @@ const currentPrice = computed(() => PLAN_MAP[selectedPlan.value].price)
|
|||||||
const currentProductId = computed(() => PLAN_MAP[selectedPlan.value].productId)
|
const currentProductId = computed(() => PLAN_MAP[selectedPlan.value].productId)
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
// H5 支付相关状态
|
||||||
|
const h5PayUrl = ref('')
|
||||||
|
const showH5Fallback = ref(false)
|
||||||
|
const h5OrderNo = ref('')
|
||||||
|
|
||||||
|
// 检测是否运行在 H5 环境
|
||||||
|
function isH5() {
|
||||||
|
// #ifdef H5
|
||||||
|
return true
|
||||||
|
// #endif
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
return false
|
||||||
|
// #endif
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
async function handlePay() {
|
async function handlePay() {
|
||||||
if (!userStore.isLoggedIn) {
|
if (!userStore.isLoggedIn) {
|
||||||
@@ -119,6 +159,39 @@ async function handlePay() {
|
|||||||
const amount = plan.price
|
const amount = plan.price
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
|
// ===== H5 环境:使用微信支付 H5 跳转链接 =====
|
||||||
|
if (isH5()) {
|
||||||
|
try {
|
||||||
|
const data = await paymentApi.createH5Order(productId, window.location.hostname, userStore.token)
|
||||||
|
h5OrderNo.value = data.orderNo
|
||||||
|
|
||||||
|
if (data.h5Url) {
|
||||||
|
h5PayUrl.value = data.h5Url
|
||||||
|
showH5Fallback.value = false
|
||||||
|
|
||||||
|
// 尝试直接跳转微信支付页面
|
||||||
|
// #ifdef H5
|
||||||
|
window.location.href = data.h5Url
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// H5 环境下跳转后不会返回,这里设置一个超时提示
|
||||||
|
setTimeout(() => {
|
||||||
|
showH5Fallback.value = true
|
||||||
|
}, 5000)
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '获取支付链接失败', icon: 'none' })
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('H5创建订单失败:', err)
|
||||||
|
uni.showToast({ title: err.message || '创建订单失败,请重试', icon: 'none' })
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 微信小程序环境:使用原生支付 =====
|
||||||
try {
|
try {
|
||||||
const data = await paymentApi.createOrder(productId, userStore.token)
|
const data = await paymentApi.createOrder(productId, userStore.token)
|
||||||
const payParam = data.payParams || data.wechatParam || data.paymentData
|
const payParam = data.payParams || data.wechatParam || data.paymentData
|
||||||
@@ -167,15 +240,52 @@ async function handlePay() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** H5 支付:复制链接到微信打开 */
|
||||||
|
function copyH5Url() {
|
||||||
|
// #ifdef H5
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(h5PayUrl.value).then(() => {
|
||||||
|
uni.showToast({ title: '链接已复制,请到微信中打开', icon: 'none' })
|
||||||
|
}).catch(() => {
|
||||||
|
// fallback
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = h5PayUrl.value
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
uni.showToast({ title: '链接已复制,请到微信中打开', icon: 'none' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/** H5 支付:再次跳转 */
|
||||||
|
function retryH5Pay() {
|
||||||
|
if (h5PayUrl.value) {
|
||||||
|
// #ifdef H5
|
||||||
|
window.location.href = h5PayUrl.value
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** H5 支付:关闭窗口返回 */
|
||||||
|
function closeH5Fallback() {
|
||||||
|
showH5Fallback.value = false
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 - 你的AI知识助手',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -384,4 +494,86 @@ onShareAppMessage(() => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer-spacer { height: 40px; }
|
.footer-spacer { height: 40px; }
|
||||||
|
|
||||||
|
// ===== H5 支付备选方案 =====
|
||||||
|
.h5-fallback-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-fallback-content {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
width: 85%;
|
||||||
|
max-width: 380px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-btn {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
background: linear-gradient(135deg, #07C160, #06AE56);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 16px rgba(7, 193, 96, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.secondary {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1.5px solid var(--glass-border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.text-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -95,11 +95,17 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 登录/退出 -->
|
<!-- 登录/退出 -->
|
||||||
<view class="action-btn" v-if="!userStore.isLoggedIn" @click="goLogin">
|
<view class="action-row" v-if="!userStore.isLoggedIn">
|
||||||
<text>微信一键登录</text>
|
<view class="action-btn primary" @click="goLogin">
|
||||||
|
<text>微信一键登录</text>
|
||||||
|
</view>
|
||||||
|
<!-- 未登录:提供显著有效的「拒绝/暂不登录」入口,避免强制登录 -->
|
||||||
|
<view class="action-btn ghost" @click="goBrowse">
|
||||||
|
<text>暂不登录,先体验</text>
|
||||||
|
</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 维度 v1.0.0</text>
|
||||||
@@ -107,6 +113,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
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'
|
||||||
@@ -207,6 +215,11 @@ function goLogin() {
|
|||||||
uni.navigateTo({ url: '/pages/login/login' })
|
uni.navigateTo({ url: '/pages/login/login' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 仅浏览:未登录用户可跳过登录直接使用首页 */
|
||||||
|
function goBrowse() {
|
||||||
|
uni.switchTab({ url: '/pages/index/index' })
|
||||||
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '退出登录',
|
title: '退出登录',
|
||||||
@@ -276,10 +289,12 @@ function goSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 - 你的AI知识助手',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -510,17 +525,39 @@ onShareAppMessage(() => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 按钮
|
// 按钮
|
||||||
|
.action-row {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
margin-top: 20px;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--dim1-color);
|
border-radius: 12px;
|
||||||
border-top: 1px solid var(--glass-border);
|
}
|
||||||
|
|
||||||
|
.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 {
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version {
|
.version {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="stats-row">
|
<view class="stats-row">
|
||||||
<view class="stat-item">
|
<view class="stat-item">
|
||||||
<text class="stat-value">{{ stats?.totalPoints || 0 }}</text>
|
<text class="stat-value">{{ stats?.totalScore || 0 }}</text>
|
||||||
<text class="stat-label">累计积分</text>
|
<text class="stat-label">累计积分</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="stat-divider"></view>
|
<view class="stat-divider"></view>
|
||||||
@@ -122,6 +122,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { knowledgeApi, userApi } from '@/utils/api'
|
import { knowledgeApi, userApi } from '@/utils/api'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
@@ -134,7 +136,7 @@ const loading = ref(true)
|
|||||||
|
|
||||||
// 学习时长(小时)
|
// 学习时长(小时)
|
||||||
const studyHours = computed(() => {
|
const studyHours = computed(() => {
|
||||||
const hours = stats.value?.studyHours || 0
|
const hours = stats.value?.totalPlayTime || 0
|
||||||
if (hours < 1) return `${Math.round(hours * 60)}分`
|
if (hours < 1) return `${Math.round(hours * 60)}分`
|
||||||
return hours.toFixed(1) + 'h'
|
return hours.toFixed(1) + 'h'
|
||||||
})
|
})
|
||||||
@@ -156,7 +158,7 @@ function initDimData() {
|
|||||||
color: dim.color,
|
color: dim.color,
|
||||||
bg: dim.bg,
|
bg: dim.bg,
|
||||||
total: 0,
|
total: 0,
|
||||||
done: exploreData[dim.id] || 0,
|
done: exploreData['dim' + dim.id]?.completed ? 1 : 0,
|
||||||
percent: 0,
|
percent: 0,
|
||||||
expanded: false
|
expanded: false
|
||||||
}))
|
}))
|
||||||
@@ -167,11 +169,7 @@ async function fetchDimTotals() {
|
|||||||
for (const dim of dimProgress.value) {
|
for (const dim of dimProgress.value) {
|
||||||
try {
|
try {
|
||||||
const data = await knowledgeApi.list({ dim: dim.id, limit: 1 })
|
const data = await knowledgeApi.list({ dim: dim.id, limit: 1 })
|
||||||
dim.total = data?.total || data?.length || (Array.isArray(data) ? data.length : 0)
|
dim.total = data?.pagination?.total || data?.items?.length || 0
|
||||||
// 如果 list 返回的是数组而不是带 total 的对象,直接用数组长度
|
|
||||||
if (Array.isArray(data) && dim.total === 0) {
|
|
||||||
dim.total = data.length
|
|
||||||
}
|
|
||||||
// 计算百分比
|
// 计算百分比
|
||||||
if (dim.total > 0) {
|
if (dim.total > 0) {
|
||||||
dim.percent = Math.round((dim.done / dim.total) * 100)
|
dim.percent = Math.round((dim.done / dim.total) * 100)
|
||||||
@@ -200,10 +198,10 @@ async function loadData() {
|
|||||||
userApi.progress(userStore.token).catch(() => ({ exploreData: {} }))
|
userApi.progress(userStore.token).catch(() => ({ exploreData: {} }))
|
||||||
])
|
])
|
||||||
|
|
||||||
// 合并 exploreData
|
// 合并 exploreData(进度接口返回 {isNewUser, data: user},exploreData 在 data 下)
|
||||||
stats.value = {
|
stats.value = {
|
||||||
...statsData,
|
...statsData,
|
||||||
exploreData: { ...statsData?.exploreData, ...progressData?.exploreData }
|
exploreData: progressData?.data?.exploreData || {}
|
||||||
}
|
}
|
||||||
|
|
||||||
dimProgress.value = initDimData()
|
dimProgress.value = initDimData()
|
||||||
@@ -225,10 +223,12 @@ function goBack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 - 你的AI知识助手',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -64,8 +64,8 @@
|
|||||||
<text class="result-title">{{ item.title }}</text>
|
<text class="result-title">{{ item.title }}</text>
|
||||||
|
|
||||||
<!-- 摘要 -->
|
<!-- 摘要 -->
|
||||||
<text class="result-summary" v-if="item.summary">
|
<text class="result-summary" v-if="item.content">
|
||||||
{{ item.summary }}
|
{{ item.content }}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<!-- 标签与分类 -->
|
<!-- 标签与分类 -->
|
||||||
@@ -85,6 +85,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { knowledgeApi } from '@/utils/api'
|
import { knowledgeApi } from '@/utils/api'
|
||||||
import { DIMENSIONS } from '@/utils/dimensions'
|
import { DIMENSIONS } from '@/utils/dimensions'
|
||||||
@@ -188,10 +190,12 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 - 你的AI知识助手',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page-settings page-layout">
|
<view class="page-settings page-layout">
|
||||||
<view class="status-bar-placeholder"></view>
|
<view class="status-bar-placeholder"></view>
|
||||||
<text class="page-title">设置</text>
|
<view class="nav-bar">
|
||||||
|
<view class="nav-back" @click="goBack">‹</view>
|
||||||
|
<text class="page-title">设置</text>
|
||||||
|
<view class="nav-placeholder"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 偏好设置 -->
|
<!-- 偏好设置 -->
|
||||||
<view class="section-title">偏好设置</view>
|
<view class="section-title">偏好设置</view>
|
||||||
@@ -60,6 +64,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
const pushOn = ref(true)
|
const pushOn = ref(true)
|
||||||
@@ -131,18 +137,20 @@ function showContact() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showAgreement() {
|
function showAgreement() {
|
||||||
uni.showModal({
|
uni.navigateTo({ url: '/pages/agreement/agreement?type=terms' })
|
||||||
title: '用户协议 / 隐私政策',
|
}
|
||||||
content: '暂无内容',
|
|
||||||
showCancel: false
|
function goBack() {
|
||||||
})
|
uni.navigateBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 - 你的AI知识助手',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -152,6 +160,24 @@ onShareAppMessage(() => ({
|
|||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-back {
|
||||||
|
font-size: 28px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0 8px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-placeholder {
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
.status-bar-placeholder { height: 44px; }
|
.status-bar-placeholder { height: 44px; }
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page-trend page-layout">
|
<view class="page-trend">
|
||||||
<view class="status-bar-placeholder"></view>
|
<view class="status-bar-placeholder"></view>
|
||||||
|
|
||||||
<!-- 顶部 -->
|
<!-- 顶部 -->
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
:key="idx"
|
:key="idx"
|
||||||
@click="activeFilter = idx"
|
@click="activeFilter = idx"
|
||||||
>
|
>
|
||||||
<text>{{ tag }}</text>
|
<text class="filter-icon">{{ tag.icon }}</text>
|
||||||
|
<text class="filter-label">{{ tag.text }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -64,10 +65,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { trendApi } from '@/utils/api'
|
import { trendApi } from '@/utils/api'
|
||||||
|
|
||||||
const filters = ref(['全部', '🚀 产品', '📄 论文', '🔧 工具', '🏢 公司'])
|
const filters = ref([
|
||||||
|
{ icon: '📰', text: '全部' },
|
||||||
|
{ icon: '🚀', text: '产品' },
|
||||||
|
{ icon: '📄', text: '论文' },
|
||||||
|
{ icon: '🔧', text: '工具' },
|
||||||
|
{ icon: '🏢', text: '公司' }
|
||||||
|
])
|
||||||
const activeFilter = ref(0)
|
const activeFilter = ref(0)
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -78,7 +87,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
const displayItems = computed(() => {
|
const displayItems = computed(() => {
|
||||||
if (activeFilter.value === 0) return items.value
|
if (activeFilter.value === 0) return items.value
|
||||||
const categoryMap = { 1: 'product', 2: 'paper', 3: 'tool', 4: 'company' }
|
const categoryMap = { 1: '产品', 2: '论文', 3: '工具', 4: '公司' }
|
||||||
const category = categoryMap[activeFilter.value]
|
const category = categoryMap[activeFilter.value]
|
||||||
if (!category) return items.value
|
if (!category) return items.value
|
||||||
return items.value.filter(item => item.category === category)
|
return items.value.filter(item => item.category === category)
|
||||||
@@ -101,19 +110,19 @@ 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 === 'product') return '🚀 产品'
|
if (item.category === '产品') return '🚀 产品'
|
||||||
if (item.category === 'paper') return '📄 论文'
|
if (item.category === '论文') return '📄 论文'
|
||||||
if (item.category === 'tool') return '🔧 工具'
|
if (item.category === '工具') return '🔧 工具'
|
||||||
if (item.category === 'company') return '🏢 公司'
|
if (item.category === '公司') return '🏢 公司'
|
||||||
return '📰 资讯'
|
return '📰 资讯'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTagClass(item) {
|
function getTagClass(item) {
|
||||||
if (item.hot) return 'hot'
|
if (item.hot) return 'hot'
|
||||||
if (item.picked) return 'pick'
|
if (item.picked) return 'pick'
|
||||||
if (item.category === 'product') return 'product'
|
if (item.category === '产品') return 'product'
|
||||||
if (item.category === 'paper') return 'paper'
|
if (item.category === '论文') return 'paper'
|
||||||
if (item.category === 'tool') return 'tool'
|
if (item.category === '工具') return 'tool'
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,10 +190,12 @@ function getSampleData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 - 你的AI知识助手',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -225,12 +236,27 @@ onShareAppMessage(() => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-item {
|
.filter-item {
|
||||||
padding: 6px 14px;
|
padding: 8px 14px 6px;
|
||||||
background: var(--glass-bg);
|
background: var(--glass-bg);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: 16px;
|
border-radius: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-item.active {
|
.filter-item.active {
|
||||||
@@ -245,7 +271,7 @@ onShareAppMessage(() => ({
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
padding: 0 0 16px;
|
padding: 0 20px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-card {
|
.trend-card {
|
||||||
|
|||||||
@@ -36,18 +36,33 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
async function h5Register(email, password, nickName, verifyCode) {
|
async function h5Register(email, password, nickName, verifyCode, username) {
|
||||||
const data = await authApi.register(email, password, nickName, verifyCode)
|
const data = await authApi.register(email, password, nickName, verifyCode, username)
|
||||||
token.value = data.token
|
token.value = data.token
|
||||||
userInfo.value = data.userInfo
|
userInfo.value = data.userInfo
|
||||||
uni.setStorageSync('token', data.token)
|
uni.setStorageSync('token', data.token)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将后端扁平的订阅字段映射为前端使用的 subInfo 结构
|
||||||
|
function mapSubscription(u) {
|
||||||
|
if (!u) return null
|
||||||
|
const isSub = !!u.isSubscriber
|
||||||
|
const expires = u.subscribeExpiry || null
|
||||||
|
if (!isSub && !expires) return null
|
||||||
|
return {
|
||||||
|
plan: u.subscriptionPlan || (isSub ? 'PRO' : 'free'),
|
||||||
|
expiresAt: expires,
|
||||||
|
startDate: u.subscribeStart || u.createdAt || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchMe() {
|
async function fetchMe() {
|
||||||
try {
|
try {
|
||||||
const data = await authApi.me(token.value)
|
const data = await authApi.me(token.value)
|
||||||
userInfo.value = data
|
userInfo.value = data
|
||||||
|
// /me 返回完整用户文档,直接派生订阅信息
|
||||||
|
subInfo.value = mapSubscription(data)
|
||||||
return data
|
return data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[userStore] fetchMe failed:', err)
|
console.error('[userStore] fetchMe failed:', err)
|
||||||
@@ -56,14 +71,11 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSubInfo(tokenVal) {
|
async function fetchSubInfo(tokenVal) {
|
||||||
// 从后端获取订阅信息 - 复用 knowledgeApi 或专门接口
|
|
||||||
// 当前后端无单独订阅接口,从 /api/user/progress 中获取
|
|
||||||
const { userApi } = await import('@/utils/api')
|
const { userApi } = await import('@/utils/api')
|
||||||
try {
|
try {
|
||||||
const progress = await userApi.progress(tokenVal)
|
const progress = await userApi.progress(tokenVal)
|
||||||
if (progress && progress.subscription) {
|
const u = progress && (progress.data || progress)
|
||||||
subInfo.value = progress.subscription
|
subInfo.value = mapSubscription(u)
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[userStore] fetchSubInfo failed:', err)
|
console.error('[userStore] fetchSubInfo failed:', err)
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-6
@@ -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'
|
||||||
|
|
||||||
// 生产环境使用域名(SSL 已续期)
|
// 开发环境走本地后端,生产环境使用线上域名
|
||||||
const API_BASE = 'https://wdkj.yuzhiran.com.cn'
|
const API_BASE = import.meta.env.DEV ? DEV_URL : BASE_URL
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用请求封装
|
* 通用请求封装
|
||||||
@@ -54,8 +54,8 @@ export const authApi = {
|
|||||||
request('POST', '/api/auth/user/login', loginData),
|
request('POST', '/api/auth/user/login', loginData),
|
||||||
|
|
||||||
/** H5 注册 */
|
/** H5 注册 */
|
||||||
register: (email, password, nickName, verifyCode) =>
|
register: (email, password, nickName, verifyCode, username) =>
|
||||||
request('POST', '/api/auth/user/register', { email, password, nickName, verifyCode }),
|
request('POST', '/api/auth/user/register', { email, password, nickName, verifyCode, username }),
|
||||||
|
|
||||||
/** 获取当前用户 */
|
/** 获取当前用户 */
|
||||||
me: (token) =>
|
me: (token) =>
|
||||||
@@ -155,10 +155,15 @@ export const paymentApi = {
|
|||||||
/** 获取商品列表 */
|
/** 获取商品列表 */
|
||||||
products: () =>
|
products: () =>
|
||||||
request('GET', '/api/payment/products'),
|
request('GET', '/api/payment/products'),
|
||||||
|
/** 创建订单(小程序支付) */
|
||||||
/** 创建订单 */
|
|
||||||
createOrder: (productId, token) =>
|
createOrder: (productId, token) =>
|
||||||
request('POST', '/api/payment/create-order', { productId }, token),
|
request('POST', '/api/payment/create-order', { productId }, token),
|
||||||
|
/** 创建H5支付订单 */
|
||||||
|
createH5Order: (productId, clientIp, token) =>
|
||||||
|
request('POST', '/api/payment/create-h5-order', { productId, clientIp }, token),
|
||||||
|
/** 验证支付结果 */
|
||||||
|
verify: (productId, orderNo, token) =>
|
||||||
|
request('POST', '/api/payment/verify', { productId, orderNo }, token),
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+18
-10
@@ -76,22 +76,30 @@ app.use((err, req, res, next) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 启动服务器
|
// 启动服务器(仅在直接运行时启动,被测试 import 时不启动)
|
||||||
const PORT = process.env.PORT || 3000
|
const PORT = process.env.PORT || 3000
|
||||||
const server = app.listen(PORT, () => {
|
let server = null
|
||||||
logger.info(`🚀 服务器启动成功`)
|
|
||||||
logger.info(`📍 端口: ${PORT}`)
|
if (require.main === module) {
|
||||||
logger.info(`🌐 环境: ${process.env.NODE_ENV || 'development'}`)
|
server = app.listen(PORT, () => {
|
||||||
logger.info(`📊 健康检查: http://localhost:${PORT}/health`)
|
logger.info(`🚀 服务器启动成功`)
|
||||||
})
|
logger.info(`📍 端口: ${PORT}`)
|
||||||
|
logger.info(`🌐 环境: ${process.env.NODE_ENV || 'development'}`)
|
||||||
|
logger.info(`📊 健康检查: http://localhost:${PORT}/health`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 优雅关闭
|
// 优雅关闭
|
||||||
process.on('SIGTERM', () => {
|
process.on('SIGTERM', () => {
|
||||||
logger.info('📴 接收到 SIGTERM 信号,正在关闭服务器...')
|
logger.info('📴 接收到 SIGTERM 信号,正在关闭服务器...')
|
||||||
server.close(() => {
|
if (server) {
|
||||||
logger.info('✅ 服务器已关闭')
|
server.close(() => {
|
||||||
|
logger.info('✅ 服务器已关闭')
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
module.exports = app
|
module.exports = app
|
||||||
|
|||||||
@@ -96,32 +96,23 @@ const userAuthMiddleware = async (req, res, next) => {
|
|||||||
|
|
||||||
const token = authHeader.replace('Bearer ', '')
|
const token = authHeader.replace('Bearer ', '')
|
||||||
|
|
||||||
// 添加调试日志
|
|
||||||
console.log('[Auth] Token received:', token.substring(0, 20) + '...')
|
|
||||||
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||||
console.log('[Auth] Token decoded:', decoded)
|
|
||||||
|
|
||||||
// 兼容两种token格式:包含userId或openid
|
// 兼容两种token格式:包含userId或openid
|
||||||
let user = null
|
let user = null
|
||||||
if (decoded.userId) {
|
if (decoded.userId) {
|
||||||
console.log('[Auth] Looking up user by userId:', decoded.userId)
|
|
||||||
user = await User.findById(decoded.userId)
|
user = await User.findById(decoded.userId)
|
||||||
} else if (decoded.openid) {
|
} else if (decoded.openid) {
|
||||||
console.log('[Auth] Looking up user by openid:', decoded.openid)
|
|
||||||
user = await User.findOne({ openid: decoded.openid })
|
user = await User.findOne({ openid: decoded.openid })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.log('[Auth] User not found')
|
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: '用户不存在或令牌无效'
|
error: '用户不存在或令牌无效'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Auth] User found:', user._id)
|
|
||||||
console.log('[Auth] User openid:', user.openid)
|
|
||||||
req.user = user
|
req.user = user
|
||||||
// 优先使用 openid,如果没有则使用 userId 作为标识
|
// 优先使用 openid,如果没有则使用 userId 作为标识
|
||||||
req.openid = user.openid || user._id.toString()
|
req.openid = user.openid || user._id.toString()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const { Schema } = mongoose;
|
|||||||
const TrendSchema = new Schema({
|
const TrendSchema = new Schema({
|
||||||
title: { type: String, required: true, trim: true },
|
title: { type: String, required: true, trim: true },
|
||||||
summary: { type: String },
|
summary: { type: String },
|
||||||
|
body: { type: String },
|
||||||
source: { type: String, default: '未知来源' },
|
source: { type: String, default: '未知来源' },
|
||||||
sourceUrl: { type: String },
|
sourceUrl: { type: String },
|
||||||
category: {
|
category: {
|
||||||
|
|||||||
@@ -94,11 +94,11 @@ const UserSchema = new Schema({
|
|||||||
dim4Score: { type: Number, default: 0 },
|
dim4Score: { type: Number, default: 0 },
|
||||||
dim5Score: { type: Number, default: 0 },
|
dim5Score: { type: Number, default: 0 },
|
||||||
|
|
||||||
// 已解锁维度
|
// 已解锁维度(新用户默认解锁第1维度)
|
||||||
unlockedDims: [{
|
unlockedDims: {
|
||||||
type: Number,
|
type: [{ type: Number, enum: [1, 2, 3, 4, 5] }],
|
||||||
enum: [1, 2, 3, 4, 5]
|
default: [1]
|
||||||
}],
|
},
|
||||||
|
|
||||||
// 商品相关
|
// 商品相关
|
||||||
ownedSkins: [{
|
ownedSkins: [{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const { Schema } = mongoose
|
|||||||
const VerificationCodeSchema = new Schema({
|
const VerificationCodeSchema = new Schema({
|
||||||
email: { type: String, required: true, index: true },
|
email: { type: String, required: true, index: true },
|
||||||
code: { type: String, required: true },
|
code: { type: String, required: true },
|
||||||
expiresAt: { type: Date, required: true, index: true },
|
expiresAt: { type: Date, required: true },
|
||||||
used: { type: Boolean, default: false },
|
used: { type: Boolean, default: false },
|
||||||
createdAt: { type: Date, default: Date.now }
|
createdAt: { type: Date, default: Date.now }
|
||||||
})
|
})
|
||||||
|
|||||||
+11
-276
@@ -1,7 +1,7 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
const mongoose = require('mongoose')
|
const mongoose = require('mongoose')
|
||||||
const { User, Order, Gallery, Knowledge, ShopItem, AdminLog, Level, AIChatQuota, Feedback, LoginLog } = require('../models')
|
const { User, Order, Knowledge, ShopItem, AdminLog, AIChatQuota, Feedback, LoginLog } = require('../models')
|
||||||
const { authMiddleware, requirePermission } = require('../middleware/auth')
|
const { authMiddleware, requirePermission } = require('../middleware/auth')
|
||||||
const ApiResponse = require('../utils/response')
|
const ApiResponse = require('../utils/response')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
@@ -40,26 +40,20 @@ router.get('/dashboard', requirePermission('dashboard:read'), async (req, res) =
|
|||||||
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
|
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
// 今日数据
|
// 今日数据
|
||||||
const [todayNewUsers, todayActiveUsers, todayOrders, todayWorks] = await Promise.all([
|
const [todayNewUsers, todayActiveUsers, todayOrders] = await Promise.all([
|
||||||
User.countDocuments({ createdAt: { $gte: today } }),
|
User.countDocuments({ createdAt: { $gte: today } }),
|
||||||
User.countDocuments({ lastLoginAt: { $gte: today } }),
|
User.countDocuments({ lastLoginAt: { $gte: today } }),
|
||||||
Order.getTodayCount(),
|
Order.getTodayCount()
|
||||||
Gallery.getTodayCount()
|
|
||||||
])
|
])
|
||||||
|
|
||||||
// 7日趋势
|
// 7日趋势
|
||||||
const [userTrend, orderTrend, workTrend] = await Promise.all([
|
const [userTrend, orderTrend] = await Promise.all([
|
||||||
User.aggregate([
|
User.aggregate([
|
||||||
{ $match: { createdAt: { $gte: weekAgo } } },
|
{ $match: { createdAt: { $gte: weekAgo } } },
|
||||||
{ $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } }, count: { $sum: 1 } } },
|
{ $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } }, count: { $sum: 1 } } },
|
||||||
{ $sort: { '_id': 1 } }
|
{ $sort: { '_id': 1 } }
|
||||||
]),
|
]),
|
||||||
Order.getWeekTrend(),
|
Order.getWeekTrend()
|
||||||
Gallery.aggregate([
|
|
||||||
{ $match: { createdAt: { $gte: weekAgo }, status: 'approved' } },
|
|
||||||
{ $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } }, count: { $sum: 1 } } },
|
|
||||||
{ $sort: { '_id': 1 } }
|
|
||||||
])
|
|
||||||
])
|
])
|
||||||
|
|
||||||
await logAction(req, 'read', 'dashboard')
|
await logAction(req, 'read', 'dashboard')
|
||||||
@@ -69,12 +63,12 @@ router.get('/dashboard', requirePermission('dashboard:read'), async (req, res) =
|
|||||||
newUsers: todayNewUsers,
|
newUsers: todayNewUsers,
|
||||||
activeUsers: todayActiveUsers,
|
activeUsers: todayActiveUsers,
|
||||||
newOrders: todayOrders,
|
newOrders: todayOrders,
|
||||||
newWorks: todayWorks
|
newWorks: 0
|
||||||
},
|
},
|
||||||
trends: {
|
trends: {
|
||||||
users: userTrend.map(t => ({ date: t._id, count: t.count })),
|
users: userTrend.map(t => ({ date: t._id, count: t.count })),
|
||||||
orders: orderTrend.map(t => ({ date: t._id, count: t.count, revenue: t.revenue })),
|
orders: orderTrend.map(t => ({ date: t._id, count: t.count, revenue: t.revenue })),
|
||||||
works: workTrend.map(t => ({ date: t._id, count: t.count }))
|
works: []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -113,17 +107,8 @@ router.get('/users', requirePermission('users:read'), async (req, res) => {
|
|||||||
.limit(parseInt(limit))
|
.limit(parseInt(limit))
|
||||||
.lean()
|
.lean()
|
||||||
|
|
||||||
// 获取每个用户的作品数
|
// 画廊(Gallery)功能已移除,作品数固定为 0
|
||||||
const userIds = users.map(u => u._id.toString())
|
|
||||||
const worksCounts = await Gallery.aggregate([
|
|
||||||
{ $match: { userId: { $in: userIds.map(id => new mongoose.Types.ObjectId(id)) } } },
|
|
||||||
{ $group: { _id: '$userId', count: { $sum: 1 } } }
|
|
||||||
])
|
|
||||||
|
|
||||||
const worksCountMap = {}
|
const worksCountMap = {}
|
||||||
worksCounts.forEach(item => {
|
|
||||||
worksCountMap[item._id.toString()] = item.count
|
|
||||||
})
|
|
||||||
|
|
||||||
// 格式化用户数据,添加等级和统计信息
|
// 格式化用户数据,添加等级和统计信息
|
||||||
const formattedUsers = users.map(user => {
|
const formattedUsers = users.map(user => {
|
||||||
@@ -198,12 +183,6 @@ router.get('/users/:id', requirePermission('users:read'), async (req, res) => {
|
|||||||
.limit(20)
|
.limit(20)
|
||||||
.lean()
|
.lean()
|
||||||
|
|
||||||
// 获取作品
|
|
||||||
const works = await Gallery.find({ openid: user.openid })
|
|
||||||
.sort({ createdAt: -1 })
|
|
||||||
.limit(10)
|
|
||||||
.lean()
|
|
||||||
|
|
||||||
// 计算等级
|
// 计算等级
|
||||||
const scorePoints = Math.floor((user.totalScore || 0) / 100)
|
const scorePoints = Math.floor((user.totalScore || 0) / 100)
|
||||||
const timePoints = Math.floor((user.totalPlayTime || 0) / 60)
|
const timePoints = Math.floor((user.totalPlayTime || 0) / 60)
|
||||||
@@ -232,8 +211,7 @@ router.get('/users/:id', requirePermission('users:read'), async (req, res) => {
|
|||||||
'维度创世者', '空间造物主', '宇宙掌控者', '维度至尊', '宇宙之神'
|
'维度创世者', '空间造物主', '宇宙掌控者', '维度至尊', '宇宙之神'
|
||||||
]
|
]
|
||||||
|
|
||||||
// 统计作品数
|
// 画廊(Gallery)功能已移除,作品数为 0
|
||||||
const worksCount = await Gallery.countDocuments({ openid: user.openid })
|
|
||||||
|
|
||||||
await logAction(req, 'read', 'users', req.params.id)
|
await logAction(req, 'read', 'users', req.params.id)
|
||||||
|
|
||||||
@@ -242,11 +220,11 @@ router.get('/users/:id', requirePermission('users:read'), async (req, res) => {
|
|||||||
level,
|
level,
|
||||||
levelName: levelNames[Math.min(level - 1, levelNames.length - 1)],
|
levelName: levelNames[Math.min(level - 1, levelNames.length - 1)],
|
||||||
levelPoints,
|
levelPoints,
|
||||||
worksCount,
|
worksCount: 0,
|
||||||
collectionCount: user.collectedKnowledge?.length || 0,
|
collectionCount: user.collectedKnowledge?.length || 0,
|
||||||
achievementCount: user.achievements?.length || 0,
|
achievementCount: user.achievements?.length || 0,
|
||||||
orders,
|
orders,
|
||||||
works
|
works: []
|
||||||
})
|
})
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -389,149 +367,6 @@ router.delete('/knowledge/:id', requirePermission('knowledge:write'), async (req
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取画廊作品列表
|
|
||||||
* GET /api/admin/gallery
|
|
||||||
*/
|
|
||||||
router.get('/gallery', requirePermission('gallery:read'), async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { page = 1, limit = 20, status } = req.query
|
|
||||||
|
|
||||||
// 构建查询条件,如果status为空字符串或undefined,则查询所有状态
|
|
||||||
const query = {}
|
|
||||||
if (status && status !== '') {
|
|
||||||
query.status = status
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = await Gallery.countDocuments(query)
|
|
||||||
const works = await Gallery.find(query)
|
|
||||||
.sort({ createdAt: -1 })
|
|
||||||
.skip((parseInt(page) - 1) * parseInt(limit))
|
|
||||||
.limit(parseInt(limit))
|
|
||||||
.lean()
|
|
||||||
|
|
||||||
await logAction(req, 'read', 'gallery', null, { page, limit, status })
|
|
||||||
|
|
||||||
return ApiResponse.paginated(res, works, {
|
|
||||||
page: parseInt(page),
|
|
||||||
limit: parseInt(limit),
|
|
||||||
total
|
|
||||||
})
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('获取画廊作品列表失败:', error)
|
|
||||||
return ApiResponse.serverError(res, error.message)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 审核画廊作品
|
|
||||||
* POST /api/admin/gallery/review
|
|
||||||
*/
|
|
||||||
router.post('/gallery/review', requirePermission('gallery:approve'), async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { workId, status, comment } = req.body
|
|
||||||
|
|
||||||
const work = await Gallery.findByIdAndUpdate(
|
|
||||||
workId,
|
|
||||||
{
|
|
||||||
status,
|
|
||||||
reviewComment: comment,
|
|
||||||
reviewedAt: new Date(),
|
|
||||||
reviewerId: req.admin._id
|
|
||||||
},
|
|
||||||
{ new: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!work) {
|
|
||||||
return ApiResponse.notFound(res, '作品不存在')
|
|
||||||
}
|
|
||||||
|
|
||||||
await logAction(req, 'review', 'gallery', workId, { status, comment })
|
|
||||||
|
|
||||||
return ApiResponse.success(res, work, '审核完成')
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('审核画廊作品失败:', error)
|
|
||||||
return ApiResponse.serverError(res, error.message)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取作品详情
|
|
||||||
* GET /api/admin/gallery/:id
|
|
||||||
*/
|
|
||||||
router.get('/gallery/:id', requirePermission('gallery:read'), async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { id } = req.params
|
|
||||||
|
|
||||||
const work = await Gallery.findById(id)
|
|
||||||
.populate('authorId', 'nickName avatarUrl')
|
|
||||||
.lean()
|
|
||||||
|
|
||||||
if (!work) {
|
|
||||||
return ApiResponse.notFound(res, '作品不存在')
|
|
||||||
}
|
|
||||||
|
|
||||||
await logAction(req, 'read', 'gallery', id, { title: work.title })
|
|
||||||
|
|
||||||
return ApiResponse.success(res, work)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('获取作品详情失败:', error)
|
|
||||||
return ApiResponse.serverError(res, error.message)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除作品
|
|
||||||
* DELETE /api/admin/gallery/:id
|
|
||||||
*/
|
|
||||||
router.delete('/gallery/:id', requirePermission('gallery:write'), async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { id } = req.params
|
|
||||||
|
|
||||||
const work = await Gallery.findByIdAndDelete(id)
|
|
||||||
|
|
||||||
if (!work) {
|
|
||||||
return ApiResponse.notFound(res, '作品不存在')
|
|
||||||
}
|
|
||||||
|
|
||||||
await logAction(req, 'delete', 'gallery', id, { title: work.title })
|
|
||||||
|
|
||||||
return ApiResponse.success(res, null, '删除成功')
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('删除作品失败:', error)
|
|
||||||
return ApiResponse.serverError(res, error.message)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量删除作品
|
|
||||||
* POST /api/admin/gallery/batch-delete
|
|
||||||
*/
|
|
||||||
router.post('/gallery/batch-delete', requirePermission('gallery:write'), async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { ids } = req.body
|
|
||||||
|
|
||||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
|
||||||
return ApiResponse.error(res, '请选择要删除的作品', 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量删除
|
|
||||||
const result = await Gallery.deleteMany({ _id: { $in: ids } })
|
|
||||||
|
|
||||||
await logAction(req, 'batch-delete', 'gallery', null, { count: result.deletedCount, ids })
|
|
||||||
|
|
||||||
return ApiResponse.success(res, { deletedCount: result.deletedCount }, `成功删除 ${result.deletedCount} 个作品`)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('批量删除作品失败:', error)
|
|
||||||
return ApiResponse.serverError(res, error.message)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取订单列表
|
* 获取订单列表
|
||||||
* GET /api/admin/orders
|
* GET /api/admin/orders
|
||||||
@@ -816,106 +651,6 @@ router.get('/logs', requirePermission('*'), async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取关卡列表
|
|
||||||
* GET /api/admin/levels
|
|
||||||
*/
|
|
||||||
router.get('/levels', requirePermission('knowledge:read'), async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { page = 1, limit = 50, dimension, isActive } = req.query
|
|
||||||
|
|
||||||
const query = {}
|
|
||||||
if (dimension) query.dimension = parseInt(dimension)
|
|
||||||
if (isActive !== undefined) query.isActive = isActive === 'true'
|
|
||||||
|
|
||||||
const pageNum = parseInt(page)
|
|
||||||
const limitNum = parseInt(limit)
|
|
||||||
const skip = (pageNum - 1) * limitNum
|
|
||||||
|
|
||||||
const [levels, total] = await Promise.all([
|
|
||||||
Level.find(query)
|
|
||||||
.sort({ dimension: 1, level: 1 })
|
|
||||||
.skip(skip)
|
|
||||||
.limit(limitNum)
|
|
||||||
.lean(),
|
|
||||||
Level.countDocuments(query)
|
|
||||||
])
|
|
||||||
|
|
||||||
return ApiResponse.paginated(res, levels, {
|
|
||||||
page: pageNum,
|
|
||||||
limit: limitNum,
|
|
||||||
total
|
|
||||||
})
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('获取关卡列表失败:', error)
|
|
||||||
return ApiResponse.serverError(res, error.message)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建/更新关卡
|
|
||||||
* POST /api/admin/level
|
|
||||||
*/
|
|
||||||
router.post('/level', requirePermission('knowledge:write'), async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { _id, ...levelData } = req.body
|
|
||||||
|
|
||||||
let level
|
|
||||||
|
|
||||||
if (_id) {
|
|
||||||
// 更新
|
|
||||||
level = await Level.findByIdAndUpdate(
|
|
||||||
_id,
|
|
||||||
{ ...levelData, updatedAt: new Date() },
|
|
||||||
{ new: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!level) {
|
|
||||||
return ApiResponse.notFound(res, '关卡不存在')
|
|
||||||
}
|
|
||||||
|
|
||||||
await logAction(req, 'update', 'level', _id, { dimension: levelData.dimension, level: levelData.level })
|
|
||||||
} else {
|
|
||||||
// 创建
|
|
||||||
level = new Level(levelData)
|
|
||||||
await level.save()
|
|
||||||
|
|
||||||
await logAction(req, 'create', 'level', level._id, { dimension: levelData.dimension, level: levelData.level })
|
|
||||||
}
|
|
||||||
|
|
||||||
return ApiResponse.success(res, level, _id ? '更新成功' : '创建成功')
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('保存关卡失败:', error)
|
|
||||||
return ApiResponse.serverError(res, error.message)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除关卡
|
|
||||||
* DELETE /api/admin/level/:id
|
|
||||||
*/
|
|
||||||
router.delete('/level/:id', requirePermission('knowledge:write'), async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { id } = req.params
|
|
||||||
|
|
||||||
const level = await Level.findByIdAndDelete(id)
|
|
||||||
|
|
||||||
if (!level) {
|
|
||||||
return ApiResponse.notFound(res, '关卡不存在')
|
|
||||||
}
|
|
||||||
|
|
||||||
await logAction(req, 'delete', 'level', id, { dimension: level.dimension, level: level.level })
|
|
||||||
|
|
||||||
return ApiResponse.success(res, { message: '删除成功' })
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('删除关卡失败:', error)
|
|
||||||
return ApiResponse.serverError(res, error.message)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取AI问答统计数据
|
* 获取AI问答统计数据
|
||||||
* GET /api/admin/ai-chat/stats
|
* GET /api/admin/ai-chat/stats
|
||||||
|
|||||||
+24
-16
@@ -119,21 +119,29 @@ async function tryModels(models, question, dimension, res, quota) {
|
|||||||
|
|
||||||
logger.info(`[AI Chat] 尝试模型: ${model.name} (${modelId})`)
|
logger.info(`[AI Chat] 尝试模型: ${model.name} (${modelId})`)
|
||||||
|
|
||||||
// 调用AI API
|
// 调用AI API(带超时,避免外部服务无响应导致请求挂起)
|
||||||
const response = await fetch(apiUrl, {
|
const controller = new AbortController()
|
||||||
method: 'POST',
|
const timer = setTimeout(() => controller.abort(), 30000)
|
||||||
headers,
|
let response
|
||||||
body: JSON.stringify({
|
try {
|
||||||
model: modelId,
|
response = await fetch(apiUrl, {
|
||||||
messages: [
|
method: 'POST',
|
||||||
{ role: 'system', content: systemPrompt },
|
headers,
|
||||||
{ role: 'user', content: question }
|
body: JSON.stringify({
|
||||||
],
|
model: modelId,
|
||||||
temperature: modelConfig.temperature || 0.7,
|
messages: [
|
||||||
max_tokens: modelConfig.maxTokens || 800,
|
{ role: 'system', content: systemPrompt },
|
||||||
top_p: modelConfig.topP || 1
|
{ role: 'user', content: question }
|
||||||
|
],
|
||||||
|
temperature: modelConfig.temperature || 0.7,
|
||||||
|
max_tokens: modelConfig.maxTokens || 800,
|
||||||
|
top_p: modelConfig.topP || 1
|
||||||
|
}),
|
||||||
|
signal: controller.signal
|
||||||
})
|
})
|
||||||
})
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}))
|
const errorData = await response.json().catch(() => ({}))
|
||||||
@@ -144,8 +152,8 @@ async function tryModels(models, question, dimension, res, quota) {
|
|||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
const message = data.choices[0].message
|
const message = data.choices[0].message
|
||||||
// 兼容标准 content 和 sensenova 的 reasoning 字段
|
// 兼容标准 content 与推理模型(sensenova reasoning / nvidia reasoning_content)
|
||||||
const answer = message.content || message.reasoning || ''
|
const answer = message.content || message.reasoning_content || message.reasoning || ''
|
||||||
|
|
||||||
// 扣除一次使用次数
|
// 扣除一次使用次数
|
||||||
await quota.useQuota()
|
await quota.useQuota()
|
||||||
|
|||||||
@@ -527,6 +527,9 @@ router.get('/me', async (req, res) => {
|
|||||||
return ApiResponse.success(res, user)
|
return ApiResponse.success(res, user)
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||||
|
return ApiResponse.unauthorized(res, '认证令牌无效或已过期')
|
||||||
|
}
|
||||||
logger.error('获取用户信息失败:', error)
|
logger.error('获取用户信息失败:', error)
|
||||||
return ApiResponse.serverError(res, error.message)
|
return ApiResponse.serverError(res, error.message)
|
||||||
}
|
}
|
||||||
@@ -568,7 +571,7 @@ router.post('/webview-token', async (req, res) => {
|
|||||||
await recordLoginLog(
|
await recordLoginLog(
|
||||||
user._id,
|
user._id,
|
||||||
openid,
|
openid,
|
||||||
'webview_auto_login',
|
'auto_login',
|
||||||
'wechat_webview',
|
'wechat_webview',
|
||||||
'webview_h5',
|
'webview_h5',
|
||||||
req,
|
req,
|
||||||
|
|||||||
@@ -80,6 +80,58 @@ router.post('/create-order', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建H5支付订单(微信H5支付)
|
||||||
|
* POST /api/payment/create-h5-order
|
||||||
|
*/
|
||||||
|
router.post('/create-h5-order', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user
|
||||||
|
const { productId, clientIp } = req.body
|
||||||
|
|
||||||
|
// 从数据库获取商品信息
|
||||||
|
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
||||||
|
if (!product) {
|
||||||
|
return ApiResponse.error(res, '无效商品 ID 或商品已下架', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成订单号
|
||||||
|
const orderNo = weChatService.generateOrderNo('WD')
|
||||||
|
|
||||||
|
// 创建订单记录
|
||||||
|
const order = new Order({
|
||||||
|
orderNo,
|
||||||
|
openid: user.openid,
|
||||||
|
userId: user._id,
|
||||||
|
productId,
|
||||||
|
productName: product.name,
|
||||||
|
amount: product.price,
|
||||||
|
status: 'pending'
|
||||||
|
})
|
||||||
|
await order.save()
|
||||||
|
|
||||||
|
// 调用微信支付H5下单
|
||||||
|
const h5PayParams = await weChatService.createH5PaymentOrder({
|
||||||
|
orderNo,
|
||||||
|
body: `宇之然-${product.name}`,
|
||||||
|
amount: product.price,
|
||||||
|
clientIp: clientIp || req.ip || req.connection.remoteAddress || '127.0.0.1'
|
||||||
|
})
|
||||||
|
|
||||||
|
return ApiResponse.success(res, {
|
||||||
|
orderNo,
|
||||||
|
productId,
|
||||||
|
productName: product.name,
|
||||||
|
amount: product.price,
|
||||||
|
h5Url: h5PayParams.h5Url
|
||||||
|
}, 'H5支付订单创建成功')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('创建H5订单失败:', error)
|
||||||
|
return ApiResponse.serverError(res, error.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 支付回调
|
* 支付回调
|
||||||
* POST /api/payment/callback
|
* POST /api/payment/callback
|
||||||
@@ -149,42 +201,48 @@ router.post('/callback', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* 验证支付结果
|
* 验证支付结果
|
||||||
* POST /api/payment/verify
|
* POST /api/payment/verify
|
||||||
|
* 只有存在该用户真实已支付订单时才解锁商品,防止未支付直接解锁
|
||||||
*/
|
*/
|
||||||
router.post('/verify', async (req, res) => {
|
router.post('/verify', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = req.user
|
const user = req.user
|
||||||
const { productId, orderNo } = req.body
|
const { productId, orderNo } = req.body
|
||||||
|
|
||||||
// 验证订单是否已处理
|
if (!orderNo || !productId) {
|
||||||
|
return ApiResponse.error(res, '缺少订单号或商品 ID', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验订单是否已支付,且属于当前用户
|
||||||
const order = await Order.findOne({
|
const order = await Order.findOne({
|
||||||
orderNo,
|
orderNo,
|
||||||
openid: user.openid,
|
openid: user.openid,
|
||||||
status: { $in: ['paid', 'completed'] }
|
status: { $in: ['paid', 'completed'] }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (order) {
|
if (!order) {
|
||||||
return ApiResponse.success(res, {
|
return ApiResponse.error(res, '订单未支付,无法解锁商品', 400)
|
||||||
alreadyProcessed: true,
|
}
|
||||||
message: '订单已处理'
|
|
||||||
})
|
// 校验订单对应商品
|
||||||
|
if (order.productId !== productId) {
|
||||||
|
return ApiResponse.error(res, '订单与商品不匹配', 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取商品信息
|
|
||||||
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
||||||
if (!product) {
|
if (!product) {
|
||||||
return ApiResponse.error(res, `未知商品: ${productId}`, 400)
|
return ApiResponse.error(res, `未知商品: ${productId}`, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新用户商品
|
// 解锁商品(幂等)
|
||||||
let updateData = {}
|
let alreadyUnlocked = false
|
||||||
|
|
||||||
if (product.type === 'skin') {
|
if (product.type === 'skin') {
|
||||||
if (!user.ownedSkins.includes(productId)) {
|
if (user.ownedSkins.includes(productId)) alreadyUnlocked = true
|
||||||
user.ownedSkins.push(productId)
|
else user.ownedSkins.push(productId)
|
||||||
}
|
|
||||||
} else if (product.type === 'noad') {
|
} else if (product.type === 'noad') {
|
||||||
|
alreadyUnlocked = user.isAdFree
|
||||||
user.isAdFree = true
|
user.isAdFree = true
|
||||||
} else if (product.type === 'subscription') {
|
} else if (product.type === 'subscription') {
|
||||||
|
alreadyUnlocked = user.isSubscriber
|
||||||
const expiry = new Date()
|
const expiry = new Date()
|
||||||
expiry.setDate(expiry.getDate() + (product.duration || 30))
|
expiry.setDate(expiry.getDate() + (product.duration || 30))
|
||||||
user.isSubscriber = true
|
user.isSubscriber = true
|
||||||
@@ -193,18 +251,18 @@ router.post('/verify', async (req, res) => {
|
|||||||
|
|
||||||
await user.save()
|
await user.save()
|
||||||
|
|
||||||
// 记录订单
|
// 订单已处理过则直接返回
|
||||||
const newOrder = new Order({
|
if (order.status === 'completed') {
|
||||||
orderNo,
|
return ApiResponse.success(res, {
|
||||||
openid: user.openid,
|
product,
|
||||||
userId: user._id,
|
message: alreadyUnlocked ? '权益已生效' : `${product.name} 已解锁!`
|
||||||
productId,
|
})
|
||||||
productName: product.name,
|
}
|
||||||
amount: product.price,
|
|
||||||
status: 'completed',
|
// 标记订单为已完成
|
||||||
completedAt: new Date()
|
order.status = 'completed'
|
||||||
})
|
order.completedAt = new Date()
|
||||||
await newOrder.save()
|
await order.save()
|
||||||
|
|
||||||
return ApiResponse.success(res, {
|
return ApiResponse.success(res, {
|
||||||
product,
|
product,
|
||||||
@@ -224,6 +282,7 @@ router.post('/verify', async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.post('/refund', async (req, res) => {
|
router.post('/refund', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const user = req.user
|
||||||
const { orderNo, reason } = req.body
|
const { orderNo, reason } = req.body
|
||||||
|
|
||||||
// 查询订单
|
// 查询订单
|
||||||
@@ -232,6 +291,11 @@ router.post('/refund', async (req, res) => {
|
|||||||
return ApiResponse.error(res, '订单不存在', 404)
|
return ApiResponse.error(res, '订单不存在', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 权限校验:只能退自己的订单
|
||||||
|
if (order.openid !== user.openid) {
|
||||||
|
return ApiResponse.error(res, '无权操作该订单', 403)
|
||||||
|
}
|
||||||
|
|
||||||
if (order.status !== 'paid' && order.status !== 'completed') {
|
if (order.status !== 'paid' && order.status !== 'completed') {
|
||||||
return ApiResponse.error(res, '订单状态不支持退款', 400)
|
return ApiResponse.error(res, '订单状态不支持退款', 400)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const Trend = require('../models/Trend');
|
const Trend = require('../models/Trend');
|
||||||
|
const { authMiddleware, requirePermission } = require('../middleware/auth');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取趋势列表
|
* 获取趋势列表
|
||||||
@@ -53,7 +54,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
* 管理员:创建趋势
|
* 管理员:创建趋势
|
||||||
* POST /api/trend/admin
|
* POST /api/trend/admin
|
||||||
*/
|
*/
|
||||||
router.post('/admin', async (req, res) => {
|
router.post('/admin', authMiddleware, requirePermission('knowledge:write'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const trend = new Trend(req.body);
|
const trend = new Trend(req.body);
|
||||||
await trend.save();
|
await trend.save();
|
||||||
@@ -67,7 +68,7 @@ router.post('/admin', async (req, res) => {
|
|||||||
* 管理员:批量创建趋势(给 cron 用)
|
* 管理员:批量创建趋势(给 cron 用)
|
||||||
* POST /api/trend/admin/batch
|
* POST /api/trend/admin/batch
|
||||||
*/
|
*/
|
||||||
router.post('/admin/batch', async (req, res) => {
|
router.post('/admin/batch', authMiddleware, requirePermission('knowledge:write'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { items } = req.body;
|
const { items } = req.body;
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
const { User, Order, Gallery } = require('../models')
|
const { User } = require('../models')
|
||||||
const ApiResponse = require('../utils/response')
|
const ApiResponse = require('../utils/response')
|
||||||
const { userAuthMiddleware } = require('../middleware/auth')
|
const { userAuthMiddleware } = require('../middleware/auth')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
@@ -345,10 +345,6 @@ router.get('/achievements', async (req, res) => {
|
|||||||
router.get('/stats', async (req, res) => {
|
router.get('/stats', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = req.user
|
const user = req.user
|
||||||
const { Gallery } = require('../models')
|
|
||||||
|
|
||||||
// 获取用户作品数
|
|
||||||
const worksCount = await Gallery.countDocuments({ userId: user._id })
|
|
||||||
|
|
||||||
// 获取等级信息
|
// 获取等级信息
|
||||||
const levelInfo = user.getLevelInfo()
|
const levelInfo = user.getLevelInfo()
|
||||||
@@ -365,7 +361,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
nextLevelPoints: levelInfo.nextLevelPoints,
|
nextLevelPoints: levelInfo.nextLevelPoints,
|
||||||
totalPlayTime: playTimeHours,
|
totalPlayTime: playTimeHours,
|
||||||
totalScore: user.totalScore || 0,
|
totalScore: user.totalScore || 0,
|
||||||
worksCount: worksCount,
|
worksCount: 0,
|
||||||
collectionCount: user.collectedKnowledge?.length || 0,
|
collectionCount: user.collectedKnowledge?.length || 0,
|
||||||
achievementCount: user.achievements?.length || 0,
|
achievementCount: user.achievements?.length || 0,
|
||||||
unlockedDims: user.unlockedDims || [1]
|
unlockedDims: user.unlockedDims || [1]
|
||||||
@@ -380,39 +376,17 @@ router.get('/stats', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* 获取用户作品列表
|
* 获取用户作品列表
|
||||||
* GET /api/user/works
|
* GET /api/user/works
|
||||||
|
* 说明:画廊(Gallery)功能已移除,统一返回空列表
|
||||||
*/
|
*/
|
||||||
router.get('/works', async (req, res) => {
|
router.get('/works', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = req.user
|
const user = req.user
|
||||||
const { page = 1, limit = 20 } = req.query
|
const { page = 1, limit = 20 } = req.query
|
||||||
const { Gallery } = require('../models')
|
|
||||||
|
|
||||||
const query = { userId: user._id }
|
return ApiResponse.paginated(res, [], {
|
||||||
|
|
||||||
const total = await Gallery.countDocuments(query)
|
|
||||||
const works = await Gallery.find(query)
|
|
||||||
.sort({ createdAt: -1 })
|
|
||||||
.skip((parseInt(page) - 1) * parseInt(limit))
|
|
||||||
.limit(parseInt(limit))
|
|
||||||
.lean()
|
|
||||||
|
|
||||||
// 格式化作品数据
|
|
||||||
const formattedWorks = works.map(work => ({
|
|
||||||
id: work._id,
|
|
||||||
title: work.title,
|
|
||||||
description: work.description,
|
|
||||||
imageUrl: work.imageUrl,
|
|
||||||
dimension: work.dimension,
|
|
||||||
status: work.status,
|
|
||||||
likes: work.likes || 0,
|
|
||||||
views: work.views || 0,
|
|
||||||
createdAt: work.createdAt
|
|
||||||
}))
|
|
||||||
|
|
||||||
return ApiResponse.paginated(res, formattedWorks, {
|
|
||||||
page: parseInt(page),
|
page: parseInt(page),
|
||||||
limit: parseInt(limit),
|
limit: parseInt(limit),
|
||||||
total
|
total: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/www/server/nodejs/v24.14.0/bin/node
|
#!/usr/bin/node
|
||||||
/**
|
/**
|
||||||
* AI 趋势数据更新脚本 —— 真实数据版
|
* AI 趋势数据更新脚本 —— 真实数据版
|
||||||
* 数据源:
|
* 数据源:
|
||||||
@@ -62,7 +62,7 @@ function getSource(url, from) {
|
|||||||
|
|
||||||
function httpGet(url) {
|
function httpGet(url) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
https.get(url, { timeout: 12000 }, (res) => {
|
https.get(url, { timeout: 20000 }, (res) => {
|
||||||
let data = '';
|
let data = '';
|
||||||
res.on('data', c => data += c);
|
res.on('data', c => data += c);
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
@@ -75,7 +75,12 @@ function httpGet(url) {
|
|||||||
|
|
||||||
/** 从 Hacker News 获取 AI 相关热门(精确匹配) */
|
/** 从 Hacker News 获取 AI 相关热门(精确匹配) */
|
||||||
async function fetchHN() {
|
async function fetchHN() {
|
||||||
const ids = await httpGet('https://hacker-news.firebaseio.com/v0/topstories.json');
|
let ids;
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
try { ids = await httpGet('https://hacker-news.firebaseio.com/v0/topstories.json'); break; }
|
||||||
|
catch (e) { console.log(` HN topstories 重试 ${attempt}: ${e.message}`); await new Promise(r => setTimeout(r, 500)); }
|
||||||
|
}
|
||||||
|
if (!Array.isArray(ids)) throw new Error('HN topstories 获取失败');
|
||||||
const batch = ids.slice(0, 120);
|
const batch = ids.slice(0, 120);
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
batch.map(id => httpGet(`https://hacker-news.firebaseio.com/v0/item/${id}.json`))
|
batch.map(id => httpGet(`https://hacker-news.firebaseio.com/v0/item/${id}.json`))
|
||||||
@@ -121,6 +126,7 @@ async function fetchArxiv() {
|
|||||||
const date = new Date(pub);
|
const date = new Date(pub);
|
||||||
return {
|
return {
|
||||||
title, summary: summary || title,
|
title, summary: summary || title,
|
||||||
|
body: summary, // arXiv 已经有完整摘要,直接作为正文
|
||||||
source: 'arXiv', sourceUrl: link,
|
source: 'arXiv', sourceUrl: link,
|
||||||
category: '论文', tags: [], hot: date.getTime() > Date.now() - 2 * 86400000,
|
category: '论文', tags: [], hot: date.getTime() > Date.now() - 2 * 86400000,
|
||||||
newsDate: date, status: 'published'
|
newsDate: date, status: 'published'
|
||||||
@@ -133,89 +139,112 @@ function hasChinese(text) {
|
|||||||
return /[\u4e00-\u9fff]/.test(text);
|
return /[\u4e00-\u9fff]/.test(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 调用 AI 模型批量翻译英文标题和摘要到中文 */
|
// 合规底线:命中以下关键词的内容不发布(可在后续按需扩充)
|
||||||
|
const COMPLIANCE_BLOCKLIST = ['色情', '赌博', '博彩', '毒品', '暴力恐怖', '涉政敏感', '代开发票', '办证'];
|
||||||
|
function isCompliant(item) {
|
||||||
|
const text = [item.title, item.summary, item.body].filter(Boolean).join(' ');
|
||||||
|
return !COMPLIANCE_BLOCKLIST.some(w => text.includes(w));
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 模型统一从数据库 AIModel 集合读取(与线上 AI 问答同款,优先 SenseNova),避免硬编码失效密钥
|
||||||
|
let _models = null;
|
||||||
|
async function loadModels() {
|
||||||
|
if (_models) return _models;
|
||||||
|
try {
|
||||||
|
const AIModel = require('../models/AIModel');
|
||||||
|
_models = await AIModel.getActiveModels();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' ⚠ 读取 AIModel 失败:', e.message);
|
||||||
|
_models = [];
|
||||||
|
}
|
||||||
|
return _models;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调用 AI(60s 超时 + 单次重试;自动在主备模型间切换;兼容 content / reasoning_content / reasoning) */
|
||||||
|
async function callAI(messages, maxTokens = 1024) {
|
||||||
|
const models = await loadModels();
|
||||||
|
if (!models.length) { console.log(' ⚠ 无可用 AI 模型,跳过本次 AI 处理'); return ''; }
|
||||||
|
let lastErr = null;
|
||||||
|
for (const m of models) {
|
||||||
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||||
|
try {
|
||||||
|
const signal = AbortSignal.timeout(60000);
|
||||||
|
const res = await fetch(m.apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + m.apiKey },
|
||||||
|
body: JSON.stringify({ model: m.modelId, messages, temperature: 0.1, max_tokens: maxTokens }),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const msg = data?.choices?.[0]?.message || {};
|
||||||
|
const text = (msg.content || msg.reasoning_content || msg.reasoning || '').trim();
|
||||||
|
if (text) return text;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
console.log(` ⚠ 模型 ${m.modelId} 第${attempt}次失败: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastErr) console.log(' ⚠ 所有模型调用失败:', lastErr.message);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 翻译英文内容到中文 */
|
||||||
async function translateItems(items) {
|
async function translateItems(items) {
|
||||||
const needTranslate = items.filter(i => !hasChinese(i.title));
|
const needTranslate = items.filter(i => !hasChinese(i.title));
|
||||||
if (needTranslate.length === 0) return items;
|
if (needTranslate.length === 0) return items;
|
||||||
|
|
||||||
const apiUrl = process.env.OPENAI_API_URL || 'https://opencode.ai/zen/v1/chat/completions';
|
|
||||||
const apiKey = ''; // OpenCode Zen 无需 API Key
|
|
||||||
const model = process.env.OPENAI_MODEL || 'deepseek-v4-flash-free';
|
|
||||||
|
|
||||||
console.log(` → 翻译 ${needTranslate.length} 条英文内容...`);
|
console.log(` → 翻译 ${needTranslate.length} 条英文内容...`);
|
||||||
|
const BATCH = 1;
|
||||||
|
|
||||||
// 分批翻译,每次最多 4 条
|
|
||||||
const BATCH = 4;
|
|
||||||
for (let i = 0; i < needTranslate.length; i += BATCH) {
|
for (let i = 0; i < needTranslate.length; i += BATCH) {
|
||||||
const batch = needTranslate.slice(i, i + BATCH);
|
const batch = needTranslate.slice(i, i + BATCH);
|
||||||
const input = batch.map((item, idx) => {
|
const input = batch.map((item, idx) =>
|
||||||
return `[${idx + 1}] Title: ${item.title}\nSummary: ${item.summary}`;
|
`[${idx + 1}] Title: ${item.title}\nSummary: ${(item.summary || '').slice(0, 250)}`
|
||||||
}).join('\n\n');
|
).join('\n\n');
|
||||||
|
|
||||||
const prompt = `You are an AI news translator. Translate the following AI-related news headlines to Chinese.
|
|
||||||
Rules:
|
|
||||||
- Keep technical terms/model names/company names in English (e.g., GPT-4, arXiv, NVIDIA, GitHub, Qwen, Grok)
|
|
||||||
- Keep proper nouns like "Show HN:", "Mindwalk" untranslated
|
|
||||||
- Output ONLY a JSON array in this exact format, no other text:
|
|
||||||
[
|
|
||||||
{"title": "translated title 1", "summary": "translated summary 1"},
|
|
||||||
{"title": "translated title 2", "summary": "translated summary 2"}
|
|
||||||
]
|
|
||||||
|
|
||||||
News to translate:
|
|
||||||
${input}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await httpPost(apiUrl, {
|
const text = await callAI([{
|
||||||
model,
|
role: 'user',
|
||||||
messages: [{ role: 'user', content: prompt }],
|
content: `Translate AI news to Chinese. Keep technical terms in English. Output ONLY this JSON array:\n[\n {"title":"chinese title","summary":"chinese summary"}\n]\n\nNews:\n${input}`
|
||||||
temperature: 0.1,
|
}], 400);
|
||||||
max_tokens: 2048
|
|
||||||
}, apiKey);
|
|
||||||
|
|
||||||
const text = result?.choices?.[0]?.message?.content || '';
|
|
||||||
console.log(` ↳ 翻译响应(前100): ${text.slice(0, 100)}`);
|
|
||||||
// 尝试提取 JSON - 用贪婪匹配(] 只在数组末尾出现)
|
|
||||||
const jsonMatch = text.match(/\[[\s\S]*\]/);
|
const jsonMatch = text.match(/\[[\s\S]*\]/);
|
||||||
if (jsonMatch) {
|
if (jsonMatch) {
|
||||||
// 尝试修复常见 JSON 截断问题
|
|
||||||
let jsonStr = jsonMatch[0];
|
|
||||||
try {
|
try {
|
||||||
const translations = JSON.parse(jsonStr);
|
const translations = JSON.parse(jsonMatch[0]);
|
||||||
if (Array.isArray(translations)) {
|
if (Array.isArray(translations)) {
|
||||||
translations.forEach((t, idx) => {
|
translations.forEach((t, idx) => {
|
||||||
if (idx < batch.length && t.title) {
|
if (idx < batch.length && t.title && !hasChinese(batch[idx].title)) {
|
||||||
batch[idx].title = t.title;
|
batch[idx].title = t.title;
|
||||||
if (t.summary) batch[idx].summary = t.summary;
|
if (t.summary) batch[idx].summary = t.summary;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
} catch (parseErr) {
|
} catch (_) {}
|
||||||
// 截断了,尝试逐行模式:按标题-摘要配对解析
|
// JSON 截断,尝试正则提取
|
||||||
console.log(` ↳ JSON 截断,尝试文本解析...`);
|
const titles = [...jsonMatch[0].matchAll(/"title"\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map(m => m[1]);
|
||||||
// 从响应中提取所有 title: 和 summary: 字段
|
titles.forEach((t, idx) => {
|
||||||
const titles = [...jsonStr.matchAll(/"title"\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map(m => m[1]);
|
if (idx < batch.length && !hasChinese(batch[idx].title)) batch[idx].title = t;
|
||||||
const summaries = [...jsonStr.matchAll(/"summary"\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map(m => m[1]);
|
});
|
||||||
titles.forEach((t, idx) => {
|
|
||||||
if (idx < batch.length && !hasChinese(batch[idx].title)) {
|
|
||||||
batch[idx].title = t;
|
|
||||||
if (summaries[idx]) batch[idx].summary = summaries[idx];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(` ⚠ 批次 ${i / BATCH + 1} 解析失败: ${text.slice(0, 100)}`);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(` ⚠ 翻译批次 ${i / BATCH + 1} 失败: ${e.message}`);
|
console.log(` ⚠ 翻译批次 ${i / BATCH + 1} 失败: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
if (i + BATCH < needTranslate.length) await new Promise(r => setTimeout(r, 500));
|
||||||
// 等一秒避免限流
|
|
||||||
if (i + BATCH < needTranslate.length) {
|
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为趋势生成正文内容:直接用已翻译的中文 summary 作为正文(避免长文本生成超时,保证稳定产出中文) */
|
||||||
|
async function enrichBody(items) {
|
||||||
|
const needBody = items.filter(i => !i.body);
|
||||||
|
if (needBody.length === 0) return items;
|
||||||
|
console.log(` → 正文使用中文摘要填充: ${needBody.length} 条`);
|
||||||
|
needBody.forEach(item => {
|
||||||
|
item.body = item.summary || item.title;
|
||||||
|
});
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,22 +316,34 @@ async function main() {
|
|||||||
// 合并数据
|
// 合并数据
|
||||||
let items = [...hn, ...arxiv];
|
let items = [...hn, ...arxiv];
|
||||||
|
|
||||||
// 如果不够,补保底
|
|
||||||
if (items.length < 6) {
|
|
||||||
const need = Math.min(FALLBACK.length, MAX_ITEMS - items.length);
|
|
||||||
const fallback = FALLBACK.slice(0, need).map(f => ({ ...f, newsDate: f.newsDate() }));
|
|
||||||
items.push(...fallback);
|
|
||||||
console.log(` ✓ 保底: ${need} 条`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 翻译英文内容为中文
|
// 翻译英文内容为中文
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
items = await translateItems(items);
|
items = await translateItems(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理旧数据
|
// 生成正文内容(body 字段,中文)
|
||||||
const cutoff = new Date(Date.now() - MAX_AGE_DAYS * 86400000);
|
if (items.length > 0) {
|
||||||
const deleted = await Trend.deleteMany({ newsDate: { $lt: cutoff } });
|
items = await enrichBody(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合规过滤:命中底线的内容不发布
|
||||||
|
items = items.filter(isCompliant);
|
||||||
|
console.log(` → 合规过滤后: ${items.length} 条`);
|
||||||
|
|
||||||
|
// 默认中文:丢弃翻译/生成后仍非中文的条目,保证 feed 全中文
|
||||||
|
items = items.filter(i => hasChinese(i.title));
|
||||||
|
console.log(` → 仅保留中文: ${items.length} 条`);
|
||||||
|
|
||||||
|
// 数量不足时用中文保底数据补充
|
||||||
|
if (items.length < 8) {
|
||||||
|
const need = Math.min(FALLBACK.length, MAX_ITEMS - items.length);
|
||||||
|
const fallback = FALLBACK.slice(0, need).map(f => ({ ...f, newsDate: f.newsDate() }));
|
||||||
|
items.push(...fallback);
|
||||||
|
console.log(` ✓ 中文保底补充: ${need} 条`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全量刷新:清空旧数据(避免残留英文 / 测试数据)
|
||||||
|
await Trend.deleteMany({});
|
||||||
|
|
||||||
// 去重插入
|
// 去重插入
|
||||||
let inserted = 0;
|
let inserted = 0;
|
||||||
@@ -331,11 +372,10 @@ async function main() {
|
|||||||
console.log(` HN=${hn.length} arXiv=${arxiv.length} 新增=${inserted} 数据库共=${finalCount} 条`);
|
console.log(` HN=${hn.length} arXiv=${arxiv.length} 新增=${inserted} 数据库共=${finalCount} 条`);
|
||||||
|
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
console.log('\n📰 最新趋势:');
|
console.log('\n📰 最新趋势(中文):');
|
||||||
items.slice(0, Math.min(items.length, 10)).forEach((t, i) => {
|
items.slice(0, Math.min(items.length, 10)).forEach((t, i) => {
|
||||||
const tag = t.hot ? '🔥' : t.picked ? '📌' : ' ';
|
const tag = t.hot ? '🔥' : t.picked ? '📌' : ' ';
|
||||||
const cat = t.category?.padEnd(4) || '';
|
const cat = (t.category || '').padEnd(4);
|
||||||
const date = t.newsDate ? new Date(t.newsDate).toLocaleDateString('zh-CN') : '';
|
|
||||||
console.log(` ${tag} [${cat}] ${t.title} (${t.source})`);
|
console.log(` ${tag} [${cat}] ${t.title} (${t.source})`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ class WeChatService {
|
|||||||
out_trade_no: orderNo,
|
out_trade_no: orderNo,
|
||||||
notify_url: this.notifyUrl,
|
notify_url: this.notifyUrl,
|
||||||
amount: {
|
amount: {
|
||||||
total: Math.round(amount * 100), // 转换为分
|
total: Math.round(amount), // 金额单位已为分
|
||||||
currency: 'CNY'
|
currency: 'CNY'
|
||||||
},
|
},
|
||||||
payer: {
|
payer: {
|
||||||
@@ -184,6 +184,56 @@ class WeChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建H5支付订单(微信H5支付/MWEB)
|
||||||
|
* @param {Object} orderData - { orderNo, body, amount, clientIp, appId }
|
||||||
|
*/
|
||||||
|
async createH5PaymentOrder(orderData) {
|
||||||
|
const { orderNo, body, amount, clientIp, appId } = orderData
|
||||||
|
|
||||||
|
const nonceStr = this.generateNonceStr()
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||||
|
|
||||||
|
// 1. 统一下单 (H5 / MWEB)
|
||||||
|
const url = '/v3/pay/transactions/h5'
|
||||||
|
const fullUrl = `https://api.mch.weixin.qq.com${url}`
|
||||||
|
|
||||||
|
const orderParams = {
|
||||||
|
appid: appId || this.appId,
|
||||||
|
mchid: this.mchId,
|
||||||
|
description: body,
|
||||||
|
out_trade_no: orderNo,
|
||||||
|
notify_url: this.notifyUrl,
|
||||||
|
amount: {
|
||||||
|
total: Math.round(amount), // 金额单位已为分
|
||||||
|
currency: 'CNY'
|
||||||
|
},
|
||||||
|
scene_info: {
|
||||||
|
h5_info: {
|
||||||
|
type: 'Wap',
|
||||||
|
client_ip: clientIp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟返回 h5_url (生产环境需调用真实微信支付接口)
|
||||||
|
// 实际生产调用示例:
|
||||||
|
// const sign = this.generateSign('POST', url, timestamp, nonceStr, JSON.stringify(orderParams))
|
||||||
|
// const response = await axios.post(fullUrl, orderParams, {
|
||||||
|
// headers: { 'Authorization': `WECHATPAY2-SHA256-RSA2048 ${sign}` }
|
||||||
|
// })
|
||||||
|
// const h5Url = response.data.h5_url
|
||||||
|
|
||||||
|
// 模拟 H5 支付链接(生产环境由微信返回真实 MWEB_URL)
|
||||||
|
const mockH5Url = `https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx_mock_${Date.now()}&package=mock_${Date.now()}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
h5Url: mockH5Url,
|
||||||
|
orderNo,
|
||||||
|
amount: Math.round(amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 申请退款
|
* 申请退款
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,15 +11,21 @@ const Admin = require('../../src/models/Admin')
|
|||||||
describe('Auth API', () => {
|
describe('Auth API', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// 连接测试数据库
|
// 连接测试数据库
|
||||||
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj'
|
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj_test'
|
||||||
if (mongoose.connection.readyState === 0) {
|
if (mongoose.connection.readyState === 0) {
|
||||||
await mongoose.connect(mongoURI)
|
await mongoose.connect(mongoURI)
|
||||||
}
|
}
|
||||||
|
// 确保唯一索引已构建(dropDatabase 后 mongoose 不会自动重建)
|
||||||
|
await mongoose.connection.syncIndexes()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// 清理并关闭连接
|
// 清理并关闭连接
|
||||||
if (mongoose.connection.readyState !== 0) {
|
if (mongoose.connection.readyState !== 0) {
|
||||||
|
// 防呆:只允许在测试库中 dropDatabase
|
||||||
|
if (mongoose.connection.name === 'wdkj') {
|
||||||
|
throw new Error('[安全] 不允许对生产数据库 wdkj 执行 dropDatabase')
|
||||||
|
}
|
||||||
await mongoose.connection.dropDatabase()
|
await mongoose.connection.dropDatabase()
|
||||||
await mongoose.connection.close()
|
await mongoose.connection.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,14 +13,20 @@ describe('User API', () => {
|
|||||||
let authToken
|
let authToken
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj'
|
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj_test'
|
||||||
if (mongoose.connection.readyState === 0) {
|
if (mongoose.connection.readyState === 0) {
|
||||||
await mongoose.connect(mongoURI)
|
await mongoose.connect(mongoURI)
|
||||||
}
|
}
|
||||||
|
// 确保唯一索引已构建(dropDatabase 后 mongoose 不会自动重建)
|
||||||
|
await mongoose.connection.syncIndexes()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (mongoose.connection.readyState !== 0) {
|
if (mongoose.connection.readyState !== 0) {
|
||||||
|
// 防呆:只允许在测试库中 dropDatabase
|
||||||
|
if (mongoose.connection.name === 'wdkj') {
|
||||||
|
throw new Error('[安全] 不允许对生产数据库 wdkj 执行 dropDatabase')
|
||||||
|
}
|
||||||
await mongoose.connection.dropDatabase()
|
await mongoose.connection.dropDatabase()
|
||||||
await mongoose.connection.close()
|
await mongoose.connection.close()
|
||||||
}
|
}
|
||||||
@@ -38,7 +44,6 @@ describe('User API', () => {
|
|||||||
dim1: { bestScore: 100, completed: true },
|
dim1: { bestScore: 100, completed: true },
|
||||||
dim2: { bestArea: 200, completed: false }
|
dim2: { bestArea: 200, completed: false }
|
||||||
},
|
},
|
||||||
totalScore: 300,
|
|
||||||
unlockedDims: [1, 2]
|
unlockedDims: [1, 2]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -58,12 +63,14 @@ describe('User API', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
expect(response.body.success).toBe(true)
|
expect(response.body.success).toBe(true)
|
||||||
expect(response.body.data.exploreData).toBeDefined()
|
expect(response.body.data.isNewUser).toBe(false)
|
||||||
expect(response.body.data.exploreData.dim1.bestScore).toBe(100)
|
const userData = response.body.data.data
|
||||||
expect(response.body.data.exploreData.dim2.bestArea).toBe(200)
|
expect(userData.exploreData).toBeDefined()
|
||||||
|
expect(userData.exploreData.dim1.bestScore).toBe(100)
|
||||||
|
expect(userData.exploreData.dim2.bestArea).toBe(200)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('不存在的用户应该返回404', async () => {
|
test('无效 token 应该返回401', async () => {
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.get('/api/user/progress')
|
.get('/api/user/progress')
|
||||||
.set('Authorization', 'Bearer invalid_token')
|
.set('Authorization', 'Bearer invalid_token')
|
||||||
@@ -78,27 +85,26 @@ describe('User API', () => {
|
|||||||
.post('/api/user/progress')
|
.post('/api/user/progress')
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
.send({
|
.send({
|
||||||
dim: 1,
|
dim: 'dim1',
|
||||||
score: 150,
|
data: { bestScore: 150 }
|
||||||
collectedEggs: 5
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
expect(response.body.success).toBe(true)
|
expect(response.body.success).toBe(true)
|
||||||
|
expect(response.body.data.totalScore).toBe(350)
|
||||||
|
|
||||||
// 验证数据库更新
|
// 验证数据库更新
|
||||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
||||||
expect(updatedUser.exploreData.dim1.bestScore).toBe(150)
|
expect(updatedUser.exploreData.dim1.bestScore).toBe(150)
|
||||||
expect(updatedUser.exploreData.dim1.collectedEggs).toBe(5)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('新用户应该自动解锁维度', async () => {
|
test('完成维度后应解锁下一维度', async () => {
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post('/api/user/progress')
|
.post('/api/user/progress')
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
.send({
|
.send({
|
||||||
dim: 3,
|
dim: 'dim2',
|
||||||
score: 100
|
data: { completed: true, bestArea: 300 }
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
@@ -110,11 +116,11 @@ describe('User API', () => {
|
|||||||
|
|
||||||
describe('GET /api/user/leaderboard', () => {
|
describe('GET /api/user/leaderboard', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// 创建多个测试用户
|
// 创建多个测试用户(分数由 exploreData 派生)
|
||||||
await User.create([
|
await User.create([
|
||||||
{ openid: 'user1', nickName: '用户1', totalScore: 500, dim1Score: 200 },
|
{ openid: 'user1', nickName: '用户1', exploreData: { dim1: { bestScore: 500 } } },
|
||||||
{ openid: 'user2', nickName: '用户2', totalScore: 300, dim1Score: 150 },
|
{ openid: 'user2', nickName: '用户2', exploreData: { dim1: { bestScore: 300 } } },
|
||||||
{ openid: 'user3', nickName: '用户3', totalScore: 400, dim1Score: 100 }
|
{ openid: 'user3', nickName: '用户3', exploreData: { dim1: { bestScore: 400 } } }
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -137,7 +143,7 @@ describe('User API', () => {
|
|||||||
.query({ dim: 1, limit: 10 })
|
.query({ dim: 1, limit: 10 })
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
expect(response.body.data[0].dim1Score).toBe(200)
|
expect(response.body.data[0].dim1Score).toBe(500)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('应该限制返回数量', async () => {
|
test('应该限制返回数量', async () => {
|
||||||
@@ -149,57 +155,16 @@ describe('User API', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('PUT /api/user/info', () => {
|
describe('GET /api/user/achievements', () => {
|
||||||
test('应该成功更新用户信息', async () => {
|
test('应该返回成就列表', async () => {
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.put('/api/user/info')
|
.get('/api/user/achievements')
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
.send({
|
|
||||||
nickName: '新名字',
|
|
||||||
avatarUrl: 'https://example.com/new-avatar.jpg'
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
expect(response.body.success).toBe(true)
|
expect(response.body.success).toBe(true)
|
||||||
|
expect(Array.isArray(response.body.data)).toBe(true)
|
||||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
expect(response.body.data.length).toBeGreaterThan(0)
|
||||||
expect(updatedUser.nickName).toBe('新名字')
|
|
||||||
expect(updatedUser.avatarUrl).toBe('https://example.com/new-avatar.jpg')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('POST /api/user/collect-knowledge', () => {
|
|
||||||
test('应该成功收藏知识', async () => {
|
|
||||||
const knowledgeId = new mongoose.Types.ObjectId()
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/api/user/collect-knowledge')
|
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
|
||||||
.send({ knowledgeId: knowledgeId.toString() })
|
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
|
||||||
expect(response.body.success).toBe(true)
|
|
||||||
|
|
||||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
|
||||||
expect(updatedUser.collectedKnowledge).toContainEqual(knowledgeId)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('重复收藏应该返回已存在', async () => {
|
|
||||||
const knowledgeId = new mongoose.Types.ObjectId()
|
|
||||||
|
|
||||||
// 第一次收藏
|
|
||||||
await request(app)
|
|
||||||
.post('/api/user/collect-knowledge')
|
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
|
||||||
.send({ knowledgeId: knowledgeId.toString() })
|
|
||||||
|
|
||||||
// 第二次收藏
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/api/user/collect-knowledge')
|
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
|
||||||
.send({ knowledgeId: knowledgeId.toString() })
|
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,11 +5,28 @@
|
|||||||
// 增加测试超时时间
|
// 增加测试超时时间
|
||||||
jest.setTimeout(30000)
|
jest.setTimeout(30000)
|
||||||
|
|
||||||
|
// 测试环境必须使用独立的测试数据库,绝不能操作生产库(wdkj)
|
||||||
|
const TEST_DB_NAME = 'wdkj_test'
|
||||||
|
const DANGER_DB_NAMES = ['wdkj']
|
||||||
|
|
||||||
|
function assertTestDatabase() {
|
||||||
|
const mongoose = require('mongoose')
|
||||||
|
const name = mongoose.connection.name
|
||||||
|
if (!name) return
|
||||||
|
if (DANGER_DB_NAMES.includes(name)) {
|
||||||
|
throw new Error(
|
||||||
|
`[安全] 测试不允许连接生产数据库 "${name}"。请通过 MONGODB_URI 指定独立的测试数据库(如 ${TEST_DB_NAME})。`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 全局清理
|
// 全局清理
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// 关闭所有数据库连接
|
// 关闭所有数据库连接
|
||||||
const mongoose = require('mongoose')
|
const mongoose = require('mongoose')
|
||||||
if (mongoose.connection.readyState !== 0) {
|
if (mongoose.connection.readyState !== 0) {
|
||||||
|
// 防呆:确认当前不是生产库后才清理/关闭
|
||||||
|
assertTestDatabase()
|
||||||
await mongoose.connection.close()
|
await mongoose.connection.close()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,11 +8,17 @@ const User = require('../../src/models/User')
|
|||||||
describe('User Model', () => {
|
describe('User Model', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// 连接测试数据库
|
// 连接测试数据库
|
||||||
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj'
|
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj_test'
|
||||||
await mongoose.connect(mongoURI)
|
await mongoose.connect(mongoURI)
|
||||||
|
// 确保唯一索引已构建(dropDatabase 后 mongoose 不会自动重建)
|
||||||
|
await mongoose.connection.syncIndexes()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
// 防呆:只允许在测试库中 dropDatabase
|
||||||
|
if (mongoose.connection.name === 'wdkj') {
|
||||||
|
throw new Error('[安全] 不允许对生产数据库 wdkj 执行 dropDatabase')
|
||||||
|
}
|
||||||
// 清理并关闭连接
|
// 清理并关闭连接
|
||||||
await mongoose.connection.dropDatabase()
|
await mongoose.connection.dropDatabase()
|
||||||
await mongoose.connection.close()
|
await mongoose.connection.close()
|
||||||
@@ -42,12 +48,15 @@ describe('User Model', () => {
|
|||||||
expect(savedUser.totalScore).toBe(0)
|
expect(savedUser.totalScore).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('openid 是必需的', async () => {
|
test('username 必须唯一', async () => {
|
||||||
const user = new User({
|
await new User({ username: 'alice', nickName: '用户A' }).save()
|
||||||
nickName: '测试用户'
|
|
||||||
|
const duplicateUser = new User({
|
||||||
|
username: 'alice',
|
||||||
|
nickName: '用户B'
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect(user.save()).rejects.toThrow()
|
await expect(duplicateUser.save()).rejects.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('openid 必须唯一', async () => {
|
test('openid 必须唯一', async () => {
|
||||||
@@ -134,11 +143,11 @@ describe('User Model', () => {
|
|||||||
|
|
||||||
describe('静态方法', () => {
|
describe('静态方法', () => {
|
||||||
test('getLeaderboard 应该返回排行榜', async () => {
|
test('getLeaderboard 应该返回排行榜', async () => {
|
||||||
// 创建测试用户
|
// 创建测试用户(分数由 exploreData 派生,保存时自动计算)
|
||||||
await User.create([
|
await User.create([
|
||||||
{ openid: 'user1', totalScore: 300, dim1Score: 100 },
|
{ openid: 'user1', exploreData: { dim1: { bestScore: 300 } } },
|
||||||
{ openid: 'user2', totalScore: 500, dim1Score: 200 },
|
{ openid: 'user2', exploreData: { dim1: { bestScore: 500 } } },
|
||||||
{ openid: 'user3', totalScore: 400, dim1Score: 150 }
|
{ openid: 'user3', exploreData: { dim1: { bestScore: 400 } } }
|
||||||
])
|
])
|
||||||
|
|
||||||
const leaderboard = await User.getLeaderboard(null, 10)
|
const leaderboard = await User.getLeaderboard(null, 10)
|
||||||
@@ -151,9 +160,9 @@ describe('User Model', () => {
|
|||||||
|
|
||||||
test('getLeaderboard 应该支持按维度排序', async () => {
|
test('getLeaderboard 应该支持按维度排序', async () => {
|
||||||
await User.create([
|
await User.create([
|
||||||
{ openid: 'user1', dim1Score: 100, totalScore: 100 },
|
{ openid: 'user1', exploreData: { dim1: { bestScore: 100 } } },
|
||||||
{ openid: 'user2', dim1Score: 200, totalScore: 200 },
|
{ openid: 'user2', exploreData: { dim1: { bestScore: 200 } } },
|
||||||
{ openid: 'user3', dim1Score: 150, totalScore: 150 }
|
{ openid: 'user3', exploreData: { dim1: { bestScore: 150 } } }
|
||||||
])
|
])
|
||||||
|
|
||||||
const leaderboard = await User.getLeaderboard(1, 10)
|
const leaderboard = await User.getLeaderboard(1, 10)
|
||||||
|
|||||||
Reference in New Issue
Block a user