Compare commits
111 Commits
1094435b5f
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b9ff56360 | |||
| df8dd3d3dc | |||
| 726e5125a7 | |||
| de125dae67 | |||
| a87a631459 | |||
| df52c039d8 | |||
| 65c6802995 | |||
| feac6371ea | |||
| 7849bd067e | |||
| f2fa96b81b | |||
| 6d463b9296 | |||
| 9ea09c95f2 | |||
| acca4fe379 | |||
| 1d742dc6dd | |||
| 61069cd095 | |||
| 7cd01d8104 | |||
| ce9f69a76e | |||
| 5f3809831f | |||
| 49946c6d21 | |||
| b7d085c475 | |||
| 1e972291d9 | |||
| 5e525b3b7c | |||
| ab3d231e73 | |||
| 15eb4727bb | |||
| bcb239c51b | |||
| 1fc358d47c | |||
| 1f0b8e7740 | |||
| c549eb532d | |||
| c5e12c67fa | |||
| 7fd32685ce | |||
| f91d8f5418 | |||
| 62fb40e415 | |||
| 1e8a0aeead | |||
| b866eb1e70 | |||
| 5959edf37e | |||
| 2bc58d0c6a | |||
| 5e098a3584 | |||
| c5da01063c | |||
| 6adf9f1c6a | |||
| 9ab08254e3 | |||
| 7a5dcf8e0e | |||
| c7d4158aca | |||
| e6cb577305 | |||
| e307fe2e50 | |||
| b5d6071a3e | |||
| 83d907f2bf | |||
| 4255e26840 | |||
| 01a60ffc25 | |||
| 2b00fa49c1 | |||
| f6d99aff3c | |||
| 7b92a7308d | |||
| 7537f7c89c | |||
| f13309e6cd | |||
| 968396bed5 | |||
| 26d318b8cb | |||
| cb978dad25 | |||
| 418e4bd976 | |||
| d7e4e33768 | |||
| 8fe3029a28 | |||
| 97f75bee35 | |||
| d44893d78d | |||
| 3777f67af6 | |||
| 281cd4877a | |||
| 3c9739381c | |||
| 5f6c0d717f | |||
| 95a28390dd | |||
| d9e0096322 | |||
| e877a18975 | |||
| 00fb91356c | |||
| 1d6cc71ec7 | |||
| 5f911fed6d | |||
| 03a8c864e1 | |||
| 6323808601 | |||
| dec8ab2749 | |||
| 7210ca4393 | |||
| 336cc3d2c9 | |||
| c7f879a0cf | |||
| 3f0d0e9b70 | |||
| 96d501275a | |||
| 1874d74ae7 | |||
| d221a45a5e | |||
| 6a3c048072 | |||
| c5ade459a5 | |||
| a4da3722ef | |||
| a11bc1b247 | |||
| e872b6d157 | |||
| 5bd1c5765c | |||
| f8435b9f5f | |||
| 6a971d3061 | |||
| 15ab3f0b63 | |||
| 47d3ea8bfa | |||
| 4f48b324c5 | |||
| 2b2a3ab432 | |||
| c8e13742f1 | |||
| ccccad2c1a | |||
| d06ae5d058 | |||
| 7b22a9a7b6 | |||
| bb0863cc78 | |||
| 24f0e038eb | |||
| 430d125a18 | |||
| b72ec4e5a2 | |||
| 4f8dd8be75 | |||
| 2a111d21c0 | |||
| cbc1a587f2 | |||
| 8c33617073 | |||
| 92d54adabb | |||
| 1f066a09cc | |||
| ea7816bae4 | |||
| 9870bee3b8 | |||
| 66de1dc396 | |||
| de8ca9c420 |
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
.env
|
||||
@@ -24,3 +24,8 @@ Thumbs.db
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
client/src/.share-done
|
||||
admin/node_modules/
|
||||
admin/dist/
|
||||
server/data/
|
||||
!server/data/certs/README.md
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## v1.2.0(2026-09-13)星图点亮体系重构
|
||||
|
||||
- **登录修复**:微信一键登录恢复(Docker 迁移环境变量补齐 + 容器网络修复);趋势页筛选按钮单行紧凑化、产品图标修复
|
||||
- **我的页就地登录**:小程序端"微信一键登录"点击直接登录(首次协议确认一次),不再跳登录页;登录页保留为账号登录入口(H5 端按钮改为"登录/注册")
|
||||
|
||||
- **详情页修复**:正文按段落渲染,71 条知识不再"暂无内容"
|
||||
- **读后检测**:AI 批量生成 36 道检测题,服务端判题,答对点亮星球(点亮进度/streak 入档案)
|
||||
- **问答去登录墙**:游客每日 3 次(IP 限流)、登录每日 5 次;AI 导师「维度精灵」人格化
|
||||
- **设计系统 2.0**:70 个线性图标替换 emoji、主题 token 化、H5/小程序深色模式、首页星域导航、我的页学习者档案
|
||||
- **服务端加固**:trust proxy 真实 IP、错误脱敏、ask 独立限流、检测题答案不下发、mongo 连接重试
|
||||
- **部署**:全栈 Docker 化(mongo/server/admin/h5 四容器),旧 systemd/裸 mongo 下线清理
|
||||
|
||||
|
||||
## v1.1.0(2026-07-10)
|
||||
|
||||
### 项目重构
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
RUN echo 'server{listen 80;root /usr/share/nginx/html;location /api/{proxy_pass http://dimension-server:3001;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;}}' > /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>AI维度 · 管理后台</title></head>
|
||||
<body><div id="app"></div><script type="module" src="/src/main.js"></script></body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "dimension-admin",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"element-plus": "^2.14.5",
|
||||
"vue": "^3.5.42"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div v-if="!token" class="login-wrap">
|
||||
<div class="login-card">
|
||||
<div class="mark">✦ AI维度 · 管理后台</div>
|
||||
<el-input v-model="username" placeholder="管理员用户名" @keyup.enter="login" />
|
||||
<el-input v-model="password" type="password" placeholder="密码" show-password @keyup.enter="login" />
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||
<el-button type="primary" :loading="busy" style="width:100%" @click="login">登 录</el-button>
|
||||
<p class="hint">© 2026 北京宇之然科技中心 · <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">京ICP备2026007249号-2</a> · <a href="https://beian.mps.gov.cn/#/query/webSearch?code=11011502039622" target="_blank" rel="noopener">京公网安备11011502039622号</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="layout">
|
||||
<aside class="side">
|
||||
<div class="side-title">✦ AI维度</div>
|
||||
<div v-for="m in menus" :key="m.key" class="menu-item" :class="{ active: view === m.key }" @click="view = m.key">
|
||||
<span>{{ m.icon }}</span>{{ m.label }}
|
||||
</div>
|
||||
<div class="side-foot"><el-button size="small" @click="logout">退出</el-button></div>
|
||||
</aside>
|
||||
<main class="content"><component :is="menus.find(m => m.key === view).comp" /></main>
|
||||
<footer class="layout-foot">
|
||||
© 2026 北京宇之然科技中心 · 联系邮箱 contact@yuzhiran.com ·
|
||||
<a href="https://www.yuzhiran.com.cn" target="_blank" rel="noopener">宇之然官网</a> ·
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">京ICP备2026007249号-2</a> · <a href="https://beian.mps.gov.cn/#/query/webSearch?code=11011502039622" target="_blank" rel="noopener">京公网安备11011502039622号</a>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, provide, onBeforeUnmount } from 'vue'
|
||||
import { getToken, setToken, clearToken, apiPost } from './api'
|
||||
import Dashboard from './views/Dashboard.vue'
|
||||
import Users from './views/Users.vue'
|
||||
import Knowledge from './views/Knowledge.vue'
|
||||
import Trends from './views/Trends.vue'
|
||||
import Quotas from './views/Quotas.vue'
|
||||
import Feedbacks from './views/Feedbacks.vue'
|
||||
import Logs from './views/Logs.vue'
|
||||
import Notify from './views/Notify.vue'
|
||||
import VpayOrders from './views/VpayOrders.vue'
|
||||
|
||||
const token = ref(getToken())
|
||||
const view = ref('dashboard')
|
||||
// 子页面跳转菜单(如看板→访问日志)
|
||||
const goto = (key) => { view.value = key }
|
||||
// 登录态过期:任何接口401 → 回登录框
|
||||
const onAuthExpired = () => { token.value = '' }
|
||||
onMounted(() => window.addEventListener('admin-auth-expired', onAuthExpired))
|
||||
onBeforeUnmount(() => window.removeEventListener('admin-auth-expired', onAuthExpired))
|
||||
provide('goto', goto)
|
||||
const username = ref(''), password = ref(''), busy = ref(false), error = ref('')
|
||||
|
||||
const menus = [
|
||||
{ key: 'dashboard', label: '数据看板', icon: '📊', comp: Dashboard },
|
||||
{ key: 'users', label: '用户管理', icon: '👥', comp: Users },
|
||||
{ key: 'knowledge', label: '知识管理', icon: '📚', comp: Knowledge },
|
||||
{ key: 'trends', label: '趋势管理', icon: '📈', comp: Trends },
|
||||
{ key: 'quotas', label: '问答额度', icon: '💬', comp: Quotas },
|
||||
{ key: 'feedbacks', label: '反馈管理', icon: '📝', comp: Feedbacks },
|
||||
{ key: 'vpay', label: '订单/余额', icon: '💰', comp: VpayOrders },
|
||||
{ key: 'logs', label: '访问日志', icon: '🗂️', comp: Logs },
|
||||
{ key: 'notify', label: '通知推送', icon: '📣', comp: Notify },
|
||||
]
|
||||
|
||||
async function login() {
|
||||
busy.value = true; error.value = ''
|
||||
try {
|
||||
const r = await apiPost('/api/auth/admin/login', { username: username.value, password: password.value })
|
||||
setToken(r.data?.token || r.token); token.value = r.data?.token || r.token
|
||||
} catch (e) { error.value = e.message }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
function logout() { clearToken(); token.value = '' }
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body { margin: 0; font-family: -apple-system, 'PingFang SC', sans-serif; background: #f5f6fa; }
|
||||
.login-wrap { min-height: 100vh; display: grid; place-items: center; }
|
||||
.login-card { width: 340px; background: #fff; border-radius: 16px; padding: 32px 28px; display: flex; flex-direction: column; gap: 14px; box-shadow: 0 8px 30px rgba(0,0,0,.1); }
|
||||
.mark { text-align: center; font-size: 18px; font-weight: 700; color: #1f2333; margin-bottom: 6px; }
|
||||
.hint { margin: 0; font-size: 11px; color: #b6bac9; text-align: center; }
|
||||
.layout { display: grid; grid-template-columns: 200px 1fr; min-height: 100vh; }
|
||||
.side { background: #1f2333; color: #cfd3e4; display: flex; flex-direction: column; padding: 16px 10px; position: sticky; top: 0; height: 100vh; }
|
||||
.side-title { font-weight: 700; color: #fff; padding: 6px 12px 18px; font-size: 16px; }
|
||||
.menu-item { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-radius: 8px; cursor: pointer; font-size: 14px; margin-bottom: 2px; }
|
||||
.menu-item:hover { background: rgba(255,255,255,.07); }
|
||||
.menu-item.active { background: #6366f1; color: #fff; }
|
||||
.side-foot { margin-top: auto; padding: 10px; }
|
||||
.content { padding: 20px 24px; }
|
||||
@media (max-width: 768px) {
|
||||
.layout { grid-template-columns: 1fr !important; grid-template-rows: auto 1fr; }
|
||||
.side {
|
||||
position: sticky !important; top: 0; z-index: 30;
|
||||
height: 52px !important; max-height: 52px !important;
|
||||
flex-direction: row !important; align-items: center;
|
||||
overflow-x: auto; overflow-y: hidden;
|
||||
padding: 0 10px; gap: 6px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.side-title {
|
||||
font-size: 14px; padding: 0 8px;
|
||||
white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.menu-item {
|
||||
height: 36px; padding: 0 14px;
|
||||
font-size: 13px; margin-bottom: 0;
|
||||
white-space: nowrap; flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.side-foot {
|
||||
margin: 0 0 0 auto; padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.content { padding: 12px; min-width: 0; overflow-x: hidden; }
|
||||
}
|
||||
|
||||
</style>
|
||||
<style>
|
||||
/* 全局:管理后台所有列表在窄屏给足表格最小宽,左右滑动查看 */
|
||||
.el-table { width: 100%; }
|
||||
.el-table__inner-wrapper { overflow-x: auto; }
|
||||
.page .el-table table { min-width: 640px; }
|
||||
|
||||
.layout-foot {
|
||||
grid-column: 1 / -1;
|
||||
padding: 12px 24px;
|
||||
font-size: 12px;
|
||||
color: #b6bac9;
|
||||
border-top: 1px solid #eef0f6;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
.layout-foot a { color: #9aa0b4; text-decoration: none; }
|
||||
.hint a { color: #9aa0b4; text-decoration: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
const TOKEN_KEY = 'dim_admin_token'
|
||||
export function getToken() { return localStorage.getItem(TOKEN_KEY) || '' }
|
||||
export function setToken(t) { localStorage.setItem(TOKEN_KEY, t) }
|
||||
export function clearToken() { localStorage.removeItem(TOKEN_KEY) }
|
||||
|
||||
export function apiGet(path) {
|
||||
return fetch(path, { headers: { Authorization: `Bearer ${getToken()}` } })
|
||||
.then(r => {
|
||||
if (r.status === 401) {
|
||||
clearToken()
|
||||
window.dispatchEvent(new CustomEvent('admin-auth-expired'))
|
||||
throw new Error('登录已过期')
|
||||
}
|
||||
return r.json()
|
||||
})
|
||||
.then(j => j.data !== undefined ? j.data : j)
|
||||
.catch(e => { throw new Error(e.message) })
|
||||
}
|
||||
|
||||
// HTTP 非 2xx 时抛出服务端返回的错误信息(否则 409/500 被当正常 JSON 处理)
|
||||
function handleResponse(r) {
|
||||
return r.json().then(j => {
|
||||
if (!r.ok) {
|
||||
// 401=登录态过期:清token并通知全局弹登录框(否则面板停留空数据)
|
||||
if (r.status === 401) {
|
||||
clearToken()
|
||||
window.dispatchEvent(new CustomEvent('admin-auth-expired'))
|
||||
}
|
||||
throw new Error(j.error || j.message || `请求失败(${r.status})`)
|
||||
}
|
||||
return j
|
||||
})
|
||||
}
|
||||
|
||||
export function apiPost(path, body) {
|
||||
return fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify(body)
|
||||
}).then(handleResponse)
|
||||
}
|
||||
|
||||
export function apiPut(path, body) {
|
||||
return fetch(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify(body)
|
||||
}).then(handleResponse)
|
||||
}
|
||||
|
||||
export function apiDelete(path) {
|
||||
return fetch(path, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${getToken()}` }
|
||||
}).then(handleResponse)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import App from './App.vue'
|
||||
createApp(App).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="cards">
|
||||
<div class="card"><b>{{ d.stats?.totalUsers ?? '-' }}</b><span>用户总数</span><i>7日新增 {{ d.stats?.newUsers7d ?? 0 }}</i></div>
|
||||
<div class="card"><b>{{ d.stats?.approvedKnowledge ?? '-' }}</b><span>已审知识</span><i>共 {{ d.stats?.totalKnowledge ?? 0 }} · 含题 {{ d.stats?.withQuiz ?? 0 }}</i></div>
|
||||
<div class="card"><b>{{ d.stats?.totalLits ?? '-' }}</b><span>点亮星球</span><i>{{ d.stats?.litUsers ?? 0 }} 人参与</i></div>
|
||||
<div class="card"><b>{{ d.stats?.aiChatUsed ?? '-' }}</b><span>AI 问答次数</span><i>{{ d.stats?.aiChatUsers ?? 0 }} 人用过</i></div>
|
||||
<div class="card"><b>{{ d.stats?.totalTrends ?? '-' }}</b><span>趋势资讯</span><i>含人话解读 {{ d.stats?.trendsWithExplain ?? 0 }}</i></div>
|
||||
<div class="card"><b>{{ d.stats?.paidOrders ?? '-' }}</b><span>付费订单</span><i>共 {{ d.stats?.totalOrders ?? 0 }} 单</i></div>
|
||||
<div class="card"><b>{{ visit.pvToday ?? '-' }}</b><span>今日访问 PV</span><i>小程序 + H5</i></div>
|
||||
<div class="card"><b>{{ visit.uvToday ?? '-' }}</b><span>今日访客 UV</span><i>按 IP 去重</i></div>
|
||||
<div class="card"><b>{{ visit.h5Today ?? '-' }}</b><span>H5 访问</span><i>小程序 {{ visit.miniToday ?? 0 }} 次</i></div>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="(visit.sources7d || []).length">
|
||||
<div class="panel">
|
||||
<h3>访问来源分布(近 7 天)</h3>
|
||||
<div v-for="s in visit.sources7d" :key="s.source" class="bar-row">
|
||||
<span class="bar-name">{{ s.source }}</span>
|
||||
<div class="bar-track"><div class="bar-fill" :style="{ width: srcPct(s.count) }"></div></div>
|
||||
<span class="bar-num">{{ s.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel grow"></div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="panel">
|
||||
<h3>星域点亮分布</h3>
|
||||
<div v-for="b in d.litByDim || []" :key="b.dim" class="bar-row">
|
||||
<span class="bar-name">{{ b.name }}</span>
|
||||
<div class="bar-track"><div class="bar-fill" :style="{ width: pct(b.count) }"></div></div>
|
||||
<span class="bar-num">{{ b.count }}</span>
|
||||
</div>
|
||||
<p v-if="!(d.litByDim || []).some(b => b.count)" class="empty">还没有点亮记录</p>
|
||||
</div>
|
||||
<div class="panel grow">
|
||||
<div class="rec-head">
|
||||
<h3 style="margin: 0">最近访问记录</h3>
|
||||
<el-button size="small" text type="primary" @click="goVisitLog">查看更多 ›</el-button>
|
||||
</div>
|
||||
<el-table :data="d.recentLogins || []" size="small" stripe>
|
||||
<el-table-column prop="createdAt" label="时间" width="160">
|
||||
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户/来源" width="130">
|
||||
<template #default="{ row }">{{ row.userName || row.openid || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" label="类型" width="70" />
|
||||
<el-table-column label="方式/终端" width="120">
|
||||
<template #default="{ row }">{{ row.method || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip" label="IP" width="120" />
|
||||
<el-table-column label="归属地" width="110">
|
||||
<template #default="{ row }">{{ row.ipLoc || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果 / 路径" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.kind === 'login'">
|
||||
<el-tag :type="row.success ? 'success' : 'danger'" size="small">{{ row.success ? '成功' : (row.failReason || '失败') }}</el-tag>
|
||||
</template>
|
||||
<template v-else>{{ row.path || '-' }}</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<p v-if="!(d.recentLogins || []).length" class="empty">暂无访问记录</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, inject } from 'vue'
|
||||
import { apiGet } from '../api'
|
||||
const d = ref({})
|
||||
const goto = inject('goto', () => {})
|
||||
function goVisitLog() { goto('logs') }
|
||||
const visit = ref({})
|
||||
function pct(n) {
|
||||
const max = Math.max(1, ...(d.value.litByDim || []).map(b => b.count))
|
||||
return Math.round((n / max) * 100) + '%'
|
||||
}
|
||||
function srcPct(n) {
|
||||
const max = Math.max(1, ...(visit.value.sources7d || []).map(s => s.count))
|
||||
return Math.round((n / max) * 100) + '%'
|
||||
}
|
||||
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '-' }
|
||||
onMounted(async () => {
|
||||
d.value = (await apiGet('/api/admin/dashboard-v2')) || {}
|
||||
try { visit.value = (await apiGet('/api/admin/visit-stats')) || {} } catch (e) { visit.value = {} }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 16px; display: flex; flex-direction: column; gap: 2px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.card b { font-size: 24px; color: #1f2333; }
|
||||
.card span { font-size: 13px; color: #6b7080; }
|
||||
.card i { font-size: 11px; color: #a6abbb; font-style: normal; }
|
||||
.row { display: grid; grid-template-columns: 300px 1fr; gap: 12px; min-width: 0; }
|
||||
@media (max-width: 900px) { .row { grid-template-columns: 1fr; } }
|
||||
.panel { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); min-width: 0; overflow-x: auto; }
|
||||
/* 移动端:固定列宽表格给足最小总宽,交给 el-table 内部横向滑动,避免列被挤压 */
|
||||
.panel :deep(.el-table) { width: 100%; }
|
||||
.panel :deep(.el-table__inner-wrapper) { overflow-x: auto; }
|
||||
.panel :deep(.el-table table) { min-width: 620px; }
|
||||
.panel h3 { margin: 0 0 12px; font-size: 14px; color: #1f2333; }
|
||||
.bar-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
.bar-name { width: 64px; font-size: 12px; color: #6b7080; flex-shrink: 0; }
|
||||
.bar-track { flex: 1; height: 8px; border-radius: 4px; background: #eef0f6; overflow: hidden; }
|
||||
.bar-fill { height: 100%; background: linear-gradient(90deg, #5b5ff8, #8b5cf6); border-radius: 4px; }
|
||||
.bar-num { width: 28px; text-align: right; font-size: 12px; color: #6b7080; }
|
||||
.empty { color: #b6bac9; font-size: 12px; text-align: center; padding: 12px 0; }
|
||||
|
||||
.rec-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-table :data="list" stripe v-loading="loading">
|
||||
<el-table-column prop="userName" label="用户" width="110" />
|
||||
<el-table-column prop="content" label="反馈内容" min-width="260" show-overflow-tooltip />
|
||||
<el-table-column prop="contact" label="联系方式" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="type" label="类型" width="90" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }"><el-tag :type="row.status === 'resolved' ? 'success' : 'warning'" size="small">{{ row.status === 'resolved' ? '已处理' : '待处理' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="150">
|
||||
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="load" style="margin-top: 12px" />
|
||||
<p v-if="!loading && !list.length" class="empty">还没有收到反馈</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { apiGet } from '../api'
|
||||
const list = ref([]), total = ref(0), page = ref(1), pageSize = 20, loading = ref(false)
|
||||
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await apiGet(`/api/admin/feedback?page=${page.value}&limit=${pageSize}`)
|
||||
list.value = r.items || []
|
||||
total.value = r.pagination?.total || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.empty { color: #b6bac9; text-align: center; padding: 24px 0; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="toolbar">
|
||||
<el-select v-model="statusFilter" style="width: 130px" @change="search">
|
||||
<el-option label="全部状态" value="" /><el-option label="已过审" value="approved" /><el-option label="草稿" value="draft" /><el-option label="待审" value="pending" />
|
||||
</el-select>
|
||||
<el-select v-model="dimFilter" style="width: 130px" @change="search">
|
||||
<el-option label="全部维度" value="" /><el-option v-for="(n, i) in DIMS" :key="i" :label="n" :value="i + 1" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="openEdit(null)">新增知识</el-button>
|
||||
</div>
|
||||
<el-table :data="list" stripe v-loading="loading">
|
||||
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="维度" width="90">
|
||||
<template #default="{ row }">{{ DIMS[row.dim - 1] || row.dim }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" width="90"><template #default="{ row }">{{ CATS[row.category] || row.category || '—' }}</template></el-table-column>
|
||||
<el-table-column label="检测题" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="(row.options || []).length ? 'success' : 'info'" size="small">{{ (row.options || []).length ? '有' : '无' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="viewCount" label="浏览" width="70" />
|
||||
<el-table-column label="更新时间" width="150">
|
||||
<template #default="{ row }">{{ fmt(row.updatedAt || row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'approved' ? 'success' : 'warning'" size="small">{{ STATUS[row.status] || row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="success" v-if="row.status !== 'approved'" @click="review(row, 'approved')">过审</el-button>
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="del(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="load" style="margin-top: 12px" />
|
||||
|
||||
<el-dialog v-model="dlg" :title="form._id ? '编辑知识' : '新增知识'" width="min(640px, 94%)" top="4vh">
|
||||
<el-form label-width="76px">
|
||||
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
|
||||
<el-form-item label="维度/分类">
|
||||
<el-select v-model="form.dim" style="width: 110px"><el-option v-for="(n, i) in DIMS" :key="i" :label="n" :value="i + 1" /></el-select>
|
||||
<el-select v-model="form.category" style="width: 130px; margin-left: 8px"><el-option v-for="c in ['concept','history','application','question']" :key="c" :label="CATS[c]" :value="c" /></el-select>
|
||||
<el-select v-model="form.status" style="width: 110px; margin-left: 8px"><el-option label="草稿" value="draft" /><el-option label="已过审" value="approved" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="正文"><el-input v-model="form.content" type="textarea" :rows="5" /></el-form-item>
|
||||
<el-form-item label="标签"><el-input v-model="tagsText" placeholder="逗号分隔" /></el-form-item>
|
||||
<el-divider content-position="left">读后检测题(答对点亮星球)</el-divider>
|
||||
<el-form-item label="题干"><el-input v-model="form.question" /></el-form-item>
|
||||
<el-form-item v-for="(opt, i) in form.options" :key="i" :label="String.fromCharCode(65 + i)">
|
||||
<div class="opt-row">
|
||||
<el-input v-model="opt.text" placeholder="选项内容" />
|
||||
<el-radio v-model="correctIdx" :value="i">正确</el-radio>
|
||||
<el-button size="small" text type="danger" @click="form.options.splice(i, 1)">删</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label=" "><el-button size="small" @click="form.options.push({ text: '', isCorrect: false })" :disabled="form.options.length >= 5">+ 加选项</el-button></el-form-item>
|
||||
<el-form-item label="解析"><el-input v-model="form.answer" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dlg = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { apiGet, apiPost, apiDelete } from '../api'
|
||||
const DIMS = ['AI 起源', 'AI 发展', 'AI 当前', 'AI 学习', 'AI 趋势']
|
||||
const CATS = { concept: '概念', history: '历史', application: '应用', question: '问答' }
|
||||
const STATUS = { approved: '已过审', draft: '草稿', pending: '待审', rejected: '已驳回' }
|
||||
const list = ref([]), total = ref(0), page = ref(1), pageSize = 20, loading = ref(false)
|
||||
const statusFilter = ref(''), dimFilter = ref('')
|
||||
const dlg = ref(false), correctIdx = ref(0), tagsText = ref('')
|
||||
const form = ref({ title: '', dim: 1, category: 'concept', status: 'draft', content: '', tags: [], question: '', options: [], answer: '' })
|
||||
function search() { page.value = 1; load() }
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const qs = `page=${page.value}&limit=${pageSize}${statusFilter.value ? `&status=${statusFilter.value}` : ''}${dimFilter.value ? `&dim=${dimFilter.value}` : ''}`
|
||||
const r = await apiGet(`/api/admin/knowledge?${qs}`)
|
||||
list.value = r.items || []
|
||||
total.value = r.pagination?.total || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
function openEdit(row) {
|
||||
if (row) {
|
||||
form.value = JSON.parse(JSON.stringify(row))
|
||||
correctIdx.value = Math.max(0, (row.options || []).findIndex(o => o.isCorrect))
|
||||
tagsText.value = (row.tags || []).join(',')
|
||||
} else {
|
||||
form.value = { title: '', dim: 1, category: 'concept', status: 'draft', content: '', tags: [], question: '', options: [{ text: '', isCorrect: true }, { text: '', isCorrect: false }], answer: '' }
|
||||
correctIdx.value = 0
|
||||
tagsText.value = ''
|
||||
}
|
||||
dlg.value = true
|
||||
}
|
||||
async function save() {
|
||||
const f = form.value
|
||||
if (!f.title || !f.content) return ElMessage.warning('标题和正文必填')
|
||||
f.options = (f.options || []).filter(o => o.text.trim())
|
||||
if (f.options.length) {
|
||||
if (f.options.length < 2) return ElMessage.warning('检测题至少两个选项')
|
||||
f.options.forEach((o, i) => (o.isCorrect = i === correctIdx.value))
|
||||
}
|
||||
f.tags = tagsText.value.split(/[,,]/).map(t => t.trim()).filter(Boolean)
|
||||
// 服务端约定:knowledgeId 表示更新;_id/__v 不能进 body;创建态 status 由服务端置 draft(用"过审"按钮发布)
|
||||
const { _id, __v, createdAt, updatedAt, ...payload } = f
|
||||
if (_id) payload.knowledgeId = _id
|
||||
try {
|
||||
await apiPost('/api/admin/knowledge', payload)
|
||||
ElMessage.success('已保存')
|
||||
dlg.value = false
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e.message || '保存失败') }
|
||||
}
|
||||
async function review(row, status) {
|
||||
try {
|
||||
await apiPost('/api/admin/knowledge/review', { knowledgeId: row._id, status, comment: '' })
|
||||
ElMessage.success('已过审')
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e.message || '操作失败') }
|
||||
}
|
||||
async function del(row) {
|
||||
await ElMessageBox.confirm(`删除「${row.title}」?不可恢复`, '确认', { type: 'warning' })
|
||||
try { await apiDelete(`/api/admin/knowledge/${row._id}`); ElMessage.success('已删除'); load() }
|
||||
catch (e) { ElMessage.error(e.message || '删除失败') }
|
||||
}
|
||||
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.opt-row { display: flex; align-items: center; gap: 10px; width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-tabs v-model="tab" @tab-change="load">
|
||||
<el-tab-pane label="登录日志" name="login" />
|
||||
<el-tab-pane label="访问日志" name="visit" />
|
||||
<el-tab-pane label="管理操作日志" name="admin" />
|
||||
</el-tabs>
|
||||
<div v-if="tab === 'visit'" class="visit-filters">
|
||||
<el-radio-group v-model="vRange" size="small" @change="vPage = 1; load()">
|
||||
<el-radio-button value="today">今日 {{ vStats.today ?? '' }}</el-radio-button>
|
||||
<el-radio-button value="yesterday">昨日 {{ vStats.yesterday ?? '' }}</el-radio-button>
|
||||
<el-radio-button value="7d">近7天 {{ vStats.last7 ?? '' }}</el-radio-button>
|
||||
<el-radio-button value="30d">近30天 {{ vStats.last30 ?? '' }}</el-radio-button>
|
||||
<el-radio-button value="all">全部</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-select v-model="vSource" size="small" clearable placeholder="全部来源" style="width: 120px" @change="vPage = 1; load()">
|
||||
<el-option value="H5" label="H5" />
|
||||
<el-option value="小程序" label="小程序" />
|
||||
<el-option value="直接访问" label="直接访问" />
|
||||
</el-select>
|
||||
<span style="font-size: 12px; color: #8a8fa3">共 {{ vTotal }} 条</span>
|
||||
</div>
|
||||
<el-table :data="list" stripe v-loading="loading" size="small">
|
||||
<template v-if="tab === 'login'">
|
||||
<el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="用户" width="120"><template #default="{ row }">{{ row.userName || '未知用户' }}</template></el-table-column>
|
||||
<el-table-column label="归属地" width="110"><template #default="{ row }">{{ row.ipLoc || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="type" label="类型" width="80" />
|
||||
<el-table-column prop="method" label="方式" width="120" />
|
||||
<el-table-column prop="ip" label="IP" width="120" />
|
||||
<el-table-column label="结果">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.success ? 'success' : 'danger'" size="small">{{ row.success ? '成功' : (row.failReason || '失败') }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="设备" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ [row.os, row.browser].filter(Boolean).join(' / ') || row.device || '—' }}</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
<template v-else-if="tab === 'visit'">
|
||||
<el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="用户" width="110"><template #default="{ row }">{{ row.userName || '游客' }}</template></el-table-column>
|
||||
<el-table-column prop="source" label="来源" width="100" />
|
||||
<el-table-column label="终端" width="150">
|
||||
<template #default="{ row }">{{ [row.os, row.browser].filter(Boolean).join(' / ') || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="device" label="设备" width="90" />
|
||||
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="ip" label="IP" width="130" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.timestamp || row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column prop="adminName" label="管理员" width="100" />
|
||||
<el-table-column prop="action" label="动作" width="90" />
|
||||
<el-table-column prop="resource" label="对象" width="120" />
|
||||
<el-table-column prop="ip" label="IP" width="120" />
|
||||
<el-table-column label="详情" min-width="200">
|
||||
<template #default="{ row }">{{ row.details ? JSON.stringify(row.details).slice(0, 100) : '—' }}</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
</el-table>
|
||||
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="load" style="margin-top: 12px" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { apiGet } from '../api'
|
||||
const tab = ref('login'), list = ref([]), total = ref(0), page = ref(1), pageSize = 20, loading = ref(false)
|
||||
// 访问埋点筛选
|
||||
const vRange = ref('today'), vSource = ref(''), vStats = ref({}), vTotal = ref(0)
|
||||
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
// adminlog 的时间字段是 timestamp,loginlog 是 createdAt
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const ep = tab.value === 'login' ? 'login-logs' : tab.value === 'visit' ? 'visits' : 'logs'
|
||||
let qs = `page=${page.value}&limit=${pageSize}`
|
||||
if (tab.value === 'visit') {
|
||||
qs += `&range=${vRange.value}`
|
||||
if (vSource.value) qs += `&source=${encodeURIComponent(vSource.value)}`
|
||||
}
|
||||
const r = await apiGet(`/api/admin/${ep}?${qs}`)
|
||||
list.value = r.items || []
|
||||
total.value = r.pagination?.total || 0
|
||||
if (tab.value === 'visit') {
|
||||
vStats.value = r.stats || {}
|
||||
vTotal.value = r.total || 0
|
||||
}
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
|
||||
.visit-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="cards">
|
||||
<div class="card"><b>{{ stats.subscribed ?? '-' }}</b><span>可通知用户</span><i>已订阅授权 {{ stats.totalUsers ?? 0 }} 用户中</i></div>
|
||||
<div class="card"><b>{{ stats.sentToday ?? '-' }}</b><span>今日已发送</span><i>累计 {{ stats.totalSent ?? 0 }} 条</i></div>
|
||||
<div class="card"><b>{{ stats.templateConfigured ? '已配置' : '未配置' }}</b><span>消息模板</span><i>{{ stats.templateId ? stats.templateId.slice(0, 14) + '…' : '设置 WX_NOTIFY_TEMPLATE_ID' }}</i></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>发送通知</h3>
|
||||
<el-form label-width="90px" style="max-width: 560px">
|
||||
<el-form-item label="场景">
|
||||
<el-radio-group v-model="scene">
|
||||
<el-radio value="release">版本发布</el-radio>
|
||||
<el-radio value="manual">运营通知</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题"><el-input v-model="title" placeholder="通知标题(20字内)" maxlength="20" show-word-limit /></el-form-item>
|
||||
<el-form-item label="内容"><el-input v-model="thing1" type="textarea" :rows="2" placeholder="主要内容(20字内,模板字段限制)" maxlength="20" show-word-limit /></el-form-item>
|
||||
<el-form-item label="补充"><el-input v-model="thing2" placeholder="补充说明(20字内,选填)" maxlength="20" show-word-limit /></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="sending" :disabled="!stats.subscribed" @click="send">发送给 {{ stats.subscribed || 0 }} 位订阅用户</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="!stats.templateConfigured" type="warning" :closable="false"
|
||||
title="未配置模板:微信公众平台→功能→订阅消息→选「新版上线提醒」类模板→复制模板ID→服务器 compose 加 WX_NOTIFY_TEMPLATE_ID 环境变量" />
|
||||
<el-alert v-else type="info" :closable="false"
|
||||
title="订阅消息为一次性授权:每次用户登录小程序会弹一次授权,授权一次可发一条。发完需等用户下次登录再授权。" />
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>发送记录</h3>
|
||||
<el-table :data="logs" size="small" stripe v-loading="loadingLogs">
|
||||
<el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="用户" width="120"><template #default="{ row }">{{ row.userId?.nickName || row.openid?.slice(0, 8) || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="场景" width="90">
|
||||
<template #default="{ row }">{{ row.scene === 'release' ? '版本发布' : '运营通知' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'sent' ? 'success' : 'danger'" size="small">{{ row.status === 'sent' ? '成功' : '失败' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="errMsg" label="失败原因" min-width="140" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<el-pagination background layout="total, prev, pager, next" :total="logsTotal" :page-size="20" v-model:current-page="logsPage" @current-change="loadLogs" style="margin-top: 12px" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { apiGet, apiPost } from '../api'
|
||||
|
||||
const stats = ref({})
|
||||
const scene = ref('release')
|
||||
const title = ref('')
|
||||
const thing1 = ref('')
|
||||
const thing2 = ref('')
|
||||
const sending = ref(false)
|
||||
const logs = ref([])
|
||||
const logsTotal = ref(0)
|
||||
const logsPage = ref(1)
|
||||
const loadingLogs = ref(false)
|
||||
|
||||
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
|
||||
async function loadStats() {
|
||||
try { stats.value = await apiGet('/api/admin/notify/stats') || {} } catch (e) { stats.value = {} }
|
||||
}
|
||||
async function loadLogs() {
|
||||
loadingLogs.value = true
|
||||
try {
|
||||
const r = await apiGet(`/api/admin/notify/logs?page=${logsPage.value}&limit=20`)
|
||||
logs.value = r.items || []
|
||||
logsTotal.value = r.total || 0
|
||||
} finally { loadingLogs.value = false }
|
||||
}
|
||||
async function send() {
|
||||
if (!title.value.trim() || !thing1.value.trim()) return ElMessage.warning('请填写标题和内容')
|
||||
sending.value = true
|
||||
try {
|
||||
const r = await apiPost('/api/admin/notify/send', { title: title.value, thing1: thing1.value, thing2: thing2.value, scene: scene.value })
|
||||
ElMessage.success(r.message || '发送完成')
|
||||
loadStats(); loadLogs()
|
||||
title.value = ''; thing1.value = ''; thing2.value = ''
|
||||
} catch (e) { ElMessage.error(e.message || '发送失败') } finally { sending.value = false }
|
||||
}
|
||||
onMounted(() => { loadStats(); loadLogs() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { display: flex; flex-direction: column; gap: 14px; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.card b { font-size: 22px; color: #1f2333; }
|
||||
.card span { font-size: 13px; color: #6b7080; display: block; }
|
||||
.card i { font-size: 11px; color: #a6abbb; font-style: normal; }
|
||||
.panel { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.panel h3 { margin: 0 0 14px; font-size: 14px; }
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="kw" placeholder="搜索 openid" style="width: 220px" clearable @keyup.enter="search" />
|
||||
<el-button type="primary" @click="search">搜索</el-button>
|
||||
<el-button type="success" @click="addDlg = true">增加额度</el-button>
|
||||
</div>
|
||||
<el-table :data="list" stripe v-loading="loading">
|
||||
<el-table-column label="OpenID" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span :title="row.openid">{{ openidShort(row.openid) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="90">
|
||||
<template #default="{ row }">{{ row.openid.startsWith('guest:') ? '游客' : '注册用户' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="freeQuota" label="免费额度" width="90" />
|
||||
<el-table-column prop="sharedQuota" label="分享获得" width="90" />
|
||||
<el-table-column prop="usedQuota" label="累计使用" width="90" />
|
||||
<el-table-column prop="dailyUsed" label="今日已用" width="90" />
|
||||
<el-table-column label="剩余可用" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="remaining(row) > 0 ? 'success' : 'danger'">{{ remaining(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后使用" width="150">
|
||||
<template #default="{ row }">{{ fmt(row.lastUsedDate) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="load" style="margin-top: 12px" />
|
||||
|
||||
<el-dialog v-model="addDlg" title="增加问答额度" width="min(380px, 92%)" style="max-width: 380px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="OpenID"><el-input v-model="addOpenid" placeholder="完整 openid" /></el-form-item>
|
||||
<el-form-item label="增加次数"><el-input-number v-model="addAmount" :min="1" :max="100" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addDlg = false">取消</el-button>
|
||||
<el-button type="primary" @click="add">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { apiGet, apiPost } from '../api'
|
||||
const list = ref([]), total = ref(0), page = ref(1), pageSize = 20, kw = ref(''), loading = ref(false)
|
||||
const addDlg = ref(false), addOpenid = ref(''), addAmount = ref(5)
|
||||
function openidShort(o) { return o && o.length > 18 ? o.slice(0, 16) + '…' : (o || '—') }
|
||||
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
function remaining(row) {
|
||||
const todayUsed = row.lastUsedDate && new Date(row.lastUsedDate).toDateString() === new Date().toDateString() ? row.dailyUsed : 0
|
||||
return Math.max(0, (row.freeQuota || 0) + (row.sharedQuota || 0) - todayUsed)
|
||||
}
|
||||
function search() { page.value = 1; load() }
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await apiGet(`/api/admin/ai-chat/quota-list?page=${page.value}&pageSize=${pageSize}&keyword=${encodeURIComponent(kw.value)}`)
|
||||
list.value = r.list || []
|
||||
total.value = r.total || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
async function add() {
|
||||
if (!addOpenid.value.trim()) return ElMessage.warning('请输入 openid')
|
||||
try {
|
||||
await apiPost('/api/admin/ai-chat/add-quota', { openid: addOpenid.value.trim(), amount: addAmount.value })
|
||||
ElMessage.success('已增加')
|
||||
addDlg.value = false
|
||||
addOpenid.value = ''
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e.message || '操作失败') }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="q" placeholder="搜索标题" style="width: 220px" clearable @keyup.enter="search" />
|
||||
<el-button type="primary" @click="search">搜索</el-button>
|
||||
<el-button :type="missingOnly ? 'warning' : 'default'" @click="filterMissing">待补 {{ missingCount }}</el-button>
|
||||
<el-button type="success" :loading="filling" :disabled="!missingCount" @click="fillMissing">AI 补齐人话解读</el-button>
|
||||
<el-button type="success" @click="openEdit(null)">新增资讯</el-button>
|
||||
</div>
|
||||
<el-table :data="list" stripe v-loading="loading">
|
||||
<el-table-column label="标题" min-width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.title }}
|
||||
<el-tag v-if="row.hot" type="danger" size="small" style="margin-left:4px">热</el-tag>
|
||||
<el-tag v-if="row.picked" type="warning" size="small" style="margin-left:4px">精</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="category" label="分类" width="80" />
|
||||
<el-table-column prop="source" label="来源" width="110" show-overflow-tooltip />
|
||||
<el-table-column label="人话解读" min-width="170" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.plainExplain">{{ row.plainExplain }}<template v-if="row.takeAway"> | {{ row.takeAway }}</template></span>
|
||||
<el-tag v-else type="warning" size="small" style="cursor:pointer" @click="filterMissing">待补(点击筛选)</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="日期" width="110">
|
||||
<template #default="{ row }">{{ (row.newsDate || row.createdAt || '').slice(0, 10) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="del(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="load" style="margin-top: 12px" />
|
||||
|
||||
<el-dialog v-model="dlg" :title="form._id ? '编辑资讯' : '新增资讯'" width="min(560px, 94%)" style="max-width: 560px">
|
||||
<el-form label-width="76px">
|
||||
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
|
||||
<el-form-item label="摘要"><el-input v-model="form.summary" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="来源/链接">
|
||||
<el-input v-model="form.source" style="width: 140px" />
|
||||
<el-input v-model="form.sourceUrl" placeholder="原文链接(选填)" style="flex:1; margin-left:8px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="人话解读"><el-input v-model="form.plainExplain" placeholder="一句大白话说清(30字内)" /></el-form-item>
|
||||
<el-form-item label="对你"><el-input v-model="form.takeAway" placeholder="对普通人意味着什么(30字内)" /></el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="form.category" style="width: 130px"><el-option v-for="c in ['产品','论文','工具','公司','政策','其他']" :key="c" :label="c" :value="c" /></el-select>
|
||||
<el-switch v-model="form.hot" active-text="热门" style="margin-left: 16px" />
|
||||
<el-switch v-model="form.picked" active-text="精选" style="margin-left: 12px" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dlg = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../api'
|
||||
const list = ref([]), total = ref(0), page = ref(1), pageSize = 20, q = ref(''), loading = ref(false)
|
||||
const dlg = ref(false)
|
||||
const form = ref({})
|
||||
function search() { page.value = 1; load() }
|
||||
const missingOnly = ref(false)
|
||||
const missingCount = ref(0)
|
||||
const filling = ref(false)
|
||||
function filterMissing() { missingOnly.value = !missingOnly.value; page.value = 1; load() }
|
||||
async function fillMissing() {
|
||||
if (!missingCount.value || filling.value) return
|
||||
filling.value = true
|
||||
try {
|
||||
const r = await apiPost('/api/admin/trends/fill-plain', {})
|
||||
ElMessage.success(`已补齐 ${r.filled || 0} 条人话解读`)
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e.message || '补齐失败') } finally { filling.value = false }
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await apiGet(`/api/admin/trends?page=${page.value}&limit=${pageSize}&keyword=${encodeURIComponent(q.value)}${missingOnly.value ? '&missing=1' : ''}`)
|
||||
list.value = r.items || []
|
||||
total.value = r.pagination?.total || 0
|
||||
missingCount.value = r.missingCount ?? 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
function openEdit(row) {
|
||||
form.value = row ? { ...row } : { title: '', summary: '', source: '', sourceUrl: '', category: '产品', hot: false, picked: false, plainExplain: '', takeAway: '' }
|
||||
dlg.value = true
|
||||
}
|
||||
async function save() {
|
||||
if (!form.value.title) return ElMessage.warning('标题必填')
|
||||
try {
|
||||
if (form.value._id) await apiPut(`/api/admin/trends/${form.value._id}`, form.value)
|
||||
else await apiPost('/api/admin/trends', form.value)
|
||||
ElMessage.success('已保存')
|
||||
dlg.value = false
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e.message || '保存失败') }
|
||||
}
|
||||
async function del(row) {
|
||||
await ElMessageBox.confirm(`删除「${row.title}」?`, '确认', { type: 'warning' })
|
||||
try { await apiDelete(`/api/admin/trends/${row._id}`); ElMessage.success('已删除'); load() }
|
||||
catch (e) { ElMessage.error(e.message || '删除失败') }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="q" placeholder="搜索昵称" style="width: 220px" clearable @keyup.enter="search" />
|
||||
<el-button type="primary" @click="search">搜索</el-button>
|
||||
</div>
|
||||
<el-table :data="list" stripe v-loading="loading">
|
||||
<el-table-column prop="nickName" label="昵称" min-width="100" />
|
||||
<el-table-column label="OpenID" min-width="130">
|
||||
<template #default="{ row }">{{ (row.openid || '').slice(0, 12) }}…</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="星尘" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ fontWeight: 700, color: (row.vpayCoins || 0) > 0 ? '#5b5ff8' : '#999' }">{{ row.vpayCoins || 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="注册时间" width="150">
|
||||
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近登录" width="150">
|
||||
<template #default="{ row }">{{ fmt(row.lastLoginAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="60">
|
||||
<template #default="{ row }"><el-tag :type="(row.status || 'active') === 'active' ? 'success' : 'info'" size="small">{{ (row.status || 'active') === 'active' ? '正常' : row.status }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" @click="openAdjust(row)">调整星尘</el-button>
|
||||
<el-button size="small" @click="viewOrders(row)">订单</el-button>
|
||||
<el-button size="small" @click="openQuota(row)">加额度</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="load" style="margin-top: 12px" />
|
||||
|
||||
<!-- 调整星尘 -->
|
||||
<el-dialog v-model="adjustDlg" title="调整星尘余额" width="min(400px, 92%)" style="max-width: 400px">
|
||||
<div class="adjust-user-info">
|
||||
<b>{{ adjustUser?.nickName || '未命名' }}</b>
|
||||
<span>当前星尘:{{ adjustUser?.vpayCoins || 0 }}</span>
|
||||
</div>
|
||||
<el-form label-width="70px" style="margin-top: 12px">
|
||||
<el-form-item label="变动值">
|
||||
<el-input-number v-model="adjustAmount" :min="-100000" :max="100000" />
|
||||
<span class="adjust-hint">正=赠送 负=扣减</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="原因"><el-input v-model="adjustReason" placeholder="如:活动赠送/异常补偿" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="adjustDlg = false">取消</el-button>
|
||||
<el-button type="primary" @click="doAdjust">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 用户订单 -->
|
||||
<el-dialog v-model="ordersDlg" :title="(ordersUser?.nickName || '用户') + '的订单'" width="min(520px, 94%)" style="max-width: 520px">
|
||||
<el-table :data="ordersList" stripe v-loading="ordersLoading" size="small">
|
||||
<el-table-column prop="orderNo" label="订单号" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="productName" label="商品" width="100" />
|
||||
<el-table-column label="金额" width="60"><template #default="{ row }">"¥" + row.amount</template></el-table-column>
|
||||
<el-table-column label="状态" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'paid' ? 'success' : row.status === 'refunded' ? 'danger' : 'warning'" size="small">
|
||||
{{ row.status === 'paid' ? '已支付' : row.status === 'refunded' ? '已退款' : row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="140"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<p v-if="!ordersLoading && !ordersList.length" style="text-align:center;color:#999;padding:20px 0">暂无订单</p>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 加问答额度(保留原有) -->
|
||||
<el-dialog v-model="quotaDlg" title="增加问答额度" width="min(360px, 92%)" style="max-width: 360px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="用户">{{ quotaUser?.nickName }}</el-form-item>
|
||||
<el-form-item label="增加次数">
|
||||
<el-input-number v-model="quotaAmount" :min="1" :max="100" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="quotaDlg = false">取消</el-button>
|
||||
<el-button type="primary" @click="addQuota">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { apiGet, apiPost } from '../api'
|
||||
|
||||
const list = ref([]), total = ref(0), page = ref(1), pageSize = 20, q = ref(''), loading = ref(false)
|
||||
const quotaDlg = ref(false), quotaUser = ref(null), quotaAmount = ref(5)
|
||||
const adjustDlg = ref(false), adjustUser = ref(null), adjustAmount = ref(100), adjustReason = ref('')
|
||||
|
||||
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
|
||||
function search() { page.value = 1; load() }
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await apiGet(`/api/admin/users?page=${page.value}&limit=${pageSize}&search=${encodeURIComponent(q.value)}`)
|
||||
list.value = r.items || []
|
||||
total.value = r.pagination?.total || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
// 调整星尘
|
||||
function openAdjust(row) {
|
||||
adjustUser.value = row
|
||||
adjustAmount.value = 100
|
||||
adjustReason.value = ''
|
||||
adjustDlg.value = true
|
||||
}
|
||||
async function doAdjust() {
|
||||
if (!adjustUser.value) return
|
||||
try {
|
||||
await apiPost('/api/admin/vpay/adjust', {
|
||||
openid: adjustUser.value.openid,
|
||||
amount: adjustAmount.value,
|
||||
reason: adjustReason.value || '管理员调整',
|
||||
})
|
||||
ElMessage.success(`已${adjustAmount.value > 0 ? '赠送' : '扣减'} ${Math.abs(adjustAmount.value)} 星尘`)
|
||||
adjustDlg.value = false
|
||||
load() // 刷新列表更新余额列
|
||||
} catch (e) { ElMessage.error(e.message || '调整失败') }
|
||||
}
|
||||
|
||||
// 查看该用户的订单(弹窗展示)
|
||||
const ordersDlg = ref(false), ordersUser = ref(null), ordersList = ref([]), ordersLoading = ref(false)
|
||||
async function viewOrders(row) {
|
||||
ordersUser.value = row
|
||||
ordersDlg.value = true
|
||||
ordersLoading.value = true
|
||||
try {
|
||||
const r = await apiGet(`/api/admin/vpay/orders?keyword=${encodeURIComponent(row.openid)}&limit=20`)
|
||||
ordersList.value = r.items || []
|
||||
} catch (e) { ordersList.value = [] }
|
||||
finally { ordersLoading.value = false }
|
||||
}
|
||||
|
||||
// 加问答额度(保留原有)
|
||||
function openQuota(row) { quotaUser.value = row; quotaAmount.value = 5; quotaDlg.value = true }
|
||||
async function addQuota() {
|
||||
try {
|
||||
await apiPost('/api/admin/ai-chat/add-quota', { openid: quotaUser.value.openid, amount: quotaAmount.value })
|
||||
ElMessage.success('已增加')
|
||||
quotaDlg.value = false
|
||||
} catch (e) { ElMessage.error(e.message || '操作失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.adjust-user-info {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: #f8f9fb; border-radius: 8px; padding: 10px 14px;
|
||||
}
|
||||
.adjust-user-info b { font-size: 15px; color: #1f2333; }
|
||||
.adjust-user-info span { font-size: 13px; color: #5b5ff8; font-weight: 600; }
|
||||
.adjust-hint { margin-left: 8px; font-size: 11px; color: #999; }
|
||||
</style>
|
||||
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="stats-cards">
|
||||
<div class="scard"><b>¥{{ stats.totalRevenueYuan?.toFixed(2) || '0.00' }}</b><span>累计收入</span></div>
|
||||
<div class="scard"><b>¥{{ stats.todayRevenueYuan?.toFixed(2) || '0.00' }}</b><span>今日收入</span></div>
|
||||
<div class="scard"><b>{{ stats.totalRechargedCoins || 0 }}</b><span>累计充值星尘</span></div>
|
||||
<div class="scard"><b>{{ stats.totalConsumedCoins || 0 }}</b><span>累计消费星尘</span></div>
|
||||
<div class="scard"><b>{{ stats.paidOrders || 0 }}</b><span>成功订单</span><i>退款 {{ stats.refundedOrders || 0 }}</i></div>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="tab">
|
||||
<el-tab-pane label="订单管理" name="orders" />
|
||||
<el-tab-pane label="星尘流水" name="ledger" />
|
||||
</el-tabs>
|
||||
|
||||
<!-- 订单 Tab -->
|
||||
<template v-if="tab === 'orders'">
|
||||
<div class="toolbar">
|
||||
<el-select v-model="statusFilter" style="width:120px" @change="load">
|
||||
<el-option label="全部" value="" /><el-option label="已支付" value="paid" /><el-option label="待支付" value="pending" /><el-option label="已退款" value="refunded" />
|
||||
</el-select>
|
||||
<el-input v-model="q" placeholder="搜订单号" style="width:200px" clearable @keyup.enter="load" />
|
||||
<el-button type="primary" @click="load">搜索</el-button>
|
||||
<el-button type="warning" @click="adjustDlg = true">调整余额</el-button>
|
||||
</div>
|
||||
<el-table :data="orders" stripe v-loading="loading" size="small">
|
||||
<el-table-column prop="orderNo" label="订单号" width="220" show-overflow-tooltip />
|
||||
<el-table-column label="用户" width="120">
|
||||
<template #default="{ row }">{{ row.userId?.nickname || (row.openid||'').slice(0,8) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="productName" label="商品" width="120" />
|
||||
<el-table-column label="金额" width="70">
|
||||
<template #default="{ row }">¥{{ row.amount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="星尘" width="60">
|
||||
<template #default="{ row }">{{ row.metadata?.coins ?? (row.amount ? Math.round(row.amount * 100) : '—') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'paid' ? 'success' : row.status === 'refunded' ? 'danger' : 'warning'" size="small">
|
||||
{{ row.status === 'paid' ? '已支付' : row.status === 'refunded' ? '已退款' : row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="150">
|
||||
<template #default="{ row }">{{ fmt(row.paidAt || row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 'paid'" size="small" type="danger" @click="refund(row)">退款</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="load" style="margin-top:12px" />
|
||||
</template>
|
||||
|
||||
<!-- 流水 Tab -->
|
||||
<template v-else>
|
||||
<div class="toolbar">
|
||||
<el-select v-model="ledgerType" style="width:120px" @change="loadLedger">
|
||||
<el-option label="全部" value="" /><el-option label="充值" value="recharge" /><el-option label="消费" value="consume" /><el-option label="退款" value="refund" /><el-option label="调整" value="adjust" />
|
||||
</el-select>
|
||||
<el-input v-model="ledgerQ" placeholder="搜openid" style="width:200px" @keyup.enter="loadLedger" />
|
||||
<el-button type="primary" @click="loadLedger">搜索</el-button>
|
||||
</div>
|
||||
<el-table :data="ledger" stripe v-loading="ledgerLoading" size="small">
|
||||
<el-table-column label="时间" width="150"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="用户" width="120"><template #default="{ row }">{{ (row.openid||'').slice(0,10) }}</template></el-table-column>
|
||||
<el-table-column label="类型" width="60">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="{ recharge: 'success', consume: 'info', refund: 'danger', adjust: 'warning' }[row.type]" size="small">
|
||||
{{ { recharge: '充值', consume: '消费', refund: '退款', adjust: '调整', gift: '赠送' }[row.type] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变动" width="70">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.amount > 0 ? '#22c55e' : '#ef4444', fontWeight: 700 }">{{ row.amount > 0 ? '+' : '' }}{{ row.amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="balanceAfter" label="余额" width="60" />
|
||||
<el-table-column prop="description" label="说明" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="orderNo" label="关联订单" width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="adminName" label="操作人" width="70" />
|
||||
</el-table>
|
||||
<el-pagination background layout="total, prev, pager, next" :total="ledgerTotal" :page-size="pageSize" v-model:current-page="ledgerPage" @current-change="loadLedger" style="margin-top:12px" />
|
||||
</template>
|
||||
|
||||
<!-- 退款确认 -->
|
||||
<el-dialog v-model="refundDlg" title="确认退款" width="420px">
|
||||
<p><b>订单:</b>{{ refundTarget?.orderNo }}</p>
|
||||
<p><b>金额:</b>¥{{ refundTarget?.amount }}</p>
|
||||
<p><b>将扣回星尘:</b>{{ refundCoins }}(余额不足将拒绝退款)</p>
|
||||
<el-alert type="warning" :closable="false" style="margin-top:10px"
|
||||
title="退款规则:余额必须足够扣回 | 支付后7天内" />
|
||||
<el-alert type="info" :closable="false" style="margin-top:6px"
|
||||
title="流程:先冻结星尘 → 调微信退款 → 成功后完成 | 微信失败自动恢复星尘" />
|
||||
<template #footer>
|
||||
<el-button @click="refundDlg = false">取消</el-button>
|
||||
<el-button type="danger" @click="doRefund">确认退款</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 调整余额 -->
|
||||
<el-dialog v-model="adjustDlg" title="调整用户余额" width="440px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="搜索用户">
|
||||
<el-input v-model="adjustForm.search" placeholder="输入昵称或openid前8位" clearable
|
||||
@input="searchUsers" style="width:100%" />
|
||||
<div v-if="searchResults.length" class="search-results">
|
||||
<div v-for="u in searchResults" :key="u._id" class="search-item" @click="pickUser(u)">
|
||||
<span>{{ u.nickname || '未命名' }}</span>
|
||||
<small>{{ (u.openid||'').slice(0,14) }}… | 星尘 {{ u.vpayCoins || 0 }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pickedUser" class="picked-user">
|
||||
✓ {{ pickedUser.nickname || '未命名' }} | 星尘 {{ pickedUser.vpayCoins || 0 }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="变动值">
|
||||
<el-input-number v-model="adjustForm.amount" :min="-100000" :max="100000" />
|
||||
<span style="margin-left:8px;font-size:12px;color:#999">正=赠送 负=扣减</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="原因"><el-input v-model="adjustForm.reason" placeholder="如:活动赠送/异常补偿" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="adjustDlg = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!pickedUser" @click="doAdjust">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { apiGet, apiPost } from '../api'
|
||||
|
||||
const tab = ref('orders')
|
||||
const stats = ref({})
|
||||
const orders = ref([]), total = ref(0), page = ref(1), pageSize = 20, q = ref(''), statusFilter = ref(''), loading = ref(false)
|
||||
const ledger = ref([]), ledgerTotal = ref(0), ledgerPage = ref(1), ledgerType = ref(''), ledgerQ = ref(''), ledgerLoading = ref(false)
|
||||
const refundDlg = ref(false), refundTarget = ref(null)
|
||||
const refundCoins = computed(() => {
|
||||
const o = refundTarget.value
|
||||
if (!o) return 0
|
||||
return o.metadata?.coins || Math.round((o.amount || 0) * 100)
|
||||
})
|
||||
const adjustDlg = ref(false), adjustForm = ref({ search: '', amount: 100, reason: '' })
|
||||
const searchResults = ref([]), pickedUser = ref(null)
|
||||
let searchTimer = null
|
||||
|
||||
function searchUsers() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
pickedUser.value = null
|
||||
searchResults.value = []
|
||||
const kw = adjustForm.value.search?.trim()
|
||||
if (!kw || kw.length < 2) return
|
||||
searchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const r = await apiGet(`/api/admin/users?search=${encodeURIComponent(kw)}&limit=5`)
|
||||
searchResults.value = (r.items || []).slice(0, 5)
|
||||
} catch (e) {}
|
||||
}, 300)
|
||||
}
|
||||
function pickUser(u) {
|
||||
pickedUser.value = u
|
||||
searchResults.value = []
|
||||
adjustForm.value.search = u.nickname || (u.openid||'').slice(0,12)
|
||||
}
|
||||
|
||||
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
|
||||
async function loadStats() {
|
||||
try { stats.value = await apiGet('/api/admin/vpay/stats') } catch (e) {}
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await apiGet(`/api/admin/vpay/orders?page=${page.value}&limit=${pageSize}&status=${statusFilter.value}&keyword=${encodeURIComponent(q.value)}`)
|
||||
orders.value = r.items || []
|
||||
total.value = r.pagination?.total || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
async function loadLedger() {
|
||||
ledgerLoading.value = true
|
||||
try {
|
||||
const r = await apiGet(`/api/admin/vpay/ledger?page=${ledgerPage.value}&limit=${pageSize}&type=${ledgerType.value}&keyword=${encodeURIComponent(ledgerQ.value)}`)
|
||||
ledger.value = r.items || []
|
||||
ledgerTotal.value = r.pagination?.total || 0
|
||||
} finally { ledgerLoading.value = false }
|
||||
}
|
||||
function refund(row) { refundTarget.value = row; refundDlg.value = true }
|
||||
async function doRefund() {
|
||||
try {
|
||||
await ElMessageBox.confirm('退款后无法撤销,确认执行?', '最终确认', { type: 'warning' })
|
||||
const r = await apiPost(`/api/admin/vpay/orders/${refundTarget.value._id}/refund`)
|
||||
ElMessage.success('退款成功(微信已退钱,星尘已扣回)')
|
||||
refundDlg.value = false
|
||||
load(); loadStats()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') {
|
||||
// 风控拦截(余额不足/超期/频率限制)直接展示拒绝原因,无强制退款
|
||||
ElMessage({ type: 'error', message: e.message || '退款失败', duration: 5000, showClose: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
async function doAdjust() {
|
||||
if (!pickedUser.value) return ElMessage.warning('请先搜索并选择用户')
|
||||
try {
|
||||
await apiPost('/api/admin/vpay/adjust', {
|
||||
openid: pickedUser.value.openid,
|
||||
amount: adjustForm.value.amount,
|
||||
reason: adjustForm.value.reason,
|
||||
})
|
||||
ElMessage.success(`调整成功:${pickedUser.value.nickname || '用户'} ${(adjustForm.value.amount > 0 ? '+' : '') + adjustForm.value.amount} 星尘`)
|
||||
adjustDlg.value = false
|
||||
pickedUser.value = null
|
||||
adjustForm.value = { search: '', amount: 100, reason: '' }
|
||||
loadLedger(); loadStats()
|
||||
} catch (e) { ElMessage.error(e.message || '调整失败') }
|
||||
}
|
||||
onMounted(() => { loadStats(); load(); loadLedger() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.stats-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-bottom: 16px; }
|
||||
.scard { background: #f8f9fb; border-radius: 10px; padding: 12px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.scard b { font-size: 20px; color: #1f2333; }
|
||||
.scard span { font-size: 12px; color: #6b7080; }
|
||||
.scard i { font-size: 11px; color: #a6abbb; font-style: normal; }
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.search-results {
|
||||
position: absolute; top: 100%; left: 0; right: 0; z-index: 10;
|
||||
background: #fff; border: 1px solid #e4e7ed; border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.1); max-height: 200px; overflow-y: auto;
|
||||
}
|
||||
.search-item {
|
||||
padding: 8px 12px; cursor: pointer; display: flex; flex-direction: column; gap: 2px;
|
||||
border-bottom: 1px solid #f5f7fa;
|
||||
}
|
||||
.search-item:hover { background: #f5f7fa; }
|
||||
.search-item small { color: #999; font-size: 11px; }
|
||||
.picked-user {
|
||||
margin-top: 6px; padding: 6px 10px; background: #f0f9eb;
|
||||
border: 1px solid #e1f3d8; border-radius: 6px; font-size: 13px; color: #67c23a;
|
||||
}
|
||||
.el-form-item { position: relative; }
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/admin/',
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5180,
|
||||
proxy: {
|
||||
'/api': { target: 'http://127.0.0.1:3001', changeOrigin: true }
|
||||
}
|
||||
},
|
||||
build: { outDir: 'dist' }
|
||||
})
|
||||
@@ -1,15 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta name="sogou_site_verification" content="bZ8oAfjvQm" />
|
||||
<meta name="360-site-verification" content="0cc596886cce77073d8fcba9d5ec7d9c" />
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||
<title>宇之然AI维度 - AI 知识探索社区</title>
|
||||
<meta name="description" content="宇之然AI维度 — 从5个维度系统理解人工智能:AI起源、发展、当前格局、学习工具、趋势跟踪。" />
|
||||
<meta name="keywords" content="宇之然AI维度,AI知识,人工智能学习,AI科普,AI趋势,大模型学习" />
|
||||
<title>宇之然AI维度 - AI知识探索社区 | 五维度系统学AI</title>
|
||||
<meta name="description" content="宇之然AI维度:从起源、发展、格局、工具、趋势五个维度,系统理解人工智能。每日更新AI行业趋势与热点,支持问答、收藏与学习进度跟踪。" />
|
||||
<meta name="keywords" content="宇之然AI维度,AI知识,AI学习,人工智能科普,AI趋势,AI大模型,AI问答,AI工具" />
|
||||
<meta name="author" content="宇之然" />
|
||||
<meta name="robots" content="index,follow" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="宇之然AI维度" />
|
||||
<meta property="og:title" content="宇之然AI维度 - AI知识探索社区" />
|
||||
<meta property="og:description" content="五维度系统学AI:起源、发展、格局、工具、趋势。每日AI行业热点更新。" />
|
||||
<meta property="og:locale" content="zh_CN" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "宇之然AI维度",
|
||||
"description": "从五个维度系统理解人工智能的AI知识探索社区:AI起源、发展、当前格局、学习工具、趋势跟踪。",
|
||||
"applicationCategory": "EducationalApplication",
|
||||
"operatingSystem": "Web, 微信小程序",
|
||||
"inLanguage": "zh-CN",
|
||||
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "CNY" }
|
||||
}
|
||||
</script>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,5 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': set this to true or false
|
||||
core-js: set this to true or false
|
||||
esbuild: set this to true or false
|
||||
vue-demi: set this to true or false
|
||||
'@parcel/watcher': true
|
||||
core-js: true
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
|
||||
@@ -8,242 +8,103 @@
|
||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||
|
||||
onLaunch(() => {
|
||||
console.log('[AI维度] App launched')
|
||||
initTheme()
|
||||
// #ifdef MP-WEIXIN
|
||||
// 版本更新检测:新版本发布后,旧版本用户冷启动时静默下载、弹窗引导重启
|
||||
const um = uni.getUpdateManager && uni.getUpdateManager()
|
||||
if (um) {
|
||||
um.onUpdateReady(() => {
|
||||
uni.showModal({
|
||||
title: '更新提示',
|
||||
content: '新版本已经准备好,是否重启应用?',
|
||||
confirmText: '立即重启',
|
||||
cancelText: '稍后',
|
||||
success: (m) => {
|
||||
if (m.confirm) um.applyUpdate()
|
||||
},
|
||||
})
|
||||
})
|
||||
um.onUpdateFailed(() => {
|
||||
uni.showModal({
|
||||
title: '更新提示',
|
||||
content: '新版本下载失败,请删除当前小程序后重新搜索打开',
|
||||
showCancel: false,
|
||||
})
|
||||
})
|
||||
}
|
||||
// #endif
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
console.log('[AI维度] App shown')
|
||||
})
|
||||
|
||||
onHide(() => {
|
||||
console.log('[AI维度] App hidden')
|
||||
})
|
||||
onShow(() => {})
|
||||
onHide(() => {})
|
||||
|
||||
// 主题:'auto'(默认,跟随系统)| 'light' | 'dark'
|
||||
function initTheme() {
|
||||
const theme = uni.getStorageSync('theme') || 'light'
|
||||
const theme = uni.getStorageSync('theme') || 'auto'
|
||||
applyTheme(theme)
|
||||
// auto 模式下监听系统切换(H5)
|
||||
if (theme === 'auto' && typeof window !== 'undefined' && window.matchMedia) {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const onChange = () => applyTheme('auto')
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange)
|
||||
else if (mq.addListener) mq.addListener(onChange)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTheme(theme) {
|
||||
if (theme !== 'auto') return theme
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return 'light'
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
const resolved = resolveTheme(theme)
|
||||
try {
|
||||
// H5 环境:操作 document class
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('theme-dark')
|
||||
root.classList.remove('theme-light')
|
||||
} else {
|
||||
root.classList.remove('theme-dark')
|
||||
root.classList.add('theme-light')
|
||||
}
|
||||
root.classList.remove('theme-dark', 'theme-light')
|
||||
root.classList.add(resolved === 'dark' ? 'theme-dark' : 'theme-light')
|
||||
} catch (e) {
|
||||
// 小程序环境:document 不存在
|
||||
// 小程序环境:document 不存在,深色由 manifest darkmode + CSS 媒体查询接管
|
||||
}
|
||||
// 小程序 tabBar 原生组件同步切换
|
||||
applyTabBarTheme(theme)
|
||||
applyTabBarTheme(resolved)
|
||||
}
|
||||
|
||||
function applyTabBarTheme(theme) {
|
||||
try {
|
||||
if (theme === 'dark') {
|
||||
uni.setTabBarStyle({
|
||||
color: 'rgba(255,255,255,0.35)',
|
||||
selectedColor: '#a78bfa',
|
||||
backgroundColor: '#0d0d1a',
|
||||
color: '#a2a2b8',
|
||||
selectedColor: '#a2b2ff',
|
||||
backgroundColor: '#181828',
|
||||
borderStyle: 'black'
|
||||
})
|
||||
} else {
|
||||
uni.setTabBarStyle({
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
selectedColor: '#6366f1',
|
||||
color: '#999999',
|
||||
selectedColor: '#5b5ff8',
|
||||
backgroundColor: '#ffffff',
|
||||
borderStyle: 'white'
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// H5 环境 setTabBarStyle 可能不支持
|
||||
// 非 tabBar 页面或不支持时忽略
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style lang="scss">
|
||||
/* ============================================
|
||||
宇之然AI维度 — 全局主题
|
||||
默认:亮色模式(蓝紫主题) | .theme-dark:暗色模式
|
||||
变量 mixin 唯一出处:styles/theme.scss
|
||||
============================================ */
|
||||
|
||||
/* ---- 亮色模式(默认:浅底+色彩点缀,现代丰富) ---- */
|
||||
page, .theme-light {
|
||||
/* 基础色 — 干净浅底 */
|
||||
--bg-primary: #f5f5f7;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #e8e8ed;
|
||||
|
||||
/* 卡片 — 纯白微阴影 */
|
||||
--glass-bg: #ffffff;
|
||||
--glass-border: rgba(0, 0, 0, 0.06);
|
||||
--glass-hover: #f8f8ff;
|
||||
--glass-hover-border: rgba(99, 102, 241, 0.15);
|
||||
--glass-strong: #ffffff;
|
||||
--glass-dim-bg: rgba(99, 102, 241, 0.04);
|
||||
|
||||
/* 文字 — 清晰深色 */
|
||||
--text-primary: #0d0d1a;
|
||||
--text-secondary: rgba(13, 13, 26, 0.6);
|
||||
--text-tertiary: rgba(13, 13, 26, 0.4);
|
||||
--text-muted: rgba(13, 13, 26, 0.25);
|
||||
--text-disabled: rgba(13, 13, 26, 0.15);
|
||||
|
||||
/* 品牌色 — Indigo 高饱和 */
|
||||
--color-brand: #5b5ff8;
|
||||
--color-brand-light: #7b87fd;
|
||||
--color-brand-dark: #4f46e5;
|
||||
--color-brand-bg: rgba(91, 95, 248, 0.08);
|
||||
--color-brand-glow: rgba(91, 95, 248, 0.1);
|
||||
|
||||
/* 维度色 — 高饱和度,在浅色底上鲜艳 */
|
||||
--dim1-color: #5b5ff8;
|
||||
--dim1-bg: rgba(91, 95, 248, 0.2);
|
||||
--dim1-icon-bg: rgba(91, 95, 248, 0.25);
|
||||
--dim1-border: rgba(91, 95, 248, 0.3);
|
||||
--dim1-glow: rgba(91, 95, 248, 0.08);
|
||||
|
||||
--dim2-color: #0095ba;
|
||||
--dim2-bg: rgba(0, 149, 186, 0.2);
|
||||
--dim2-icon-bg: rgba(0, 149, 186, 0.25);
|
||||
--dim2-border: rgba(0, 149, 186, 0.3);
|
||||
--dim2-glow: rgba(0, 149, 186, 0.08);
|
||||
|
||||
--dim3-color: #009b6a;
|
||||
--dim3-bg: rgba(0, 155, 106, 0.2);
|
||||
--dim3-icon-bg: rgba(0, 155, 106, 0.25);
|
||||
--dim3-border: rgba(0, 155, 106, 0.3);
|
||||
--dim3-glow: rgba(0, 155, 106, 0.08);
|
||||
|
||||
--dim4-color: #df7700;
|
||||
--dim4-bg: rgba(223, 119, 0, 0.2);
|
||||
--dim4-icon-bg: rgba(223, 119, 0, 0.25);
|
||||
--dim4-border: rgba(223, 119, 0, 0.3);
|
||||
--dim4-glow: rgba(223, 119, 0, 0.08);
|
||||
|
||||
--dim5-color: #e51c1c;
|
||||
--dim5-bg: rgba(229, 28, 28, 0.2);
|
||||
--dim5-icon-bg: rgba(229, 28, 28, 0.25);
|
||||
--dim5-border: rgba(229, 28, 28, 0.3);
|
||||
--dim5-glow: rgba(229, 28, 28, 0.08);
|
||||
|
||||
/* 功能色 */
|
||||
--color-free: #009b6a;
|
||||
--color-free-bg: rgba(0, 155, 106, 0.1);
|
||||
--color-pro: #df7700;
|
||||
--color-pro-bg: rgba(223, 119, 0, 0.1);
|
||||
--color-vip: #e51c1c;
|
||||
--color-vip-bg: rgba(229, 28, 28, 0.1);
|
||||
--color-hot: #e51c1c;
|
||||
--color-hot-bg: rgba(229, 28, 28, 0.12);
|
||||
--color-new: #0095ba;
|
||||
--color-new-bg: rgba(0, 149, 186, 0.12);
|
||||
--color-pick: #5b5ff8;
|
||||
--color-pick-bg: rgba(91, 95, 248, 0.12);
|
||||
|
||||
/* 圆角/间距/过渡 */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 12px;
|
||||
--spacing-lg: 16px;
|
||||
--spacing-xl: 20px;
|
||||
--spacing-2xl: 24px;
|
||||
--blur-bg: 20px;
|
||||
--transition-fast: 0.2s ease;
|
||||
--transition-normal: 0.3s ease;
|
||||
--glow-brand: 0 0 20px rgba(99, 102, 241, 0.08);
|
||||
--glow-brand-strong: 0 0 30px rgba(99, 102, 241, 0.12);
|
||||
|
||||
/* 亮色专属:卡片阴影 */
|
||||
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02);
|
||||
--card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* ---- 暗色模式 ---- */
|
||||
.theme-dark {
|
||||
/* 基础色 */
|
||||
--bg-primary: #14141e;
|
||||
--bg-secondary: #181828;
|
||||
--bg-tertiary: #1c1a34;
|
||||
|
||||
--glass-bg: rgba(255, 255, 255, 0.06);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--glass-hover: rgba(255, 255, 255, 0.1);
|
||||
--glass-hover-border: rgba(255, 255, 255, 0.15);
|
||||
--glass-strong: rgba(255, 255, 255, 0.12);
|
||||
--glass-dim-bg: rgba(99, 102, 241, 0.06);
|
||||
|
||||
--text-primary: rgba(255, 255, 255, 0.95);
|
||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
||||
--text-tertiary: rgba(255, 255, 255, 0.5);
|
||||
--text-muted: rgba(255, 255, 255, 0.3);
|
||||
--text-disabled: rgba(255, 255, 255, 0.15);
|
||||
|
||||
--color-brand: #7b87fd;
|
||||
--color-brand-light: #a2b2ff;
|
||||
--color-brand-dark: #5b5ff8;
|
||||
--color-brand-bg: rgba(91, 95, 248, 0.12);
|
||||
--color-brand-glow: rgba(91, 95, 248, 0.15);
|
||||
|
||||
--dim1-color: #a485ff;
|
||||
--dim1-bg: rgba(164, 133, 255, 0.12);
|
||||
--dim1-icon-bg: rgba(164, 133, 255, 0.2);
|
||||
--dim1-border: rgba(164, 133, 255, 0.18);
|
||||
--dim1-glow: rgba(164, 133, 255, 0.08);
|
||||
|
||||
--dim2-color: #5ba4ff;
|
||||
--dim2-bg: rgba(91, 164, 255, 0.12);
|
||||
--dim2-icon-bg: rgba(91, 164, 255, 0.2);
|
||||
--dim2-border: rgba(91, 164, 255, 0.18);
|
||||
--dim2-glow: rgba(91, 164, 255, 0.08);
|
||||
|
||||
--dim3-color: #2cda9b;
|
||||
--dim3-bg: rgba(44, 218, 155, 0.12);
|
||||
--dim3-icon-bg: rgba(44, 218, 155, 0.2);
|
||||
--dim3-border: rgba(44, 218, 155, 0.18);
|
||||
--dim3-glow: rgba(44, 218, 155, 0.08);
|
||||
|
||||
--dim4-color: #ffc020;
|
||||
--dim4-bg: rgba(255, 192, 32, 0.12);
|
||||
--dim4-icon-bg: rgba(255, 192, 32, 0.2);
|
||||
--dim4-border: rgba(255, 192, 32, 0.18);
|
||||
--dim4-glow: rgba(255, 192, 32, 0.08);
|
||||
|
||||
--dim5-color: #ff6c82;
|
||||
--dim5-bg: rgba(255, 108, 130, 0.12);
|
||||
--dim5-icon-bg: rgba(255, 108, 130, 0.2);
|
||||
--dim5-border: rgba(255, 108, 130, 0.18);
|
||||
--dim5-glow: rgba(255, 108, 130, 0.08);
|
||||
|
||||
--color-free: #2cda9b;
|
||||
--color-free-bg: rgba(44, 218, 155, 0.12);
|
||||
--color-pro: #ffc020;
|
||||
--color-pro-bg: rgba(255, 192, 32, 0.12);
|
||||
--color-vip: #ff6c82;
|
||||
--color-vip-bg: rgba(255, 108, 130, 0.12);
|
||||
--color-hot: #ff6c82;
|
||||
--color-hot-bg: rgba(255, 108, 130, 0.15);
|
||||
--color-new: #5ba4ff;
|
||||
--color-new-bg: rgba(91, 164, 255, 0.15);
|
||||
--color-pick: #a485ff;
|
||||
--color-pick-bg: rgba(164, 133, 255, 0.15);
|
||||
|
||||
--glow-brand: 0 0 20px rgba(99, 102, 241, 0.1);
|
||||
--glow-brand-strong: 0 0 30px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
/* 全局排版 */
|
||||
/* 小程序基础:浅色默认 */
|
||||
page {
|
||||
@include theme-light-vars;
|
||||
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@@ -252,6 +113,19 @@ page {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* H5:html 类切换主题(需覆盖 page 上的浅色定义,特异性更高) */
|
||||
/* #ifdef H5 */
|
||||
html.theme-light page { @include theme-light-vars; }
|
||||
html.theme-dark, html.theme-dark page { @include theme-dark-vars; }
|
||||
/* #endif */
|
||||
|
||||
/* 小程序:跟随系统深色(需 manifest darkmode: true) */
|
||||
/* #ifdef MP-WEIXIN */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
page { @include theme-dark-vars; }
|
||||
}
|
||||
/* #endif */
|
||||
|
||||
view {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -269,7 +143,6 @@ view {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* 页面副标题(统一) */
|
||||
.page-subtitle {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
@@ -282,10 +155,8 @@ view {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
backdrop-filter: blur(var(--blur-bg));
|
||||
-webkit-backdrop-filter: blur(var(--blur-bg));
|
||||
box-shadow: var(--card-shadow, none);
|
||||
transition: all var(--transition-normal);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.gradient-brand {
|
||||
@@ -298,4 +169,25 @@ view {
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* 区块标题(统一) */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 24px 0 12px;
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.section-more {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<image
|
||||
class="app-icon"
|
||||
:class="{ dim: dim }"
|
||||
:src="`/static/icons/${name}.png`"
|
||||
:style="{ width: size + 'px', height: size + 'px' }"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
/** 图标名(static/icons/ 下的文件名,不含扩展名) */
|
||||
name: { type: String, required: true },
|
||||
/** 显示尺寸 px */
|
||||
size: { type: Number, default: 20 },
|
||||
/** 透明度弱化(次要操作) */
|
||||
dim: { type: Boolean, default: false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-icon {
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.app-icon.dim {
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<view class="ring-wrap" :style="{ width: size + 'px', height: size + 'px' }">
|
||||
<view class="ring-bg"></view>
|
||||
<view
|
||||
class="ring-fill"
|
||||
:style="{
|
||||
background: `conic-gradient(${color} ${percent * 3.6}deg, transparent ${percent * 3.6}deg)`,
|
||||
'-webkit-mask': maskStyle,
|
||||
mask: maskStyle
|
||||
}"
|
||||
></view>
|
||||
<view class="ring-center">
|
||||
<slot>
|
||||
<text class="ring-num" :style="{ fontSize: numSize + 'px' }">{{ percent }}%</text>
|
||||
</slot>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** 0-100 */
|
||||
percent: { type: Number, default: 0 },
|
||||
size: { type: Number, default: 56 },
|
||||
color: { type: String, default: 'var(--color-brand)' },
|
||||
numSize: { type: Number, default: 12 }
|
||||
})
|
||||
|
||||
// 用径向遮罩挖出圆环(兼容小程序与 H5,无需 SVG)
|
||||
const maskStyle = computed(() => {
|
||||
const inner = Math.round(props.size * 0.22)
|
||||
return `radial-gradient(circle ${inner}px at center, transparent 98%, #000 100%)`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ring-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ring-bg,
|
||||
.ring-fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.ring-bg {
|
||||
background: var(--glass-border);
|
||||
}
|
||||
.ring-center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ring-num {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "宇之然AI维度",
|
||||
"appid": "wxdad62baf4ccd09e3",
|
||||
"versionName": "1.0.0",
|
||||
"versionName": "1.4.9",
|
||||
"versionCode": "100",
|
||||
"description": "AI 知识探索社区 — 从5个维度系统理解人工智能",
|
||||
"uni-app": {
|
||||
@@ -17,6 +17,15 @@
|
||||
},
|
||||
"usingComponents": true,
|
||||
"permission": {},
|
||||
"requiredPrivateInfos": []
|
||||
"requiredPrivateInfos": [],
|
||||
"darkmode": true,
|
||||
"themeLocation": "theme.json"
|
||||
},
|
||||
"app-plus": {},
|
||||
"h5": {
|
||||
"router": {
|
||||
"mode": "hash",
|
||||
"base": "/h5/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,148 @@
|
||||
{
|
||||
"pages": [
|
||||
{"path": "pages/index/index", "style": {"navigationBarTitleText": "AI 维度", "navigationStyle": "custom"}},
|
||||
{"path": "pages/login/login", "style": {"navigationBarTitleText": "登录", "navigationStyle": "custom"}},
|
||||
{"path": "pages/detail/detail", "style": {"navigationBarTitleText": "AI 知识", "navigationStyle": "custom"}},
|
||||
{"path": "pages/payment/payment", "style": {"navigationBarTitleText": "订阅升级", "navigationStyle": "custom"}},
|
||||
{"path": "pages/favorites/favorites", "style": {"navigationBarTitleText": "我的收藏", "navigationStyle": "custom"}},
|
||||
{"path": "pages/achievements/achievements", "style": {"navigationBarTitleText": "成就", "navigationStyle": "custom"}},
|
||||
{"path": "pages/leaderboard/leaderboard", "style": {"navigationBarTitleText": "排行榜", "navigationStyle": "custom"}},
|
||||
{"path": "pages/search/search", "style": {"navigationBarTitleText": "搜索", "navigationStyle": "custom"}},
|
||||
{"path": "pages/progress/progress", "style": {"navigationBarTitleText": "学习进度", "navigationStyle": "custom"}},
|
||||
{"path": "pages/feedback/feedback", "style": {"navigationBarTitleText": "意见反馈", "navigationStyle": "custom"}},
|
||||
{"path": "pages/settings/settings", "style": {"navigationBarTitleText": "设置", "navigationStyle": "custom"}},
|
||||
{"path": "pages/news/news", "style": {"navigationBarTitleText": "AI 资讯", "navigationStyle": "custom"}},
|
||||
{"path": "pages/dimension/dimension", "style": {"navigationBarTitleText": "", "navigationStyle": "custom"}},
|
||||
{"path": "pages/chat/chat", "style": {"navigationBarTitleText": "AI 问答", "navigationStyle": "custom"}},
|
||||
{"path": "pages/trend/trend", "style": {"navigationBarTitleText": "AI 趋势", "navigationStyle": "custom"}},
|
||||
{"path": "pages/profile/profile", "style": {"navigationBarTitleText": "我的", "navigationStyle": "custom"}},
|
||||
{"path": "pages/agreement/agreement", "style": {"navigationBarTitleText": "协议", "navigationStyle": "custom"}}
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "宇之然AI维度-AI知识学习与人工智能资讯",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/detail/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI知识详情-人工智能知识点",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/payment/payment",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订阅升级",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/favorites/favorites",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的AI知识收藏",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/achievements/achievements",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的AI学习成就",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/leaderboard/leaderboard",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI学习排行榜",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/search/search",
|
||||
"style": {
|
||||
"navigationBarTitleText": "搜索",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/progress/progress",
|
||||
"style": {
|
||||
"navigationBarTitleText": "学习进度",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/feedback/feedback",
|
||||
"style": {
|
||||
"navigationBarTitleText": "意见反馈",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/settings/settings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "设置",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/news/news",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI资讯速递-人工智能热点解读",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/dimension/dimension",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI知识星域-五大维度学人工智能",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/chat",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI问答-人工智能知识解答",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/trend/trend",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI趋势-每日人工智能行业动态",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/profile",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/agreement/agreement",
|
||||
"style": {
|
||||
"navigationBarTitleText": "协议",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/vpay/recharge",
|
||||
"style": {
|
||||
"navigationBarTitleText": "充值星尘"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/vpay/ledger",
|
||||
"style": {
|
||||
"navigationBarTitleText": "星尘明细"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTextStyle": "@navTxt",
|
||||
"navigationBarTitleText": "宇之然AI维度",
|
||||
"navigationBarBackgroundColor": "#f5f5f7",
|
||||
"backgroundColor": "#f5f5f7",
|
||||
"navigationBarBackgroundColor": "@navBg",
|
||||
"backgroundColor": "@bg",
|
||||
"backgroundColorTop": "#f5f5f7",
|
||||
"backgroundColorBottom": "#f5f5f7",
|
||||
"app-plus": {
|
||||
@@ -30,15 +150,35 @@
|
||||
}
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "rgba(0,0,0,0.3)",
|
||||
"selectedColor": "#6366f1",
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderStyle": "white",
|
||||
"color": "@tabTxt",
|
||||
"selectedColor": "@tabSel",
|
||||
"backgroundColor": "@tabBg",
|
||||
"borderStyle": "@tabBorder",
|
||||
"list": [
|
||||
{"pagePath": "pages/index/index", "text": "探索", "iconPath": "static/icons/explore.png", "selectedIconPath": "static/icons/explore-active.png"},
|
||||
{"pagePath": "pages/chat/chat", "text": "问答", "iconPath": "static/icons/chat.png", "selectedIconPath": "static/icons/chat-active.png"},
|
||||
{"pagePath": "pages/trend/trend", "text": "趋势", "iconPath": "static/icons/trend.png", "selectedIconPath": "static/icons/trend-active.png"},
|
||||
{"pagePath": "pages/profile/profile", "text": "我的", "iconPath": "static/icons/profile.png", "selectedIconPath": "static/icons/profile-active.png"}
|
||||
{
|
||||
"pagePath": "pages/index/index",
|
||||
"text": "探索",
|
||||
"iconPath": "static/icons/explore.png",
|
||||
"selectedIconPath": "static/icons/explore-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/chat/chat",
|
||||
"text": "问答",
|
||||
"iconPath": "static/icons/chat.png",
|
||||
"selectedIconPath": "static/icons/chat-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/trend/trend",
|
||||
"text": "趋势",
|
||||
"iconPath": "static/icons/trend.png",
|
||||
"selectedIconPath": "static/icons/trend-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/profile/profile",
|
||||
"text": "我的",
|
||||
"iconPath": "static/icons/profile.png",
|
||||
"selectedIconPath": "static/icons/profile-active.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<view class="header">
|
||||
<text class="title">🏆 成就</text>
|
||||
<text class="title">成就</text>
|
||||
<text class="unlock-count">{{ unlockedCount }}/{{ totalCount }}</text>
|
||||
</view>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view class="empty-state" v-else-if="!loading">
|
||||
<text class="empty-icon">🏆</text>
|
||||
<text class="empty-icon">暂无成就</text>
|
||||
<text class="empty-text">还没有获得任何成就</text>
|
||||
<text class="empty-sub">继续学习,解锁属于你的成就吧</text>
|
||||
</view>
|
||||
@@ -76,6 +76,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { userApi } from '@/utils/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@@ -86,9 +88,9 @@ const loading = ref(true)
|
||||
|
||||
// 成就分类的默认配置
|
||||
const CATEGORY_CONFIG = [
|
||||
{ name: '知识探索', icon: '📖', color: 'var(--dim1-color)' },
|
||||
{ name: '收藏成就', icon: '⭐', color: 'var(--dim2-color)' },
|
||||
{ name: 'AI 问答', icon: '🤖', color: 'var(--dim3-color)' },
|
||||
{ name: '知识探索', icon: '星', color: 'var(--dim1-color)' },
|
||||
{ name: '收藏成就', icon: '藏', color: 'var(--dim2-color)' },
|
||||
{ name: 'AI 问答', icon: '问', color: 'var(--dim3-color)' },
|
||||
{ name: '社交成就', icon: '👥', color: 'var(--dim4-color)' }
|
||||
]
|
||||
|
||||
@@ -113,8 +115,7 @@ const progressPercent = computed(() => {
|
||||
|
||||
onMounted(() => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loadAchievements()
|
||||
@@ -140,7 +141,7 @@ async function loadAchievements() {
|
||||
|
||||
function catIcon(name) {
|
||||
const config = CATEGORY_CONFIG.find((c) => c.name === name)
|
||||
return config ? config.icon : '🏆'
|
||||
return config ? config.icon : '成'
|
||||
}
|
||||
|
||||
function achColor(icon) {
|
||||
@@ -156,6 +157,14 @@ function formatDate(dateStr) {
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 - 你的AI知识助手',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -90,6 +90,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const type = ref('terms')
|
||||
@@ -112,6 +114,14 @@ function onLoad(fn) {
|
||||
fn(page.$page.options || {})
|
||||
}
|
||||
}
|
||||
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI知识社区 · 宇之然AI维度',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -2,28 +2,29 @@
|
||||
<view class="page-chat page-layout">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 聊天头部 -->
|
||||
<!-- 聊天头部:维度精灵 -->
|
||||
<view class="chat-header">
|
||||
<view class="chat-avatar">
|
||||
<text class="avatar-text">∞</text>
|
||||
<AppIcon name="planet-white" :size="22" />
|
||||
</view>
|
||||
<view class="chat-info">
|
||||
<text class="chat-name">AI 助手</text>
|
||||
<text class="chat-status">随时为你解答</text>
|
||||
<text class="chat-name">维度精灵</text>
|
||||
<text class="chat-status">你的 AI 知识探索向导</text>
|
||||
</view>
|
||||
<!-- 配额徽章(登录后显示) -->
|
||||
<view class="quota-badge" v-if="userStore.isLoggedIn" @click="goShare">
|
||||
<text class="quota-text">{{ quotaDisplay }}</text>
|
||||
<text class="quota-icon">🎁</text>
|
||||
<view class="quota-badge" @click="onQuotaTap">
|
||||
<AppIcon :name="isGuest ? 'gift' : 'gift-gray'" :size="13" />
|
||||
<text class="quota-text">{{ quotaText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息列表(始终可见) -->
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view class="msg-list" scroll-y :scroll-into-view="scrollTo">
|
||||
<!-- 欢迎消息 -->
|
||||
<!-- 欢迎 -->
|
||||
<view class="welcome-card">
|
||||
<text class="welcome-icon">💡</text>
|
||||
<text class="welcome-text">试试问我这些问题:</text>
|
||||
<view class="welcome-head">
|
||||
<AppIcon name="sparkle" :size="16" />
|
||||
<text class="welcome-text">{{ welcomeText }}</text>
|
||||
</view>
|
||||
<view class="suggest-list">
|
||||
<view class="suggest-item" @click="sendSuggest(item)" v-for="(item, idx) in suggestions" :key="idx">
|
||||
<text>{{ item }}</text>
|
||||
@@ -32,7 +33,9 @@
|
||||
</view>
|
||||
|
||||
<view v-for="(msg, idx) in messages" :key="idx" :id="'msg-' + idx" class="chat-msg" :class="msg.role">
|
||||
<view v-if="msg.role === 'assistant'" class="mini-avatar">∞</view>
|
||||
<view v-if="msg.role === 'assistant'" class="mini-avatar">
|
||||
<AppIcon name="planet-white" :size="14" />
|
||||
</view>
|
||||
<view class="msg-content">
|
||||
<view class="bubble" v-html="msg.content"></view>
|
||||
</view>
|
||||
@@ -40,7 +43,9 @@
|
||||
|
||||
<!-- 加载动画 -->
|
||||
<view v-if="loading" class="chat-msg assistant">
|
||||
<view class="mini-avatar">∞</view>
|
||||
<view class="mini-avatar">
|
||||
<AppIcon name="planet-white" :size="14" />
|
||||
</view>
|
||||
<view class="msg-content">
|
||||
<view class="bubble loading-dots">
|
||||
<text class="dot"></text>
|
||||
@@ -49,38 +54,42 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="list-bottom"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 输入区(始终可见,未登录时提示登录) -->
|
||||
<!-- 输入区:游客与登录用户一致,始终可见 -->
|
||||
<view class="chat-input-area">
|
||||
<view class="chat-input" v-if="userStore.isLoggedIn">
|
||||
<view v-if="guestExhausted" class="login-guide" @click="goLogin">
|
||||
<text class="guide-text">{{ guestExhaustedText }}</text>
|
||||
<view class="guide-btn">
|
||||
<text>登录后每天 5 次</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="chat-input" v-else>
|
||||
<input
|
||||
v-model="question"
|
||||
class="input-field"
|
||||
placeholder="输入你对 AI 的疑问..."
|
||||
:placeholder="inputPlaceholder"
|
||||
:disabled="loading"
|
||||
@confirm="sendMessage"
|
||||
confirm-type="send"
|
||||
/>
|
||||
<view class="send-btn" @click="sendMessage">
|
||||
<text>➤</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 未登录:显示登录引导 -->
|
||||
<view class="login-input-placeholder" v-else @click="goLogin">
|
||||
<text class="login-input-text">登录后即可体验 AI 问答</text>
|
||||
<view class="login-input-btn">
|
||||
<text>去登录 →</text>
|
||||
<view class="send-btn" :class="{ disabled: loading || !question.trim() }" @click="sendMessage">
|
||||
<AppIcon name="send-white" :size="16" />
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="isGuest && !guestExhausted" class="guest-hint">游客模式 · 每日 3 次体验</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { aiChatApi } from '@/utils/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
|
||||
const question = ref('')
|
||||
const messages = ref([])
|
||||
@@ -88,35 +97,44 @@ const loading = ref(false)
|
||||
const scrollTo = ref('')
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 建议问题
|
||||
const quotaInfo = ref(null) // { remainingQuota, freeQuota, isGuest }
|
||||
const guestExhausted = ref(false)
|
||||
|
||||
const WELCOME_LOGGED = '你好呀,我是维度精灵。任何 AI 概念都能问我,我用大白话讲明白。'
|
||||
const WELCOME_GUEST = '嗨,我是维度精灵。游客身份也能提问,试试下面的问题。'
|
||||
const GUEST_EXHAUSTED = '今日 3 次游客体验已用完,登录后每天可问 5 次,还能点亮知识星球。'
|
||||
const INPUT_LOGGED = '输入你对 AI 的疑问...'
|
||||
const INPUT_GUEST = '游客模式,直接提问试试...'
|
||||
|
||||
const suggestions = ref([
|
||||
'"Transformer 的核心原理是什么?"',
|
||||
'"RAG 和微调有什么区别?"',
|
||||
'"2026 年最值得关注的 AI 趋势?"'
|
||||
'用大白话解释 Transformer 是什么',
|
||||
'RAG 和微调有什么区别?',
|
||||
'2026 年最值得关注的 AI 趋势?'
|
||||
])
|
||||
|
||||
// 配额显示
|
||||
const quotaDisplay = computed(() => {
|
||||
const q = userStore.quota
|
||||
if (!q) return '?次'
|
||||
const used = q.usedQuota || q.dailyUsed || 0
|
||||
const remaining = q.remainingQuota || 0
|
||||
const total = q.freeQuota || remaining + used
|
||||
return `${remaining}/${total}`
|
||||
const isGuest = computed(() => !userStore.isLoggedIn)
|
||||
const welcomeText = computed(() => (isGuest.value ? WELCOME_GUEST : WELCOME_LOGGED))
|
||||
const inputPlaceholder = computed(() => (isGuest.value ? INPUT_GUEST : INPUT_LOGGED))
|
||||
const guestExhaustedText = GUEST_EXHAUSTED
|
||||
|
||||
const quotaText = computed(() => {
|
||||
const q = quotaInfo.value
|
||||
if (!q) return '...'
|
||||
return `${q.remainingQuota} 次`
|
||||
})
|
||||
|
||||
// 监听登录状态变化,加载配额
|
||||
const isLoggedIn = computed(() => userStore.isLoggedIn)
|
||||
import { watch } from 'vue'
|
||||
watch(isLoggedIn, (val) => {
|
||||
if (val) {
|
||||
loadQuota()
|
||||
}
|
||||
guestExhausted.value = false
|
||||
loadQuota()
|
||||
}, { immediate: true })
|
||||
|
||||
async function loadQuota() {
|
||||
if (userStore.token) {
|
||||
await userStore.fetchQuota(userStore.token)
|
||||
try {
|
||||
const data = await aiChatApi.quota(userStore.isLoggedIn ? userStore.token : undefined)
|
||||
quotaInfo.value = data
|
||||
} catch (e) {
|
||||
quotaInfo.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,28 +142,42 @@ async function sendMessage() {
|
||||
const q = question.value.trim()
|
||||
if (!q || loading.value) return
|
||||
|
||||
// 未登录 → 跳登录
|
||||
if (!userStore.isLoggedIn) {
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
|
||||
messages.value.push({ role: 'user', content: escapeHtml(q) })
|
||||
question.value = ''
|
||||
scrollToLast()
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await aiChatApi.ask(q, 'general', userStore.token)
|
||||
const data = await aiChatApi.ask(q, 'general', userStore.isLoggedIn ? userStore.token : undefined)
|
||||
messages.value.push({ role: 'assistant', content: escapeHtml(data.answer) })
|
||||
await userStore.fetchQuota(userStore.token)
|
||||
if (quotaInfo.value) {
|
||||
quotaInfo.value.remainingQuota = data.remainingQuota
|
||||
}
|
||||
if (isGuest.value && data.remainingQuota <= 0) {
|
||||
guestExhausted.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('AI 问答失败:', e)
|
||||
const errorMsg = e.message || 'AI 服务暂时不可用,请稍后再试'
|
||||
messages.value.push({
|
||||
role: 'assistant',
|
||||
content: escapeHtml(`抱歉,${errorMsg}`)
|
||||
})
|
||||
const msg = e.message || 'AI 服务暂时不可用,请稍后再试'
|
||||
if (msg.includes('游客体验已用完')) {
|
||||
guestExhausted.value = true
|
||||
} else if (msg.includes('免费次数已用完') || msg.includes('星尘余额不足') || msg.includes('充值星尘')) {
|
||||
// 引导充值
|
||||
uni.showModal({
|
||||
title: '免费次数用完了',
|
||||
content: '充值星尘可继续提问(10星尘/次),还可以享受更多功能',
|
||||
confirmText: '去充值',
|
||||
cancelText: '分享得次数',
|
||||
success: (m) => {
|
||||
if (m.confirm) {
|
||||
uni.navigateTo({ url: '/pages/vpay/recharge' })
|
||||
} else {
|
||||
onQuotaTap()
|
||||
}
|
||||
},
|
||||
})
|
||||
} else {
|
||||
messages.value.push({ role: 'assistant', content: escapeHtml(`抱歉,${msg}`) })
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
scrollToLast()
|
||||
@@ -159,8 +191,9 @@ function sendSuggest(text) {
|
||||
|
||||
function scrollToLast() {
|
||||
setTimeout(() => {
|
||||
scrollTo.value = 'msg-' + (messages.value.length - 1)
|
||||
}, 100)
|
||||
scrollTo.value = ''
|
||||
setTimeout(() => { scrollTo.value = 'msg-' + (messages.value.length - 1) }, 50)
|
||||
}, 80)
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
@@ -175,26 +208,51 @@ function goLogin() {
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
}
|
||||
|
||||
function goShare() {
|
||||
async function onQuotaTap() {
|
||||
if (isGuest.value) {
|
||||
// 静默登录一键升级(微信 code 换 token 无感),失败再显式引导
|
||||
const ok = await userStore.ensureLogin()
|
||||
if (ok) {
|
||||
uni.showToast({ title: '已升级为登录用户,每天 5 次', icon: 'success' })
|
||||
loadQuota()
|
||||
return
|
||||
}
|
||||
uni.showModal({
|
||||
title: '游客体验',
|
||||
content: '每天可免费提问 3 次;登录后每天 5 次,答对检测题还能点亮知识星球',
|
||||
confirmText: '去登录',
|
||||
cancelText: '继续体验',
|
||||
success: (res) => {
|
||||
if (res.confirm) goLogin()
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
uni.showModal({
|
||||
title: '获取更多次数',
|
||||
content: '将小程序分享给好友,即可获得 5 次额外问答机会',
|
||||
confirmText: '去分享',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// 微信小程序分享能力
|
||||
if (res.confirm && userStore.token) {
|
||||
uni.showShareMenu({ withShareTicket: true })
|
||||
// 调用分享获得次数接口
|
||||
if (userStore.token) {
|
||||
aiChatApi.shareGain(userStore.token).then(() => {
|
||||
uni.showToast({ title: '已记录分享', icon: 'success' })
|
||||
loadQuota()
|
||||
}).catch(() => {})
|
||||
}
|
||||
aiChatApi.shareGain(userStore.token).then(() => {
|
||||
uni.showToast({ title: '已记录分享', icon: 'success' })
|
||||
loadQuota()
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: '维度精灵 · 你的 AI 知识探索向导',
|
||||
path: '/pages/chat/chat'
|
||||
}))
|
||||
onShareTimeline(() => ({
|
||||
title: '维度精灵 · 你的 AI 知识探索向导'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -202,7 +260,7 @@ function goShare() {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 20px;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
@@ -212,248 +270,230 @@ function goShare() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0 16px;
|
||||
padding: 10px 0 14px;
|
||||
}
|
||||
|
||||
.chat-avatar,
|
||||
.mini-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, rgba(124, 77, 255, 0.3), rgba(0, 188, 212, 0.2));
|
||||
border: 1px solid rgba(124, 77, 255, 0.15);
|
||||
background: linear-gradient(135deg, var(--color-brand), #8b5cf6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 14px var(--color-brand-glow);
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mini-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.chat-avatar { width: 42px; height: 42px; }
|
||||
.mini-avatar { width: 28px; height: 28px; border-radius: 9px; }
|
||||
|
||||
.chat-info { flex: 1; }
|
||||
|
||||
.chat-name {
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.chat-status {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// 配额徽章
|
||||
.quota-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(124, 77, 255, 0.1);
|
||||
border: 1px solid rgba(124, 77, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-brand-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
|
||||
.quota-text {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.quota-text {
|
||||
font-size: 12px;
|
||||
color: var(--dim1-color);
|
||||
font-weight: 600;
|
||||
.msg-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.quota-icon { font-size: 14px; }
|
||||
.list-bottom { height: 16px; }
|
||||
|
||||
// 未登录输入区占位
|
||||
.login-input-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
background: var(--glass-bg);
|
||||
border: 1px dashed var(--glass-border);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-input-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.login-input-btn {
|
||||
padding: 6px 16px;
|
||||
background: var(--color-brand);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.login-input-btn text {
|
||||
font-size: 13px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// 欢迎卡片
|
||||
.welcome-card {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--card-shadow, none);
|
||||
}
|
||||
.welcome-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.welcome-icon {
|
||||
font-size: 20px;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
.welcome-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.suggest-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.suggest-item {
|
||||
padding: 8px 12px;
|
||||
background: rgba(124, 77, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
padding: 11px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--glass-border);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
// 消息列表
|
||||
.msg-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.suggest-item:active { border-color: var(--color-brand); color: var(--color-brand); }
|
||||
|
||||
.chat-msg {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.chat-msg.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
&.assistant .msg-content { align-items: flex-start; }
|
||||
|
||||
.chat-msg.assistant {
|
||||
flex-direction: row;
|
||||
}
|
||||
.msg-content {
|
||||
max-width: 82%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
max-width: 82%;
|
||||
&.user {
|
||||
justify-content: flex-end;
|
||||
|
||||
.msg-content { align-items: flex-end; }
|
||||
.bubble {
|
||||
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
|
||||
color: #fff;
|
||||
border-radius: 16px 16px 4px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&.assistant .bubble {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--text-primary);
|
||||
border-radius: 16px 16px 16px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.7;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-msg.assistant .bubble {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--text-primary);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-msg.user .bubble {
|
||||
background: linear-gradient(135deg, rgba(124, 77, 255, 0.3), rgba(124, 77, 255, 0.15));
|
||||
border: 1px solid rgba(124, 77, 255, 0.15);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
// 加载动画
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 14px 18px;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding: 16px 14px;
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-brand);
|
||||
animation: dotBounce 1.2s infinite ease-in-out;
|
||||
|
||||
&:nth-child(2) { animation-delay: 0.15s; }
|
||||
&:nth-child(3) { animation-delay: 0.3s; }
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
animation: blink 1.4s infinite;
|
||||
@keyframes dotBounce {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-5px); opacity: 1; }
|
||||
}
|
||||
|
||||
.dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes blink {
|
||||
0%, 20% { opacity: 0; transform: scale(0.6); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(0.6); }
|
||||
}
|
||||
|
||||
// 输入区
|
||||
/* 输入区 */
|
||||
.chat-input-area {
|
||||
padding: 12px 0 30px;
|
||||
background: var(--bg-primary);
|
||||
flex-shrink: 0;
|
||||
padding: 10px 0 12px;
|
||||
}
|
||||
|
||||
.guest-hint {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 6px 6px 6px 18px;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
flex: 1;
|
||||
height: 24px;
|
||||
height: 38px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-field::placeholder {
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #7c4dff, #b388ff);
|
||||
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
box-shadow: 0 0 20px rgba(124, 77, 255, 0.2);
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&.disabled { opacity: 0.45; }
|
||||
}
|
||||
|
||||
.send-btn text {
|
||||
font-size: 16px;
|
||||
/* 用完额度引导 */
|
||||
.login-guide {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(135deg, var(--color-brand-bg), rgba(139, 92, 246, 0.06));
|
||||
border: 1px solid var(--glass-hover-border);
|
||||
|
||||
.guide-text {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.guide-btn {
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
<template>
|
||||
<view class="detail-page page-layout">
|
||||
<!-- 状态栏占位 -->
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<view class="nav-bar">
|
||||
<view class="nav-back" @click="goBack">‹</view>
|
||||
<view class="nav-back" @click="goBack">
|
||||
<AppIcon name="back-gray" :size="18" />
|
||||
</view>
|
||||
<text class="nav-title">AI 知识</text>
|
||||
<view class="nav-collect" @click="toggleCollect">
|
||||
<text v-if="isCollected" class="icon icon-collected">★</text>
|
||||
<text v-else class="icon">☆</text>
|
||||
<AppIcon v-if="isCollected" name="star-fill" :size="20" />
|
||||
<AppIcon v-else name="star-gray" :size="20" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 知识头部 -->
|
||||
<view class="detail-header">
|
||||
<view class="dim-tag" :style="{ background: dimColor }">
|
||||
<text class="dim-name">{{ dimensionName }}</text>
|
||||
<view class="dim-tag" :style="{ background: dimInfo.bg, color: dimInfo.color }">
|
||||
{{ dimInfo.name }}
|
||||
</view>
|
||||
<text class="detail-title">{{ detail?.title || '加载中...' }}</text>
|
||||
<view class="meta-row">
|
||||
<text class="meta-item">📖 {{ viewCountText }}</text>
|
||||
<text class="meta-item">⭐ {{ collectionCount }}</text>
|
||||
<text class="meta-item" v-if="detail?.category">
|
||||
{{ categoryLabel }}
|
||||
</text>
|
||||
<view class="meta-item">
|
||||
<AppIcon name="search-gray" :size="12" />
|
||||
<text>{{ viewCountText }} 次阅读</text>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<AppIcon name="star-gray" :size="12" />
|
||||
<text>{{ collectionCount }} 人收藏</text>
|
||||
</view>
|
||||
<text class="meta-item" v-if="readMinutes">{{ readMinutes }} 分钟读完</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -32,83 +38,140 @@
|
||||
<text class="tag" v-for="tag in detail.tags" :key="tag">#{{ tag }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<scroll-view class="content-scroll" scroll-y>
|
||||
<!-- 分段内容(如果存在 sections) -->
|
||||
<view class="content-section" v-if="detail?.sections?.length">
|
||||
<view class="section-block" v-for="(section, idx) in detail.sections" :key="idx">
|
||||
<text class="section-title">{{ section.title }}</text>
|
||||
<text class="section-content">{{ section.content }}</text>
|
||||
<view class="section-images" v-if="section.images?.length">
|
||||
<image
|
||||
class="section-img"
|
||||
v-for="(img, i) in section.images"
|
||||
:key="i"
|
||||
:src="img"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 正文 -->
|
||||
<view class="content-card">
|
||||
<view v-if="!detail" class="loading-block">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 普通内容 -->
|
||||
<view class="content-block" v-else>
|
||||
<text class="content-text">{{ detail?.content || '暂无内容' }}</text>
|
||||
<template v-else-if="paragraphs.length">
|
||||
<text
|
||||
v-for="(p, i) in paragraphs"
|
||||
:key="i"
|
||||
class="para"
|
||||
:class="{ 'para-lead': i === 0 }"
|
||||
>{{ p }}</text>
|
||||
</template>
|
||||
<view v-else class="loading-block">
|
||||
<text>暂无内容</text>
|
||||
</view>
|
||||
|
||||
<!-- 问答区域 -->
|
||||
<view class="qa-section" v-if="detail?.category === 'question' && detail?.question">
|
||||
<view class="qa-question">
|
||||
<text class="qa-label">问题</text>
|
||||
<text class="qa-text">{{ detail.question }}</text>
|
||||
</view>
|
||||
<view class="qa-options" v-if="detail?.options?.length">
|
||||
<view
|
||||
class="qa-option"
|
||||
v-for="(opt, idx) in detail.options"
|
||||
:key="idx"
|
||||
:class="{ correct: selectedOption === idx && opt.isCorrect }"
|
||||
@click="selectedOption = idx"
|
||||
>
|
||||
<text class="option-letter">{{ String.fromCharCode(65 + idx) }}</text>
|
||||
<text class="option-text">{{ opt.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="qa-answer" v-if="selectedOption !== null">
|
||||
<text class="answer-label">
|
||||
{{ detail.options?.[selectedOption]?.isCorrect ? '✅ 正确' : '❌ 错误' }}
|
||||
</text>
|
||||
<text class="answer-text" v-if="detail.answer">{{ detail.answer }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部 AI 问答入口 -->
|
||||
<view class="ai-banner" @click="goToAI">
|
||||
<text class="ai-icon">🤖</text>
|
||||
<text class="ai-text">还有疑问?问问 AI 助手</text>
|
||||
<text class="ai-arrow">→</text>
|
||||
</view>
|
||||
|
||||
<!-- 检测题(读后测,答对点亮星球) -->
|
||||
<view class="quiz-card" v-if="detail?.question && detail?.options?.length">
|
||||
<view class="quiz-head">
|
||||
<AppIcon name="sparkle" :size="16" />
|
||||
<text class="quiz-title">读后检测 · 答对点亮这颗星球</text>
|
||||
</view>
|
||||
<text class="quiz-question">{{ detail.question }}</text>
|
||||
|
||||
<template v-if="!quizResult">
|
||||
<view
|
||||
v-for="(opt, idx) in detail.options"
|
||||
:key="idx"
|
||||
class="quiz-option"
|
||||
:class="{ selected: selectedOption === idx }"
|
||||
@click="selectedOption = idx"
|
||||
>
|
||||
<text class="option-letter">{{ optionLetters[idx] }}</text>
|
||||
<text class="option-text">{{ opt.text }}</text>
|
||||
</view>
|
||||
<view
|
||||
class="quiz-submit"
|
||||
:class="{ disabled: selectedOption === null }"
|
||||
@click="submitQuiz"
|
||||
>
|
||||
<text>{{ selectedOption === null ? '选择你的答案' : '确认作答' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<view class="quiz-result" :class="quizResult.correct ? 'ok' : 'bad'">
|
||||
<view class="result-icon">
|
||||
<AppIcon :name="quizResult.correct ? 'check-green' : 'lock-gray'" :size="18" />
|
||||
</view>
|
||||
<view class="result-text">
|
||||
<text class="result-title">{{ quizResult.correct ? resultOkTitle : resultBadTitle }}</text>
|
||||
<text class="result-desc" v-if="quizResult.answer">{{ quizResult.answer }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="quiz-actions">
|
||||
<view v-if="!quizResult.correct" class="quiz-retry" @click="retryQuiz">
|
||||
<text>再试一次</text>
|
||||
</view>
|
||||
<view class="quiz-next" @click="goChat">
|
||||
<text>还有疑问?问维度精灵</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<!-- 底部 AI 导师入口 -->
|
||||
<view class="ai-banner" @click="goChat">
|
||||
<AppIcon name="bulb-white" :size="16" />
|
||||
<text class="ai-text">读完想深入聊聊?问维度精灵</text>
|
||||
<AppIcon name="arrow-white" :size="12" />
|
||||
</view>
|
||||
|
||||
<view class="bottom-spacer"></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { knowledgeApi } from '@/utils/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { DIMENSIONS } from '@/utils/dimensions'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
|
||||
const RESULT_OK_TITLE = '回答正确,星球已点亮'
|
||||
const RESULT_OK_GUEST = '回答正确!登录后可永久记录点亮'
|
||||
const RESULT_BAD_TITLE = '差一点点,再想想'
|
||||
|
||||
const detailId = ref('')
|
||||
const detail = ref(null)
|
||||
const isCollected = ref(false)
|
||||
const selectedOption = ref(null)
|
||||
const quizResult = ref(null)
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 从路由参数获取 ID
|
||||
const optionLetters = ['A', 'B', 'C', 'D', 'E']
|
||||
|
||||
// 正文按段落渲染(兼容全部存量数据:content 字符串)
|
||||
const paragraphs = computed(() => {
|
||||
const raw = detail.value?.content
|
||||
if (!raw) return []
|
||||
return String(raw)
|
||||
.split(/\n+/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
|
||||
const readMinutes = computed(() => {
|
||||
const len = detail.value?.content?.length || 0
|
||||
return len ? Math.max(1, Math.ceil(len / 300)) : 0
|
||||
})
|
||||
|
||||
const dimInfo = computed(() => {
|
||||
const dim = detail.value?.dim
|
||||
return DIMENSIONS.find(d => d.id === dim) || DIMENSIONS[0]
|
||||
})
|
||||
|
||||
const viewCountText = computed(() => {
|
||||
const count = detail.value?.viewCount || 0
|
||||
return count >= 10000 ? (count / 10000).toFixed(1) + '万' : String(count)
|
||||
})
|
||||
const collectionCount = computed(() => detail.value?.collectionCount || 0)
|
||||
|
||||
const resultOkTitle = computed(() =>
|
||||
quizResult.value?.lit ? RESULT_OK_TITLE : RESULT_OK_GUEST
|
||||
)
|
||||
const resultBadTitle = RESULT_BAD_TITLE
|
||||
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages()
|
||||
const currentPage = pages[pages.length - 1]
|
||||
const query = currentPage.$page?.query || {}
|
||||
const query = currentPage.$page?.query || currentPage.options || {}
|
||||
detailId.value = query.id || ''
|
||||
|
||||
if (detailId.value) {
|
||||
@@ -116,41 +179,19 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 维度信息
|
||||
const dimColor = computed(() => {
|
||||
const dim = detail.value?.dim
|
||||
if (!dim) return 'var(--dim1-color)'
|
||||
const dimInfo = DIMENSIONS.find(d => d.id === dim)
|
||||
return dimInfo ? dimInfo.color : 'var(--dim1-color)'
|
||||
})
|
||||
const dimensionName = computed(() => {
|
||||
const dim = detail.value?.dim
|
||||
if (!dim) return ''
|
||||
const dimInfo = DIMENSIONS.find(d => d.id === dim)
|
||||
return dimInfo ? dimInfo.name : ''
|
||||
})
|
||||
|
||||
// 分类标签
|
||||
const categoryLabel = computed(() => {
|
||||
const map = {
|
||||
concept: '📚 概念',
|
||||
history: '📜 历史',
|
||||
application: '🛠️ 应用',
|
||||
question: '❓ 问答'
|
||||
}
|
||||
return map[detail.value?.category] || ''
|
||||
})
|
||||
|
||||
const viewCountText = computed(() => {
|
||||
const count = detail.value?.viewCount || 0
|
||||
return count >= 10000 ? (count / 10000).toFixed(1) + '万' : count
|
||||
})
|
||||
const collectionCount = computed(() => detail.value?.collectionCount || 0)
|
||||
|
||||
async function fetchDetail() {
|
||||
try {
|
||||
detail.value = await knowledgeApi.detail(detailId.value)
|
||||
// 检查是否已收藏
|
||||
|
||||
// 记录"继续探索"
|
||||
try {
|
||||
uni.setStorageSync('last_read', {
|
||||
id: detailId.value,
|
||||
title: detail.value.title || '',
|
||||
dim: detail.value.dim
|
||||
})
|
||||
} catch (e) {}
|
||||
|
||||
if (userStore.isLoggedIn) {
|
||||
const collected = await knowledgeApi.collected(userStore.token)
|
||||
isCollected.value = collected.some(item => item.id === detailId.value)
|
||||
@@ -161,10 +202,49 @@ async function fetchDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitQuiz() {
|
||||
if (selectedOption.value === null || !detailId.value) return
|
||||
try {
|
||||
const res = await knowledgeApi.quiz(
|
||||
detailId.value,
|
||||
selectedOption.value,
|
||||
userStore.isLoggedIn ? userStore.token : ''
|
||||
)
|
||||
quizResult.value = res
|
||||
if (res.correct) {
|
||||
uni.setStorageSync('lit_today', new Date().toDateString())
|
||||
if (res.lit) {
|
||||
uni.showToast({ title: '星球已点亮', icon: 'success' })
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '提交失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function retryQuiz() {
|
||||
selectedOption.value = null
|
||||
quizResult.value = null
|
||||
}
|
||||
|
||||
async function toggleCollect() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
return
|
||||
// 静默登录(微信 code 换 token,用户无感);失败再显式引导
|
||||
const ok = await userStore.ensureLogin()
|
||||
if (!ok) {
|
||||
uni.showModal({
|
||||
title: '登录后收藏',
|
||||
content: '登录后即可收藏喜欢的 AI 知识',
|
||||
confirmText: '去登录',
|
||||
cancelText: '暂不',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (isCollected.value) {
|
||||
@@ -185,36 +265,45 @@ function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function goToAI() {
|
||||
function goChat() {
|
||||
uni.switchTab({ url: '/pages/chat/chat' })
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: (detail.value?.title ? detail.value.title + ' · AI 维度' : 'AI 维度 · 知识星球'),
|
||||
path: `/pages/detail/detail?id=${detailId.value}`
|
||||
}))
|
||||
onShareTimeline(() => ({
|
||||
title: detail.value?.title || 'AI 维度 · 知识星球'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
padding: 0 20px 30px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
// 顶部导航
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 50px 20px 12px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 0 14px;
|
||||
|
||||
.nav-back {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-title {
|
||||
font-size: 16px;
|
||||
@@ -222,226 +311,259 @@ function goToAI() {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-collect {
|
||||
width: 30px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.icon {
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.icon-collected {
|
||||
color: var(--dim5-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 知识头部
|
||||
.detail-header {
|
||||
padding: 0 20px 16px;
|
||||
flex-shrink: 0;
|
||||
padding: 4px 0 14px;
|
||||
|
||||
.dim-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.9;
|
||||
|
||||
.dim-name { color: white; }
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-tertiary);
|
||||
|
||||
.meta-item { display: flex; align-items: center; }
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标签
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 20px 16px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 0 14px;
|
||||
|
||||
.tag {
|
||||
font-size: 12px;
|
||||
color: var(--dim1-color);
|
||||
background: rgba(179, 136, 255, 0.1);
|
||||
font-size: 11px;
|
||||
color: var(--color-brand);
|
||||
background: var(--color-brand-bg);
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
}
|
||||
|
||||
// 内容滚动区
|
||||
.content-scroll {
|
||||
flex: 1;
|
||||
padding: 0 20px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 80px;
|
||||
.content-card {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 18px 16px;
|
||||
margin-bottom: 14px;
|
||||
box-shadow: var(--card-shadow, none);
|
||||
}
|
||||
|
||||
.content-section,
|
||||
.content-block,
|
||||
.qa-section {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
.loading-block {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.para {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.9;
|
||||
margin-bottom: 14px;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
&:last-child { margin-bottom: 0; }
|
||||
}
|
||||
.para-lead {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 检测题 */
|
||||
.quiz-card {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
margin-bottom: 14px;
|
||||
box-shadow: var(--card-shadow, none);
|
||||
}
|
||||
|
||||
.quiz-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.quiz-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.quiz-question {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.quiz-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--bg-primary);
|
||||
border: 1.5px solid var(--glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.selected {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-brand-bg);
|
||||
}
|
||||
|
||||
.section-block {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child { margin-bottom: 0; }
|
||||
|
||||
.section-title {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--dim1-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.section-content {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.8;
|
||||
}
|
||||
.section-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
.section-img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
.option-letter {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
.option-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// 问答区域
|
||||
.qa-section {
|
||||
.qa-question {
|
||||
.qa-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.qa-text {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.quiz-submit {
|
||||
margin-top: 6px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.quiz-result {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
&.ok {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
&.bad {
|
||||
background: rgba(229, 28, 28, 0.07);
|
||||
border: 1px solid rgba(229, 28, 28, 0.2);
|
||||
}
|
||||
|
||||
.qa-options {
|
||||
.result-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.result-title {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.result-desc {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.quiz-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
|
||||
.quiz-retry {
|
||||
flex: 1;
|
||||
height: 42px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1.5px solid var(--glass-border);
|
||||
background: var(--glass-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.qa-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.correct {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
.option-letter {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.option-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.qa-answer {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(179, 136, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
|
||||
.answer-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.answer-text {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.quiz-next {
|
||||
flex: 1.6;
|
||||
height: 42px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// AI 底部入口
|
||||
/* AI 入口 */
|
||||
.ai-banner {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(135deg, var(--dim1-color), var(--dim3-color));
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px rgba(179, 136, 255, 0.3);
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, var(--color-brand-dark), var(--color-brand));
|
||||
border-radius: var(--radius-md);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px var(--color-brand-glow);
|
||||
|
||||
.ai-icon { font-size: 18px; }
|
||||
.ai-text { font-size: 14px; font-weight: 600; }
|
||||
.ai-arrow { font-size: 14px; }
|
||||
.ai-text { font-size: 14px; font-weight: 600; color: #fff; }
|
||||
}
|
||||
|
||||
.bottom-spacer { height: 24px; }
|
||||
</style>
|
||||
|
||||
@@ -2,70 +2,96 @@
|
||||
<view class="page-dim page-layout">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<view class="back-btn" @tap="goBack">
|
||||
<text>← 返回</text>
|
||||
<view class="nav-row">
|
||||
<view class="back-btn" @tap="goBack">
|
||||
<AppIcon name="back-gray" :size="16" />
|
||||
<text>返回</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 维度横幅 -->
|
||||
<!-- 星域横幅 -->
|
||||
<view class="dim-banner" :style="{ borderColor: currentDim?.border }">
|
||||
<text class="dim-banner-icon">{{ currentDim?.icon }}</text>
|
||||
<text class="dim-banner-title">{{ currentDim?.name }}</text>
|
||||
<text class="dim-banner-desc">{{ bannerDesc }}</text>
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" :style="{ width: progress + '%' }"></view>
|
||||
<view class="banner-top">
|
||||
<view class="dim-icon" :style="{ background: currentDim?.iconBg }">
|
||||
<AppIcon :name="currentDim?.icon || 'planet'" :size="20" />
|
||||
</view>
|
||||
<view class="banner-info">
|
||||
<text class="dim-banner-title">{{ currentDim?.name }}</text>
|
||||
<text class="dim-banner-desc">{{ currentDim?.tagline }}</text>
|
||||
</view>
|
||||
<ProgressRing
|
||||
:size="46"
|
||||
:num-size="10"
|
||||
:percent="litPercent"
|
||||
:color="currentDim?.color"
|
||||
/>
|
||||
</view>
|
||||
<text class="progress-label">探索进度 {{ progress }}% · 已完成 {{ readCount }}/{{ totalCount }} 篇</text>
|
||||
<text class="progress-label">{{ litCount }}/{{ totalCount }} 颗星球已点亮 · {{ litPercent }}%</text>
|
||||
</view>
|
||||
|
||||
<!-- 知识列表 -->
|
||||
<view v-for="item in knowledgeList" :key="item._id" class="knowledge-item" @tap="goDetail(item)">
|
||||
<view class="k-icon" :style="getItemStyle(item)">
|
||||
<text>{{ item.icon || '📄' }}</text>
|
||||
<!-- 星球列表 -->
|
||||
<view
|
||||
v-for="item in knowledgeList"
|
||||
:key="item._id"
|
||||
class="planet-item"
|
||||
@tap="goDetail(item)"
|
||||
>
|
||||
<view class="p-icon" :class="{ lit: item.isLit }" :style="item.isLit ? litStyle : null">
|
||||
<AppIcon :name="item.isLit ? 'star-fill-white' : 'star-gray'" :size="16" />
|
||||
</view>
|
||||
<view class="k-info">
|
||||
<text class="k-title">{{ item.title }}</text>
|
||||
<text class="k-desc">{{ item.desc }}</text>
|
||||
<view class="k-meta">
|
||||
<text>📖 {{ item.readTime || 5 }} 分钟</text>
|
||||
<text v-if="item.collected">⭐ 已收藏</text>
|
||||
<text v-else>🔖 {{ item.collectCount || 0 }} 人收藏</text>
|
||||
<text class="k-badge" :class="item.isPremium ? 'pro' : 'free'">
|
||||
{{ item.isPremium ? 'Pro' : '免费' }}
|
||||
</text>
|
||||
<view class="p-info">
|
||||
<text class="p-title">{{ item.title }}</text>
|
||||
<text class="p-desc">{{ item.desc }}</text>
|
||||
<view class="p-meta">
|
||||
<view class="p-lit" v-if="item.isLit" :style="{ color: currentDim?.color, background: currentDim?.bg }">已点亮</view>
|
||||
<text class="p-read">{{ item.readTime }} 分钟</text>
|
||||
<text class="p-quiz" v-if="item.hasQuiz">含检测题</text>
|
||||
</view>
|
||||
</view>
|
||||
<AppIcon name="arrow-gray" :size="13" />
|
||||
</view>
|
||||
|
||||
<!-- 空态 -->
|
||||
<view v-if="!loading && !knowledgeList.length" class="empty-state">
|
||||
<AppIcon name="planet-gray" :size="40" />
|
||||
<text class="empty-title">这片星域还没有星球</text>
|
||||
<text class="empty-desc">内容正在扩充中,先去别的星域逛逛</text>
|
||||
</view>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="bottom-spacer"></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getDimension } from '@/utils/dimensions'
|
||||
import { knowledgeApi } from '@/utils/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ProgressRing from '@/components/ProgressRing.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const dimId = ref(1)
|
||||
const currentDim = computed(() => getDimension(dimId.value))
|
||||
const knowledgeList = ref([])
|
||||
const loading = ref(false)
|
||||
const progress = ref(35)
|
||||
const readCount = ref(4)
|
||||
const totalCount = ref(12)
|
||||
const litIds = ref([])
|
||||
|
||||
const bannerDesc = computed(() => {
|
||||
const descs = {
|
||||
1: '从 1950 年图灵测试到 2026 年通用人工智能,追溯 70 年演进之路',
|
||||
2: '从深度学习到 Transformer,从 MoE 到多模态,理解每一次技术跃迁',
|
||||
3: '大模型格局、Agent 生态、具身智能——2026 年 AI 世界全景图',
|
||||
4: '提示词工程、RAG 系统、模型微调、工具链——从入门到上手',
|
||||
5: '每日追踪 AI 前沿动态,不错过每一个重要时刻'
|
||||
}
|
||||
return descs[dimId.value] || '探索人工智能的精彩世界'
|
||||
})
|
||||
const totalCount = computed(() => knowledgeList.value.length)
|
||||
const litCount = computed(() => knowledgeList.value.filter(i => i.isLit).length)
|
||||
const litPercent = computed(() =>
|
||||
totalCount.value ? Math.round((litCount.value / totalCount.value) * 100) : 0
|
||||
)
|
||||
|
||||
const litStyle = computed(() => ({
|
||||
background: currentDim.value?.iconBg,
|
||||
borderColor: currentDim.value?.border
|
||||
}))
|
||||
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages()
|
||||
@@ -79,91 +105,44 @@ onMounted(() => {
|
||||
async function loadKnowledge() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await knowledgeApi.list({ dim: dimId.value })
|
||||
knowledgeList.value = data.map(item => ({
|
||||
const [data, stats] = await Promise.all([
|
||||
knowledgeApi.list({ dim: dimId.value, limit: 100 }),
|
||||
knowledgeApi.stats(userStore.isLoggedIn ? userStore.token : '').catch(() => null)
|
||||
])
|
||||
litIds.value = stats?.user?.litIds || []
|
||||
|
||||
knowledgeList.value = (data.items || []).map(item => ({
|
||||
...item,
|
||||
icon: getDimIcon(item.dim),
|
||||
desc: item.content?.substring(0, 40) + '...' || '',
|
||||
readTime: Math.ceil((item.content?.length || 300) / 200),
|
||||
isPremium: item.isPremium,
|
||||
collected: false,
|
||||
collectCount: item.collectionCount
|
||||
desc: item.content ? item.content.substring(0, 42) + '...' : '',
|
||||
readTime: Math.max(1, Math.ceil((item.content?.length || 300) / 300)),
|
||||
hasQuiz: !!(item.question && (item.options || []).length),
|
||||
isLit: litIds.value.includes(String(item._id))
|
||||
}))
|
||||
} catch (e) {
|
||||
// 用示例数据填充
|
||||
knowledgeList.value = getSampleData(dimId.value)
|
||||
knowledgeList.value = []
|
||||
uni.showToast({ title: '加载失败,请下拉重试', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getItemStyle(item) {
|
||||
const colors = [
|
||||
{ bg: 'rgba(124,77,255,0.1)', color: '#b388ff' },
|
||||
{ bg: 'rgba(0,188,212,0.1)', color: '#4dd0e1' },
|
||||
{ bg: 'rgba(0,200,150,0.1)', color: '#4db6ac' },
|
||||
{ bg: 'rgba(255,193,7,0.1)', color: '#ffd54f' },
|
||||
{ bg: 'rgba(239,83,80,0.1)', color: '#ef5350' }
|
||||
]
|
||||
const c = colors[(item.dim || 1) - 1] || colors[0]
|
||||
return {
|
||||
background: c.bg,
|
||||
color: c.color,
|
||||
borderColor: c.bg
|
||||
}
|
||||
}
|
||||
|
||||
function getDimIcon(dim) {
|
||||
const icons = ['📜', '🏛', '♟', '🧠', '⚡']
|
||||
return icons[(dim || 1) - 1]
|
||||
}
|
||||
|
||||
function getSampleData(dim) {
|
||||
const samples = {
|
||||
1: [
|
||||
{ title: '图灵测试:人工智能的哲学起点', content: '1950 年,艾伦·图灵提出"机器能思考吗?"' },
|
||||
{ title: '达特茅斯会议:AI 的诞生时刻', content: '1956 年,一群科学家正式命名了"人工智能"' },
|
||||
{ title: '深蓝 vs 卡斯帕罗夫', content: '1997 年,IBM 深蓝首次击败国际象棋世界冠军' },
|
||||
],
|
||||
2: [
|
||||
{ title: '深度学习革命', content: '从神经网络到深度学习的演进之路' },
|
||||
{ title: 'Transformer 架构详解', content: '2017 年 Google 提出的革命性架构' },
|
||||
],
|
||||
3: [
|
||||
{ title: '2026 年大模型格局', content: 'GPT、Claude、Gemini、DeepSeek 全面对比' },
|
||||
{ title: 'Agent 智能体生态', content: '从 Chatbot 到自主 Agent 的进化' },
|
||||
],
|
||||
4: [
|
||||
{ title: '提示词工程入门', content: '如何写出高质量的 prompt' },
|
||||
{ title: 'RAG 系统搭建指南', content: '检索增强生成从零到一' },
|
||||
],
|
||||
5: [
|
||||
{ title: 'DeepSeek 自研推理芯片', content: '降低对英伟达依赖的重要一步' },
|
||||
{ title: 'WAIC 2026 即将开幕', content: '7 月 17 日上海,1100+ 企业参展' },
|
||||
]
|
||||
}
|
||||
const data = samples[dim] || samples[1]
|
||||
return data.map((item, i) => ({
|
||||
...item,
|
||||
_id: `sample-${dim}-${i}`,
|
||||
dim,
|
||||
icon: getDimIcon(dim),
|
||||
readTime: 5 + i * 2,
|
||||
isPremium: i >= 3,
|
||||
collected: i === 0,
|
||||
collectionCount: 10 - i * 2
|
||||
}))
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function goDetail(item) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/detail/detail?id=${item._id}`
|
||||
})
|
||||
uni.navigateTo({ url: `/pages/detail/detail?id=${item._id}` })
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: (currentDim.value?.name || 'AI 维度') + '星域 · 每颗星球都是一则 AI 知识',
|
||||
path: `/pages/dimension/dimension?id=${dimId.value}`
|
||||
}))
|
||||
onShareTimeline(() => ({
|
||||
title: (currentDim.value?.name || 'AI 维度') + '星域 · AI 维度'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -175,107 +154,148 @@ function goDetail(item) {
|
||||
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
.nav-row { padding-bottom: 12px; }
|
||||
.back-btn {
|
||||
color: rgba(255,255,255,0.4);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 0 14px;
|
||||
gap: 4px;
|
||||
padding: 8px 14px 8px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dim-banner {
|
||||
border-radius: 18px;
|
||||
padding: 24px 20px;
|
||||
margin-bottom: 20px;
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.1), rgba(0,0,0,0.3));
|
||||
border: 1px solid rgba(124,77,255,0.12);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dim-banner-icon { font-size: 28px; display: block; margin-bottom: 8px; }
|
||||
.dim-banner-title { font-size: 22px; color: var(--text-primary); display: block; margin-bottom: 6px; }
|
||||
.dim-banner-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 18px 16px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--glass-bg);
|
||||
border: 1.5px solid var(--glass-border);
|
||||
box-shadow: var(--card-shadow, none);
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 3px;
|
||||
background: var(--glass-bg);
|
||||
border-radius: 2px;
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #b388ff, #7c4dff);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.progress-label {
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.2);
|
||||
margin-top: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.knowledge-item {
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
.banner-top {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.k-icon {
|
||||
.dim-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
border: 1px solid var(--glass-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.k-info { flex: 1; }
|
||||
.k-title {
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.85);
|
||||
font-weight: 500;
|
||||
.banner-info { flex: 1; min-width: 0; }
|
||||
.dim-banner-title {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.k-desc {
|
||||
font-size: 11px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
.dim-banner-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
.k-meta {
|
||||
.progress-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.planet-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--card-shadow, none);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.planet-item:active { transform: scale(0.98); }
|
||||
|
||||
.p-icon {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 13px;
|
||||
border: 1.5px dashed var(--glass-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.lit { border-style: solid; }
|
||||
}
|
||||
|
||||
.p-info { flex: 1; min-width: 0; }
|
||||
.p-title {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.p-desc {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.p-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.15);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.k-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
.p-lit {
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.p-quiz {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 50px 0 30px;
|
||||
gap: 8px;
|
||||
|
||||
.empty-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.empty-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
.k-badge.free { background: rgba(76,175,80,0.12); color: #66bb6a; }
|
||||
.k-badge.pro { background: rgba(255,152,0,0.12); color: #ffa726; }
|
||||
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: rgba(255,255,255,0.2);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bottom-spacer { height: 20px; }
|
||||
</style>
|
||||
@@ -19,9 +19,9 @@
|
||||
<!-- 维度标签 -->
|
||||
<view
|
||||
class="dim-tag"
|
||||
:style="{ color: dimColor(item.dimensionId) }"
|
||||
:style="{ color: dimColor(item.dimension) }"
|
||||
>
|
||||
{{ dimName(item.dimensionId) }}
|
||||
{{ dimName(item.dimension) }}
|
||||
</view>
|
||||
<!-- 标题 -->
|
||||
<text class="fav-title">{{ item.title }}</text>
|
||||
@@ -34,9 +34,10 @@
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view class="empty-state" v-else>
|
||||
<text class="empty-icon">⭐</text>
|
||||
<text class="empty-text">你还没有收藏任何内容</text>
|
||||
<text class="empty-sub">去首页发现有趣的 AI 知识吧</text>
|
||||
<text class="empty-icon">还没有收藏</text>
|
||||
<text class="empty-text" v-if="userStore.isLoggedIn">你还没有收藏任何内容</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">
|
||||
<text>开始探索</text>
|
||||
</view>
|
||||
@@ -45,6 +46,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { knowledgeApi } from '@/utils/api'
|
||||
import { getDimension } from '@/utils/dimensions'
|
||||
@@ -56,8 +59,8 @@ const loading = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
// 未登录时显示空状态提示,不强制跳转登录
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loadFavorites()
|
||||
@@ -112,6 +115,14 @@ async function removeFav(id, idx) {
|
||||
function goHome() {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 - 你的AI知识助手',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -101,6 +101,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { feedbackApi } from '@/utils/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@@ -110,7 +112,7 @@ const userStore = useUserStore()
|
||||
// 反馈类型
|
||||
const feedbackTypes = [
|
||||
{ value: 'bug', name: '问题报告', icon: '🐛' },
|
||||
{ value: 'feature', name: '功能建议', icon: '💡' },
|
||||
{ value: 'feature', name: '功能建议', icon: '★' },
|
||||
{ value: 'content', name: '内容纠错', icon: '📝' },
|
||||
{ value: 'other', name: '其他', icon: '💬' }
|
||||
]
|
||||
@@ -145,7 +147,7 @@ function statusLabel(status) {
|
||||
function typeLabel(type) {
|
||||
const map = {
|
||||
bug: '🐛 问题报告',
|
||||
feature: '💡 功能建议',
|
||||
feature: '功能建议',
|
||||
content: '📝 内容纠错',
|
||||
other: '💬 其他'
|
||||
}
|
||||
@@ -226,6 +228,14 @@ onMounted(() => {
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: '帮AI维度变得更好 · 意见反馈',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,75 +1,92 @@
|
||||
<template>
|
||||
<view class="page-home page-layout">
|
||||
<!-- 状态栏占位 -->
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<view class="header">
|
||||
<view class="brand">
|
||||
<view class="brand-icon">✦</view>
|
||||
<view class="brand-icon">
|
||||
<AppIcon name="planet-white" :size="20" />
|
||||
</view>
|
||||
<view class="brand-text">
|
||||
<text class="title">AI 维度</text>
|
||||
<text class="subtitle">宇之然 · 探索</text>
|
||||
<text class="subtitle">宇之然 · 知识星图</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="header-actions">
|
||||
<view class="icon-btn" @tap="onNotify">
|
||||
<text>🔔</text>
|
||||
</view>
|
||||
<view class="icon-btn" @tap="goProfile">
|
||||
<text>👤</text>
|
||||
<AppIcon name="user-gray" :size="18" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 问候语 -->
|
||||
<view class="greeting">
|
||||
<text class="greeting-title">探索 </text>
|
||||
<text class="greeting-highlight">人工智能</text>
|
||||
<text class="greeting-desc">从 5 个维度,系统理解 AI 的过去、现在与未来</text>
|
||||
<!-- 连续点亮 streak -->
|
||||
<view class="streak-bar" v-if="streak > 0" @tap="goFirstUnlit">
|
||||
<AppIcon name="flame" :size="16" />
|
||||
<text class="streak-text">已连续点亮 {{ streak }} 天</text>
|
||||
<text class="streak-sub">{{ todayLit ? '今日已点亮,继续保持' : '今日还没点亮,去逛逛' }}</text>
|
||||
<view class="streak-go">
|
||||
<AppIcon name="arrow-white" :size="12" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 继续探索 / 今日推荐(二合一:有上次阅读显示继续,没有则推荐今日一条) -->
|
||||
<view class="resume-card" v-if="lastRead || todayKnowledge" @tap="resumeRead">
|
||||
<view class="resume-icon">
|
||||
<AppIcon :name="lastRead ? 'clock' : 'book'" :size="18" />
|
||||
</view>
|
||||
<view class="resume-info">
|
||||
<text class="resume-label">{{ lastRead ? '继续探索' : '今日推荐' }}</text>
|
||||
<text class="resume-title">{{ (lastRead || todayKnowledge)?.title }}</text>
|
||||
</view>
|
||||
<AppIcon name="arrow-gray" :size="14" />
|
||||
</view>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<view class="search-bar" @tap="onSearch">
|
||||
<text class="search-icon">🔍</text>
|
||||
<AppIcon name="search-gray" :size="16" />
|
||||
<text class="search-placeholder">搜搜你想了解的 AI 知识...</text>
|
||||
</view>
|
||||
|
||||
<!-- 五维度网格 -->
|
||||
<!-- 五大星域 -->
|
||||
<view class="section-header">
|
||||
<text class="section-title">✦ 五个维度</text>
|
||||
<text class="section-title">五大星域</text>
|
||||
<text class="section-more" v-if="userStats">{{ userStats.totalLit }}/{{ statsTotal }} 颗已点亮</text>
|
||||
</view>
|
||||
<view class="dim-grid">
|
||||
<view
|
||||
v-for="dim in dimensions"
|
||||
:key="dim.id"
|
||||
class="dim-card"
|
||||
:class="{ 'dim-full': dim.isFull }"
|
||||
:style="{ background: dim.bg }"
|
||||
@tap="goDimension(dim)"
|
||||
>
|
||||
<view class="dim-icon" :style="{ background: dim.iconBg, color: dim.color }">
|
||||
<text>{{ dim.icon }}</text>
|
||||
<view class="dim-top">
|
||||
<view class="dim-icon" :style="{ background: dim.iconBg }">
|
||||
<AppIcon :name="dim.icon" :size="18" />
|
||||
</view>
|
||||
<ProgressRing
|
||||
:size="34"
|
||||
:num-size="9"
|
||||
:percent="dimPercent(dim.id)"
|
||||
:color="dim.color"
|
||||
/>
|
||||
</view>
|
||||
<text class="dim-name">{{ dim.name }}</text>
|
||||
<text class="dim-desc">{{ dim.tagline }}</text>
|
||||
<view class="dim-foot">
|
||||
<view class="dim-lit" :style="{ background: dim.bg, color: dim.color }">
|
||||
{{ dimLitText(dim.id) }}
|
||||
</view>
|
||||
<text class="dim-count">{{ dimCountText(dim.id) }}</text>
|
||||
</view>
|
||||
<text class="dim-name" :style="{ color: dim.color }">{{ dim.name }}</text>
|
||||
<text class="dim-desc">{{ dim.desc }}</text>
|
||||
<text class="dim-count">
|
||||
{{ dim.isFull ? '今日已更新 8 条' : `${dim.articleCount} 篇 · ${dim.quizCount} 个问答` }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 今日快讯 -->
|
||||
<view class="section-header">
|
||||
<text class="section-title">📡 今日快讯</text>
|
||||
<text class="section-more" @tap="goTrend">查看全部 →</text>
|
||||
<text class="section-title">今日快讯</text>
|
||||
<text class="section-more" @tap="goTrend">查看全部</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(item, idx) in newsList"
|
||||
:key="idx"
|
||||
class="news-card"
|
||||
@tap="goNewsDetail(item)"
|
||||
>
|
||||
<view v-for="(item, idx) in newsList" :key="idx" class="news-card" @tap="goNewsDetail(item)">
|
||||
<view class="news-tag" :class="item.tagClass">{{ item.tag }}</view>
|
||||
<text class="news-title">{{ item.title }}</text>
|
||||
<view class="news-meta">
|
||||
@@ -83,24 +100,80 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onPullDownRefresh, onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { ref, computed } from 'vue'
|
||||
import { DIMENSIONS } from '@/utils/dimensions'
|
||||
import { trendApi } from '@/utils/api'
|
||||
import { knowledgeApi, trendApi } from '@/utils/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ProgressRing from '@/components/ProgressRing.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const dimensions = ref(DIMENSIONS)
|
||||
|
||||
const newsList = ref([])
|
||||
const loading = ref(true)
|
||||
const byDim = ref({}) // 各星域知识总数
|
||||
const todayKnowledge = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
fetchNews()
|
||||
const todayDimName = computed(() => {
|
||||
const d = DIMENSIONS.find(x => x.id === todayKnowledge.value?.dim)
|
||||
return d ? d.name : ''
|
||||
})
|
||||
|
||||
async function fetchNews() {
|
||||
function goTodayKnowledge() {
|
||||
if (todayKnowledge.value?._id) {
|
||||
uni.navigateTo({ url: `/pages/detail/detail?id=${todayKnowledge.value._id}` })
|
||||
}
|
||||
}
|
||||
const userStats = ref(null) // { totalLit, litByDim, streak }
|
||||
const lastRead = ref(null)
|
||||
|
||||
const TAG_HOT = '热门'
|
||||
const TAG_PICK = '精选'
|
||||
const TAG_NEW = '最新'
|
||||
|
||||
const statsTotal = computed(() => Object.values(byDim.value).reduce((a, b) => a + b, 0))
|
||||
const streak = computed(() => userStats.value?.streak || 0)
|
||||
const todayLit = computed(() => !!uni.getStorageSync('lit_today'))
|
||||
|
||||
onShow(() => {
|
||||
loadStats()
|
||||
loadNews()
|
||||
loadTodayKnowledge()
|
||||
lastRead.value = uni.getStorageSync('last_read') || null
|
||||
})
|
||||
|
||||
// 今日知识卡:按日期取当日第 N 条(同一天所有人看到同一条,形成"每日一词"共识感)
|
||||
async function loadTodayKnowledge() {
|
||||
try {
|
||||
const data = await trendApi.list({ limit: 5 })
|
||||
newsList.value = data.list.map(item => ({
|
||||
tag: item.hot ? '🔥 热门' : item.picked ? '📌 精选' : '🚀 最新',
|
||||
const data = await knowledgeApi.list({ limit: 50, page: 1 })
|
||||
const items = (data.items || []).filter((x) => x.content)
|
||||
if (!items.length) return
|
||||
const dayIndex = Math.floor(Date.now() / 86400000)
|
||||
todayKnowledge.value = items[dayIndex % items.length]
|
||||
} catch (e) { /* 静默 */ }
|
||||
}
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
await Promise.all([loadStats(), loadNews()])
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const data = await knowledgeApi.stats(userStore.isLoggedIn ? userStore.token : '')
|
||||
byDim.value = data.byDim || {}
|
||||
userStats.value = data.user || null
|
||||
} catch (e) {
|
||||
// 静默降级:环形显示 0%
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNews() {
|
||||
try {
|
||||
const data = await trendApi.list({ limit: 3 })
|
||||
newsList.value = (data.list || []).slice(0, 3).map(item => ({
|
||||
tag: item.hot ? TAG_HOT : item.picked ? TAG_PICK : TAG_NEW,
|
||||
tagClass: item.hot ? 'hot' : item.picked ? 'pick' : 'new',
|
||||
title: item.title,
|
||||
source: item.source,
|
||||
@@ -108,17 +181,27 @@ async function fetchNews() {
|
||||
_id: item._id
|
||||
}))
|
||||
} catch (e) {
|
||||
// 降级:本地数据
|
||||
newsList.value = [
|
||||
{ tag: '🔥 热门', tagClass: 'hot', title: 'DeepSeek 被曝自研推理芯片', source: '量子位', time: '2 小时前' },
|
||||
{ tag: '🚀 最新', tagClass: 'new', title: '蚂蚁灵波开源 LingBot-World 2.0', source: '量子位', time: '4 小时前' },
|
||||
{ tag: '📌 精选', tagClass: 'pick', title: '阿里获 ACL 2026 最佳资源论文奖', source: '机器之心', time: '6 小时前' }
|
||||
]
|
||||
} finally {
|
||||
loading.value = false
|
||||
newsList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function dimPercent(dimId) {
|
||||
const total = byDim.value[dimId] || 0
|
||||
if (!total) return 0
|
||||
const lit = userStats.value?.litByDim?.[dimId] || 0
|
||||
return Math.round((lit / total) * 100)
|
||||
}
|
||||
|
||||
function dimLitText(dimId) {
|
||||
const lit = userStats.value?.litByDim?.[dimId] || 0
|
||||
return `${lit} 颗已点亮`
|
||||
}
|
||||
|
||||
function dimCountText(dimId) {
|
||||
const total = byDim.value[dimId] || 0
|
||||
return `共 ${total} 颗星球`
|
||||
}
|
||||
|
||||
function formatTime(dateStr) {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
@@ -131,6 +214,18 @@ function goDimension(dim) {
|
||||
uni.navigateTo({ url: `/pages/dimension/dimension?id=${dim.id}&name=${dim.name}` })
|
||||
}
|
||||
|
||||
function goFirstUnlit() {
|
||||
uni.navigateTo({ url: '/pages/dimension/dimension?id=1' })
|
||||
}
|
||||
|
||||
function resumeRead() {
|
||||
if (lastRead.value?.id) {
|
||||
uni.navigateTo({ url: `/pages/detail/detail?id=${lastRead.value.id}` })
|
||||
} else if (todayKnowledge.value?.id || todayKnowledge.value?._id) {
|
||||
uni.navigateTo({ url: `/pages/detail/detail?id=${todayKnowledge.value.id || todayKnowledge.value._id}` })
|
||||
}
|
||||
}
|
||||
|
||||
function goTrend() {
|
||||
uni.switchTab({ url: '/pages/trend/trend' })
|
||||
}
|
||||
@@ -143,14 +238,20 @@ function onSearch() {
|
||||
uni.navigateTo({ url: '/pages/search/search' })
|
||||
}
|
||||
|
||||
function onNotify() {
|
||||
uni.showToast({ title: '暂无新消息', icon: 'none' })
|
||||
}
|
||||
|
||||
function goNewsDetail(item) {
|
||||
uni.setStorageSync('__news_item__', JSON.stringify(item))
|
||||
uni.navigateTo({ url: '/pages/news/news' })
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 · 5 分钟点亮一颗 AI 知识星球',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
onShareTimeline(() => ({
|
||||
title: 'AI 维度 · 5 分钟点亮一颗 AI 知识星球'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -160,15 +261,13 @@ function goNewsDetail(item) {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.status-bar-placeholder {
|
||||
height: 44px;
|
||||
}
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0 20px;
|
||||
padding: 6px 0 18px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -178,38 +277,32 @@ function goNewsDetail(item) {
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(0,188,212,0.2));
|
||||
border: 1px solid rgba(124,77,255,0.2);
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--color-brand), #8b5cf6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 0 20px rgba(124,77,255,0.1);
|
||||
box-shadow: 0 4px 14px var(--color-brand-glow);
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.brand-text { display: flex; flex-direction: column; }
|
||||
.brand-text .title {
|
||||
font-size: 20px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.brand-text .subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 1.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
.icon-btn {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50%;
|
||||
@@ -218,167 +311,188 @@ function goNewsDetail(item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
margin-bottom: 20px;
|
||||
padding: 24px 20px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, rgba(99,102,241,0.08), rgba(139,92,246,0.04));
|
||||
border: 1px solid rgba(99,102,241,0.1);
|
||||
/* streak */
|
||||
.streak-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, rgba(229, 28, 28, 0.08), rgba(223, 119, 0, 0.06));
|
||||
border: 1px solid rgba(229, 28, 28, 0.15);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.greeting-title {
|
||||
font-size: 26px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.greeting-highlight {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #b388ff, #7c4dff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.greeting-desc {
|
||||
display: block;
|
||||
.streak-text {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--dim5-color);
|
||||
}
|
||||
.streak-sub {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.streak-go {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--dim5-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
/* 继续探索 */
|
||||
.resume-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
padding: 12px 16px;
|
||||
box-shadow: var(--card-shadow, none);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.resume-icon {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-brand-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.resume-info { flex: 1; min-width: 0; }
|
||||
.resume-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.resume-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.search-icon { color: var(--text-muted); }
|
||||
.search-placeholder { color: var(--text-muted); font-size: 14px; }
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(99,102,241,0.04);
|
||||
}
|
||||
.section-title {
|
||||
font-size: 17px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.section-more {
|
||||
font-size: 12px;
|
||||
.search-placeholder {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 星域网格 */
|
||||
.dim-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.dim-card {
|
||||
border-radius: 18px;
|
||||
padding: 20px 16px;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
.dim-full {
|
||||
grid-column: 1 / -1;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px;
|
||||
box-shadow: var(--card-shadow, none);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.dim-card:active { transform: scale(0.97); }
|
||||
|
||||
.dim-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.dim-icon {
|
||||
width: 42px; height: 42px;
|
||||
border-radius: 12px;
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.dim-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.dim-desc {
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.dim-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.dim-lit {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.dim-count {
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 快讯 */
|
||||
.news-card {
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: var(--card-shadow, none);
|
||||
}
|
||||
|
||||
.news-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.news-tag.hot {
|
||||
background: rgba(239,83,80,0.12);
|
||||
color: #ef5350;
|
||||
}
|
||||
.news-tag.new {
|
||||
background: rgba(0,188,212,0.12);
|
||||
color: #4dd0e1;
|
||||
}
|
||||
.news-tag.pick {
|
||||
background: rgba(124,77,255,0.12);
|
||||
color: #b388ff;
|
||||
}
|
||||
|
||||
.news-tag.hot { background: var(--color-hot-bg); color: var(--color-hot); }
|
||||
.news-tag.new { background: var(--color-new-bg); color: var(--color-new); }
|
||||
.news-tag.pick { background: var(--color-pick-bg); color: var(--color-pick); }
|
||||
.news-title {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.news-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.bottom-spacer {
|
||||
height: 40px;
|
||||
}
|
||||
.bottom-spacer { height: 30px; }
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -37,20 +37,20 @@
|
||||
<!-- 头像 -->
|
||||
<view class="avatar">
|
||||
<image
|
||||
v-if="item.avatarUrl"
|
||||
:src="item.avatarUrl"
|
||||
v-if="item.avatar"
|
||||
:src="item.avatar"
|
||||
class="avatar-img"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<text v-else class="avatar-text">
|
||||
{{ (item.nickName || item.username || '用').charAt(0) }}
|
||||
{{ (item.nickname || item.username || '用').charAt(0) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 信息 -->
|
||||
<view class="user-info">
|
||||
<text class="user-name">{{ item.nickName || item.username || '用户' }}</text>
|
||||
<text class="user-score">{{ item.score || 0 }} 分</text>
|
||||
<text class="user-name">{{ item.nickname || item.username || '用户' }}</text>
|
||||
<text class="user-score">{{ item.totalScore || 0 }} 分</text>
|
||||
</view>
|
||||
|
||||
<!-- 已解锁维度 -->
|
||||
@@ -81,6 +81,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { userApi } from '@/utils/api'
|
||||
import { getDimension } from '@/utils/dimensions'
|
||||
@@ -102,8 +104,7 @@ const dimTabs = [
|
||||
|
||||
onMounted(() => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loadBoard()
|
||||
@@ -116,10 +117,10 @@ async function loadBoard() {
|
||||
const data = await userApi.leaderboard(dimParam)
|
||||
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) => ({
|
||||
...item,
|
||||
isCurrentUser: String(item.userId || item.id || '') === String(myId)
|
||||
isCurrentUser: item.openid === myId
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('[leaderboard] load failed:', e)
|
||||
@@ -133,6 +134,14 @@ function dimDotColor(id) {
|
||||
const dim = getDimension(id)
|
||||
return dim ? dim.color : 'rgba(255,255,255,0.2)'
|
||||
}
|
||||
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI学习排行榜 · 宇之然AI维度',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
<view class="bg-orb orb-2"></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="logo-wrap">
|
||||
<view class="logo-ring"></view>
|
||||
@@ -28,13 +36,18 @@
|
||||
<input class="form-input" v-model="loginForm.password" type="password" placeholder="请输入密码" />
|
||||
</view>
|
||||
|
||||
<!-- 忘记密码 -->
|
||||
<view class="forgot-row">
|
||||
<text class="forgot-link" @click="goForgotPassword">忘记密码?</text>
|
||||
</view>
|
||||
|
||||
<!-- 登录协议勾选 -->
|
||||
<view class="agreement-row">
|
||||
<view class="agreement-checkbox" :class="{ checked: loginAgreed }" @click="loginAgreed = !loginAgreed">
|
||||
<text v-if="loginAgreed">✓</text>
|
||||
</view>
|
||||
<text class="agreement-text">
|
||||
登录即表示同意
|
||||
我已阅读并同意
|
||||
<text class="agreement-link" @click.stop="openAgreement('terms')">《用户协议》</text>
|
||||
和
|
||||
<text class="agreement-link" @click.stop="openAgreement('privacy')">《隐私政策》</text>
|
||||
@@ -45,12 +58,14 @@
|
||||
<text>{{ loading ? '登录中...' : '登录' }}</text>
|
||||
</view>
|
||||
<!-- 微信小程序一键登录 -->
|
||||
<view class="wx-login-row" v-if="isMiniApp">
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<view class="wx-login-row">
|
||||
<view class="wx-login-btn" @click="wechatLogin">
|
||||
<text class="wx-icon">💬</text>
|
||||
<text>微信一键登录</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
|
||||
<!-- 注册表单 -->
|
||||
@@ -59,6 +74,10 @@
|
||||
<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" />
|
||||
</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">
|
||||
<text class="form-label">昵称 <text style="color:var(--text-muted);font-size:11px;">(选填)</text></text>
|
||||
<input class="form-input" v-model="registerForm.nickName" placeholder="显示名称,留空自动生成" />
|
||||
@@ -100,22 +119,22 @@
|
||||
<view class="footer-info">
|
||||
<text>© 2026 宇之然 · AI 维度</text>
|
||||
</view>
|
||||
|
||||
<!-- 显著有效的「拒绝/暂不登录」入口,避免强制登录 -->
|
||||
<view class="skip-login" @click="goBrowse">
|
||||
<text>暂不登录,先体验</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { askSubscribe } from '../../utils/notify'
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useUserStore } from '@/stores/index'
|
||||
|
||||
const isMiniApp = ref(false)
|
||||
try {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
if (sysInfo && sysInfo.version && sysInfo.platform) {
|
||||
isMiniApp.value = true
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const activeTab = ref('login')
|
||||
const loading = ref(false)
|
||||
const agreed = ref(false)
|
||||
@@ -127,7 +146,7 @@ let countdownTimer = null
|
||||
const userStore = useUserStore()
|
||||
|
||||
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'
|
||||
|
||||
@@ -189,9 +208,11 @@ const wechatLogin = async () => {
|
||||
loading.value = true
|
||||
await userStore.login(loginRes.code, {})
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => askSubscribe(), 800) // 登录成功后请求订阅授权(接受新版通知等)
|
||||
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
|
||||
} catch (err) {
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
const msg = (err && (err.message || err.errMsg)) || ''
|
||||
uni.showToast({ title: msg ? `登录失败:${msg}` : '登录失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -216,6 +237,7 @@ const handleLogin = async () => {
|
||||
: { username: identifier, password: loginForm.value.password }
|
||||
await userStore.h5Login(loginData)
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => askSubscribe(), 800) // 登录成功后请求订阅授权(接受新版通知等)
|
||||
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '登录失败', icon: 'none' })
|
||||
@@ -241,6 +263,10 @@ const handleRegister = async () => {
|
||||
uni.showToast({ title: '请输入密码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!registerForm.value.username) {
|
||||
uni.showToast({ title: '请输入用户名', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (registerForm.value.password.length < 6) {
|
||||
uni.showToast({ title: '密码至少 6 位', icon: 'none' })
|
||||
return
|
||||
@@ -255,7 +281,8 @@ const handleRegister = async () => {
|
||||
registerForm.value.email,
|
||||
registerForm.value.password,
|
||||
registerForm.value.nickName || '',
|
||||
registerForm.value.verifyCode
|
||||
registerForm.value.verifyCode,
|
||||
registerForm.value.username
|
||||
)
|
||||
uni.showToast({ title: '注册成功', icon: 'success' })
|
||||
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
|
||||
@@ -273,6 +300,27 @@ function openAgreement(type) {
|
||||
}
|
||||
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(() => ({
|
||||
title: '登录解锁AI学习完整功能 · 宇之然AI维度',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -297,6 +345,19 @@ function openAgreement(type) {
|
||||
.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); }
|
||||
|
||||
// 顶部导航栏
|
||||
.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 {
|
||||
position: relative; z-index: 1;
|
||||
width: 100%; max-width: 360px;
|
||||
@@ -348,6 +409,15 @@ function openAgreement(type) {
|
||||
}
|
||||
}
|
||||
|
||||
// 忘记密码
|
||||
.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 {
|
||||
display: flex; gap: 10px;
|
||||
@@ -411,5 +481,20 @@ function openAgreement(type) {
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -11,11 +11,6 @@
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="content">
|
||||
<!-- 资讯头图 -->
|
||||
<view class="news-hero" :style="{background: heroBg}">
|
||||
<text class="hero-emoji">{{ heroEmoji }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 分类标签 -->
|
||||
<view class="category-tag">
|
||||
<text class="tag-text">{{ categoryLabel }}</text>
|
||||
@@ -39,7 +34,7 @@
|
||||
<text class="meta-value">{{ news.viewCount || 0 }} 次</text>
|
||||
</view>
|
||||
<view class="meta-item" v-if="news.hot">
|
||||
<text class="meta-label hot">🔥</text>
|
||||
<text class="meta-label hot">热</text>
|
||||
<text class="meta-value">{{ news.hotCount || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -50,6 +45,14 @@
|
||||
<text class="summary-text">{{ news.summary }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 人话解读 -->
|
||||
<view class="summary-card plain" v-if="news.plainExplain">
|
||||
<text class="summary-label">💡 一句话说清</text>
|
||||
<text class="summary-text">{{ news.plainExplain }}</text>
|
||||
<text class="summary-label" style="margin-top: 12px" v-if="news.takeAway">🎯 对你意味着</text>
|
||||
<text class="summary-text" v-if="news.takeAway">{{ news.takeAway }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 正文 -->
|
||||
<view class="news-body" v-if="newsBody">
|
||||
<rich-text :nodes="newsBody"></rich-text>
|
||||
@@ -90,6 +93,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
const userStore = useUserStore()
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { trendApi, knowledgeApi } from '@/utils/api'
|
||||
|
||||
@@ -108,7 +115,8 @@ const heroBg = computed(() => {
|
||||
})
|
||||
|
||||
const heroEmoji = computed(() => {
|
||||
return { product: '🚀', paper: '📄', tool: '🔧', company: '🏢' }[news.value.category || 'product'] || '📰'
|
||||
const map = { product: '产品', paper: '论文', tool: '工具', company: '公司' }
|
||||
return map[news.value.category || 'product'] || '资讯'
|
||||
})
|
||||
|
||||
const categoryLabel = computed(() => {
|
||||
@@ -140,9 +148,14 @@ const relatedTags = computed(() => {
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
const id = uni.getStorageSync('__news_id__')
|
||||
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) {
|
||||
try {
|
||||
const data = await trendApi.detail(id)
|
||||
@@ -150,17 +163,11 @@ async function loadDetail() {
|
||||
isCollected.value = data.collected || false
|
||||
return
|
||||
} catch (e) {
|
||||
// fall through to local
|
||||
// 回退到本地缓存
|
||||
}
|
||||
}
|
||||
// Fallback: read from trend page's stored item
|
||||
try {
|
||||
const raw = uni.getStorageSync('__news_item__')
|
||||
if (raw) {
|
||||
news.value = JSON.parse(raw)
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
if (item) {
|
||||
news.value = item
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,16 +175,37 @@ function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function onFavorite() {
|
||||
async function onFavorite() {
|
||||
// 静默登录后提示(收藏态由详情页统一管理)
|
||||
const ok = await userStore.ensureLogin()
|
||||
if (!ok) {
|
||||
uni.showModal({
|
||||
title: '登录后收藏',
|
||||
content: '登录后即可收藏资讯',
|
||||
confirmText: '去登录',
|
||||
cancelText: '暂不',
|
||||
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/login' }) }
|
||||
})
|
||||
return
|
||||
}
|
||||
isCollected.value = !isCollected.value
|
||||
uni.showToast({ title: isCollected.value ? '已收藏' : '已取消收藏', icon: 'none' })
|
||||
}
|
||||
|
||||
function onShare() {
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.showModal({
|
||||
title: '分享给朋友',
|
||||
content: '点击右上角「···」→ 转发给朋友或分享到朋友圈',
|
||||
showCancel: false,
|
||||
})
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
uni.setClipboardData({
|
||||
data: news.value.link || `https://ai-dimension.yuzhiran.com.cn/news/${uni.getStorageSync('__news_id__')}`,
|
||||
data: news.value.sourceUrl || `https://wdkj.yuzhiran.com.cn/trends/${news.value._id || ''}`,
|
||||
success: () => uni.showToast({ title: '链接已复制', icon: 'none' })
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
function openSource() {
|
||||
@@ -197,6 +225,14 @@ function openSource() {
|
||||
onMounted(() => {
|
||||
loadDetail()
|
||||
})
|
||||
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 - 你的AI知识助手',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -30,12 +30,15 @@
|
||||
<text class="plan-name">Pro</text>
|
||||
<view class="plan-badge" v-if="selectedPlan === 'pro'">推荐</view>
|
||||
</view>
|
||||
<text class="plan-price">¥19.9</text>
|
||||
<text class="plan-unit">/ 月</text>
|
||||
<view class="plan-price-row">
|
||||
<text class="plan-price">¥19.9</text>
|
||||
<text class="plan-unit">/ 月</text>
|
||||
</view>
|
||||
<view class="plan-features">
|
||||
<text class="plan-feature">✓ AI 问答无限次</text>
|
||||
<text class="plan-feature">✓ 全量知识解锁</text>
|
||||
<text class="plan-feature">✓ 收藏无限容量</text>
|
||||
<text class="plan-feature">✓ 趋势优先推送</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
@@ -47,52 +50,15 @@
|
||||
<text class="plan-name">VIP</text>
|
||||
<view class="plan-badge vip-badge" v-if="selectedPlan === 'vip'">尊享</view>
|
||||
</view>
|
||||
<text class="plan-price vip-price">¥39.9</text>
|
||||
<text class="plan-unit">/ 月</text>
|
||||
<view class="plan-price-row">
|
||||
<text class="plan-price vip-price">¥39.9</text>
|
||||
<text class="plan-unit">/ 月</text>
|
||||
</view>
|
||||
<view class="plan-features">
|
||||
<text class="plan-feature">✓ 含 Pro 全部权益</text>
|
||||
<text class="plan-feature">✓ 专属客服支持</text>
|
||||
<text class="plan-feature">✓ 专属会员标识</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 套餐时长 -->
|
||||
<view class="duration-section">
|
||||
<text class="section-title">选择套餐</text>
|
||||
<view class="duration-list">
|
||||
<view
|
||||
class="duration-item"
|
||||
:class="{ active: selectedDuration === item.key }"
|
||||
v-for="item in durationOptions"
|
||||
:key="item.key"
|
||||
@click="selectedDuration = item.key"
|
||||
>
|
||||
<text class="d-label">{{ item.label }}</text>
|
||||
<text class="d-price">¥{{ item.price }}</text>
|
||||
<text class="d-save" v-if="item.save">省 {{ item.save }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 权益清单 -->
|
||||
<view class="benefits-section">
|
||||
<text class="section-title">权益对比</text>
|
||||
<view class="benefits-card">
|
||||
<view
|
||||
class="benefit-row"
|
||||
v-for="(item, idx) in benefitList"
|
||||
:key="idx"
|
||||
>
|
||||
<view class="benefit-left">
|
||||
<text class="benefit-icon">{{ item.icon }}</text>
|
||||
<text class="benefit-text">{{ item.text }}</text>
|
||||
</view>
|
||||
<view class="benefit-check">
|
||||
<text class="check-mark" :class="{ 'pro-only': item.proOnly, 'vip-only': !item.proOnly }">
|
||||
{{ item.proOnly ? 'Pro' : (item.hasPro ? 'Pro+VIP' : 'VIP') }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="plan-feature">✓ 工具教程 + 学习路径</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -107,49 +73,84 @@
|
||||
>
|
||||
<text class="pay-btn-text">立即支付 ¥{{ currentPrice }}</text>
|
||||
</button>
|
||||
<text class="pay-agreement">
|
||||
点击支付即表示同意《用户协议》和《隐私政策》
|
||||
</text>
|
||||
<view class="pay-agreement-row">
|
||||
<text class="pay-agreement-text">
|
||||
会员服务说明:
|
||||
<text class="pay-agreement-link" @click.stop="openAgreement('terms')">《用户协议》</text>
|
||||
·
|
||||
<text class="pay-agreement-link" @click.stop="openAgreement('privacy')">《隐私政策》</text>
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer-spacer"></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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { paymentApi } from '@/utils/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const selectedPlan = ref('pro')
|
||||
const selectedDuration = ref('pro-monthly')
|
||||
const selectedPlan = ref('pro') // 'pro' | 'vip'
|
||||
|
||||
const durationOptions = computed(() => {
|
||||
const isPro = selectedPlan.value === 'pro'
|
||||
return [
|
||||
{ key: isPro ? 'pro-monthly' : 'vip-monthly', label: '月付', price: isPro ? 19.9 : 39.9 },
|
||||
{ key: isPro ? 'pro-quarterly' : 'vip-quarterly', label: '季付', price: isPro ? 49.9 : 99.9, save: isPro ? '¥10' : '¥20' }
|
||||
]
|
||||
})
|
||||
// 方案 → 对应数据库的商品 ID 和价格(单位:元)
|
||||
const PLAN_MAP = {
|
||||
pro: { productId: 'pro_monthly', price: 19.9 },
|
||||
vip: { productId: 'vip_monthly', price: 39.9 }
|
||||
}
|
||||
|
||||
const benefitList = ref([
|
||||
{ icon: '🤖', text: 'AI 问答无限次', proOnly: true, hasPro: true },
|
||||
{ icon: '📚', text: '全量知识内容解锁', proOnly: true, hasPro: true },
|
||||
{ icon: '📡', text: '每日趋势优先推送', proOnly: true, hasPro: true },
|
||||
{ icon: '⭐', text: '收藏夹无限容量', proOnly: true, hasPro: true },
|
||||
{ icon: '💬', text: '专属客服支持', proOnly: false, hasPro: false },
|
||||
{ icon: '🎁', text: '专属会员标识', proOnly: false, hasPro: false }
|
||||
])
|
||||
|
||||
const currentPrice = computed(() => {
|
||||
const item = durationOptions.value.find(d => d.key === selectedDuration.value)
|
||||
return item ? item.price : 19.9
|
||||
})
|
||||
const currentPrice = computed(() => PLAN_MAP[selectedPlan.value].price)
|
||||
const currentProductId = computed(() => PLAN_MAP[selectedPlan.value].productId)
|
||||
|
||||
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 openAgreement(type) {
|
||||
uni.navigateTo({ url: '/pages/agreement/agreement?type=' + (type || 'terms') })
|
||||
}
|
||||
|
||||
async function handlePay() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
@@ -157,14 +158,65 @@ async function handlePay() {
|
||||
return
|
||||
}
|
||||
|
||||
const productId = selectedDuration.value
|
||||
const plan = PLAN_MAP[selectedPlan.value]
|
||||
if (!plan || !plan.productId || plan.price <= 0) {
|
||||
uni.showToast({ title: '请选择有效的套餐', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const productId = plan.productId
|
||||
const amount = plan.price
|
||||
|
||||
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 {
|
||||
const data = await paymentApi.createOrder(productId, userStore.token)
|
||||
const payParam = data.payParams || data.wechatParam || data.paymentData
|
||||
|
||||
if (payParam) {
|
||||
// 校验支付参数金额(分)与所选金额一致
|
||||
// 微信返回的 amount 可能是 number 或 string
|
||||
const payAmountFen = payParam.totalFee || payParam.amount || data.amount
|
||||
const expectedFen = Math.round(amount * 100)
|
||||
if (parseInt(payAmountFen, 10) !== expectedFen) {
|
||||
console.error(`支付金额不匹配: 前端 ¥${amount} / 后端 ${payAmountFen} 分`)
|
||||
uni.showToast({ title: '支付金额异常,请重试', icon: 'none' })
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
uni.requestPayment({
|
||||
provider: 'wxpay',
|
||||
timeStamp: payParam.timeStamp || payParam.timestamp,
|
||||
@@ -173,7 +225,7 @@ async function handlePay() {
|
||||
signType: payParam.signType || 'MD5',
|
||||
paySign: payParam.paySign,
|
||||
success: () => {
|
||||
uni.showToast({ title: '支付成功!', icon: 'success' })
|
||||
uni.showToast({ title: `支付成功!金额 ¥${amount}`, icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
}, 1500)
|
||||
@@ -197,9 +249,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() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI知识社区 · 宇之然AI维度',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -341,7 +436,12 @@ function goBack() {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--dim1-color);
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.plan-price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.plan-card.vip .plan-price {
|
||||
@@ -367,120 +467,6 @@ function goBack() {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// 套餐时长
|
||||
.duration-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.duration-list {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.duration-item {
|
||||
flex: 1;
|
||||
padding: 14px 12px;
|
||||
background: var(--glass-bg);
|
||||
border: 1.5px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
text-align: center;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.active {
|
||||
border-color: var(--dim1-color);
|
||||
background: linear-gradient(135deg, rgba(99,102,241,0.1), rgba(99,102,241,0.05));
|
||||
}
|
||||
}
|
||||
|
||||
.d-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.d-price {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.d-save {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #4db6ac;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
// 权益对比
|
||||
.benefits-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.benefits-card {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.benefit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
}
|
||||
|
||||
.benefit-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.benefit-icon {
|
||||
font-size: 16px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.benefit-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.benefit-check {
|
||||
.check-mark {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 8px;
|
||||
|
||||
&.pro-only {
|
||||
background: rgba(99,102,241,0.1);
|
||||
color: var(--dim1-color);
|
||||
}
|
||||
|
||||
&.vip-only {
|
||||
background: rgba(255,167,38,0.1);
|
||||
color: #ffa726;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 支付
|
||||
.pay-section {
|
||||
margin-bottom: 24px;
|
||||
@@ -517,4 +503,91 @@ function goBack() {
|
||||
}
|
||||
|
||||
.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 scoped>
|
||||
.pay-agreement-row { display: flex; align-items: center; justify-content: center; gap: 6px; margin-top: 10px; }
|
||||
.pay-agreement-text { font-size: 11px; color: var(--text-tertiary); }
|
||||
.pay-agreement-link { color: var(--color-brand); }
|
||||
</style>
|
||||
@@ -17,7 +17,7 @@
|
||||
</view>
|
||||
<view class="stats-row">
|
||||
<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>
|
||||
</view>
|
||||
<view class="stat-divider"></view>
|
||||
@@ -122,6 +122,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { knowledgeApi, userApi } from '@/utils/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@@ -134,7 +136,7 @@ const loading = ref(true)
|
||||
|
||||
// 学习时长(小时)
|
||||
const studyHours = computed(() => {
|
||||
const hours = stats.value?.studyHours || 0
|
||||
const hours = stats.value?.totalPlayTime || 0
|
||||
if (hours < 1) return `${Math.round(hours * 60)}分`
|
||||
return hours.toFixed(1) + 'h'
|
||||
})
|
||||
@@ -156,7 +158,7 @@ function initDimData() {
|
||||
color: dim.color,
|
||||
bg: dim.bg,
|
||||
total: 0,
|
||||
done: exploreData[dim.id] || 0,
|
||||
done: exploreData['dim' + dim.id]?.completed ? 1 : 0,
|
||||
percent: 0,
|
||||
expanded: false
|
||||
}))
|
||||
@@ -167,11 +169,7 @@ async function fetchDimTotals() {
|
||||
for (const dim of dimProgress.value) {
|
||||
try {
|
||||
const data = await knowledgeApi.list({ dim: dim.id, limit: 1 })
|
||||
dim.total = data?.total || data?.length || (Array.isArray(data) ? data.length : 0)
|
||||
// 如果 list 返回的是数组而不是带 total 的对象,直接用数组长度
|
||||
if (Array.isArray(data) && dim.total === 0) {
|
||||
dim.total = data.length
|
||||
}
|
||||
dim.total = data?.pagination?.total || data?.items?.length || 0
|
||||
// 计算百分比
|
||||
if (dim.total > 0) {
|
||||
dim.percent = Math.round((dim.done / dim.total) * 100)
|
||||
@@ -200,10 +198,10 @@ async function loadData() {
|
||||
userApi.progress(userStore.token).catch(() => ({ exploreData: {} }))
|
||||
])
|
||||
|
||||
// 合并 exploreData
|
||||
// 合并 exploreData(进度接口返回 {isNewUser, data: user},exploreData 在 data 下)
|
||||
stats.value = {
|
||||
...statsData,
|
||||
exploreData: { ...statsData?.exploreData, ...progressData?.exploreData }
|
||||
exploreData: progressData?.data?.exploreData || {}
|
||||
}
|
||||
|
||||
dimProgress.value = initDimData()
|
||||
@@ -223,6 +221,14 @@ onMounted(() => {
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: '我的AI学习进度 · 宇之然AI维度',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
@confirm="doSearch"
|
||||
confirm-type="search"
|
||||
/>
|
||||
<view class="search-icon">🔍</view>
|
||||
<view class="search-icon"></view>
|
||||
<view v-if="searchText" class="clear-btn" @click="clearSearch">✕</view>
|
||||
</view>
|
||||
|
||||
@@ -31,11 +31,13 @@
|
||||
<text class="loading-text">搜索中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 空搜索提示 -->
|
||||
<!-- 未搜索:热门词快捷入口 -->
|
||||
<view class="empty-state" v-else-if="!hasSearched">
|
||||
<text class="empty-icon">🔍</text>
|
||||
<text class="empty-text">输入关键词搜索 AI 知识</text>
|
||||
<text class="empty-hint">支持按标题、摘要、标签搜索</text>
|
||||
<view class="hot-words">
|
||||
<text v-for="w in HOT_WORDS" :key="w" class="hot-word" @click="quickSearch(w)">{{ w }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 无结果提示 -->
|
||||
@@ -64,8 +66,8 @@
|
||||
<text class="result-title">{{ item.title }}</text>
|
||||
|
||||
<!-- 摘要 -->
|
||||
<text class="result-summary" v-if="item.summary">
|
||||
{{ item.summary }}
|
||||
<text class="result-summary" v-if="item.content">
|
||||
{{ item.content }}
|
||||
</text>
|
||||
|
||||
<!-- 标签与分类 -->
|
||||
@@ -85,11 +87,18 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { knowledgeApi } from '@/utils/api'
|
||||
import { DIMENSIONS } from '@/utils/dimensions'
|
||||
|
||||
const searchText = ref('')
|
||||
const HOT_WORDS = ['大模型', 'Transformer', 'DeepSeek', '提示词', 'AI绘画', '智能体', 'RAG', '具身智能']
|
||||
function quickSearch(w) {
|
||||
searchText.value = w
|
||||
doSearch()
|
||||
}
|
||||
const results = ref([])
|
||||
const loading = ref(false)
|
||||
const hasSearched = ref(false)
|
||||
@@ -186,6 +195,14 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
})
|
||||
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: '搜一搜想了解的AI知识 · 宇之然AI维度',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -385,4 +402,20 @@ onUnmounted(() => {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hot-words {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.hot-word {
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
background: var(--glass-bg, #f3f4fa);
|
||||
color: var(--text-secondary, #4a4f63);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,17 @@
|
||||
<template>
|
||||
<view class="page-settings page-layout">
|
||||
<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="card">
|
||||
<view class="setting-item" @click="togglePush">
|
||||
<view class="s-icon" style="background:rgba(255,213,79,0.1);color:#ffd54f">🔔</view>
|
||||
<view class="s-icon" style="background:rgba(91,95,248,0.1)">铃</view>
|
||||
<view class="s-text">
|
||||
<text class="s-label">推送通知</text>
|
||||
<text class="s-desc">接收每日 AI 趋势推送</text>
|
||||
@@ -16,6 +20,7 @@
|
||||
<view class="s-switch-knob" :class="{ on: pushOn }"></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #ifdef H5 -->
|
||||
<view class="setting-item" @click="toggleDarkMode">
|
||||
<view class="s-icon" style="background:rgba(77,208,225,0.1);color:#4dd0e1">🌙</view>
|
||||
<view class="s-text">
|
||||
@@ -26,6 +31,17 @@
|
||||
<view class="s-switch-knob" :class="{ on: darkMode }"></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<view class="setting-item" @click="showDarkTip">
|
||||
<view class="s-icon" style="background:rgba(77,208,225,0.1);color:#4dd0e1">🌙</view>
|
||||
<view class="s-text">
|
||||
<text class="s-label">深色模式</text>
|
||||
<text class="s-desc">跟随系统设置自动切换</text>
|
||||
</view>
|
||||
<text class="s-arrow">›</text>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
|
||||
<!-- 其他 -->
|
||||
@@ -55,11 +71,14 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="version">宇之然 AI 维度 v1.0.0</text>
|
||||
<text class="version">宇之然 AI 维度 v{{ appVersion }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { APP_VERSION as appVersion } from '@/utils/version'
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const pushOn = ref(true)
|
||||
@@ -78,6 +97,14 @@ function togglePush() {
|
||||
})
|
||||
}
|
||||
|
||||
function showDarkTip() {
|
||||
uni.showModal({
|
||||
title: '深色模式',
|
||||
content: '小程序深色模式跟随系统:在手机的 系统设置 → 显示与亮度 中切换深色/浅色,小程序将自动适配。',
|
||||
showCancel: false,
|
||||
})
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
darkMode.value = !darkMode.value
|
||||
const theme = darkMode.value ? 'dark' : 'light'
|
||||
@@ -117,7 +144,7 @@ function applyTabBarTheme(theme) {
|
||||
function showAbout() {
|
||||
uni.showModal({
|
||||
title: '关于「宇之然AI维度」',
|
||||
content: '宇之然 AI 维度 v1.0.0\n从 5 个维度系统理解人工智能\n© 2026 宇之然',
|
||||
content: `宇之然AI维度 v${appVersion}\n从 5 个维度系统理解人工智能\n© 2026 宇之然`,
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
@@ -131,12 +158,20 @@ function showContact() {
|
||||
}
|
||||
|
||||
function showAgreement() {
|
||||
uni.showModal({
|
||||
title: '用户协议 / 隐私政策',
|
||||
content: '暂无内容',
|
||||
showCancel: false
|
||||
})
|
||||
uni.navigateTo({ url: '/pages/agreement/agreement?type=terms' })
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI知识社区 · 宇之然AI维度',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -146,6 +181,24 @@ function showAgreement() {
|
||||
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; }
|
||||
|
||||
.page-title {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="page-trend page-layout">
|
||||
<view class="page-trend">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<!-- 顶部 -->
|
||||
@@ -8,7 +8,7 @@
|
||||
<text class="header-desc">每天精选 AI 行业最新动态</text>
|
||||
</view>
|
||||
|
||||
<!-- 分类筛选 -->
|
||||
<!-- 分类筛选:紧凑胶囊单行 -->
|
||||
<view class="filter-bar">
|
||||
<view
|
||||
class="filter-item"
|
||||
@@ -17,7 +17,8 @@
|
||||
:key="idx"
|
||||
@click="activeFilter = idx"
|
||||
>
|
||||
<text>{{ tag }}</text>
|
||||
<AppIcon :name="tag.icon" :size="12" />
|
||||
<text class="filter-label">{{ tag.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -40,11 +41,23 @@
|
||||
{{ item.summary }}
|
||||
</text>
|
||||
|
||||
<!-- 人话解读:一句话说清 + 对你意味着什么 -->
|
||||
<view class="card-plain" v-if="item.plainExplain">
|
||||
<view class="plain-line">
|
||||
<text class="plain-tag">人话</text>
|
||||
<text class="plain-text">{{ item.plainExplain }}</text>
|
||||
</view>
|
||||
<view class="plain-line" v-if="item.takeAway">
|
||||
<text class="plain-tag alt">对你</text>
|
||||
<text class="plain-text">{{ item.takeAway }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<view class="card-footer">
|
||||
<text class="card-date">{{ formatDate(item.newsDate) }}</text>
|
||||
<view class="card-stats">
|
||||
<text class="stat-item" v-if="item.hot">🔥 {{ item.hotCount }}</text>
|
||||
<text class="stat-item" v-if="item.hot">{{ item.hotCount }} 人觉得热</text>
|
||||
<text class="stat-item">👁️ {{ item.viewCount || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -55,7 +68,7 @@
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="displayItems.length === 0" class="empty-state">
|
||||
<text class="empty-icon">📡</text>
|
||||
<text class="empty-icon">暂时没有快讯</text>
|
||||
<text class="empty-text">暂无趋势资讯</text>
|
||||
<text class="empty-sub">稍后再来看最新 AI 动态</text>
|
||||
</view>
|
||||
@@ -64,21 +77,38 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { trendApi } from '@/utils/api'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
|
||||
const filters = ref(['全部', '🚀 产品', '📄 论文', '🔧 工具', '🏢 公司'])
|
||||
const filters = ref([
|
||||
{ icon: 'sparkle', text: '全部' },
|
||||
{ icon: 'rocket', text: '产品' },
|
||||
{ icon: 'book', text: '论文' },
|
||||
{ icon: 'settings', text: '工具' },
|
||||
{ icon: 'user', text: '公司' }
|
||||
])
|
||||
const activeFilter = ref(0)
|
||||
const items = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
const data = await trendApi.list({})
|
||||
if (typeof setList === "function") setList(data)
|
||||
} catch (e) {}
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchTrends()
|
||||
})
|
||||
|
||||
const displayItems = computed(() => {
|
||||
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]
|
||||
if (!category) return items.value
|
||||
return items.value.filter(item => item.category === category)
|
||||
@@ -91,29 +121,28 @@ async function fetchTrends() {
|
||||
items.value = data.list || data.items || []
|
||||
} catch (err) {
|
||||
console.error('获取趋势失败:', err)
|
||||
// 降级:本地示例
|
||||
items.value = getSampleData()
|
||||
uni.showToast({ title: '加载失败,下拉重试', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getTagText(item) {
|
||||
if (item.hot) return '🔥 热门'
|
||||
if (item.picked) return '📌 精选'
|
||||
if (item.category === 'product') return '🚀 产品'
|
||||
if (item.category === 'paper') return '📄 论文'
|
||||
if (item.category === 'tool') return '🔧 工具'
|
||||
if (item.category === 'company') return '🏢 公司'
|
||||
if (item.hot) return '热门'
|
||||
if (item.picked) return '精选'
|
||||
if (item.category === '产品') return '产品'
|
||||
if (item.category === '论文') return '📄 论文'
|
||||
if (item.category === '工具') return '🔧 工具'
|
||||
if (item.category === '公司') return '🏢 公司'
|
||||
return '📰 资讯'
|
||||
}
|
||||
|
||||
function getTagClass(item) {
|
||||
if (item.hot) return 'hot'
|
||||
if (item.picked) return 'pick'
|
||||
if (item.category === 'product') return 'product'
|
||||
if (item.category === 'paper') return 'paper'
|
||||
if (item.category === 'tool') return 'tool'
|
||||
if (item.category === '产品') return 'product'
|
||||
if (item.category === '论文') return 'paper'
|
||||
if (item.category === '工具') return 'tool'
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -129,56 +158,21 @@ function formatDate(dateStr) {
|
||||
}
|
||||
|
||||
function goDetail(item) {
|
||||
// 如果有原文链接,跳转外部
|
||||
if (item.link) {
|
||||
// 微信小程序不支持直接打开外部链接,复制链接提示
|
||||
uni.setClipboardData({
|
||||
data: item.link,
|
||||
success: () => {
|
||||
uni.showModal({
|
||||
title: '打开链接',
|
||||
content: '链接已复制,请在浏览器中打开',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
// 始终进入站内详情页(人话解读/阅读量统计/收藏/分享);原文链接在详情页内提供
|
||||
uni.setStorageSync('__news_item__', JSON.stringify(item))
|
||||
uni.navigateTo({ url: '/pages/news/news' })
|
||||
}
|
||||
|
||||
function getSampleData() {
|
||||
return [
|
||||
{
|
||||
title: 'DeepSeek 发布 R1 推理模型',
|
||||
summary: 'DeepSeek R1 在多项基准测试中超越 GPT-4o,引发全球关注',
|
||||
source: '量子位',
|
||||
newsDate: new Date(Date.now() - 2 * 3600000).toISOString(),
|
||||
category: 'product',
|
||||
hot: true,
|
||||
hotCount: 2000,
|
||||
viewCount: 15000
|
||||
},
|
||||
{
|
||||
title: 'Google Gemini 1.5 Pro 发布',
|
||||
summary: '支持 200 万 token 上下文窗口,多模态能力大幅提升',
|
||||
source: '机器之心',
|
||||
newsDate: new Date(Date.now() - 6 * 3600000).toISOString(),
|
||||
category: 'product',
|
||||
viewCount: 8000
|
||||
},
|
||||
{
|
||||
title: 'Transformer 论文作者新方向',
|
||||
summary: '从注意力机制到状态空间模型,作者分享最新研究进展',
|
||||
source: 'PaperWeekly',
|
||||
newsDate: new Date(Date.now() - 12 * 3600000).toISOString(),
|
||||
category: 'paper',
|
||||
picked: true,
|
||||
viewCount: 5000
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => {
|
||||
const latest = (items.value || [])[0]
|
||||
return {
|
||||
title: latest ? `今日AI速递:${String(latest.title).slice(0, 24)}… · AI维度` : 'AI 维度 · 每天 5 分钟看懂 AI 大事',
|
||||
path: '/pages/trend/trend'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -214,23 +208,33 @@ function getSampleData() {
|
||||
.filter-bar {
|
||||
padding: 0 20px 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
padding: 6px 14px;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 11px;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
border-radius: 999px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-item.active {
|
||||
background: rgba(124, 77, 255, 0.15);
|
||||
border-color: rgba(124, 77, 255, 0.3);
|
||||
color: var(--dim1-color);
|
||||
background: var(--color-brand-bg);
|
||||
border-color: var(--color-brand);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
// 列表
|
||||
@@ -239,7 +243,7 @@ function getSampleData() {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 0 0 16px;
|
||||
padding: 0 20px 16px;
|
||||
}
|
||||
|
||||
.trend-card {
|
||||
@@ -271,7 +275,7 @@ function getSampleData() {
|
||||
|
||||
.card-tag.pick {
|
||||
background: rgba(124, 77, 255, 0.12);
|
||||
color: #b388ff;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.card-tag.product {
|
||||
@@ -281,7 +285,7 @@ function getSampleData() {
|
||||
|
||||
.card-tag.paper {
|
||||
background: rgba(179, 136, 255, 0.12);
|
||||
color: #b388ff;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.card-tag.tool {
|
||||
@@ -364,3 +368,29 @@ function getSampleData() {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.card-plain {
|
||||
margin-top: 8px;
|
||||
background: var(--color-brand-bg);
|
||||
border: 1px solid var(--glass-hover-border);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.plain-line { display: flex; align-items: flex-start; gap: 6px; }
|
||||
.plain-tag {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: var(--color-brand);
|
||||
border-radius: 5px;
|
||||
padding: 1px 6px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.plain-tag.alt { background: var(--dim3-color); }
|
||||
.plain-text { font-size: 12px; color: var(--text-primary); line-height: 1.6; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<view class="ledger-page">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<view class="balance-header">
|
||||
<text class="balance-num">{{ balance }}</text>
|
||||
<text class="balance-label">星尘余额</text>
|
||||
</view>
|
||||
|
||||
<view class="filter-row">
|
||||
<view class="filter-tag" :class="{ active: filter === '' }" @tap="filter = ''; load()">全部</view>
|
||||
<view class="filter-tag" :class="{ active: filter === 'recharge' }" @tap="filter = 'recharge'; load()">充值</view>
|
||||
<view class="filter-tag" :class="{ active: filter === 'consume' }" @tap="filter = 'consume'; load()">消费</view>
|
||||
</view>
|
||||
|
||||
<view v-for="item in items" :key="item._id" class="ledger-item">
|
||||
<view class="li-left">
|
||||
<text class="li-desc">{{ item.description || typeLabel(item.type) }}</text>
|
||||
<text class="li-time">{{ fmt(item.createdAt) }}</text>
|
||||
</view>
|
||||
<text class="li-amount" :class="{ in: item.amount > 0, out: item.amount < 0 }">
|
||||
{{ item.amount > 0 ? '+' : '' }}{{ item.amount }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-if="!loading && !items.length" class="empty">
|
||||
<text>暂无记录</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { request } from '../../utils/api'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const items = ref([])
|
||||
const balance = ref(0)
|
||||
const filter = ref('')
|
||||
const loading = ref(false)
|
||||
const TYPE_LABELS = { recharge: '充值', consume: '消费', refund: '退款', adjust: '调整', gift: '赠送' }
|
||||
|
||||
function typeLabel(t) { return TYPE_LABELS[t] || t }
|
||||
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '' }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await request('GET', `/api/vpay/ledger?limit=50`, null, userStore.token)
|
||||
items.value = (data.items || []).filter(i => !filter.value || i.type === filter.value)
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
return
|
||||
}
|
||||
load()
|
||||
try {
|
||||
const info = await request('GET', '/api/vpay/info', null, userStore.token)
|
||||
balance.value = info.balance || 0
|
||||
} catch (e) {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ledger-page { min-height: 100vh; padding: 0 20px 30px; background: var(--bg-primary); }
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
.balance-header { text-align: center; padding: 20px 0; }
|
||||
.balance-num { font-size: 36px; font-weight: 800; color: var(--text-primary); display: block; }
|
||||
.balance-label { font-size: 12px; color: var(--text-tertiary); }
|
||||
.filter-row { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.filter-tag {
|
||||
padding: 6px 16px; border-radius: 999px; font-size: 12px;
|
||||
background: var(--glass-bg); border: 1px solid var(--glass-border); color: var(--text-secondary);
|
||||
}
|
||||
.filter-tag.active { background: var(--color-brand); color: #fff; border-color: var(--color-brand); }
|
||||
.ledger-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: var(--glass-bg); border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-md); padding: 12px 16px; margin-bottom: 8px;
|
||||
}
|
||||
.li-left { flex: 1; }
|
||||
.li-desc { font-size: 14px; color: var(--text-primary); display: block; }
|
||||
.li-time { font-size: 11px; color: var(--text-muted); }
|
||||
.li-amount { font-size: 16px; font-weight: 700; }
|
||||
.li-amount.in { color: #22c55e; }
|
||||
.li-amount.out { color: #ef4444; }
|
||||
.empty { text-align: center; padding: 40px; color: var(--text-muted); font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<view class="vpay-page page-layout">
|
||||
<view class="status-bar-placeholder"></view>
|
||||
|
||||
<view class="balance-card">
|
||||
<text class="balance-label">我的{{ info.coinName || '星尘' }}</text>
|
||||
<text class="balance-num">{{ info.balance || 0 }}</text>
|
||||
<text class="balance-hint">{{ info.env === 'sandbox' ? '(沙箱环境)' : '' }}1元 = 100 {{ info.coinName || '星尘' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="section-header">
|
||||
<text class="section-title">选择充值档位</text>
|
||||
</view>
|
||||
|
||||
<view class="pkg-grid">
|
||||
<view
|
||||
v-for="p in info.packages || []"
|
||||
:key="p.id"
|
||||
class="pkg-card"
|
||||
:class="{ active: picked === p.id }"
|
||||
@tap="picked = p.id"
|
||||
>
|
||||
<text class="pkg-coins">{{ p.label }}</text>
|
||||
<text class="pkg-price">¥{{ p.priceYuan }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="use-cases">
|
||||
<text class="uc-title">{{ info.coinName || '星尘' }}用途</text>
|
||||
<view class="uc-item"><text class="uc-tag">问答</text><text>AI 问答一次 10 {{ info.coinName || '星尘' }}</text></view>
|
||||
<view class="uc-item"><text class="uc-tag">连续</text><text>更多星尘用途陆续开放</text></view>
|
||||
</view>
|
||||
|
||||
<view class="pay-agreement-row">
|
||||
<text class="pay-agreement-text">
|
||||
充值即消耗型虚拟商品,不支持退款(法定情形除外),详见
|
||||
<text class="pay-agreement-link" @click.stop="openAgreement">《用户协议》</text>
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<button class="recharge-btn" :disabled="!picked || paying" @tap="recharge">
|
||||
{{ paying ? '拉起支付中…' : '立即充值' }}
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { request } from '../../utils/api'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const info = ref({})
|
||||
const picked = ref('')
|
||||
const paying = ref(false)
|
||||
|
||||
function openAgreement() {
|
||||
uni.navigateTo({ url: '/pages/agreement/agreement?type=terms' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
uni.showModal({
|
||||
title: '请先登录',
|
||||
content: '登录后即可充值星尘,每天还有 5 次免费问答',
|
||||
confirmText: '去登录',
|
||||
showCancel: false,
|
||||
success: () => uni.redirectTo({ url: '/pages/login/login' }),
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
info.value = await request('GET', '/api/vpay/info', null, userStore.token)
|
||||
if ((info.value.packages || []).length) picked.value = info.value.packages[0].id
|
||||
} catch (e) {
|
||||
if (String(e.message || '').includes('登录')) {
|
||||
uni.showModal({
|
||||
title: '登录已过期',
|
||||
content: '请重新登录后再充值',
|
||||
confirmText: '去登录',
|
||||
showCancel: false,
|
||||
success: () => uni.redirectTo({ url: '/pages/login/login' }),
|
||||
})
|
||||
} else {
|
||||
uni.showToast({ title: e.message || '获取充值信息失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function recharge() {
|
||||
if (!picked.value || paying.value) return
|
||||
paying.value = true
|
||||
try {
|
||||
const data = await request('POST', '/api/vpay/recharge', { packageId: picked.value }, userStore.token)
|
||||
const pay = data.payParams
|
||||
if (!pay) throw new Error('支付参数异常')
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
await new Promise((resolve, reject) => {
|
||||
uni.requestVirtualPayment({
|
||||
mode: pay.mode,
|
||||
env: pay.env,
|
||||
offerId: pay.offerId,
|
||||
buyQuantity: pay.buyQuantity,
|
||||
currencyType: pay.currencyType,
|
||||
platform: pay.platform,
|
||||
outTradeNo: pay.outTradeNo,
|
||||
signData: pay.signData,
|
||||
paySig: pay.paySig,
|
||||
signature: pay.signature,
|
||||
success: resolve,
|
||||
fail: (err) => reject(new Error(err.errMsg || err.errCode || '支付失败')),
|
||||
})
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
uni.showToast({ title: '请在微信小程序内充值', icon: 'none' })
|
||||
return
|
||||
// #endif
|
||||
|
||||
uni.showToast({ title: '充值成功!', icon: 'success' })
|
||||
// 刷新余额后返回"我的"(该页 onShow 会重新拉取余额与额度)
|
||||
try {
|
||||
info.value = await request('GET', '/api/vpay/info', null, userStore.token)
|
||||
} catch (e) {}
|
||||
setTimeout(() => {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
fail: () => uni.switchTab({ url: '/pages/profile/profile' }),
|
||||
})
|
||||
}, 1200)
|
||||
} catch (e) {
|
||||
const msg = e.message || '支付未完成'
|
||||
if (msg.includes('cancel')) {
|
||||
uni.showToast({ title: '已取消支付', icon: 'none' })
|
||||
} else {
|
||||
uni.showToast({ title: msg, icon: 'none', duration: 3000 })
|
||||
}
|
||||
} finally {
|
||||
paying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vpay-page { min-height: 100vh; padding: 0 20px 40px; background: var(--bg-primary); }
|
||||
.status-bar-placeholder { height: 44px; }
|
||||
.balance-card {
|
||||
background: linear-gradient(135deg, #5b5ff8, #8b5cf6);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 22px 20px;
|
||||
margin-bottom: 16px;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
.balance-label { font-size: 12px; color: rgba(255,255,255,.7); }
|
||||
.balance-num { font-size: 36px; font-weight: 800; color: #fff; }
|
||||
.balance-hint { font-size: 11px; color: rgba(255,255,255,.5); }
|
||||
}
|
||||
.section-header { display: flex; align-items: center; margin: 16px 0 12px;
|
||||
.section-title { font-size: 15px; font-weight: 700; color: var(--text-primary); } }
|
||||
.pkg-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.pkg-card {
|
||||
background: var(--glass-bg); border: 2px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg); padding: 16px 14px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
transition: border-color .2s;
|
||||
&.active { border-color: var(--color-brand); background: var(--color-brand-bg); }
|
||||
.pkg-coins { font-size: 16px; font-weight: 700; color: var(--text-primary); }
|
||||
.pkg-price { font-size: 14px; color: var(--color-brand); font-weight: 600; }
|
||||
}
|
||||
.use-cases {
|
||||
background: var(--glass-bg); border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg); padding: 14px 16px; margin-top: 16px;
|
||||
.uc-title { font-size: 13px; font-weight: 700; color: var(--text-primary); display: block; margin-bottom: 8px; }
|
||||
.uc-item { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 12px; color: var(--text-tertiary); }
|
||||
.uc-tag {
|
||||
background: var(--color-brand-bg); color: var(--color-brand);
|
||||
border-radius: 4px; padding: 1px 6px; font-size: 10px; font-weight: 600; flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
.pay-agreement-row { display: flex; justify-content: center; margin-top: 14px; }
|
||||
.pay-agreement-text { font-size: 10px; color: var(--text-muted); text-align: center; }
|
||||
.pay-agreement-link { color: var(--color-brand); }
|
||||
.recharge-btn {
|
||||
width: 100%; height: 48px; margin-top: 16px;
|
||||
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
|
||||
color: #fff; font-size: 16px; font-weight: 700; border-radius: var(--radius-lg);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
&[disabled] { opacity: .5; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
|
||||
"rules": [
|
||||
{ "action": "allow", "page": "*" }
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 989 B |
|
After Width: | Height: | Size: 496 B |
|
After Width: | Height: | Size: 1007 B |
|
After Width: | Height: | Size: 859 B |
|
After Width: | Height: | Size: 448 B |
|
After Width: | Height: | Size: 874 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 934 B |
|
Before Width: | Height: | Size: 297 B After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 660 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 984 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 737 B |
|
After Width: | Height: | Size: 1012 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |