Compare commits
109 Commits
66de1dc396
...
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 |
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
@@ -24,3 +24,8 @@ Thumbs.db
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
client/src/.share-done
|
||||||
|
admin/node_modules/
|
||||||
|
admin/dist/
|
||||||
|
server/data/
|
||||||
|
!server/data/certs/README.md
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
# Changelog
|
# 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)
|
## 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>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
|
<meta name="sogou_site_verification" content="bZ8oAfjvQm" />
|
||||||
|
<meta name="360-site-verification" content="0cc596886cce77073d8fcba9d5ec7d9c" />
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||||
<title>宇之然AI维度 - AI 知识探索社区</title>
|
<title>宇之然AI维度 - AI知识探索社区 | 五维度系统学AI</title>
|
||||||
<meta name="description" content="宇之然AI维度 — 从5个维度系统理解人工智能:AI起源、发展、当前格局、学习工具、趋势跟踪。" />
|
<meta name="description" content="宇之然AI维度:从起源、发展、格局、工具、趋势五个维度,系统理解人工智能。每日更新AI行业趋势与热点,支持问答、收藏与学习进度跟踪。" />
|
||||||
<meta name="keywords" content="宇之然AI维度,AI知识,人工智能学习,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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"><!--app-html--></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -8,242 +8,103 @@
|
|||||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
onLaunch(() => {
|
onLaunch(() => {
|
||||||
console.log('[AI维度] App launched')
|
|
||||||
initTheme()
|
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(() => {
|
onShow(() => {})
|
||||||
console.log('[AI维度] App shown')
|
onHide(() => {})
|
||||||
})
|
|
||||||
|
|
||||||
onHide(() => {
|
|
||||||
console.log('[AI维度] App hidden')
|
|
||||||
})
|
|
||||||
|
|
||||||
|
// 主题:'auto'(默认,跟随系统)| 'light' | 'dark'
|
||||||
function initTheme() {
|
function initTheme() {
|
||||||
const theme = uni.getStorageSync('theme') || 'light'
|
const theme = uni.getStorageSync('theme') || 'auto'
|
||||||
applyTheme(theme)
|
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) {
|
function applyTheme(theme) {
|
||||||
|
const resolved = resolveTheme(theme)
|
||||||
try {
|
try {
|
||||||
// H5 环境:操作 document class
|
// H5 环境:操作 document class
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
if (theme === 'dark') {
|
root.classList.remove('theme-dark', 'theme-light')
|
||||||
root.classList.add('theme-dark')
|
root.classList.add(resolved === 'dark' ? 'theme-dark' : 'theme-light')
|
||||||
root.classList.remove('theme-light')
|
|
||||||
} else {
|
|
||||||
root.classList.remove('theme-dark')
|
|
||||||
root.classList.add('theme-light')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 小程序环境:document 不存在
|
// 小程序环境:document 不存在,深色由 manifest darkmode + CSS 媒体查询接管
|
||||||
}
|
}
|
||||||
// 小程序 tabBar 原生组件同步切换
|
applyTabBarTheme(resolved)
|
||||||
applyTabBarTheme(theme)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTabBarTheme(theme) {
|
function applyTabBarTheme(theme) {
|
||||||
try {
|
try {
|
||||||
if (theme === 'dark') {
|
if (theme === 'dark') {
|
||||||
uni.setTabBarStyle({
|
uni.setTabBarStyle({
|
||||||
color: 'rgba(255,255,255,0.35)',
|
color: '#a2a2b8',
|
||||||
selectedColor: '#a78bfa',
|
selectedColor: '#a2b2ff',
|
||||||
backgroundColor: '#0d0d1a',
|
backgroundColor: '#181828',
|
||||||
borderStyle: 'black'
|
borderStyle: 'black'
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
uni.setTabBarStyle({
|
uni.setTabBarStyle({
|
||||||
color: 'rgba(0,0,0,0.3)',
|
color: '#999999',
|
||||||
selectedColor: '#6366f1',
|
selectedColor: '#5b5ff8',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
borderStyle: 'white'
|
borderStyle: 'white'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// H5 环境 setTabBarStyle 可能不支持
|
// 非 tabBar 页面或不支持时忽略
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style lang="scss">
|
||||||
/* ============================================
|
/* ============================================
|
||||||
宇之然AI维度 — 全局主题
|
宇之然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 {
|
page {
|
||||||
|
@include theme-light-vars;
|
||||||
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
@@ -252,6 +113,19 @@ page {
|
|||||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
|
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 {
|
view {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -269,7 +143,6 @@ view {
|
|||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 页面副标题(统一) */
|
|
||||||
.page-subtitle {
|
.page-subtitle {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -282,10 +155,8 @@ view {
|
|||||||
background: var(--glass-bg);
|
background: var(--glass-bg);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
backdrop-filter: blur(var(--blur-bg));
|
|
||||||
-webkit-backdrop-filter: blur(var(--blur-bg));
|
|
||||||
box-shadow: var(--card-shadow, none);
|
box-shadow: var(--card-shadow, none);
|
||||||
transition: all var(--transition-normal);
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gradient-brand {
|
.gradient-brand {
|
||||||
@@ -298,4 +169,25 @@ view {
|
|||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
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>
|
</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维度",
|
"name": "宇之然AI维度",
|
||||||
"appid": "wxdad62baf4ccd09e3",
|
"appid": "wxdad62baf4ccd09e3",
|
||||||
"versionName": "1.0.0",
|
"versionName": "1.4.9",
|
||||||
"versionCode": "100",
|
"versionCode": "100",
|
||||||
"description": "AI 知识探索社区 — 从5个维度系统理解人工智能",
|
"description": "AI 知识探索社区 — 从5个维度系统理解人工智能",
|
||||||
"uni-app": {
|
"uni-app": {
|
||||||
@@ -17,6 +17,15 @@
|
|||||||
},
|
},
|
||||||
"usingComponents": true,
|
"usingComponents": true,
|
||||||
"permission": {},
|
"permission": {},
|
||||||
"requiredPrivateInfos": []
|
"requiredPrivateInfos": [],
|
||||||
|
"darkmode": true,
|
||||||
|
"themeLocation": "theme.json"
|
||||||
|
},
|
||||||
|
"app-plus": {},
|
||||||
|
"h5": {
|
||||||
|
"router": {
|
||||||
|
"mode": "hash",
|
||||||
|
"base": "/h5/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,148 @@
|
|||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
{"path": "pages/index/index", "style": {"navigationBarTitleText": "AI 维度", "navigationStyle": "custom"}},
|
{
|
||||||
{"path": "pages/login/login", "style": {"navigationBarTitleText": "登录", "navigationStyle": "custom"}},
|
"path": "pages/index/index",
|
||||||
{"path": "pages/detail/detail", "style": {"navigationBarTitleText": "AI 知识", "navigationStyle": "custom"}},
|
"style": {
|
||||||
{"path": "pages/payment/payment", "style": {"navigationBarTitleText": "订阅升级", "navigationStyle": "custom"}},
|
"navigationBarTitleText": "宇之然AI维度-AI知识学习与人工智能资讯",
|
||||||
{"path": "pages/favorites/favorites", "style": {"navigationBarTitleText": "我的收藏", "navigationStyle": "custom"}},
|
"navigationStyle": "custom",
|
||||||
{"path": "pages/achievements/achievements", "style": {"navigationBarTitleText": "成就", "navigationStyle": "custom"}},
|
"enablePullDownRefresh": true,
|
||||||
{"path": "pages/leaderboard/leaderboard", "style": {"navigationBarTitleText": "排行榜", "navigationStyle": "custom"}},
|
"backgroundTextStyle": "dark"
|
||||||
{"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/login/login",
|
||||||
{"path": "pages/news/news", "style": {"navigationBarTitleText": "AI 资讯", "navigationStyle": "custom"}},
|
"style": {
|
||||||
{"path": "pages/dimension/dimension", "style": {"navigationBarTitleText": "", "navigationStyle": "custom"}},
|
"navigationBarTitleText": "登录",
|
||||||
{"path": "pages/chat/chat", "style": {"navigationBarTitleText": "AI 问答", "navigationStyle": "custom"}},
|
"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/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": {
|
"globalStyle": {
|
||||||
"navigationBarTextStyle": "black",
|
"navigationBarTextStyle": "@navTxt",
|
||||||
"navigationBarTitleText": "宇之然AI维度",
|
"navigationBarTitleText": "宇之然AI维度",
|
||||||
"navigationBarBackgroundColor": "#f5f5f7",
|
"navigationBarBackgroundColor": "@navBg",
|
||||||
"backgroundColor": "#f5f5f7",
|
"backgroundColor": "@bg",
|
||||||
"backgroundColorTop": "#f5f5f7",
|
"backgroundColorTop": "#f5f5f7",
|
||||||
"backgroundColorBottom": "#f5f5f7",
|
"backgroundColorBottom": "#f5f5f7",
|
||||||
"app-plus": {
|
"app-plus": {
|
||||||
@@ -30,15 +150,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tabBar": {
|
"tabBar": {
|
||||||
"color": "rgba(0,0,0,0.3)",
|
"color": "@tabTxt",
|
||||||
"selectedColor": "#6366f1",
|
"selectedColor": "@tabSel",
|
||||||
"backgroundColor": "#ffffff",
|
"backgroundColor": "@tabBg",
|
||||||
"borderStyle": "white",
|
"borderStyle": "@tabBorder",
|
||||||
"list": [
|
"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/index/index",
|
||||||
{"pagePath": "pages/trend/trend", "text": "趋势", "iconPath": "static/icons/trend.png", "selectedIconPath": "static/icons/trend-active.png"},
|
"text": "探索",
|
||||||
{"pagePath": "pages/profile/profile", "text": "我的", "iconPath": "static/icons/profile.png", "selectedIconPath": "static/icons/profile-active.png"}
|
"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">
|
<view class="header">
|
||||||
<text class="title">🏆 成就</text>
|
<text class="title">成就</text>
|
||||||
<text class="unlock-count">{{ unlockedCount }}/{{ totalCount }}</text>
|
<text class="unlock-count">{{ unlockedCount }}/{{ totalCount }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<view class="empty-state" v-else-if="!loading">
|
<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-text">还没有获得任何成就</text>
|
||||||
<text class="empty-sub">继续学习,解锁属于你的成就吧</text>
|
<text class="empty-sub">继续学习,解锁属于你的成就吧</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -88,9 +88,9 @@ const loading = ref(true)
|
|||||||
|
|
||||||
// 成就分类的默认配置
|
// 成就分类的默认配置
|
||||||
const CATEGORY_CONFIG = [
|
const CATEGORY_CONFIG = [
|
||||||
{ name: '知识探索', icon: '📖', color: 'var(--dim1-color)' },
|
{ name: '知识探索', icon: '星', color: 'var(--dim1-color)' },
|
||||||
{ name: '收藏成就', icon: '⭐', color: 'var(--dim2-color)' },
|
{ name: '收藏成就', icon: '藏', color: 'var(--dim2-color)' },
|
||||||
{ name: 'AI 问答', icon: '🤖', color: 'var(--dim3-color)' },
|
{ name: 'AI 问答', icon: '问', color: 'var(--dim3-color)' },
|
||||||
{ name: '社交成就', icon: '👥', color: 'var(--dim4-color)' }
|
{ name: '社交成就', icon: '👥', color: 'var(--dim4-color)' }
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ async function loadAchievements() {
|
|||||||
|
|
||||||
function catIcon(name) {
|
function catIcon(name) {
|
||||||
const config = CATEGORY_CONFIG.find((c) => c.name === name)
|
const config = CATEGORY_CONFIG.find((c) => c.name === name)
|
||||||
return config ? config.icon : '🏆'
|
return config ? config.icon : '成'
|
||||||
}
|
}
|
||||||
|
|
||||||
function achColor(icon) {
|
function achColor(icon) {
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ function onLoad(fn) {
|
|||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI知识社区 · 宇之然AI维度',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
// #endif
|
// #endif
|
||||||
|
|||||||
@@ -2,28 +2,29 @@
|
|||||||
<view class="page-chat page-layout">
|
<view class="page-chat page-layout">
|
||||||
<view class="status-bar-placeholder"></view>
|
<view class="status-bar-placeholder"></view>
|
||||||
|
|
||||||
<!-- 聊天头部 -->
|
<!-- 聊天头部:维度精灵 -->
|
||||||
<view class="chat-header">
|
<view class="chat-header">
|
||||||
<view class="chat-avatar">
|
<view class="chat-avatar">
|
||||||
<text class="avatar-text">∞</text>
|
<AppIcon name="planet-white" :size="22" />
|
||||||
</view>
|
</view>
|
||||||
<view class="chat-info">
|
<view class="chat-info">
|
||||||
<text class="chat-name">AI 助手</text>
|
<text class="chat-name">维度精灵</text>
|
||||||
<text class="chat-status">随时为你解答</text>
|
<text class="chat-status">你的 AI 知识探索向导</text>
|
||||||
</view>
|
</view>
|
||||||
<!-- 配额徽章(登录后显示) -->
|
<view class="quota-badge" @click="onQuotaTap">
|
||||||
<view class="quota-badge" v-if="userStore.isLoggedIn" @click="goShare">
|
<AppIcon :name="isGuest ? 'gift' : 'gift-gray'" :size="13" />
|
||||||
<text class="quota-text">{{ quotaDisplay }}</text>
|
<text class="quota-text">{{ quotaText }}</text>
|
||||||
<text class="quota-icon">🎁</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 消息列表(始终可见) -->
|
<!-- 消息列表 -->
|
||||||
<scroll-view class="msg-list" scroll-y :scroll-into-view="scrollTo">
|
<scroll-view class="msg-list" scroll-y :scroll-into-view="scrollTo">
|
||||||
<!-- 欢迎消息 -->
|
<!-- 欢迎 -->
|
||||||
<view class="welcome-card">
|
<view class="welcome-card">
|
||||||
<text class="welcome-icon">💡</text>
|
<view class="welcome-head">
|
||||||
<text class="welcome-text">试试问我这些问题:</text>
|
<AppIcon name="sparkle" :size="16" />
|
||||||
|
<text class="welcome-text">{{ welcomeText }}</text>
|
||||||
|
</view>
|
||||||
<view class="suggest-list">
|
<view class="suggest-list">
|
||||||
<view class="suggest-item" @click="sendSuggest(item)" v-for="(item, idx) in suggestions" :key="idx">
|
<view class="suggest-item" @click="sendSuggest(item)" v-for="(item, idx) in suggestions" :key="idx">
|
||||||
<text>{{ item }}</text>
|
<text>{{ item }}</text>
|
||||||
@@ -32,7 +33,9 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-for="(msg, idx) in messages" :key="idx" :id="'msg-' + idx" class="chat-msg" :class="msg.role">
|
<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="msg-content">
|
||||||
<view class="bubble" v-html="msg.content"></view>
|
<view class="bubble" v-html="msg.content"></view>
|
||||||
</view>
|
</view>
|
||||||
@@ -40,7 +43,9 @@
|
|||||||
|
|
||||||
<!-- 加载动画 -->
|
<!-- 加载动画 -->
|
||||||
<view v-if="loading" class="chat-msg assistant">
|
<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="msg-content">
|
||||||
<view class="bubble loading-dots">
|
<view class="bubble loading-dots">
|
||||||
<text class="dot"></text>
|
<text class="dot"></text>
|
||||||
@@ -49,40 +54,42 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="list-bottom"></view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<!-- 输入区(始终可见,未登录时提示登录) -->
|
<!-- 输入区:游客与登录用户一致,始终可见 -->
|
||||||
<view class="chat-input-area">
|
<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
|
<input
|
||||||
v-model="question"
|
v-model="question"
|
||||||
class="input-field"
|
class="input-field"
|
||||||
placeholder="输入你对 AI 的疑问..."
|
:placeholder="inputPlaceholder"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@confirm="sendMessage"
|
@confirm="sendMessage"
|
||||||
confirm-type="send"
|
confirm-type="send"
|
||||||
/>
|
/>
|
||||||
<view class="send-btn" @click="sendMessage">
|
<view class="send-btn" :class="{ disabled: loading || !question.trim() }" @click="sendMessage">
|
||||||
<text>➤</text>
|
<AppIcon name="send-white" :size="16" />
|
||||||
</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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<text v-if="isGuest && !guestExhausted" class="guest-hint">游客模式 · 每日 3 次体验</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { aiChatApi } from '@/utils/api'
|
import { aiChatApi } from '@/utils/api'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import AppIcon from '@/components/AppIcon.vue'
|
||||||
|
|
||||||
const question = ref('')
|
const question = ref('')
|
||||||
const messages = ref([])
|
const messages = ref([])
|
||||||
@@ -90,35 +97,44 @@ const loading = ref(false)
|
|||||||
const scrollTo = ref('')
|
const scrollTo = ref('')
|
||||||
const userStore = useUserStore()
|
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([
|
const suggestions = ref([
|
||||||
'"Transformer 的核心原理是什么?"',
|
'用大白话解释 Transformer 是什么',
|
||||||
'"RAG 和微调有什么区别?"',
|
'RAG 和微调有什么区别?',
|
||||||
'"2026 年最值得关注的 AI 趋势?"'
|
'2026 年最值得关注的 AI 趋势?'
|
||||||
])
|
])
|
||||||
|
|
||||||
// 配额显示
|
const isGuest = computed(() => !userStore.isLoggedIn)
|
||||||
const quotaDisplay = computed(() => {
|
const welcomeText = computed(() => (isGuest.value ? WELCOME_GUEST : WELCOME_LOGGED))
|
||||||
const q = userStore.quota
|
const inputPlaceholder = computed(() => (isGuest.value ? INPUT_GUEST : INPUT_LOGGED))
|
||||||
if (!q) return '?次'
|
const guestExhaustedText = GUEST_EXHAUSTED
|
||||||
const used = q.usedQuota || q.dailyUsed || 0
|
|
||||||
const remaining = q.remainingQuota || 0
|
const quotaText = computed(() => {
|
||||||
const total = q.freeQuota || remaining + used
|
const q = quotaInfo.value
|
||||||
return `${remaining}/${total}`
|
if (!q) return '...'
|
||||||
|
return `${q.remainingQuota} 次`
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听登录状态变化,加载配额
|
|
||||||
const isLoggedIn = computed(() => userStore.isLoggedIn)
|
const isLoggedIn = computed(() => userStore.isLoggedIn)
|
||||||
import { watch } from 'vue'
|
|
||||||
watch(isLoggedIn, (val) => {
|
watch(isLoggedIn, (val) => {
|
||||||
if (val) {
|
guestExhausted.value = false
|
||||||
loadQuota()
|
loadQuota()
|
||||||
}
|
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
async function loadQuota() {
|
async function loadQuota() {
|
||||||
if (userStore.token) {
|
try {
|
||||||
await userStore.fetchQuota(userStore.token)
|
const data = await aiChatApi.quota(userStore.isLoggedIn ? userStore.token : undefined)
|
||||||
|
quotaInfo.value = data
|
||||||
|
} catch (e) {
|
||||||
|
quotaInfo.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,28 +142,42 @@ async function sendMessage() {
|
|||||||
const q = question.value.trim()
|
const q = question.value.trim()
|
||||||
if (!q || loading.value) return
|
if (!q || loading.value) return
|
||||||
|
|
||||||
// 未登录 → 跳登录
|
|
||||||
if (!userStore.isLoggedIn) {
|
|
||||||
uni.navigateTo({ url: '/pages/login/login' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.value.push({ role: 'user', content: escapeHtml(q) })
|
messages.value.push({ role: 'user', content: escapeHtml(q) })
|
||||||
question.value = ''
|
question.value = ''
|
||||||
scrollToLast()
|
scrollToLast()
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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) })
|
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) {
|
} catch (e) {
|
||||||
console.error('AI 问答失败:', e)
|
const msg = e.message || 'AI 服务暂时不可用,请稍后再试'
|
||||||
const errorMsg = e.message || 'AI 服务暂时不可用,请稍后再试'
|
if (msg.includes('游客体验已用完')) {
|
||||||
messages.value.push({
|
guestExhausted.value = true
|
||||||
role: 'assistant',
|
} else if (msg.includes('免费次数已用完') || msg.includes('星尘余额不足') || msg.includes('充值星尘')) {
|
||||||
content: escapeHtml(`抱歉,${errorMsg}`)
|
// 引导充值
|
||||||
|
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 {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
scrollToLast()
|
scrollToLast()
|
||||||
@@ -161,8 +191,9 @@ function sendSuggest(text) {
|
|||||||
|
|
||||||
function scrollToLast() {
|
function scrollToLast() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollTo.value = 'msg-' + (messages.value.length - 1)
|
scrollTo.value = ''
|
||||||
}, 100)
|
setTimeout(() => { scrollTo.value = 'msg-' + (messages.value.length - 1) }, 50)
|
||||||
|
}, 80)
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
@@ -177,33 +208,50 @@ function goLogin() {
|
|||||||
uni.navigateTo({ url: '/pages/login/login' })
|
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({
|
uni.showModal({
|
||||||
title: '获取更多次数',
|
title: '获取更多次数',
|
||||||
content: '将小程序分享给好友,即可获得 5 次额外问答机会',
|
content: '将小程序分享给好友,即可获得 5 次额外问答机会',
|
||||||
confirmText: '去分享',
|
confirmText: '去分享',
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm && userStore.token) {
|
||||||
// 微信小程序分享能力
|
|
||||||
uni.showShareMenu({ withShareTicket: true })
|
uni.showShareMenu({ withShareTicket: true })
|
||||||
// 调用分享获得次数接口
|
|
||||||
if (userStore.token) {
|
|
||||||
aiChatApi.shareGain(userStore.token).then(() => {
|
aiChatApi.shareGain(userStore.token).then(() => {
|
||||||
uni.showToast({ title: '已记录分享', icon: 'success' })
|
uni.showToast({ title: '已记录分享', icon: 'success' })
|
||||||
loadQuota()
|
loadQuota()
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序右上角三点分享
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - AI 问答',
|
title: '维度精灵 · 你的 AI 知识探索向导',
|
||||||
path: '/pages/chat/chat'
|
path: '/pages/chat/chat'
|
||||||
}))
|
}))
|
||||||
|
onShareTimeline(() => ({
|
||||||
|
title: '维度精灵 · 你的 AI 知识探索向导'
|
||||||
|
}))
|
||||||
// #endif
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -212,7 +260,7 @@ onShareAppMessage(() => ({
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0 20px;
|
padding: 0 16px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,248 +270,230 @@ onShareAppMessage(() => ({
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 0 16px;
|
padding: 10px 0 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-avatar,
|
.chat-avatar,
|
||||||
.mini-avatar {
|
.mini-avatar {
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: linear-gradient(135deg, rgba(124, 77, 255, 0.3), rgba(0, 188, 212, 0.2));
|
background: linear-gradient(135deg, var(--color-brand), #8b5cf6);
|
||||||
border: 1px solid rgba(124, 77, 255, 0.15);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 4px 14px var(--color-brand-glow);
|
||||||
}
|
}
|
||||||
|
.chat-avatar { width: 42px; height: 42px; }
|
||||||
.avatar-text {
|
.mini-avatar { width: 28px; height: 28px; border-radius: 9px; }
|
||||||
font-size: 20px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-avatar {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-info { flex: 1; }
|
.chat-info { flex: 1; }
|
||||||
|
|
||||||
.chat-name {
|
.chat-name {
|
||||||
font-size: 16px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
display: block;
|
display: block;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-status {
|
.chat-status {
|
||||||
|
display: block;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
display: block;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配额徽章
|
|
||||||
.quota-badge {
|
.quota-badge {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 5px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: rgba(124, 77, 255, 0.1);
|
border-radius: var(--radius-full);
|
||||||
border: 1px solid rgba(124, 77, 255, 0.2);
|
background: var(--color-brand-bg);
|
||||||
border-radius: 16px;
|
border: 1px solid var(--glass-border);
|
||||||
}
|
|
||||||
|
|
||||||
.quota-text {
|
.quota-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--dim1-color);
|
font-weight: 700;
|
||||||
font-weight: 600;
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.quota-icon { font-size: 14px; }
|
.msg-list {
|
||||||
|
flex: 1;
|
||||||
// 未登录输入区占位
|
min-height: 0;
|
||||||
.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 {
|
.list-bottom { height: 16px; }
|
||||||
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 {
|
.welcome-card {
|
||||||
background: var(--glass-bg);
|
background: var(--glass-bg);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: 14px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
box-shadow: var(--card-shadow, none);
|
||||||
}
|
}
|
||||||
|
.welcome-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
.welcome-icon {
|
.welcome-text {
|
||||||
font-size: 20px;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-text {
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-tertiary);
|
font-weight: 600;
|
||||||
display: block;
|
color: var(--text-primary);
|
||||||
margin-bottom: 10px;
|
line-height: 1.5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggest-list {
|
.suggest-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggest-item {
|
.suggest-item {
|
||||||
padding: 8px 12px;
|
padding: 11px 14px;
|
||||||
background: rgba(124, 77, 255, 0.08);
|
border-radius: var(--radius-md);
|
||||||
border-radius: 8px;
|
background: var(--bg-primary);
|
||||||
font-size: 12px;
|
border: 1px solid var(--glass-border);
|
||||||
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
.suggest-item:active { border-color: var(--color-brand); color: var(--color-brand); }
|
||||||
// 消息列表
|
|
||||||
.msg-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-msg {
|
.chat-msg {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
|
||||||
|
|
||||||
.chat-msg.user {
|
&.assistant .msg-content { align-items: flex-start; }
|
||||||
flex-direction: row-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-msg.assistant {
|
.msg-content {
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg-content {
|
|
||||||
max-width: 82%;
|
max-width: 82%;
|
||||||
}
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.bubble {
|
&.user {
|
||||||
padding: 12px 16px;
|
justify-content: flex-end;
|
||||||
border-radius: 16px;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-msg.assistant .bubble {
|
.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);
|
background: var(--glass-bg);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border-bottom-left-radius: 4px;
|
border-radius: 16px 16px 16px 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg.user .bubble {
|
.bubble {
|
||||||
background: linear-gradient(135deg, rgba(124, 77, 255, 0.3), rgba(124, 77, 255, 0.15));
|
padding: 12px 14px;
|
||||||
border: 1px solid rgba(124, 77, 255, 0.15);
|
font-size: 14px;
|
||||||
color: white;
|
line-height: 1.7;
|
||||||
border-bottom-right-radius: 4px;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载动画
|
|
||||||
.loading-dots {
|
.loading-dots {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 5px;
|
||||||
padding: 14px 18px;
|
align-items: center;
|
||||||
}
|
padding: 16px 14px;
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
width: 6px;
|
width: 7px;
|
||||||
height: 6px;
|
height: 7px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(255, 255, 255, 0.4);
|
background: var(--color-brand);
|
||||||
animation: blink 1.4s infinite;
|
animation: dotBounce 1.2s infinite ease-in-out;
|
||||||
|
|
||||||
|
&:nth-child(2) { animation-delay: 0.15s; }
|
||||||
|
&:nth-child(3) { animation-delay: 0.3s; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot:nth-child(2) { animation-delay: 0.2s; }
|
@keyframes dotBounce {
|
||||||
.dot:nth-child(3) { animation-delay: 0.4s; }
|
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||||
|
30% { transform: translateY(-5px); opacity: 1; }
|
||||||
@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 {
|
.chat-input-area {
|
||||||
padding: 12px 0 30px;
|
flex-shrink: 0;
|
||||||
background: var(--bg-primary);
|
padding: 10px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-hint {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input {
|
.chat-input {
|
||||||
background: var(--glass-bg);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 6px 6px 6px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-field {
|
.input-field {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 24px;
|
height: 38px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: none;
|
background: transparent;
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field::placeholder {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-btn {
|
.send-btn {
|
||||||
width: 36px;
|
width: 38px;
|
||||||
height: 36px;
|
height: 38px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #7c4dff, #b388ff);
|
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: white;
|
flex-shrink: 0;
|
||||||
box-shadow: 0 0 20px rgba(124, 77, 255, 0.2);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,29 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="detail-page page-layout">
|
<view class="detail-page page-layout">
|
||||||
<!-- 状态栏占位 -->
|
|
||||||
<view class="status-bar-placeholder"></view>
|
<view class="status-bar-placeholder"></view>
|
||||||
|
|
||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<view class="nav-bar">
|
<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>
|
<text class="nav-title">AI 知识</text>
|
||||||
<view class="nav-collect" @click="toggleCollect">
|
<view class="nav-collect" @click="toggleCollect">
|
||||||
<text v-if="isCollected" class="icon icon-collected">★</text>
|
<AppIcon v-if="isCollected" name="star-fill" :size="20" />
|
||||||
<text v-else class="icon">☆</text>
|
<AppIcon v-else name="star-gray" :size="20" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 知识头部 -->
|
<!-- 知识头部 -->
|
||||||
<view class="detail-header">
|
<view class="detail-header">
|
||||||
<view class="dim-tag" :style="{ background: dimColor }">
|
<view class="dim-tag" :style="{ background: dimInfo.bg, color: dimInfo.color }">
|
||||||
<text class="dim-name">{{ dimensionName }}</text>
|
{{ dimInfo.name }}
|
||||||
</view>
|
</view>
|
||||||
<text class="detail-title">{{ detail?.title || '加载中...' }}</text>
|
<text class="detail-title">{{ detail?.title || '加载中...' }}</text>
|
||||||
<view class="meta-row">
|
<view class="meta-row">
|
||||||
<text class="meta-item">📖 {{ viewCountText }}</text>
|
<view class="meta-item">
|
||||||
<text class="meta-item">⭐ {{ collectionCount }}</text>
|
<AppIcon name="search-gray" :size="12" />
|
||||||
<text class="meta-item" v-if="detail?.category">
|
<text>{{ viewCountText }} 次阅读</text>
|
||||||
{{ categoryLabel }}
|
</view>
|
||||||
</text>
|
<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>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -32,85 +38,140 @@
|
|||||||
<text class="tag" v-for="tag in detail.tags" :key="tag">#{{ tag }}</text>
|
<text class="tag" v-for="tag in detail.tags" :key="tag">#{{ tag }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 内容区域 -->
|
<!-- 正文 -->
|
||||||
<scroll-view class="content-scroll" scroll-y>
|
<view class="content-card">
|
||||||
<!-- 分段内容(如果存在 sections) -->
|
<view v-if="!detail" class="loading-block">
|
||||||
<view class="content-section" v-if="detail?.sections?.length">
|
<text>加载中...</text>
|
||||||
<view class="section-block" v-for="(section, idx) in detail.sections" :key="idx">
|
</view>
|
||||||
<text class="section-title">{{ section.title }}</text>
|
<template v-else-if="paragraphs.length">
|
||||||
<text class="section-content">{{ section.content }}</text>
|
<text
|
||||||
<view class="section-images" v-if="section.images?.length">
|
v-for="(p, i) in paragraphs"
|
||||||
<image
|
|
||||||
class="section-img"
|
|
||||||
v-for="(img, i) in section.images"
|
|
||||||
:key="i"
|
:key="i"
|
||||||
:src="img"
|
class="para"
|
||||||
mode="aspectFill"
|
:class="{ 'para-lead': i === 0 }"
|
||||||
/>
|
>{{ p }}</text>
|
||||||
</view>
|
</template>
|
||||||
|
<view v-else class="loading-block">
|
||||||
|
<text>暂无内容</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 普通内容 -->
|
<!-- 检测题(读后测,答对点亮星球) -->
|
||||||
<view class="content-block" v-else>
|
<view class="quiz-card" v-if="detail?.question && detail?.options?.length">
|
||||||
<text class="content-text">{{ detail?.content || '暂无内容' }}</text>
|
<view class="quiz-head">
|
||||||
|
<AppIcon name="sparkle" :size="16" />
|
||||||
|
<text class="quiz-title">读后检测 · 答对点亮这颗星球</text>
|
||||||
</view>
|
</view>
|
||||||
|
<text class="quiz-question">{{ detail.question }}</text>
|
||||||
|
|
||||||
<!-- 问答区域 -->
|
<template v-if="!quizResult">
|
||||||
<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
|
<view
|
||||||
class="qa-option"
|
|
||||||
v-for="(opt, idx) in detail.options"
|
v-for="(opt, idx) in detail.options"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
:class="{ correct: selectedOption === idx && opt.isCorrect }"
|
class="quiz-option"
|
||||||
|
:class="{ selected: selectedOption === idx }"
|
||||||
@click="selectedOption = idx"
|
@click="selectedOption = idx"
|
||||||
>
|
>
|
||||||
<text class="option-letter">{{ String.fromCharCode(65 + idx) }}</text>
|
<text class="option-letter">{{ optionLetters[idx] }}</text>
|
||||||
<text class="option-text">{{ opt.text }}</text>
|
<text class="option-text">{{ opt.text }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view
|
||||||
|
class="quiz-submit"
|
||||||
|
:class="{ disabled: selectedOption === null }"
|
||||||
|
@click="submitQuiz"
|
||||||
|
>
|
||||||
|
<text>{{ selectedOption === null ? '选择你的答案' : '确认作答' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="qa-answer" v-if="selectedOption !== null">
|
</template>
|
||||||
<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 问答入口 -->
|
<template v-else>
|
||||||
<view class="ai-banner" @click="goToAI">
|
<view class="quiz-result" :class="quizResult.correct ? 'ok' : 'bad'">
|
||||||
<text class="ai-icon">🤖</text>
|
<view class="result-icon">
|
||||||
<text class="ai-text">还有疑问?问问 AI 助手</text>
|
<AppIcon :name="quizResult.correct ? 'check-green' : 'lock-gray'" :size="18" />
|
||||||
<text class="ai-arrow">→</text>
|
|
||||||
</view>
|
</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>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { knowledgeApi } from '@/utils/api'
|
import { knowledgeApi } from '@/utils/api'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { DIMENSIONS } from '@/utils/dimensions'
|
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 detailId = ref('')
|
||||||
const detail = ref(null)
|
const detail = ref(null)
|
||||||
const isCollected = ref(false)
|
const isCollected = ref(false)
|
||||||
const selectedOption = ref(null)
|
const selectedOption = ref(null)
|
||||||
|
const quizResult = ref(null)
|
||||||
const userStore = useUserStore()
|
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(() => {
|
onMounted(() => {
|
||||||
const pages = getCurrentPages()
|
const pages = getCurrentPages()
|
||||||
const currentPage = pages[pages.length - 1]
|
const currentPage = pages[pages.length - 1]
|
||||||
const query = currentPage.$page?.query || {}
|
const query = currentPage.$page?.query || currentPage.options || {}
|
||||||
detailId.value = query.id || ''
|
detailId.value = query.id || ''
|
||||||
|
|
||||||
if (detailId.value) {
|
if (detailId.value) {
|
||||||
@@ -118,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() {
|
async function fetchDetail() {
|
||||||
try {
|
try {
|
||||||
detail.value = await knowledgeApi.detail(detailId.value)
|
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) {
|
if (userStore.isLoggedIn) {
|
||||||
const collected = await knowledgeApi.collected(userStore.token)
|
const collected = await knowledgeApi.collected(userStore.token)
|
||||||
isCollected.value = collected.some(item => item.id === detailId.value)
|
isCollected.value = collected.some(item => item.id === detailId.value)
|
||||||
@@ -163,9 +202,36 @@ 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() {
|
async function toggleCollect() {
|
||||||
if (!userStore.isLoggedIn) {
|
if (!userStore.isLoggedIn) {
|
||||||
// 未登录:弹窗给用户选择,可取消,不强制跳转
|
// 静默登录(微信 code 换 token,用户无感);失败再显式引导
|
||||||
|
const ok = await userStore.ensureLogin()
|
||||||
|
if (!ok) {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '登录后收藏',
|
title: '登录后收藏',
|
||||||
content: '登录后即可收藏喜欢的 AI 知识',
|
content: '登录后即可收藏喜欢的 AI 知识',
|
||||||
@@ -179,6 +245,7 @@ async function toggleCollect() {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (isCollected.value) {
|
if (isCollected.value) {
|
||||||
await knowledgeApi.uncollect(detailId.value, userStore.token)
|
await knowledgeApi.uncollect(detailId.value, userStore.token)
|
||||||
@@ -198,44 +265,45 @@ function goBack() {
|
|||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToAI() {
|
function goChat() {
|
||||||
uni.switchTab({ url: '/pages/chat/chat' })
|
uni.switchTab({ url: '/pages/chat/chat' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: (detail.value?.title ? detail.value.title + ' · AI 维度' : 'AI 维度 · 知识星球'),
|
||||||
path: '/pages/index/index'
|
path: `/pages/detail/detail?id=${detailId.value}`
|
||||||
|
}))
|
||||||
|
onShareTimeline(() => ({
|
||||||
|
title: detail.value?.title || 'AI 维度 · 知识星球'
|
||||||
}))
|
}))
|
||||||
// #endif
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.detail-page {
|
.detail-page {
|
||||||
width: 100%;
|
min-height: 100vh;
|
||||||
height: 100vh;
|
padding: 0 20px 30px;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 顶部导航
|
.status-bar-placeholder { height: 44px; }
|
||||||
|
|
||||||
.nav-bar {
|
.nav-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 50px 20px 12px;
|
padding: 0 0 14px;
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
.nav-back {
|
.nav-back {
|
||||||
width: 30px;
|
width: 32px;
|
||||||
height: 30px;
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 22px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
.nav-title {
|
.nav-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -243,166 +311,140 @@ onShareAppMessage(() => ({
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.nav-collect {
|
.nav-collect {
|
||||||
width: 30px;
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
.icon {
|
|
||||||
font-size: 18px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.icon-collected {
|
|
||||||
color: var(--dim5-color);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 知识头部
|
|
||||||
.detail-header {
|
.detail-header {
|
||||||
padding: 0 20px 16px;
|
padding: 4px 0 14px;
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
.dim-tag {
|
.dim-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-full);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: white;
|
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
opacity: 0.9;
|
|
||||||
|
|
||||||
.dim-name { color: white; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-title {
|
.detail-title {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 20px;
|
font-size: 21px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.4;
|
line-height: 1.45;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-row {
|
.meta-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 14px;
|
||||||
font-size: 12px;
|
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 {
|
.tag-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0 20px 16px;
|
padding: 0 0 14px;
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: var(--dim1-color);
|
color: var(--color-brand);
|
||||||
background: rgba(179, 136, 255, 0.1);
|
background: var(--color-brand-bg);
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-full);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 内容滚动区
|
.content-card {
|
||||||
.content-scroll {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0 20px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-bottom: 80px;
|
|
||||||
|
|
||||||
.content-section,
|
|
||||||
.content-block,
|
|
||||||
.qa-section {
|
|
||||||
background: var(--glass-bg);
|
background: var(--glass-bg);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 16px;
|
padding: 18px 16px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 14px;
|
||||||
}
|
box-shadow: var(--card-shadow, none);
|
||||||
|
}
|
||||||
|
|
||||||
.section-block {
|
.loading-block {
|
||||||
margin-bottom: 16px;
|
text-align: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
&:last-child { margin-bottom: 0; }
|
.para {
|
||||||
|
|
||||||
.section-title {
|
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
|
||||||
color: var(--dim1-color);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.section-content {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.8;
|
line-height: 1.9;
|
||||||
}
|
margin-bottom: 14px;
|
||||||
.section-images {
|
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;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
margin-top: 10px;
|
margin-bottom: 10px;
|
||||||
|
|
||||||
.section-img {
|
.quiz-title {
|
||||||
width: 100%;
|
font-size: 13px;
|
||||||
height: 200px;
|
font-weight: 700;
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-text {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.8;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 问答区域
|
.quiz-question {
|
||||||
.qa-section {
|
|
||||||
.qa-question {
|
|
||||||
.qa-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
.qa-text {
|
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-options {
|
.quiz-option {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
.qa-option {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: var(--glass-bg);
|
background: var(--bg-primary);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1.5px solid var(--glass-border);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-md);
|
||||||
transition: all 0.3s;
|
margin-bottom: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
&.correct {
|
&.selected {
|
||||||
background: rgba(34, 197, 94, 0.15);
|
border-color: var(--color-brand);
|
||||||
border-color: rgba(34, 197, 94, 0.4);
|
background: var(--color-brand-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-letter {
|
.option-letter {
|
||||||
@@ -411,58 +453,117 @@ onShareAppMessage(() => ({
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: var(--bg-tertiary);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.option-text {
|
.option-text {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
line-height: 1.5;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI 底部入口
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.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-banner {
|
.ai-banner {
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
left: 20px;
|
|
||||||
right: 20px;
|
|
||||||
height: 48px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: linear-gradient(135deg, var(--dim1-color), var(--dim3-color));
|
height: 48px;
|
||||||
border-radius: 12px;
|
background: linear-gradient(135deg, var(--color-brand-dark), var(--color-brand));
|
||||||
color: white;
|
border-radius: var(--radius-md);
|
||||||
box-shadow: 0 4px 16px rgba(179, 136, 255, 0.3);
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 16px var(--color-brand-glow);
|
||||||
|
|
||||||
.ai-icon { font-size: 18px; }
|
.ai-text { font-size: 14px; font-weight: 600; color: #fff; }
|
||||||
.ai-text { font-size: 14px; font-weight: 600; }
|
|
||||||
.ai-arrow { font-size: 14px; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom-spacer { height: 24px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,72 +2,96 @@
|
|||||||
<view class="page-dim page-layout">
|
<view class="page-dim page-layout">
|
||||||
<view class="status-bar-placeholder"></view>
|
<view class="status-bar-placeholder"></view>
|
||||||
|
|
||||||
|
<view class="nav-row">
|
||||||
<view class="back-btn" @tap="goBack">
|
<view class="back-btn" @tap="goBack">
|
||||||
<text>← 返回</text>
|
<AppIcon name="back-gray" :size="16" />
|
||||||
|
<text>返回</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 维度横幅 -->
|
<!-- 星域横幅 -->
|
||||||
<view class="dim-banner" :style="{ borderColor: currentDim?.border }">
|
<view class="dim-banner" :style="{ borderColor: currentDim?.border }">
|
||||||
<text class="dim-banner-icon">{{ currentDim?.icon }}</text>
|
<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-title">{{ currentDim?.name }}</text>
|
||||||
<text class="dim-banner-desc">{{ bannerDesc }}</text>
|
<text class="dim-banner-desc">{{ currentDim?.tagline }}</text>
|
||||||
<view class="progress-track">
|
|
||||||
<view class="progress-fill" :style="{ width: progress + '%' }"></view>
|
|
||||||
</view>
|
</view>
|
||||||
<text class="progress-label">探索进度 {{ progress }}% · 已完成 {{ readCount }}/{{ totalCount }} 篇</text>
|
<ProgressRing
|
||||||
|
:size="46"
|
||||||
|
:num-size="10"
|
||||||
|
:percent="litPercent"
|
||||||
|
:color="currentDim?.color"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text class="progress-label">{{ litCount }}/{{ totalCount }} 颗星球已点亮 · {{ litPercent }}%</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 知识列表 -->
|
<!-- 星球列表 -->
|
||||||
<view v-for="item in knowledgeList" :key="item._id" class="knowledge-item" @tap="goDetail(item)">
|
<view
|
||||||
<view class="k-icon" :style="getItemStyle(item)">
|
v-for="item in knowledgeList"
|
||||||
<text>{{ item.icon || '📄' }}</text>
|
: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>
|
||||||
<view class="k-info">
|
<view class="p-info">
|
||||||
<text class="k-title">{{ item.title }}</text>
|
<text class="p-title">{{ item.title }}</text>
|
||||||
<text class="k-desc">{{ item.desc }}</text>
|
<text class="p-desc">{{ item.desc }}</text>
|
||||||
<view class="k-meta">
|
<view class="p-meta">
|
||||||
<text>📖 {{ item.readTime || 5 }} 分钟</text>
|
<view class="p-lit" v-if="item.isLit" :style="{ color: currentDim?.color, background: currentDim?.bg }">已点亮</view>
|
||||||
<text v-if="item.collected">⭐ 已收藏</text>
|
<text class="p-read">{{ item.readTime }} 分钟</text>
|
||||||
<text v-else>🔖 {{ item.collectCount || 0 }} 人收藏</text>
|
<text class="p-quiz" v-if="item.hasQuiz">含检测题</text>
|
||||||
<text class="k-badge" :class="item.isPremium ? 'pro' : 'free'">
|
|
||||||
{{ item.isPremium ? 'Pro' : '免费' }}
|
|
||||||
</text>
|
|
||||||
</view>
|
</view>
|
||||||
</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>
|
||||||
|
|
||||||
<!-- 加载状态 -->
|
|
||||||
<view v-if="loading" class="loading-state">
|
<view v-if="loading" class="loading-state">
|
||||||
<text>加载中...</text>
|
<text>加载中...</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="bottom-spacer"></view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { getDimension } from '@/utils/dimensions'
|
import { getDimension } from '@/utils/dimensions'
|
||||||
import { knowledgeApi } from '@/utils/api'
|
import { knowledgeApi } from '@/utils/api'
|
||||||
|
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 dimId = ref(1)
|
||||||
const currentDim = computed(() => getDimension(dimId.value))
|
const currentDim = computed(() => getDimension(dimId.value))
|
||||||
const knowledgeList = ref([])
|
const knowledgeList = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const progress = ref(35)
|
const litIds = ref([])
|
||||||
const readCount = ref(4)
|
|
||||||
const totalCount = ref(12)
|
|
||||||
|
|
||||||
const bannerDesc = computed(() => {
|
const totalCount = computed(() => knowledgeList.value.length)
|
||||||
const descs = {
|
const litCount = computed(() => knowledgeList.value.filter(i => i.isLit).length)
|
||||||
1: '从 1950 年图灵测试到 2026 年通用人工智能,追溯 70 年演进之路',
|
const litPercent = computed(() =>
|
||||||
2: '从深度学习到 Transformer,从 MoE 到多模态,理解每一次技术跃迁',
|
totalCount.value ? Math.round((litCount.value / totalCount.value) * 100) : 0
|
||||||
3: '大模型格局、Agent 生态、具身智能——2026 年 AI 世界全景图',
|
)
|
||||||
4: '提示词工程、RAG 系统、模型微调、工具链——从入门到上手',
|
|
||||||
5: '每日追踪 AI 前沿动态,不错过每一个重要时刻'
|
const litStyle = computed(() => ({
|
||||||
}
|
background: currentDim.value?.iconBg,
|
||||||
return descs[dimId.value] || '探索人工智能的精彩世界'
|
borderColor: currentDim.value?.border
|
||||||
})
|
}))
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const pages = getCurrentPages()
|
const pages = getCurrentPages()
|
||||||
@@ -81,97 +105,42 @@ onMounted(() => {
|
|||||||
async function loadKnowledge() {
|
async function loadKnowledge() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await knowledgeApi.list({ dim: dimId.value })
|
const [data, stats] = await Promise.all([
|
||||||
|
knowledgeApi.list({ dim: dimId.value, limit: 100 }),
|
||||||
|
knowledgeApi.stats(userStore.isLoggedIn ? userStore.token : '').catch(() => null)
|
||||||
|
])
|
||||||
|
litIds.value = stats?.user?.litIds || []
|
||||||
|
|
||||||
knowledgeList.value = (data.items || []).map(item => ({
|
knowledgeList.value = (data.items || []).map(item => ({
|
||||||
...item,
|
...item,
|
||||||
icon: getDimIcon(item.dim),
|
desc: item.content ? item.content.substring(0, 42) + '...' : '',
|
||||||
desc: item.content?.substring(0, 40) + '...' || '',
|
readTime: Math.max(1, Math.ceil((item.content?.length || 300) / 300)),
|
||||||
readTime: Math.ceil((item.content?.length || 300) / 200),
|
hasQuiz: !!(item.question && (item.options || []).length),
|
||||||
isPremium: false,
|
isLit: litIds.value.includes(String(item._id))
|
||||||
collected: false,
|
|
||||||
collectCount: item.collectionCount
|
|
||||||
}))
|
}))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 用示例数据填充
|
knowledgeList.value = []
|
||||||
knowledgeList.value = getSampleData(dimId.value)
|
uni.showToast({ title: '加载失败,请下拉重试', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
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() {
|
function goBack() {
|
||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
function goDetail(item) {
|
function goDetail(item) {
|
||||||
uni.navigateTo({
|
uni.navigateTo({ url: `/pages/detail/detail?id=${item._id}` })
|
||||||
url: `/pages/detail/detail?id=${item._id}`
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: (currentDim.value?.name || 'AI 维度') + '星域 · 每颗星球都是一则 AI 知识',
|
||||||
path: '/pages/index/index'
|
path: `/pages/dimension/dimension?id=${dimId.value}`
|
||||||
|
}))
|
||||||
|
onShareTimeline(() => ({
|
||||||
|
title: (currentDim.value?.name || 'AI 维度') + '星域 · AI 维度'
|
||||||
}))
|
}))
|
||||||
// #endif
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
@@ -185,107 +154,148 @@ onShareAppMessage(() => ({
|
|||||||
|
|
||||||
.status-bar-placeholder { height: 44px; }
|
.status-bar-placeholder { height: 44px; }
|
||||||
|
|
||||||
|
.nav-row { padding-bottom: 12px; }
|
||||||
.back-btn {
|
.back-btn {
|
||||||
color: rgba(255,255,255,0.4);
|
display: inline-flex;
|
||||||
font-size: 14px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
padding: 10px 0 14px;
|
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 {
|
.dim-banner {
|
||||||
border-radius: 18px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 24px 20px;
|
padding: 18px 16px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 16px;
|
||||||
background: linear-gradient(135deg, rgba(124,77,255,0.1), rgba(0,0,0,0.3));
|
background: var(--glass-bg);
|
||||||
border: 1px solid rgba(124,77,255,0.12);
|
border: 1.5px solid var(--glass-border);
|
||||||
position: relative;
|
box-shadow: var(--card-shadow, none);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-track {
|
.banner-top {
|
||||||
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);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
align-items: center;
|
||||||
align-items: flex-start;
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
.dim-icon {
|
||||||
.k-icon {
|
|
||||||
width: 44px; height: 44px;
|
width: 44px; height: 44px;
|
||||||
border-radius: 12px;
|
border-radius: 14px;
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 18px;
|
flex-shrink: 0;
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
}
|
}
|
||||||
.k-info { flex: 1; }
|
.banner-info { flex: 1; min-width: 0; }
|
||||||
.k-title {
|
.dim-banner-title {
|
||||||
font-size: 14px;
|
|
||||||
color: rgba(255,255,255,0.85);
|
|
||||||
font-weight: 500;
|
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 4px;
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 3px;
|
||||||
}
|
}
|
||||||
.k-desc {
|
.dim-banner-desc {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: rgba(255,255,255,0.3);
|
color: var(--text-tertiary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin-bottom: 8px;
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
.k-meta {
|
.progress-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planet-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 12px;
|
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;
|
align-items: center;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: rgba(255,255,255,0.15);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.k-badge {
|
.p-lit {
|
||||||
font-size: 10px;
|
font-weight: 600;
|
||||||
padding: 2px 10px;
|
padding: 2px 8px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-full);
|
||||||
font-weight: 500;
|
}
|
||||||
|
.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 {
|
.loading-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px 0;
|
padding: 40px 0;
|
||||||
color: rgba(255,255,255,0.2);
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom-spacer { height: 20px; }
|
||||||
</style>
|
</style>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<view class="empty-state" v-else>
|
<view class="empty-state" v-else>
|
||||||
<text class="empty-icon">⭐</text>
|
<text class="empty-icon">还没有收藏</text>
|
||||||
<text class="empty-text" v-if="userStore.isLoggedIn">你还没有收藏任何内容</text>
|
<text class="empty-text" v-if="userStore.isLoggedIn">你还没有收藏任何内容</text>
|
||||||
<text class="empty-text" v-else>登录后可收藏喜欢的 AI 知识</text>
|
<text class="empty-text" v-else>登录后可收藏喜欢的 AI 知识</text>
|
||||||
<text class="empty-sub" v-if="userStore.isLoggedIn">去首页发现有趣的 AI 知识吧</text>
|
<text class="empty-sub" v-if="userStore.isLoggedIn">去首页发现有趣的 AI 知识吧</text>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ const userStore = useUserStore()
|
|||||||
// 反馈类型
|
// 反馈类型
|
||||||
const feedbackTypes = [
|
const feedbackTypes = [
|
||||||
{ value: 'bug', name: '问题报告', icon: '🐛' },
|
{ value: 'bug', name: '问题报告', icon: '🐛' },
|
||||||
{ value: 'feature', name: '功能建议', icon: '💡' },
|
{ value: 'feature', name: '功能建议', icon: '★' },
|
||||||
{ value: 'content', name: '内容纠错', icon: '📝' },
|
{ value: 'content', name: '内容纠错', icon: '📝' },
|
||||||
{ value: 'other', name: '其他', icon: '💬' }
|
{ value: 'other', name: '其他', icon: '💬' }
|
||||||
]
|
]
|
||||||
@@ -147,7 +147,7 @@ function statusLabel(status) {
|
|||||||
function typeLabel(type) {
|
function typeLabel(type) {
|
||||||
const map = {
|
const map = {
|
||||||
bug: '🐛 问题报告',
|
bug: '🐛 问题报告',
|
||||||
feature: '💡 功能建议',
|
feature: '功能建议',
|
||||||
content: '📝 内容纠错',
|
content: '📝 内容纠错',
|
||||||
other: '💬 其他'
|
other: '💬 其他'
|
||||||
}
|
}
|
||||||
@@ -232,7 +232,7 @@ function goBack() {
|
|||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: '帮AI维度变得更好 · 意见反馈',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
// #endif
|
// #endif
|
||||||
|
|||||||
@@ -1,75 +1,92 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page-home page-layout">
|
<view class="page-home page-layout">
|
||||||
<!-- 状态栏占位 -->
|
|
||||||
<view class="status-bar-placeholder"></view>
|
<view class="status-bar-placeholder"></view>
|
||||||
|
|
||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<view class="header">
|
<view class="header">
|
||||||
<view class="brand">
|
<view class="brand">
|
||||||
<view class="brand-icon">✦</view>
|
<view class="brand-icon">
|
||||||
|
<AppIcon name="planet-white" :size="20" />
|
||||||
|
</view>
|
||||||
<view class="brand-text">
|
<view class="brand-text">
|
||||||
<text class="title">AI 维度</text>
|
<text class="title">AI 维度</text>
|
||||||
<text class="subtitle">宇之然 · 探索</text>
|
<text class="subtitle">宇之然 · 知识星图</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="header-actions">
|
<view class="header-actions">
|
||||||
<view class="icon-btn" @tap="onNotify">
|
|
||||||
<text>🔔</text>
|
|
||||||
</view>
|
|
||||||
<view class="icon-btn" @tap="goProfile">
|
<view class="icon-btn" @tap="goProfile">
|
||||||
<text>👤</text>
|
<AppIcon name="user-gray" :size="18" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 问候语 -->
|
<!-- 连续点亮 streak -->
|
||||||
<view class="greeting">
|
<view class="streak-bar" v-if="streak > 0" @tap="goFirstUnlit">
|
||||||
<text class="greeting-title">探索 </text>
|
<AppIcon name="flame" :size="16" />
|
||||||
<text class="greeting-highlight">人工智能</text>
|
<text class="streak-text">已连续点亮 {{ streak }} 天</text>
|
||||||
<text class="greeting-desc">从 5 个维度,系统理解 AI 的过去、现在与未来</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>
|
||||||
|
|
||||||
<!-- 搜索栏 -->
|
<!-- 搜索栏 -->
|
||||||
<view class="search-bar" @tap="onSearch">
|
<view class="search-bar" @tap="onSearch">
|
||||||
<text class="search-icon">🔍</text>
|
<AppIcon name="search-gray" :size="16" />
|
||||||
<text class="search-placeholder">搜搜你想了解的 AI 知识...</text>
|
<text class="search-placeholder">搜搜你想了解的 AI 知识...</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 五维度网格 -->
|
<!-- 五大星域 -->
|
||||||
<view class="section-header">
|
<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>
|
||||||
<view class="dim-grid">
|
<view class="dim-grid">
|
||||||
<view
|
<view
|
||||||
v-for="dim in dimensions"
|
v-for="dim in dimensions"
|
||||||
:key="dim.id"
|
:key="dim.id"
|
||||||
class="dim-card"
|
class="dim-card"
|
||||||
:class="{ 'dim-full': dim.isFull }"
|
|
||||||
:style="{ background: dim.bg }"
|
|
||||||
@tap="goDimension(dim)"
|
@tap="goDimension(dim)"
|
||||||
>
|
>
|
||||||
<view class="dim-icon" :style="{ background: dim.iconBg, color: dim.color }">
|
<view class="dim-top">
|
||||||
<text>{{ dim.icon }}</text>
|
<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>
|
</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>
|
</view>
|
||||||
|
|
||||||
<!-- 今日快讯 -->
|
<!-- 今日快讯 -->
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<text class="section-title">📡 今日快讯</text>
|
<text class="section-title">今日快讯</text>
|
||||||
<text class="section-more" @tap="goTrend">查看全部 →</text>
|
<text class="section-more" @tap="goTrend">查看全部</text>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view v-for="(item, idx) in newsList" :key="idx" class="news-card" @tap="goNewsDetail(item)">
|
||||||
v-for="(item, idx) in newsList"
|
|
||||||
:key="idx"
|
|
||||||
class="news-card"
|
|
||||||
@tap="goNewsDetail(item)"
|
|
||||||
>
|
|
||||||
<view class="news-tag" :class="item.tagClass">{{ item.tag }}</view>
|
<view class="news-tag" :class="item.tagClass">{{ item.tag }}</view>
|
||||||
<text class="news-title">{{ item.title }}</text>
|
<text class="news-title">{{ item.title }}</text>
|
||||||
<view class="news-meta">
|
<view class="news-meta">
|
||||||
@@ -83,26 +100,80 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onPullDownRefresh, onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { DIMENSIONS } from '@/utils/dimensions'
|
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 dimensions = ref(DIMENSIONS)
|
||||||
|
|
||||||
const newsList = ref([])
|
const newsList = ref([])
|
||||||
const loading = ref(true)
|
const byDim = ref({}) // 各星域知识总数
|
||||||
|
const todayKnowledge = ref(null)
|
||||||
|
|
||||||
onMounted(() => {
|
const todayDimName = computed(() => {
|
||||||
fetchNews()
|
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 {
|
try {
|
||||||
const data = await trendApi.list({ limit: 5 })
|
const data = await knowledgeApi.list({ limit: 50, page: 1 })
|
||||||
newsList.value = data.list.map(item => ({
|
const items = (data.items || []).filter((x) => x.content)
|
||||||
tag: item.hot ? '🔥 热门' : item.picked ? '📌 精选' : '🚀 最新',
|
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',
|
tagClass: item.hot ? 'hot' : item.picked ? 'pick' : 'new',
|
||||||
title: item.title,
|
title: item.title,
|
||||||
source: item.source,
|
source: item.source,
|
||||||
@@ -110,17 +181,27 @@ async function fetchNews() {
|
|||||||
_id: item._id
|
_id: item._id
|
||||||
}))
|
}))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 降级:本地数据
|
newsList.value = []
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function formatTime(dateStr) {
|
||||||
const diff = Date.now() - new Date(dateStr).getTime()
|
const diff = Date.now() - new Date(dateStr).getTime()
|
||||||
const hours = Math.floor(diff / 3600000)
|
const hours = Math.floor(diff / 3600000)
|
||||||
@@ -133,6 +214,18 @@ function goDimension(dim) {
|
|||||||
uni.navigateTo({ url: `/pages/dimension/dimension?id=${dim.id}&name=${dim.name}` })
|
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() {
|
function goTrend() {
|
||||||
uni.switchTab({ url: '/pages/trend/trend' })
|
uni.switchTab({ url: '/pages/trend/trend' })
|
||||||
}
|
}
|
||||||
@@ -145,21 +238,19 @@ function onSearch() {
|
|||||||
uni.navigateTo({ url: '/pages/search/search' })
|
uni.navigateTo({ url: '/pages/search/search' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onNotify() {
|
|
||||||
uni.showToast({ title: '暂无新消息', icon: 'none' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function goNewsDetail(item) {
|
function goNewsDetail(item) {
|
||||||
uni.setStorageSync('__news_item__', JSON.stringify(item))
|
uni.setStorageSync('__news_item__', JSON.stringify(item))
|
||||||
uni.navigateTo({ url: '/pages/news/news' })
|
uni.navigateTo({ url: '/pages/news/news' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 微信小程序分享
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI 维度 · 5 分钟点亮一颗 AI 知识星球',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
|
onShareTimeline(() => ({
|
||||||
|
title: 'AI 维度 · 5 分钟点亮一颗 AI 知识星球'
|
||||||
|
}))
|
||||||
// #endif
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -170,15 +261,13 @@ onShareAppMessage(() => ({
|
|||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar-placeholder {
|
.status-bar-placeholder { height: 44px; }
|
||||||
height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 6px 0 20px;
|
padding: 6px 0 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -188,38 +277,32 @@ onShareAppMessage(() => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-icon {
|
.brand-icon {
|
||||||
width: 36px;
|
width: 38px;
|
||||||
height: 36px;
|
height: 38px;
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(0,188,212,0.2));
|
background: linear-gradient(135deg, var(--color-brand), #8b5cf6);
|
||||||
border: 1px solid rgba(124,77,255,0.2);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 18px;
|
box-shadow: 0 4px 14px var(--color-brand-glow);
|
||||||
box-shadow: 0 0 20px rgba(124,77,255,0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-text {
|
.brand-text { display: flex; flex-direction: column; }
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.brand-text .title {
|
.brand-text .title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
.brand-text .subtitle {
|
.brand-text .subtitle {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-tertiary);
|
||||||
letter-spacing: 2px;
|
letter-spacing: 1.5px;
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions { display: flex; gap: 10px; }
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
width: 36px; height: 36px;
|
width: 36px; height: 36px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -228,167 +311,188 @@ onShareAppMessage(() => ({
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 15px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.greeting {
|
/* streak */
|
||||||
margin-bottom: 20px;
|
.streak-bar {
|
||||||
padding: 24px 20px;
|
display: flex;
|
||||||
border-radius: 18px;
|
align-items: center;
|
||||||
background: linear-gradient(135deg, rgba(99,102,241,0.08), rgba(139,92,246,0.04));
|
gap: 8px;
|
||||||
border: 1px solid rgba(99,102,241,0.1);
|
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 {
|
.streak-text {
|
||||||
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;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--dim5-color);
|
||||||
|
}
|
||||||
|
.streak-sub {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 11px;
|
||||||
color: var(--text-tertiary);
|
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);
|
background: var(--glass-bg);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: 14px;
|
box-shadow: var(--card-shadow, none);
|
||||||
padding: 12px 16px;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 24px;
|
padding: 12px 16px;
|
||||||
backdrop-filter: blur(10px);
|
border-radius: var(--radius-full);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
.search-icon { color: var(--text-muted); }
|
.search-placeholder {
|
||||||
.search-placeholder { color: var(--text-muted); font-size: 14px; }
|
font-size: 13px;
|
||||||
|
|
||||||
.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;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 星域网格 */
|
||||||
.dim-grid {
|
.dim-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dim-card {
|
.dim-card {
|
||||||
border-radius: 18px;
|
background: var(--glass-bg);
|
||||||
padding: 20px 16px;
|
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
backdrop-filter: blur(10px);
|
border-radius: var(--radius-lg);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
padding: 14px;
|
||||||
}
|
box-shadow: var(--card-shadow, none);
|
||||||
.dim-full {
|
transition: transform 0.2s ease;
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
}
|
||||||
|
.dim-card:active { transform: scale(0.97); }
|
||||||
|
|
||||||
|
.dim-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
.dim-icon {
|
.dim-icon {
|
||||||
width: 42px; height: 42px;
|
width: 34px; height: 34px;
|
||||||
border-radius: 12px;
|
border-radius: 11px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 20px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dim-name {
|
.dim-name {
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
display: block;
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 3px;
|
||||||
}
|
}
|
||||||
.dim-desc {
|
.dim-desc {
|
||||||
font-size: 12px;
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
line-height: 1.5;
|
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 {
|
.dim-count {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 10px;
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 快讯 */
|
||||||
.news-card {
|
.news-card {
|
||||||
border-radius: 14px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
background: var(--glass-bg);
|
background: var(--glass-bg);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
backdrop-filter: blur(10px);
|
border-radius: var(--radius-md);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
box-shadow: var(--card-shadow, none);
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-tag {
|
.news-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 2px 10px;
|
font-weight: 600;
|
||||||
border-radius: 6px;
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
.news-tag.hot {
|
.news-tag.hot { background: var(--color-hot-bg); color: var(--color-hot); }
|
||||||
background: rgba(239,83,80,0.12);
|
.news-tag.new { background: var(--color-new-bg); color: var(--color-new); }
|
||||||
color: #ef5350;
|
.news-tag.pick { background: var(--color-pick-bg); color: var(--color-pick); }
|
||||||
}
|
|
||||||
.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-title {
|
.news-title {
|
||||||
|
display: block;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
font-weight: 500;
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-meta {
|
.news-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
justify-content: space-between;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-spacer {
|
.bottom-spacer { height: 30px; }
|
||||||
height: 40px;
|
</style>
|
||||||
}
|
|
||||||
|
<style scoped>
|
||||||
</style>
|
</style>
|
||||||
@@ -138,7 +138,7 @@ function dimDotColor(id) {
|
|||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI学习排行榜 · 宇之然AI维度',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
// #endif
|
// #endif
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
<text v-if="loginAgreed">✓</text>
|
<text v-if="loginAgreed">✓</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="agreement-text">
|
<text class="agreement-text">
|
||||||
登录即表示同意
|
我已阅读并同意
|
||||||
<text class="agreement-link" @click.stop="openAgreement('terms')">《用户协议》</text>
|
<text class="agreement-link" @click.stop="openAgreement('terms')">《用户协议》</text>
|
||||||
和
|
和
|
||||||
<text class="agreement-link" @click.stop="openAgreement('privacy')">《隐私政策》</text>
|
<text class="agreement-link" @click.stop="openAgreement('privacy')">《隐私政策》</text>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<!-- 微信小程序一键登录 -->
|
<!-- 微信小程序一键登录 -->
|
||||||
<!-- #ifdef MP-WEIXIN -->
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
<view class="wx-login-row" v-if="isMiniApp">
|
<view class="wx-login-row">
|
||||||
<view class="wx-login-btn" @click="wechatLogin">
|
<view class="wx-login-btn" @click="wechatLogin">
|
||||||
<text class="wx-icon">💬</text>
|
<text class="wx-icon">💬</text>
|
||||||
<text>微信一键登录</text>
|
<text>微信一键登录</text>
|
||||||
@@ -129,21 +129,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { askSubscribe } from '../../utils/notify'
|
||||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useUserStore } from '@/stores/index'
|
import { useUserStore } from '@/stores/index'
|
||||||
|
|
||||||
const isMiniApp = ref(false)
|
|
||||||
// #ifdef MP-WEIXIN
|
|
||||||
try {
|
|
||||||
const sysInfo = uni.getSystemInfoSync()
|
|
||||||
if (sysInfo && sysInfo.version && sysInfo.platform) {
|
|
||||||
isMiniApp.value = true
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
const activeTab = ref('login')
|
const activeTab = ref('login')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const agreed = ref(false)
|
const agreed = ref(false)
|
||||||
@@ -217,9 +208,11 @@ const wechatLogin = async () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
await userStore.login(loginRes.code, {})
|
await userStore.login(loginRes.code, {})
|
||||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||||
|
setTimeout(() => askSubscribe(), 800) // 登录成功后请求订阅授权(接受新版通知等)
|
||||||
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
|
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
const msg = (err && (err.message || err.errMsg)) || ''
|
||||||
|
uni.showToast({ title: msg ? `登录失败:${msg}` : '登录失败,请重试', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -244,6 +237,7 @@ const handleLogin = async () => {
|
|||||||
: { username: identifier, password: loginForm.value.password }
|
: { username: identifier, password: loginForm.value.password }
|
||||||
await userStore.h5Login(loginData)
|
await userStore.h5Login(loginData)
|
||||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||||
|
setTimeout(() => askSubscribe(), 800) // 登录成功后请求订阅授权(接受新版通知等)
|
||||||
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
|
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
uni.showToast({ title: err.message || '登录失败', icon: 'none' })
|
uni.showToast({ title: err.message || '登录失败', icon: 'none' })
|
||||||
@@ -323,7 +317,7 @@ function goForgotPassword() {
|
|||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: '登录解锁AI学习完整功能 · 宇之然AI维度',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
// #endif
|
// #endif
|
||||||
|
|||||||
@@ -11,11 +11,6 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<scroll-view scroll-y class="content">
|
<scroll-view scroll-y class="content">
|
||||||
<!-- 资讯头图 -->
|
|
||||||
<view class="news-hero" :style="{background: heroBg}">
|
|
||||||
<text class="hero-emoji">{{ heroEmoji }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 分类标签 -->
|
<!-- 分类标签 -->
|
||||||
<view class="category-tag">
|
<view class="category-tag">
|
||||||
<text class="tag-text">{{ categoryLabel }}</text>
|
<text class="tag-text">{{ categoryLabel }}</text>
|
||||||
@@ -39,7 +34,7 @@
|
|||||||
<text class="meta-value">{{ news.viewCount || 0 }} 次</text>
|
<text class="meta-value">{{ news.viewCount || 0 }} 次</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="meta-item" v-if="news.hot">
|
<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>
|
<text class="meta-value">{{ news.hotCount || 0 }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -50,6 +45,14 @@
|
|||||||
<text class="summary-text">{{ news.summary }}</text>
|
<text class="summary-text">{{ news.summary }}</text>
|
||||||
</view>
|
</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">
|
<view class="news-body" v-if="newsBody">
|
||||||
<rich-text :nodes="newsBody"></rich-text>
|
<rich-text :nodes="newsBody"></rich-text>
|
||||||
@@ -91,6 +94,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { trendApi, knowledgeApi } from '@/utils/api'
|
import { trendApi, knowledgeApi } from '@/utils/api'
|
||||||
@@ -110,7 +115,8 @@ const heroBg = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const heroEmoji = 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(() => {
|
const categoryLabel = computed(() => {
|
||||||
@@ -169,16 +175,37 @@ function goBack() {
|
|||||||
uni.navigateBack()
|
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
|
isCollected.value = !isCollected.value
|
||||||
uni.showToast({ title: isCollected.value ? '已收藏' : '已取消收藏', icon: 'none' })
|
uni.showToast({ title: isCollected.value ? '已收藏' : '已取消收藏', icon: 'none' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onShare() {
|
function onShare() {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
uni.showModal({
|
||||||
|
title: '分享给朋友',
|
||||||
|
content: '点击右上角「···」→ 转发给朋友或分享到朋友圈',
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
// #ifdef H5
|
||||||
uni.setClipboardData({
|
uni.setClipboardData({
|
||||||
data: news.value.link || `https://ai-dimension.yuzhiran.com.cn/news/${news.value._id || ''}`,
|
data: news.value.sourceUrl || `https://wdkj.yuzhiran.com.cn/trends/${news.value._id || ''}`,
|
||||||
success: () => uni.showToast({ title: '链接已复制', icon: 'none' })
|
success: () => uni.showToast({ title: '链接已复制', icon: 'none' })
|
||||||
})
|
})
|
||||||
|
// #endif
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSource() {
|
function openSource() {
|
||||||
|
|||||||
@@ -73,10 +73,15 @@
|
|||||||
>
|
>
|
||||||
<text class="pay-btn-text">立即支付 ¥{{ currentPrice }}</text>
|
<text class="pay-btn-text">立即支付 ¥{{ currentPrice }}</text>
|
||||||
</button>
|
</button>
|
||||||
<text class="pay-agreement">
|
<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>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="footer-spacer"></view>
|
<view class="footer-spacer"></view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
@@ -143,6 +148,10 @@ function isH5() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openAgreement(type) {
|
||||||
|
uni.navigateTo({ url: '/pages/agreement/agreement?type=' + (type || 'terms') })
|
||||||
|
}
|
||||||
|
|
||||||
async function handlePay() {
|
async function handlePay() {
|
||||||
if (!userStore.isLoggedIn) {
|
if (!userStore.isLoggedIn) {
|
||||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||||
@@ -282,7 +291,7 @@ function goBack() {
|
|||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI知识社区 · 宇之然AI维度',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
// #endif
|
// #endif
|
||||||
@@ -577,3 +586,8 @@ onShareAppMessage(() => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ function goBack() {
|
|||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: '我的AI学习进度 · 宇之然AI维度',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
// #endif
|
// #endif
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
@confirm="doSearch"
|
@confirm="doSearch"
|
||||||
confirm-type="search"
|
confirm-type="search"
|
||||||
/>
|
/>
|
||||||
<view class="search-icon">🔍</view>
|
<view class="search-icon"></view>
|
||||||
<view v-if="searchText" class="clear-btn" @click="clearSearch">✕</view>
|
<view v-if="searchText" class="clear-btn" @click="clearSearch">✕</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -31,11 +31,13 @@
|
|||||||
<text class="loading-text">搜索中...</text>
|
<text class="loading-text">搜索中...</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 空搜索提示 -->
|
<!-- 未搜索:热门词快捷入口 -->
|
||||||
<view class="empty-state" v-else-if="!hasSearched">
|
<view class="empty-state" v-else-if="!hasSearched">
|
||||||
<text class="empty-icon">🔍</text>
|
<text class="empty-icon">🔍</text>
|
||||||
<text class="empty-text">输入关键词搜索 AI 知识</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>
|
</view>
|
||||||
|
|
||||||
<!-- 无结果提示 -->
|
<!-- 无结果提示 -->
|
||||||
@@ -92,6 +94,11 @@ import { knowledgeApi } from '@/utils/api'
|
|||||||
import { DIMENSIONS } from '@/utils/dimensions'
|
import { DIMENSIONS } from '@/utils/dimensions'
|
||||||
|
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
|
const HOT_WORDS = ['大模型', 'Transformer', 'DeepSeek', '提示词', 'AI绘画', '智能体', 'RAG', '具身智能']
|
||||||
|
function quickSearch(w) {
|
||||||
|
searchText.value = w
|
||||||
|
doSearch()
|
||||||
|
}
|
||||||
const results = ref([])
|
const results = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const hasSearched = ref(false)
|
const hasSearched = ref(false)
|
||||||
@@ -192,7 +199,7 @@ onUnmounted(() => {
|
|||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: '搜一搜想了解的AI知识 · 宇之然AI维度',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
// #endif
|
// #endif
|
||||||
@@ -395,4 +402,20 @@ onShareAppMessage(() => ({
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary);
|
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>
|
</style>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<view class="section-title">偏好设置</view>
|
<view class="section-title">偏好设置</view>
|
||||||
<view class="card">
|
<view class="card">
|
||||||
<view class="setting-item" @click="togglePush">
|
<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">
|
<view class="s-text">
|
||||||
<text class="s-label">推送通知</text>
|
<text class="s-label">推送通知</text>
|
||||||
<text class="s-desc">接收每日 AI 趋势推送</text>
|
<text class="s-desc">接收每日 AI 趋势推送</text>
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
<view class="s-switch-knob" :class="{ on: pushOn }"></view>
|
<view class="s-switch-knob" :class="{ on: pushOn }"></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<!-- #ifdef H5 -->
|
||||||
<view class="setting-item" @click="toggleDarkMode">
|
<view class="setting-item" @click="toggleDarkMode">
|
||||||
<view class="s-icon" style="background:rgba(77,208,225,0.1);color:#4dd0e1">🌙</view>
|
<view class="s-icon" style="background:rgba(77,208,225,0.1);color:#4dd0e1">🌙</view>
|
||||||
<view class="s-text">
|
<view class="s-text">
|
||||||
@@ -30,6 +31,17 @@
|
|||||||
<view class="s-switch-knob" :class="{ on: darkMode }"></view>
|
<view class="s-switch-knob" :class="{ on: darkMode }"></view>
|
||||||
</view>
|
</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>
|
</view>
|
||||||
|
|
||||||
<!-- 其他 -->
|
<!-- 其他 -->
|
||||||
@@ -59,11 +71,12 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<text class="version">宇之然 AI 维度 v1.0.0</text>
|
<text class="version">宇之然 AI 维度 v{{ appVersion }}</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { APP_VERSION as appVersion } from '@/utils/version'
|
||||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
@@ -84,6 +97,14 @@ function togglePush() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showDarkTip() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '深色模式',
|
||||||
|
content: '小程序深色模式跟随系统:在手机的 系统设置 → 显示与亮度 中切换深色/浅色,小程序将自动适配。',
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function toggleDarkMode() {
|
function toggleDarkMode() {
|
||||||
darkMode.value = !darkMode.value
|
darkMode.value = !darkMode.value
|
||||||
const theme = darkMode.value ? 'dark' : 'light'
|
const theme = darkMode.value ? 'dark' : 'light'
|
||||||
@@ -123,7 +144,7 @@ function applyTabBarTheme(theme) {
|
|||||||
function showAbout() {
|
function showAbout() {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '关于「宇之然AI维度」',
|
title: '关于「宇之然AI维度」',
|
||||||
content: '宇之然 AI 维度 v1.0.0\n从 5 个维度系统理解人工智能\n© 2026 宇之然',
|
content: `宇之然AI维度 v${appVersion}\n从 5 个维度系统理解人工智能\n© 2026 宇之然`,
|
||||||
showCancel: false
|
showCancel: false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -147,7 +168,7 @@ function goBack() {
|
|||||||
// 微信小程序分享
|
// 微信小程序分享
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
title: 'AI知识社区 · 宇之然AI维度',
|
||||||
path: '/pages/index/index'
|
path: '/pages/index/index'
|
||||||
}))
|
}))
|
||||||
// #endif
|
// #endif
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<text class="header-desc">每天精选 AI 行业最新动态</text>
|
<text class="header-desc">每天精选 AI 行业最新动态</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 分类筛选 -->
|
<!-- 分类筛选:紧凑胶囊单行 -->
|
||||||
<view class="filter-bar">
|
<view class="filter-bar">
|
||||||
<view
|
<view
|
||||||
class="filter-item"
|
class="filter-item"
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
:key="idx"
|
:key="idx"
|
||||||
@click="activeFilter = idx"
|
@click="activeFilter = idx"
|
||||||
>
|
>
|
||||||
<text class="filter-icon">{{ tag.icon }}</text>
|
<AppIcon :name="tag.icon" :size="12" />
|
||||||
<text class="filter-label">{{ tag.text }}</text>
|
<text class="filter-label">{{ tag.text }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -41,11 +41,23 @@
|
|||||||
{{ item.summary }}
|
{{ item.summary }}
|
||||||
</text>
|
</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">
|
<view class="card-footer">
|
||||||
<text class="card-date">{{ formatDate(item.newsDate) }}</text>
|
<text class="card-date">{{ formatDate(item.newsDate) }}</text>
|
||||||
<view class="card-stats">
|
<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>
|
<text class="stat-item">👁️ {{ item.viewCount || 0 }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -56,7 +68,7 @@
|
|||||||
<text>加载中...</text>
|
<text>加载中...</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-else-if="displayItems.length === 0" class="empty-state">
|
<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-text">暂无趋势资讯</text>
|
||||||
<text class="empty-sub">稍后再来看最新 AI 动态</text>
|
<text class="empty-sub">稍后再来看最新 AI 动态</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -65,22 +77,31 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { trendApi } from '@/utils/api'
|
import { trendApi } from '@/utils/api'
|
||||||
|
import AppIcon from '@/components/AppIcon.vue'
|
||||||
|
|
||||||
const filters = ref([
|
const filters = ref([
|
||||||
{ icon: '📰', text: '全部' },
|
{ icon: 'sparkle', text: '全部' },
|
||||||
{ icon: '🚀', text: '产品' },
|
{ icon: 'rocket', text: '产品' },
|
||||||
{ icon: '📄', text: '论文' },
|
{ icon: 'book', text: '论文' },
|
||||||
{ icon: '🔧', text: '工具' },
|
{ icon: 'settings', text: '工具' },
|
||||||
{ icon: '🏢', text: '公司' }
|
{ icon: 'user', text: '公司' }
|
||||||
])
|
])
|
||||||
const activeFilter = ref(0)
|
const activeFilter = ref(0)
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
try {
|
||||||
|
const data = await trendApi.list({})
|
||||||
|
if (typeof setList === "function") setList(data)
|
||||||
|
} catch (e) {}
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchTrends()
|
fetchTrends()
|
||||||
})
|
})
|
||||||
@@ -100,17 +121,16 @@ async function fetchTrends() {
|
|||||||
items.value = data.list || data.items || []
|
items.value = data.list || data.items || []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('获取趋势失败:', err)
|
console.error('获取趋势失败:', err)
|
||||||
// 降级:本地示例
|
uni.showToast({ title: '加载失败,下拉重试', icon: 'none' })
|
||||||
items.value = getSampleData()
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTagText(item) {
|
function getTagText(item) {
|
||||||
if (item.hot) return '🔥 热门'
|
if (item.hot) return '热门'
|
||||||
if (item.picked) return '📌 精选'
|
if (item.picked) return '精选'
|
||||||
if (item.category === '产品') return '🚀 产品'
|
if (item.category === '产品') return '产品'
|
||||||
if (item.category === '论文') return '📄 论文'
|
if (item.category === '论文') return '📄 论文'
|
||||||
if (item.category === '工具') return '🔧 工具'
|
if (item.category === '工具') return '🔧 工具'
|
||||||
if (item.category === '公司') return '🏢 公司'
|
if (item.category === '公司') return '🏢 公司'
|
||||||
@@ -138,63 +158,20 @@ function formatDate(dateStr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goDetail(item) {
|
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.setStorageSync('__news_item__', JSON.stringify(item))
|
||||||
uni.navigateTo({ url: '/pages/news/news' })
|
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
|
// #ifdef MP-WEIXIN
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => {
|
||||||
title: 'AI 维度 - 你的AI知识助手',
|
const latest = (items.value || [])[0]
|
||||||
path: '/pages/index/index'
|
return {
|
||||||
}))
|
title: latest ? `今日AI速递:${String(latest.title).slice(0, 24)}… · AI维度` : 'AI 维度 · 每天 5 分钟看懂 AI 大事',
|
||||||
|
path: '/pages/trend/trend'
|
||||||
|
}
|
||||||
|
})
|
||||||
// #endif
|
// #endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -231,38 +208,33 @@ onShareAppMessage(() => ({
|
|||||||
.filter-bar {
|
.filter-bar {
|
||||||
padding: 0 20px 12px;
|
padding: 0 20px 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-item {
|
.filter-item {
|
||||||
padding: 8px 14px 6px;
|
flex-shrink: 0;
|
||||||
|
padding: 6px 11px;
|
||||||
background: var(--glass-bg);
|
background: var(--glass-bg);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: 12px;
|
border-radius: 999px;
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 4px;
|
||||||
min-width: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-label {
|
.filter-label {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
line-height: 1.2;
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-item.active {
|
.filter-item.active {
|
||||||
background: rgba(124, 77, 255, 0.15);
|
background: var(--color-brand-bg);
|
||||||
border-color: rgba(124, 77, 255, 0.3);
|
border-color: var(--color-brand);
|
||||||
color: var(--dim1-color);
|
color: var(--color-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 列表
|
// 列表
|
||||||
@@ -303,7 +275,7 @@ onShareAppMessage(() => ({
|
|||||||
|
|
||||||
.card-tag.pick {
|
.card-tag.pick {
|
||||||
background: rgba(124, 77, 255, 0.12);
|
background: rgba(124, 77, 255, 0.12);
|
||||||
color: #b388ff;
|
color: var(--color-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-tag.product {
|
.card-tag.product {
|
||||||
@@ -313,7 +285,7 @@ onShareAppMessage(() => ({
|
|||||||
|
|
||||||
.card-tag.paper {
|
.card-tag.paper {
|
||||||
background: rgba(179, 136, 255, 0.12);
|
background: rgba(179, 136, 255, 0.12);
|
||||||
color: #b388ff;
|
color: var(--color-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-tag.tool {
|
.card-tag.tool {
|
||||||
@@ -396,3 +368,29 @@ onShareAppMessage(() => ({
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
</style>
|
</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 |
|
After Width: | Height: | Size: 1.8 KiB |