Compare commits

...

111 Commits

Author SHA1 Message Date
Hermes Agent 8b9ff56360 fix: 管理后台登录态过期不再停留空数据页——任何接口401自动清token弹登录框 2026-09-22 21:44:52 +08:00
Hermes Agent df8dd3d3dc fix: admin.js解构补NotifyLog(通知接口500修复)+配置模板ID 2026-09-20 20:58:31 +08:00
Hermes Agent 726e5125a7 fix(critical): 登录页白屏——notify.js的import.meta在小程序CJS互操作下编译为__filename访问,模块顶层崩溃;改为编译期常量;版本v1.4.9 2026-09-19 22:06:09 +08:00
Hermes Agent de125dae67 fix: news.vue补userStore定义(v1.4.6引入的收藏报错);登录页isMiniApp简化(去掉v-if由条件编译保证);版本v1.4.8 2026-09-19 21:46:10 +08:00
Hermes Agent a87a631459 ux: 深色模式平台适配——小程序改为跟随系统说明项(原开关在小程序无效),H5保留手动开关;版本v1.4.7 2026-09-19 21:22:19 +08:00
Hermes Agent df52c039d8 fix+feat: 小程序登录按钮不渲染修复(isMiniApp编译期判定);静默登录ensureLogin——收藏/游客问答额度升级一键无感登录;版本v1.4.6 2026-09-19 21:16:00 +08:00
Hermes Agent 65c6802995 chore: 版本 v1.4.5 2026-09-19 20:34:48 +08:00
Hermes Agent feac6371ea ux: 首页今日推荐可点进详情;搜索页热门词;趋势页去假数据/始终进站内详情(阅读量统计生效);资讯详情去大emoji头图/加人话解读卡/分享按钮修复 2026-09-19 20:31:58 +08:00
Hermes Agent 7849bd067e fix(critical): auth.js补userAuthMiddleware导入——缺失导致服务启动崩溃循环、API全502 2026-09-19 20:04:27 +08:00
Hermes Agent f2fa96b81b fix: notify.js改用userStore取token(api.js无getToken导出) 2026-09-19 19:32:16 +08:00
Hermes Agent 6d463b9296 feat: 订阅消息通知体系——登录后请求订阅授权(额度记账)+服务端subscribe/send(access_token缓存/原子扣减/失败回补/发送记录)+管理后台通知推送页;版本v1.4.4 2026-09-19 19:28:40 +08:00
Hermes Agent 9ea09c95f2 feat: 小程序版本更新检测——新版本发布后旧版本冷启动静默下载+弹窗引导重启;版本v1.4.3 2026-09-19 19:06:45 +08:00
Hermes Agent acca4fe379 fix: 补齐兼容推理型模型——content空时取reasoning尾部答案,max_tokens提到2000 2026-09-19 18:58:39 +08:00
Hermes Agent 1d742dc6dd fix: fill-plain导入AIModel(500修复) 2026-09-19 18:56:18 +08:00
Hermes Agent 61069cd095 feat: 知识管理加更新时间列;趋势管理待补筛选+统计+AI批量补齐人话解读接口 2026-09-19 18:51:44 +08:00
Hermes Agent 7cd01d8104 ux: 访问日志默认显示当天 2026-09-19 18:02:09 +08:00
Hermes Agent ce9f69a76e fix: 埋点正则兼容/api前缀(originalUrl含前缀) 2026-09-19 17:02:16 +08:00
Hermes Agent 5f3809831f fix: 访问统计口径与体验——PV只计页面级接口(过滤轮询,今日221→真实口径);埋点记录userId并显示昵称;访问日志Tab合并用户列 2026-09-19 16:59:04 +08:00
Hermes Agent 49946c6d21 feat: 看板最近访问查看更多跳转+访问埋点今日/昨日/7天/30天/来源筛选(visits接口补range/source参数与统计) 2026-09-19 15:53:04 +08:00
Hermes Agent b7d085c475 docs: 代码评审报告(已修复+已接受残留) 2026-09-19 12:01:43 +08:00
Hermes Agent 1e972291d9 security: 遗留清零——aiModel密钥永不下发/搜索正则注入转义/排行榜limit封顶且去openid/删除重复shop-items路由/登录入参强转防对象注入/sessionKey默认不返回 2026-09-19 11:48:27 +08:00
Hermes Agent 5e525b3b7c chore: 版本 v1.4.2(页面标题/分享标题搜索优化) 2026-09-19 09:28:13 +08:00
Hermes Agent ab3d231e73 fix: share-gain防刷改用shareRecords统计(schema strict丢弃了临时字段) 2026-09-19 09:27:24 +08:00
Hermes Agent 15eb4727bb security+fix: 评审修复——下线webview-token任意账号接管;progress接口付费字段只读;检测题答案脱敏;读接口不记日志;share-gain每日限3次防刷;页面标题/分享标题搜索优化 2026-09-19 09:25:29 +08:00
Hermes Agent bcb239c51b seo: 新增/sitemap-baidu.xml纯净站点地图(去llms.txt,规避百度索引型判定) 2026-09-17 18:25:34 +08:00
Hermes Agent 1fc358d47c seo: robots爬虫声明补全国内外全量(AI引擎+传统搜索) 2026-09-17 17:22:30 +08:00
Hermes Agent 1f0b8e7740 feat: 访问埋点记录完整referer——支持推广渠道参数(src=xx)区分来源 2026-09-17 17:10:24 +08:00
Hermes Agent c549eb532d feat: 管理后台IP归属地——移植ip2region离线库,登录日志/访问埋点/看板最近访问统一显示归属地 2026-09-16 07:45:23 +08:00
Hermes Agent c5e12c67fa fix: 看板最近访问记录合并登录+访问埋点(显示昵称/来源/终端/路径),修复只显示登录日志看不到最新访问的问题 2026-09-16 06:49:39 +08:00
Hermes Agent 7fd32685ce fix: 访问日志接口补 pagination 字段(修复埋点Tab总条数显示0) 2026-09-15 21:32:47 +08:00
Hermes Agent f91d8f5418 feat: 访问埋点统计——VisitLog模型+/api GET埋点中间件(小程序/H5/直接访问来源识别)+访问统计接口+看板PV/UV/来源分布卡+日志页访问埋点Tab 2026-09-15 21:29:30 +08:00
Hermes Agent 62fb40e415 fix: H5 路由与资源 base 设为 /h5/——修复公网 /h5/ 入口白屏(资源404) 2026-09-15 20:00:09 +08:00
Hermes Agent 1e8a0aeead chore: 版本 v1.4.1(v1.4.0 已发布,尾号递增) 2026-09-15 15:30:51 +08:00
Hermes Agent b866eb1e70 fix: 备案号补全——京ICP备2026007249号-2+京公网安备11011502039622号(yuzhiran域),我的页/admin两处 2026-09-15 15:23:05 +08:00
Hermes Agent 5959edf37e feat: 页脚信息对齐官网——我的页(小程序/H5)与管理后台加公司版权+联系邮箱+备案链接+微信客服二维码(长按识别) 2026-09-15 13:57:46 +08:00
Hermes Agent 2bc58d0c6a feat: 默认昵称带序号(星辰旅行者1001 起步,跳过占用),与画屿画友方案一致,纯后端无需发版 2026-09-15 12:16:27 +08:00
Hermes Agent 5e098a3584 fix: 管理后台巡检修复——访问日志用户/设备列字段对齐接口(userName/os/browser),订单星尘列无metadata时按金额兜底显示 2026-09-14 22:15:08 +08:00
Hermes Agent c5da01063c feat: 昵称可修改——PUT /api/auth/nickname + 我的页昵称点击编辑 2026-09-14 19:55:28 +08:00
Hermes Agent 6adf9f1c6a feat: 取消退款次数限制(每人每月1次)——保留余额够扣+7天窗口;后台退款弹窗规则文案同步 2026-09-14 16:02:21 +08:00
Hermes Agent 9ab08254e3 security: 微信回调POST验签——明文模式signature校验防伪造支付回调白拿星尘 2026-09-14 15:50:49 +08:00
Hermes Agent 7a5dcf8e0e chore: 小程序版本定版 v1.4.0(充值体验修复,沿用待审版本号重传) 2026-09-14 15:33:46 +08:00
Hermes Agent c7d4158aca fix: 充值体验修复——回调写星尘流水(明细页有记录)+重推幂等+退款回调写流水+充值成功返回我的+我的页onShow刷新余额 2026-09-14 15:21:24 +08:00
txai-dev e6cb577305 fix: 微信退款API参数修正——refund_order正确字段(openid/order_id/refund_order_id/left_fee/refund_fee/refund_reason/req_from/env)替换错误的offerId/outTradeNo 2026-09-14 14:23:44 +08:00
txai-dev e307fe2e50 feat: 用户管理页增强——星尘余额列+调整星尘按钮(免搜openid)+查看订单弹窗+保留加额度 2026-09-14 13:51:25 +08:00
txai-dev b5d6071a3e feat: 调整余额支持用户搜索选择(昵称/openid前缀,不再需要手打28位完整openid)+ API前缀匹配 2026-09-14 13:48:32 +08:00
txai-dev 83d907f2bf fix: apiPost/apiPut/apiDelete 统一检查HTTP状态码——409风控拦截不再被误报为成功 2026-09-14 13:39:44 +08:00
txai-dev 4255e26840 fix: 订单余额页白屏——computed 未导入 2026-09-14 13:35:17 +08:00
txai-dev 01a60ffc25 fix: 退款弹窗修正——星尘数按金额×100兜底(旧订单metadata.coins缺失);去掉强制退款按钮(风控拦截直接展示原因);弹窗内展示退款规则和流程说明 2026-09-14 13:32:02 +08:00
txai-dev 2b00fa49c1 fix: 退款原子化——先冻结星尘(findOneAndUpdate+原子扣减防并发)→再调微信→失败回滚星尘;修复语法残留 2026-09-14 13:23:28 +08:00
txai-dev f6d99aff3c feat: 退款三层风控——余额必须够扣(已消费不可退)+7天窗口+每人每月限1次 2026-09-14 13:03:01 +08:00
txai-dev 7b92a7308d fix: 退款完善——余额预检+二次确认强制退款+顺序注释(先微信退钱再扣星尘)+卡住订单重置 2026-09-14 12:59:42 +08:00
txai-dev 7537f7c89c fix: 退款三修复——coins按金额×100兜底旧订单、调微信xpay/refund_order真实退钱、AdminLog枚举补refund/adjust 2026-09-14 12:50:17 +08:00
txai-dev f13309e6cd feat: 完整支付配套——CoinLedger流水模型+充值/消费/退款全链写流水+管理后台订单/余额/退款/调整/统计页+用户明细页+余额卡明细入口 2026-09-14 12:26:41 +08:00
txai-dev 968396bed5 fix: 回调读取CoinInfo.Quantity嵌套字段+补发到账 2026-09-14 12:20:42 +08:00
txai-dev 26d318b8cb chore: 镜像 v1.5.0(完整构建含全部 vpay 修复) 2026-09-14 12:03:57 +08:00
txai-dev cb978dad25 fix: compose 补 VPAY_* 环境变量透传(OfferID/AppKey 容器内为空导致 INVALID) 2026-09-14 11:59:23 +08:00
txai-dev 418e4bd976 fix: 修复我的页模板嵌套错乱——已登录余额卡与未登录引导卡现在是两个独立平级区块 2026-09-14 11:53:21 +08:00
txai-dev d7e4e33768 fix: 首页精简——删独立今日知识卡,合并进'继续探索/今日推荐'二合一卡位(无阅读记录时显示每日推荐,有则显示继续上次),消除视觉不一致 2026-09-14 11:40:39 +08:00
txai-dev 8fe3029a28 fix: tabBar颜色rgba→hex、我的页去重复五星域进度条(首页已有)、signData补currencyType=CNY修复ILLEGAL_CURRENCY_TYPE 2026-09-14 11:33:40 +08:00
txai-dev 97f75bee35 fix: 我的页去重复充值入口+未登录引导卡;充值页去Pro会员残留+未登录弹窗引导登录 2026-09-14 11:25:57 +08:00
txai-dev d44893d78d feat: 简化为'每天5次免费→充值星尘'模型——问答免费优先星尘扣减(10/次)+充值引导弹窗+我的页星尘余额卡(去掉免费/Pro分层) 2026-09-14 11:07:19 +08:00
txai-dev 3777f67af6 fix: vpay mode 改为 short_series_coin + signData 按字母序 + offerId 字符串化 2026-09-14 11:04:57 +08:00
txai-dev 281cd4877a fix: vpay 导出为直接函数 2026-09-14 10:48:23 +08:00
txai-dev 3c9739381c fix: vpay 导出方式修正为函数模式 2026-09-14 10:47:14 +08:00
txai-dev 5f6c0d717f feat: 虚拟支付完整链路——vpay服务模块(双签名/充值下单/回调到账/退款) + 充值页 + 我的入口 + session_key存储 2026-09-14 10:43:57 +08:00
txai-dev 95a28390dd feat: wxmsg 数据格式JSON兼容;compose v1.3.12 2026-09-14 10:29:27 +08:00
txai-dev d9e0096322 chore: 生产镜像 v1.3.11(微信消息推送入口) 2026-09-14 10:19:28 +08:00
txai-dev e877a18975 feat: 微信统一消息推送入口——echostr握手+xpay事件接收(虚拟支付回调通道) 2026-09-14 10:18:34 +08:00
txai-dev 00fb91356c chore: 生产镜像 v1.3.10(管理端解读配套) 2026-09-13 20:37:50 +08:00
txai-dev 1d6cc71ec7 feat: 配套完善——管理端趋势表可查看/编辑人话解读,看板加解读覆盖率,llms.txt提及解读能力 2026-09-13 20:37:13 +08:00
txai-dev 5f911fed6d chore: 生产镜像 v1.3.9 2026-09-13 19:43:00 +08:00
txai-dev 03a8c864e1 fix: 今日知识卡 ref<any> 在JS中解析为比较表达式导致首页白屏 2026-09-13 19:40:45 +08:00
txai-dev 6323808601 chore: 版本 v1.3.0(趋势人话解读+今日知识卡);采集解读 token 上限提升 2026-09-13 19:33:59 +08:00
txai-dev dec8ab2749 feat: 市场调研落地——趋势AI人话解读(plainExplain/takeAway,采集自动生成+SSR+小程序卡)+ 首页今日知识卡 2026-09-13 19:18:01 +08:00
txai-dev 7210ca4393 fix: 支付页协议区去冗余——协议同意已在登录环节完成(合规勾选),支付页改为一行纯说明链接(无同意语义),减少转化摩擦 2026-09-13 18:57:10 +08:00
txai-dev 336cc3d2c9 fix: 隐私协议合规整改——登录页文案改'我已阅读并同意';订阅支付页新增默认不勾选协议勾选框,未勾选拦截支付并引导(应对审核:不得默认自动同意) 2026-09-13 18:55:14 +08:00
txai-dev c7f879a0cf fix: 管理后台移动端表格给足最小宽左右滑动(列不再挤压) 2026-09-13 18:40:43 +08:00
txai-dev 3f0d0e9b70 chore: 生产镜像 v1.3.8(SSR趋势页) 2026-09-13 18:38:31 +08:00
txai-dev 96d501275a fix: SSR趋势路由真正落盘(上次锚点不存在静默未插入) 2026-09-13 18:35:44 +08:00
txai-dev 1874d74ae7 feat: SEO/GEO升级——SSR趋势列表/详情页、sitemap动态化(每日趋势URL)、llms.txt趋势说明;小程序sitemap.json搜索收录+趋势分享动态标题;看板移动端溢出修复 2026-09-13 18:32:14 +08:00
txai-dev d221a45a5e fix: 管理后台空值修复(users无status字段兜底正常/adminlog时间字段timestamp/知识分类中文/额度列表字段全面修正)+ 弹窗移动端宽度自适应 2026-09-13 18:17:43 +08:00
txai-dev 6a3c048072 chore: 生产镜像 v1.3.6(管理后台升级接口) 2026-09-13 18:05:08 +08:00
txai-dev c5ade459a5 feat: 管理后台全面升级——修复菜单无数据根因(items字段不匹配+trends接口缺失),新增知识管理(审核/检测题编辑)/访问日志/看板v2(最近访问+点亮分布+全量指标),trends CRUD,用户/反馈列表修正 2026-09-13 17:45:29 +08:00
txai-dev a4da3722ef chore: 生产镜像 v1.3.5(登录错误透出) 2026-09-13 13:43:36 +08:00
txai-dev a11bc1b247 fix: 我的页按用户决定移除微信一键登录/暂不登录按钮(订阅卡为登录入口);版本号统一 manifest 单一来源(设置页+我的页);登录错误真实原因透出到前端与日志 2026-09-13 13:42:37 +08:00
txai-dev e872b6d157 feat: v3支付接线商户证书——请求签名用商户证书序列号(WX_PAY_MCH_CERT_SERIAL),PUB_KEY_ID仅用于应答验签 2026-09-13 12:43:34 +08:00
txai-dev 5bd1c5765c chore: 生产镜像标签对齐 v1.3.3 2026-09-13 12:35:29 +08:00
txai-dev f8435b9f5f fix: 下单接口 PAY_NOT_CONFIGURED 返回503+明确文案,其余错误不再泄露内部信息 2026-09-13 12:34:57 +08:00
txai-dev 6a971d3061 feat: 微信支付v3真实下单实现(商户证书就绪即通)+缺证书时明确503提示;certs目录说明 2026-09-13 12:31:44 +08:00
txai-dev 15ab3f0b63 feat: 我的页微信一键登录就地完成(不跳登录页),H5 端走登录页入口——v1.2.0 未提审沿用同版本号 2026-09-13 12:20:12 +08:00
txai-dev 47d3ea8bfa chore: 版本规范落地——本次发布定版 v1.2.0(基线为线上 v1.1.5),上传脚本版本号以 manifest 为唯一事实来源,新增 docs/版本规则.md 2026-09-13 10:34:16 +08:00
txai-dev 4f48b324c5 fix: 趋势页筛选按钮改紧凑单行胶囊(第四个不换行)+ 线性图标修复产品图标不显示 2026-09-13 10:28:39 +08:00
txai-dev 2b2a3ab432 fix: 补齐 Docker 迁移丢失的环境变量透传(微信登录/支付/管理后台/SMTP)——登录失败根因 2026-09-13 10:20:21 +08:00
txai-dev c8e13742f1 ops: 数据持久化挂载(logs/certs 宿主机绑定)+ 全容器内存限额与日志轮转,mongo WiredTiger 缓存 256M 2026-09-13 00:43:42 +08:00
txai-dev ccccad2c1a docs: CHANGELOG v1.3.0 2026-09-13 00:40:44 +08:00
txai-dev d06ae5d058 chore: 版本号 v1.3.0(星图点亮体系重构版) 2026-09-13 00:39:08 +08:00
txai-dev 7b22a9a7b6 fix: H5 index.html no-cache 头(避免发版后旧首页缓存) 2026-09-13 00:38:39 +08:00
txai-dev bb0863cc78 chore: 生产编排 v1.3.1 镜像标签与 SITE_URL 统一为 H5 域名 2026-09-13 00:36:33 +08:00
txai-dev 24f0e038eb fix: mongo 连接重试(容器编排 DNS 竞态导致启动循环重启) 2026-09-13 00:33:46 +08:00
txai-dev 430d125a18 fix: admin 容器 nginx 去掉静态正则 location(与 alias 冲突致 404) 2026-09-13 00:32:33 +08:00
txai-dev b72ec4e5a2 fix: admin nginx 容器路由(dist 在根路径,/admin/ 前缀 rewrite) 2026-09-13 00:31:41 +08:00
txai-dev 4f8dd8be75 build: 生产编排(独立compose/nginx配置/预构建产物)+图标生成脚本 2026-09-12 23:41:17 +08:00
txai-dev 2a111d21c0 feat: v1.3.0 差异化重构——星图点亮体系
P0 修复:
- 详情页正文渲染(content 分段),71 条知识不再空白
- 问答页去登录墙:游客每日 3 次(IP 限流),登录后每日 5 次
- 修正问答配额为真正的每日重置语义

点亮闭环:
- KnowledgeLit 模型 + 服务端判题(POST /:id/quiz,答案不下发)
- /api/knowledge/stats 星域统计 + 点亮进度 + streak + litIds
- AI 批量生成 71 条读后检测题(scripts/generate-quiz.js)

设计系统 2.0:
- 70 个统一线性图标替换全部 emoji 图标
- 主题 token mixin 化,修复 H5 暗色级联 bug,MP 跟随系统(darkmode)
- 首页星域导航(进度环)、我的页学习者档案、星域列表页重写
- 硬编码色清理,误植 map 页移除

服务端加固:
- trust proxy 真实 IP、错误信息脱敏、ask 独立限流、无效 ObjectId 404
- seo.js ESM→CJS(修复容器启动)、AI 导师"维度精灵"人格化
2026-09-12 23:39:05 +08:00
txai-dev cbc1a587f2 docs: 差异化重构升级方案——核心闭环断裂诊断(详情页sections空导致全线空白/问答登录墙)+ 设计系统2.0 + 星图点亮体系 2026-09-12 22:42:19 +08:00
txai-dev 8c33617073 chore: 整合部署文件与管理端源码入仓
- deploy/server.Dockerfile、deploy/client-h5.Dockerfile(自 ai-art 仓库迁出,去重)
- docker-compose.yml 迁入并修正路径(admin 上下文 ./admin),新增可选 h5 服务(profile 隔离)
- admin/:管理前端完整源码(Vite+Vue3,自 workspace/dimension-admin 合并)
2026-09-12 22:34:52 +08:00
Yuzhiran Dev 92d54adabb feat: 地图闯关——知识岛地图+知识关问答页,对比度微调,SEO路由,v1.2.7 2026-09-12 18:55:45 +08:00
Yuzhiran Dev 1f066a09cc chore: 版本号 v1.1.5(分享优化+下拉刷新+触感反馈+SEO路由) 2026-09-05 12:50:05 +08:00
Yuzhiran Dev ea7816bae4 feat: dimension SEO/GEO——robots/sitemap/llms.txt/about 站长验证文件 + 体验优化 2026-09-05 12:24:17 +08:00
Yuzhiran Dev 9870bee3b8 feat: 体验优化——探索/趋势页下拉刷新、触感反馈工具函数
- pages.json: 探索/趋势页开启 enablePullDownRefresh
- 新增 utils/haptics.js: 关键操作轻震动反馈(仅微信端)
- 探索页接入 onPullDownRefresh 重新拉取趋势数据
2026-09-05 08:41:38 +08:00
Yuzhiran Dev 66de1dc396 feat: 微信分享钩子全页面接入 + 订阅信息结构化映射 + JWT 错误处理加强
- client: 全部页面接入 onShareAppMessage/onShareTimeline 分享钩子
- client: login 页增强、profile 订阅展示优化
- client/stores: 订阅字段扁平结构映射为 subInfo(plan/expiresAt/startDate)
- server: auth 路由 JWT 无效/过期统一返回 401 未授权响应
- server: models/tests 同步更新
2026-09-04 11:47:29 +08:00
Yuzhiran Dev de8ca9c420 升级pro页:删除选择套餐,保留Pro/VIP方案卡;支付时校验金额匹配;修正product ID为下划线格式 2026-07-14 14:46:07 +08:00
174 changed files with 12766 additions and 6096 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
*.log
.env
+6 -1
View File
@@ -23,4 +23,9 @@ Thumbs.db
.idea/
.vscode/
*.swp
*.swo
*.swo
client/src/.share-done
admin/node_modules/
admin/dist/
server/data/
!server/data/certs/README.md
+13
View File
@@ -1,5 +1,18 @@
# Changelog
## v1.2.0(2026-09-13)星图点亮体系重构
- **登录修复**:微信一键登录恢复(Docker 迁移环境变量补齐 + 容器网络修复);趋势页筛选按钮单行紧凑化、产品图标修复
- **我的页就地登录**:小程序端"微信一键登录"点击直接登录(首次协议确认一次),不再跳登录页;登录页保留为账号登录入口(H5 端按钮改为"登录/注册")
- **详情页修复**:正文按段落渲染,71 条知识不再"暂无内容"
- **读后检测**:AI 批量生成 36 道检测题,服务端判题,答对点亮星球(点亮进度/streak 入档案)
- **问答去登录墙**:游客每日 3 次(IP 限流)、登录每日 5 次;AI 导师「维度精灵」人格化
- **设计系统 2.0**:70 个线性图标替换 emoji、主题 token 化、H5/小程序深色模式、首页星域导航、我的页学习者档案
- **服务端加固**:trust proxy 真实 IP、错误脱敏、ask 独立限流、检测题答案不下发、mongo 连接重试
- **部署**:全栈 Docker 化(mongo/server/admin/h5 四容器),旧 systemd/裸 mongo 下线清理
## v1.1.0(2026-07-10)
### 项目重构
+11
View File
@@ -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
+5
View File
@@ -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>
+1428
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+136
View File
@@ -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>
+56
View File
@@ -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)
}
+6
View File
@@ -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')
+122
View File
@@ -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>
+39
View File
@@ -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>
+144
View File
@@ -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>
+105
View File
@@ -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>
+103
View File
@@ -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>
+82
View File
@@ -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>
+117
View File
@@ -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>
+168
View File
@@ -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>
+253
View File
@@ -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>
+14
View File
@@ -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' }
})
+32 -6
View File
@@ -1,15 +1,41 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta name="sogou_site_verification" content="bZ8oAfjvQm" />
<meta name="360-site-verification" content="0cc596886cce77073d8fcba9d5ec7d9c" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<title>宇之然AI维度 - AI 知识探索社区</title>
<meta name="description" content="宇之然AI维度 — 从5个维度系统理解人工智能:AI起源、发展、当前格局、学习工具、趋势跟踪。" />
<meta name="keywords" content="宇之然AI维度,AI知识,人工智能学习,AI科普,AI趋势,大模型学习" />
<title>宇之然AI维度 - AI知识探索社区 | 五维度系统学AI</title>
<meta name="description" content="宇之然AI维度:从起源、发展、格局、工具、趋势五个维度,系统理解人工智能。每日更新AI行业趋势与热点,支持问答、收藏与学习进度跟踪。" />
<meta name="keywords" content="宇之然AI维度,AI知识,AI学习,人工智能科普,AI趋势,AI大模型,AI问答,AI工具" />
<meta name="author" content="宇之然" />
<meta name="robots" content="index,follow" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="宇之然AI维度" />
<meta property="og:title" content="宇之然AI维度 - AI知识探索社区" />
<meta property="og:description" content="五维度系统学AI:起源、发展、格局、工具、趋势。每日AI行业热点更新。" />
<meta property="og:locale" content="zh_CN" />
<meta name="twitter:card" content="summary" />
<meta name="format-detection" content="telephone=no" />
<meta name="theme-color" content="#6366f1" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "宇之然AI维度",
"description": "从五个维度系统理解人工智能的AI知识探索社区:AI起源、发展、当前格局、学习工具、趋势跟踪。",
"applicationCategory": "EducationalApplication",
"operatingSystem": "Web, 微信小程序",
"inLanguage": "zh-CN",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "CNY" }
}
</script>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"></div>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
</html>
+4 -4
View File
@@ -1,5 +1,5 @@
allowBuilds:
'@parcel/watcher': set this to true or false
core-js: set this to true or false
esbuild: set this to true or false
vue-demi: set this to true or false
'@parcel/watcher': true
core-js: true
esbuild: true
vue-demi: true
+95 -203
View File
@@ -8,242 +8,103 @@
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
onLaunch(() => {
console.log('[AI维度] App launched')
initTheme()
// #ifdef MP-WEIXIN
// 版本更新检测:新版本发布后,旧版本用户冷启动时静默下载、弹窗引导重启
const um = uni.getUpdateManager && uni.getUpdateManager()
if (um) {
um.onUpdateReady(() => {
uni.showModal({
title: '更新提示',
content: '新版本已经准备好,是否重启应用?',
confirmText: '立即重启',
cancelText: '稍后',
success: (m) => {
if (m.confirm) um.applyUpdate()
},
})
})
um.onUpdateFailed(() => {
uni.showModal({
title: '更新提示',
content: '新版本下载失败,请删除当前小程序后重新搜索打开',
showCancel: false,
})
})
}
// #endif
})
onShow(() => {
console.log('[AI维度] App shown')
})
onHide(() => {
console.log('[AI维度] App hidden')
})
onShow(() => {})
onHide(() => {})
// 主题:'auto'(默认,跟随系统)| 'light' | 'dark'
function initTheme() {
const theme = uni.getStorageSync('theme') || 'light'
const theme = uni.getStorageSync('theme') || 'auto'
applyTheme(theme)
// auto 模式下监听系统切换(H5)
if (theme === 'auto' && typeof window !== 'undefined' && window.matchMedia) {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onChange = () => applyTheme('auto')
if (mq.addEventListener) mq.addEventListener('change', onChange)
else if (mq.addListener) mq.addListener(onChange)
}
}
function resolveTheme(theme) {
if (theme !== 'auto') return theme
if (typeof window !== 'undefined' && window.matchMedia) {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
return 'light'
}
function applyTheme(theme) {
const resolved = resolveTheme(theme)
try {
// H5 环境:操作 document class
const root = document.documentElement
if (theme === 'dark') {
root.classList.add('theme-dark')
root.classList.remove('theme-light')
} else {
root.classList.remove('theme-dark')
root.classList.add('theme-light')
}
root.classList.remove('theme-dark', 'theme-light')
root.classList.add(resolved === 'dark' ? 'theme-dark' : 'theme-light')
} catch (e) {
// 小程序环境:document 不存在
// 小程序环境:document 不存在,深色由 manifest darkmode + CSS 媒体查询接管
}
// 小程序 tabBar 原生组件同步切换
applyTabBarTheme(theme)
applyTabBarTheme(resolved)
}
function applyTabBarTheme(theme) {
try {
if (theme === 'dark') {
uni.setTabBarStyle({
color: 'rgba(255,255,255,0.35)',
selectedColor: '#a78bfa',
backgroundColor: '#0d0d1a',
color: '#a2a2b8',
selectedColor: '#a2b2ff',
backgroundColor: '#181828',
borderStyle: 'black'
})
} else {
uni.setTabBarStyle({
color: 'rgba(0,0,0,0.3)',
selectedColor: '#6366f1',
color: '#999999',
selectedColor: '#5b5ff8',
backgroundColor: '#ffffff',
borderStyle: 'white'
})
}
} catch (e) {
// H5 环境 setTabBarStyle 可能不支持
// 非 tabBar 页面或不支持时忽略
}
}
</script>
<style>
<style lang="scss">
/* ============================================
宇之然AI维度 — 全局主题
默认:亮色模式(蓝紫主题) | .theme-dark:暗色模式
变量 mixin 唯一出处:styles/theme.scss
============================================ */
/* ---- 亮色模式(默认:浅底+色彩点缀,现代丰富) ---- */
page, .theme-light {
/* 基础色 — 干净浅底 */
--bg-primary: #f5f5f7;
--bg-secondary: #ffffff;
--bg-tertiary: #e8e8ed;
/* 卡片 — 纯白微阴影 */
--glass-bg: #ffffff;
--glass-border: rgba(0, 0, 0, 0.06);
--glass-hover: #f8f8ff;
--glass-hover-border: rgba(99, 102, 241, 0.15);
--glass-strong: #ffffff;
--glass-dim-bg: rgba(99, 102, 241, 0.04);
/* 文字 — 清晰深色 */
--text-primary: #0d0d1a;
--text-secondary: rgba(13, 13, 26, 0.6);
--text-tertiary: rgba(13, 13, 26, 0.4);
--text-muted: rgba(13, 13, 26, 0.25);
--text-disabled: rgba(13, 13, 26, 0.15);
/* 品牌色 — Indigo 高饱和 */
--color-brand: #5b5ff8;
--color-brand-light: #7b87fd;
--color-brand-dark: #4f46e5;
--color-brand-bg: rgba(91, 95, 248, 0.08);
--color-brand-glow: rgba(91, 95, 248, 0.1);
/* 维度色 — 高饱和度,在浅色底上鲜艳 */
--dim1-color: #5b5ff8;
--dim1-bg: rgba(91, 95, 248, 0.2);
--dim1-icon-bg: rgba(91, 95, 248, 0.25);
--dim1-border: rgba(91, 95, 248, 0.3);
--dim1-glow: rgba(91, 95, 248, 0.08);
--dim2-color: #0095ba;
--dim2-bg: rgba(0, 149, 186, 0.2);
--dim2-icon-bg: rgba(0, 149, 186, 0.25);
--dim2-border: rgba(0, 149, 186, 0.3);
--dim2-glow: rgba(0, 149, 186, 0.08);
--dim3-color: #009b6a;
--dim3-bg: rgba(0, 155, 106, 0.2);
--dim3-icon-bg: rgba(0, 155, 106, 0.25);
--dim3-border: rgba(0, 155, 106, 0.3);
--dim3-glow: rgba(0, 155, 106, 0.08);
--dim4-color: #df7700;
--dim4-bg: rgba(223, 119, 0, 0.2);
--dim4-icon-bg: rgba(223, 119, 0, 0.25);
--dim4-border: rgba(223, 119, 0, 0.3);
--dim4-glow: rgba(223, 119, 0, 0.08);
--dim5-color: #e51c1c;
--dim5-bg: rgba(229, 28, 28, 0.2);
--dim5-icon-bg: rgba(229, 28, 28, 0.25);
--dim5-border: rgba(229, 28, 28, 0.3);
--dim5-glow: rgba(229, 28, 28, 0.08);
/* 功能色 */
--color-free: #009b6a;
--color-free-bg: rgba(0, 155, 106, 0.1);
--color-pro: #df7700;
--color-pro-bg: rgba(223, 119, 0, 0.1);
--color-vip: #e51c1c;
--color-vip-bg: rgba(229, 28, 28, 0.1);
--color-hot: #e51c1c;
--color-hot-bg: rgba(229, 28, 28, 0.12);
--color-new: #0095ba;
--color-new-bg: rgba(0, 149, 186, 0.12);
--color-pick: #5b5ff8;
--color-pick-bg: rgba(91, 95, 248, 0.12);
/* 圆角/间距/过渡 */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-full: 9999px;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 20px;
--spacing-2xl: 24px;
--blur-bg: 20px;
--transition-fast: 0.2s ease;
--transition-normal: 0.3s ease;
--glow-brand: 0 0 20px rgba(99, 102, 241, 0.08);
--glow-brand-strong: 0 0 30px rgba(99, 102, 241, 0.12);
/* 亮色专属:卡片阴影 */
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02);
--card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* ---- 暗色模式 ---- */
.theme-dark {
/* 基础色 */
--bg-primary: #14141e;
--bg-secondary: #181828;
--bg-tertiary: #1c1a34;
--glass-bg: rgba(255, 255, 255, 0.06);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-hover: rgba(255, 255, 255, 0.1);
--glass-hover-border: rgba(255, 255, 255, 0.15);
--glass-strong: rgba(255, 255, 255, 0.12);
--glass-dim-bg: rgba(99, 102, 241, 0.06);
--text-primary: rgba(255, 255, 255, 0.95);
--text-secondary: rgba(255, 255, 255, 0.7);
--text-tertiary: rgba(255, 255, 255, 0.5);
--text-muted: rgba(255, 255, 255, 0.3);
--text-disabled: rgba(255, 255, 255, 0.15);
--color-brand: #7b87fd;
--color-brand-light: #a2b2ff;
--color-brand-dark: #5b5ff8;
--color-brand-bg: rgba(91, 95, 248, 0.12);
--color-brand-glow: rgba(91, 95, 248, 0.15);
--dim1-color: #a485ff;
--dim1-bg: rgba(164, 133, 255, 0.12);
--dim1-icon-bg: rgba(164, 133, 255, 0.2);
--dim1-border: rgba(164, 133, 255, 0.18);
--dim1-glow: rgba(164, 133, 255, 0.08);
--dim2-color: #5ba4ff;
--dim2-bg: rgba(91, 164, 255, 0.12);
--dim2-icon-bg: rgba(91, 164, 255, 0.2);
--dim2-border: rgba(91, 164, 255, 0.18);
--dim2-glow: rgba(91, 164, 255, 0.08);
--dim3-color: #2cda9b;
--dim3-bg: rgba(44, 218, 155, 0.12);
--dim3-icon-bg: rgba(44, 218, 155, 0.2);
--dim3-border: rgba(44, 218, 155, 0.18);
--dim3-glow: rgba(44, 218, 155, 0.08);
--dim4-color: #ffc020;
--dim4-bg: rgba(255, 192, 32, 0.12);
--dim4-icon-bg: rgba(255, 192, 32, 0.2);
--dim4-border: rgba(255, 192, 32, 0.18);
--dim4-glow: rgba(255, 192, 32, 0.08);
--dim5-color: #ff6c82;
--dim5-bg: rgba(255, 108, 130, 0.12);
--dim5-icon-bg: rgba(255, 108, 130, 0.2);
--dim5-border: rgba(255, 108, 130, 0.18);
--dim5-glow: rgba(255, 108, 130, 0.08);
--color-free: #2cda9b;
--color-free-bg: rgba(44, 218, 155, 0.12);
--color-pro: #ffc020;
--color-pro-bg: rgba(255, 192, 32, 0.12);
--color-vip: #ff6c82;
--color-vip-bg: rgba(255, 108, 130, 0.12);
--color-hot: #ff6c82;
--color-hot-bg: rgba(255, 108, 130, 0.15);
--color-new: #5ba4ff;
--color-new-bg: rgba(91, 164, 255, 0.15);
--color-pick: #a485ff;
--color-pick-bg: rgba(164, 133, 255, 0.15);
--glow-brand: 0 0 20px rgba(99, 102, 241, 0.1);
--glow-brand-strong: 0 0 30px rgba(99, 102, 241, 0.15);
}
/* 全局排版 */
/* 小程序基础:浅色默认 */
page {
@include theme-light-vars;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -252,6 +113,19 @@ page {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
}
/* H5:html 类切换主题(需覆盖 page 上的浅色定义,特异性更高) */
/* #ifdef H5 */
html.theme-light page { @include theme-light-vars; }
html.theme-dark, html.theme-dark page { @include theme-dark-vars; }
/* #endif */
/* 小程序:跟随系统深色(需 manifest darkmode: true) */
/* #ifdef MP-WEIXIN */
@media (prefers-color-scheme: dark) {
page { @include theme-dark-vars; }
}
/* #endif */
view {
box-sizing: border-box;
}
@@ -269,7 +143,6 @@ view {
background: var(--bg-primary);
}
/* 页面副标题(统一) */
.page-subtitle {
display: block;
font-size: 13px;
@@ -282,10 +155,8 @@ view {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
backdrop-filter: blur(var(--blur-bg));
-webkit-backdrop-filter: blur(var(--blur-bg));
box-shadow: var(--card-shadow, none);
transition: all var(--transition-normal);
transition: all 0.3s ease;
}
.gradient-brand {
@@ -298,4 +169,25 @@ view {
-webkit-text-fill-color: transparent;
background-clip: text;
}
</style>
/* 区块标题(统一) */
.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>
+30
View File
@@ -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>
+63
View File
@@ -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>
+11 -2
View File
@@ -1,7 +1,7 @@
{
"name": "宇之然AI维度",
"appid": "wxdad62baf4ccd09e3",
"versionName": "1.0.0",
"versionName": "1.4.9",
"versionCode": "100",
"description": "AI 知识探索社区 — 从5个维度系统理解人工智能",
"uni-app": {
@@ -17,6 +17,15 @@
},
"usingComponents": true,
"permission": {},
"requiredPrivateInfos": []
"requiredPrivateInfos": [],
"darkmode": true,
"themeLocation": "theme.json"
},
"app-plus": {},
"h5": {
"router": {
"mode": "hash",
"base": "/h5/"
}
}
}
+168 -28
View File
@@ -1,28 +1,148 @@
{
"pages": [
{"path": "pages/index/index", "style": {"navigationBarTitleText": "AI 维度", "navigationStyle": "custom"}},
{"path": "pages/login/login", "style": {"navigationBarTitleText": "登录", "navigationStyle": "custom"}},
{"path": "pages/detail/detail", "style": {"navigationBarTitleText": "AI 知识", "navigationStyle": "custom"}},
{"path": "pages/payment/payment", "style": {"navigationBarTitleText": "订阅升级", "navigationStyle": "custom"}},
{"path": "pages/favorites/favorites", "style": {"navigationBarTitleText": "我的收藏", "navigationStyle": "custom"}},
{"path": "pages/achievements/achievements", "style": {"navigationBarTitleText": "成就", "navigationStyle": "custom"}},
{"path": "pages/leaderboard/leaderboard", "style": {"navigationBarTitleText": "排行榜", "navigationStyle": "custom"}},
{"path": "pages/search/search", "style": {"navigationBarTitleText": "搜索", "navigationStyle": "custom"}},
{"path": "pages/progress/progress", "style": {"navigationBarTitleText": "学习进度", "navigationStyle": "custom"}},
{"path": "pages/feedback/feedback", "style": {"navigationBarTitleText": "意见反馈", "navigationStyle": "custom"}},
{"path": "pages/settings/settings", "style": {"navigationBarTitleText": "设置", "navigationStyle": "custom"}},
{"path": "pages/news/news", "style": {"navigationBarTitleText": "AI 资讯", "navigationStyle": "custom"}},
{"path": "pages/dimension/dimension", "style": {"navigationBarTitleText": "", "navigationStyle": "custom"}},
{"path": "pages/chat/chat", "style": {"navigationBarTitleText": "AI 问答", "navigationStyle": "custom"}},
{"path": "pages/trend/trend", "style": {"navigationBarTitleText": "AI 趋势", "navigationStyle": "custom"}},
{"path": "pages/profile/profile", "style": {"navigationBarTitleText": "我的", "navigationStyle": "custom"}},
{"path": "pages/agreement/agreement", "style": {"navigationBarTitleText": "协议", "navigationStyle": "custom"}}
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "宇之然AI维度-AI知识学习与人工智能资讯",
"navigationStyle": "custom",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "登录",
"navigationStyle": "custom"
}
},
{
"path": "pages/detail/detail",
"style": {
"navigationBarTitleText": "AI知识详情-人工智能知识点",
"navigationStyle": "custom"
}
},
{
"path": "pages/payment/payment",
"style": {
"navigationBarTitleText": "订阅升级",
"navigationStyle": "custom"
}
},
{
"path": "pages/favorites/favorites",
"style": {
"navigationBarTitleText": "我的AI知识收藏",
"navigationStyle": "custom"
}
},
{
"path": "pages/achievements/achievements",
"style": {
"navigationBarTitleText": "我的AI学习成就",
"navigationStyle": "custom"
}
},
{
"path": "pages/leaderboard/leaderboard",
"style": {
"navigationBarTitleText": "AI学习排行榜",
"navigationStyle": "custom"
}
},
{
"path": "pages/search/search",
"style": {
"navigationBarTitleText": "搜索",
"navigationStyle": "custom"
}
},
{
"path": "pages/progress/progress",
"style": {
"navigationBarTitleText": "学习进度",
"navigationStyle": "custom"
}
},
{
"path": "pages/feedback/feedback",
"style": {
"navigationBarTitleText": "意见反馈",
"navigationStyle": "custom"
}
},
{
"path": "pages/settings/settings",
"style": {
"navigationBarTitleText": "设置",
"navigationStyle": "custom"
}
},
{
"path": "pages/news/news",
"style": {
"navigationBarTitleText": "AI资讯速递-人工智能热点解读",
"navigationStyle": "custom",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{
"path": "pages/dimension/dimension",
"style": {
"navigationBarTitleText": "AI知识星域-五大维度学人工智能",
"navigationStyle": "custom"
}
},
{
"path": "pages/chat/chat",
"style": {
"navigationBarTitleText": "AI问答-人工智能知识解答",
"navigationStyle": "custom"
}
},
{
"path": "pages/trend/trend",
"style": {
"navigationBarTitleText": "AI趋势-每日人工智能行业动态",
"navigationStyle": "custom",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{
"path": "pages/profile/profile",
"style": {
"navigationBarTitleText": "我的",
"navigationStyle": "custom"
}
},
{
"path": "pages/agreement/agreement",
"style": {
"navigationBarTitleText": "协议",
"navigationStyle": "custom"
}
},
{
"path": "pages/vpay/recharge",
"style": {
"navigationBarTitleText": "充值星尘"
}
},
{
"path": "pages/vpay/ledger",
"style": {
"navigationBarTitleText": "星尘明细"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTextStyle": "@navTxt",
"navigationBarTitleText": "宇之然AI维度",
"navigationBarBackgroundColor": "#f5f5f7",
"backgroundColor": "#f5f5f7",
"navigationBarBackgroundColor": "@navBg",
"backgroundColor": "@bg",
"backgroundColorTop": "#f5f5f7",
"backgroundColorBottom": "#f5f5f7",
"app-plus": {
@@ -30,15 +150,35 @@
}
},
"tabBar": {
"color": "rgba(0,0,0,0.3)",
"selectedColor": "#6366f1",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"color": "@tabTxt",
"selectedColor": "@tabSel",
"backgroundColor": "@tabBg",
"borderStyle": "@tabBorder",
"list": [
{"pagePath": "pages/index/index", "text": "探索", "iconPath": "static/icons/explore.png", "selectedIconPath": "static/icons/explore-active.png"},
{"pagePath": "pages/chat/chat", "text": "问答", "iconPath": "static/icons/chat.png", "selectedIconPath": "static/icons/chat-active.png"},
{"pagePath": "pages/trend/trend", "text": "趋势", "iconPath": "static/icons/trend.png", "selectedIconPath": "static/icons/trend-active.png"},
{"pagePath": "pages/profile/profile", "text": "我的", "iconPath": "static/icons/profile.png", "selectedIconPath": "static/icons/profile-active.png"}
{
"pagePath": "pages/index/index",
"text": "探索",
"iconPath": "static/icons/explore.png",
"selectedIconPath": "static/icons/explore-active.png"
},
{
"pagePath": "pages/chat/chat",
"text": "问答",
"iconPath": "static/icons/chat.png",
"selectedIconPath": "static/icons/chat-active.png"
},
{
"pagePath": "pages/trend/trend",
"text": "趋势",
"iconPath": "static/icons/trend.png",
"selectedIconPath": "static/icons/trend-active.png"
},
{
"pagePath": "pages/profile/profile",
"text": "我的",
"iconPath": "static/icons/profile.png",
"selectedIconPath": "static/icons/profile-active.png"
}
]
}
}
+17 -8
View File
@@ -4,7 +4,7 @@
<!-- 顶部导航 -->
<view class="header">
<text class="title">🏆 成就</text>
<text class="title">成就</text>
<text class="unlock-count">{{ unlockedCount }}/{{ totalCount }}</text>
</view>
@@ -62,7 +62,7 @@
<!-- 空状态 -->
<view class="empty-state" v-else-if="!loading">
<text class="empty-icon">🏆</text>
<text class="empty-icon">暂无成就</text>
<text class="empty-text">还没有获得任何成就</text>
<text class="empty-sub">继续学习,解锁属于你的成就吧</text>
</view>
@@ -76,6 +76,8 @@
</template>
<script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, onMounted } from 'vue'
import { userApi } from '@/utils/api'
import { useUserStore } from '@/stores/user'
@@ -86,9 +88,9 @@ const loading = ref(true)
// 成就分类的默认配置
const CATEGORY_CONFIG = [
{ name: '知识探索', icon: '📖', color: 'var(--dim1-color)' },
{ name: '收藏成就', icon: '⭐', color: 'var(--dim2-color)' },
{ name: 'AI 问答', icon: '🤖', color: 'var(--dim3-color)' },
{ name: '知识探索', icon: '星', color: 'var(--dim1-color)' },
{ name: '收藏成就', icon: '藏', color: 'var(--dim2-color)' },
{ name: 'AI 问答', icon: '问', color: 'var(--dim3-color)' },
{ name: '社交成就', icon: '👥', color: 'var(--dim4-color)' }
]
@@ -113,8 +115,7 @@ const progressPercent = computed(() => {
onMounted(() => {
if (!userStore.isLoggedIn) {
uni.showToast({ title: '请先登录', icon: 'none' })
uni.navigateTo({ url: '/pages/login/login' })
loading.value = false
return
}
loadAchievements()
@@ -140,7 +141,7 @@ async function loadAchievements() {
function catIcon(name) {
const config = CATEGORY_CONFIG.find((c) => c.name === name)
return config ? config.icon : '🏆'
return config ? config.icon : '成'
}
function achColor(icon) {
@@ -156,6 +157,14 @@ function formatDate(dateStr) {
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// 微信小程序分享
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手',
path: '/pages/index/index'
}))
// #endif
</script>
<style lang="scss" scoped>
+10
View File
@@ -90,6 +90,8 @@
</template>
<script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'
const type = ref('terms')
@@ -112,6 +114,14 @@ function onLoad(fn) {
fn(page.$page.options || {})
}
}
// 微信小程序分享
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: 'AI知识社区 · 宇之然AI维度',
path: '/pages/index/index'
}))
// #endif
</script>
<style lang="scss" scoped>
+271 -231
View File
@@ -2,28 +2,29 @@
<view class="page-chat page-layout">
<view class="status-bar-placeholder"></view>
<!-- 聊天头部 -->
<!-- 聊天头部:维度精灵 -->
<view class="chat-header">
<view class="chat-avatar">
<text class="avatar-text">∞</text>
<AppIcon name="planet-white" :size="22" />
</view>
<view class="chat-info">
<text class="chat-name">AI 助手</text>
<text class="chat-status">随时为你解答</text>
<text class="chat-name">维度精灵</text>
<text class="chat-status">你的 AI 知识探索向导</text>
</view>
<!-- 配额徽章(登录后显示) -->
<view class="quota-badge" v-if="userStore.isLoggedIn" @click="goShare">
<text class="quota-text">{{ quotaDisplay }}</text>
<text class="quota-icon">🎁</text>
<view class="quota-badge" @click="onQuotaTap">
<AppIcon :name="isGuest ? 'gift' : 'gift-gray'" :size="13" />
<text class="quota-text">{{ quotaText }}</text>
</view>
</view>
<!-- 消息列表(始终可见) -->
<!-- 消息列表 -->
<scroll-view class="msg-list" scroll-y :scroll-into-view="scrollTo">
<!-- 欢迎消息 -->
<!-- 欢迎 -->
<view class="welcome-card">
<text class="welcome-icon">💡</text>
<text class="welcome-text">试试问我这些问题:</text>
<view class="welcome-head">
<AppIcon name="sparkle" :size="16" />
<text class="welcome-text">{{ welcomeText }}</text>
</view>
<view class="suggest-list">
<view class="suggest-item" @click="sendSuggest(item)" v-for="(item, idx) in suggestions" :key="idx">
<text>{{ item }}</text>
@@ -32,7 +33,9 @@
</view>
<view v-for="(msg, idx) in messages" :key="idx" :id="'msg-' + idx" class="chat-msg" :class="msg.role">
<view v-if="msg.role === 'assistant'" class="mini-avatar">∞</view>
<view v-if="msg.role === 'assistant'" class="mini-avatar">
<AppIcon name="planet-white" :size="14" />
</view>
<view class="msg-content">
<view class="bubble" v-html="msg.content"></view>
</view>
@@ -40,7 +43,9 @@
<!-- 加载动画 -->
<view v-if="loading" class="chat-msg assistant">
<view class="mini-avatar">∞</view>
<view class="mini-avatar">
<AppIcon name="planet-white" :size="14" />
</view>
<view class="msg-content">
<view class="bubble loading-dots">
<text class="dot"></text>
@@ -49,38 +54,42 @@
</view>
</view>
</view>
<view class="list-bottom"></view>
</scroll-view>
<!-- 输入区(始终可见,未登录时提示登录) -->
<!-- 输入区:游客与登录用户一致,始终可见 -->
<view class="chat-input-area">
<view class="chat-input" v-if="userStore.isLoggedIn">
<view v-if="guestExhausted" class="login-guide" @click="goLogin">
<text class="guide-text">{{ guestExhaustedText }}</text>
<view class="guide-btn">
<text>登录后每天 5 次</text>
</view>
</view>
<view class="chat-input" v-else>
<input
v-model="question"
class="input-field"
placeholder="输入你对 AI 的疑问..."
:placeholder="inputPlaceholder"
:disabled="loading"
@confirm="sendMessage"
confirm-type="send"
/>
<view class="send-btn" @click="sendMessage">
<text>➤</text>
</view>
</view>
<!-- 未登录:显示登录引导 -->
<view class="login-input-placeholder" v-else @click="goLogin">
<text class="login-input-text">登录后即可体验 AI 问答</text>
<view class="login-input-btn">
<text>去登录 →</text>
<view class="send-btn" :class="{ disabled: loading || !question.trim() }" @click="sendMessage">
<AppIcon name="send-white" :size="16" />
</view>
</view>
<text v-if="isGuest && !guestExhausted" class="guest-hint">游客模式 · 每日 3 次体验</text>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, watch } from 'vue'
import { aiChatApi } from '@/utils/api'
import { useUserStore } from '@/stores/user'
import AppIcon from '@/components/AppIcon.vue'
const question = ref('')
const messages = ref([])
@@ -88,35 +97,44 @@ const loading = ref(false)
const scrollTo = ref('')
const userStore = useUserStore()
// 建议问题
const quotaInfo = ref(null) // { remainingQuota, freeQuota, isGuest }
const guestExhausted = ref(false)
const WELCOME_LOGGED = '你好呀,我是维度精灵。任何 AI 概念都能问我,我用大白话讲明白。'
const WELCOME_GUEST = '嗨,我是维度精灵。游客身份也能提问,试试下面的问题。'
const GUEST_EXHAUSTED = '今日 3 次游客体验已用完,登录后每天可问 5 次,还能点亮知识星球。'
const INPUT_LOGGED = '输入你对 AI 的疑问...'
const INPUT_GUEST = '游客模式,直接提问试试...'
const suggestions = ref([
'"Transformer 的核心原理是什么?"',
'"RAG 和微调有什么区别?"',
'"2026 年最值得关注的 AI 趋势?"'
'用大白话解释 Transformer 是什么',
'RAG 和微调有什么区别?',
'2026 年最值得关注的 AI 趋势?'
])
// 配额显示
const quotaDisplay = computed(() => {
const q = userStore.quota
if (!q) return '?次'
const used = q.usedQuota || q.dailyUsed || 0
const remaining = q.remainingQuota || 0
const total = q.freeQuota || remaining + used
return `${remaining}/${total}`
const isGuest = computed(() => !userStore.isLoggedIn)
const welcomeText = computed(() => (isGuest.value ? WELCOME_GUEST : WELCOME_LOGGED))
const inputPlaceholder = computed(() => (isGuest.value ? INPUT_GUEST : INPUT_LOGGED))
const guestExhaustedText = GUEST_EXHAUSTED
const quotaText = computed(() => {
const q = quotaInfo.value
if (!q) return '...'
return `${q.remainingQuota} 次`
})
// 监听登录状态变化,加载配额
const isLoggedIn = computed(() => userStore.isLoggedIn)
import { watch } from 'vue'
watch(isLoggedIn, (val) => {
if (val) {
loadQuota()
}
guestExhausted.value = false
loadQuota()
}, { immediate: true })
async function loadQuota() {
if (userStore.token) {
await userStore.fetchQuota(userStore.token)
try {
const data = await aiChatApi.quota(userStore.isLoggedIn ? userStore.token : undefined)
quotaInfo.value = data
} catch (e) {
quotaInfo.value = null
}
}
@@ -124,28 +142,42 @@ async function sendMessage() {
const q = question.value.trim()
if (!q || loading.value) return
// 未登录 → 跳登录
if (!userStore.isLoggedIn) {
uni.navigateTo({ url: '/pages/login/login' })
return
}
messages.value.push({ role: 'user', content: escapeHtml(q) })
question.value = ''
scrollToLast()
loading.value = true
try {
const data = await aiChatApi.ask(q, 'general', userStore.token)
const data = await aiChatApi.ask(q, 'general', userStore.isLoggedIn ? userStore.token : undefined)
messages.value.push({ role: 'assistant', content: escapeHtml(data.answer) })
await userStore.fetchQuota(userStore.token)
if (quotaInfo.value) {
quotaInfo.value.remainingQuota = data.remainingQuota
}
if (isGuest.value && data.remainingQuota <= 0) {
guestExhausted.value = true
}
} catch (e) {
console.error('AI 问答失败:', e)
const errorMsg = e.message || 'AI 服务暂时不可用,请稍后再试'
messages.value.push({
role: 'assistant',
content: escapeHtml(`抱歉,${errorMsg}`)
})
const msg = e.message || 'AI 服务暂时不可用,请稍后再试'
if (msg.includes('游客体验已用完')) {
guestExhausted.value = true
} else if (msg.includes('免费次数已用完') || msg.includes('星尘余额不足') || msg.includes('充值星尘')) {
// 引导充值
uni.showModal({
title: '免费次数用完了',
content: '充值星尘可继续提问(10星尘/次),还可以享受更多功能',
confirmText: '去充值',
cancelText: '分享得次数',
success: (m) => {
if (m.confirm) {
uni.navigateTo({ url: '/pages/vpay/recharge' })
} else {
onQuotaTap()
}
},
})
} else {
messages.value.push({ role: 'assistant', content: escapeHtml(`抱歉,${msg}`) })
}
} finally {
loading.value = false
scrollToLast()
@@ -159,8 +191,9 @@ function sendSuggest(text) {
function scrollToLast() {
setTimeout(() => {
scrollTo.value = 'msg-' + (messages.value.length - 1)
}, 100)
scrollTo.value = ''
setTimeout(() => { scrollTo.value = 'msg-' + (messages.value.length - 1) }, 50)
}, 80)
}
function escapeHtml(text) {
@@ -175,26 +208,51 @@ function goLogin() {
uni.navigateTo({ url: '/pages/login/login' })
}
function goShare() {
async function onQuotaTap() {
if (isGuest.value) {
// 静默登录一键升级(微信 code 换 token 无感),失败再显式引导
const ok = await userStore.ensureLogin()
if (ok) {
uni.showToast({ title: '已升级为登录用户,每天 5 次', icon: 'success' })
loadQuota()
return
}
uni.showModal({
title: '游客体验',
content: '每天可免费提问 3 次;登录后每天 5 次,答对检测题还能点亮知识星球',
confirmText: '去登录',
cancelText: '继续体验',
success: (res) => {
if (res.confirm) goLogin()
}
})
return
}
uni.showModal({
title: '获取更多次数',
content: '将小程序分享给好友,即可获得 5 次额外问答机会',
confirmText: '去分享',
success: (res) => {
if (res.confirm) {
// 微信小程序分享能力
if (res.confirm && userStore.token) {
uni.showShareMenu({ withShareTicket: true })
// 调用分享获得次数接口
if (userStore.token) {
aiChatApi.shareGain(userStore.token).then(() => {
uni.showToast({ title: '已记录分享', icon: 'success' })
loadQuota()
}).catch(() => {})
}
aiChatApi.shareGain(userStore.token).then(() => {
uni.showToast({ title: '已记录分享', icon: 'success' })
loadQuota()
}).catch(() => {})
}
}
})
}
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: '维度精灵 · 你的 AI 知识探索向导',
path: '/pages/chat/chat'
}))
onShareTimeline(() => ({
title: '维度精灵 · 你的 AI 知识探索向导'
}))
// #endif
</script>
<style lang="scss" scoped>
@@ -202,7 +260,7 @@ function goShare() {
height: 100vh;
display: flex;
flex-direction: column;
padding: 0 20px;
padding: 0 16px;
background: var(--bg-primary);
}
@@ -212,248 +270,230 @@ function goShare() {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0 16px;
padding: 10px 0 14px;
}
.chat-avatar,
.mini-avatar {
width: 42px;
height: 42px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(124, 77, 255, 0.3), rgba(0, 188, 212, 0.2));
border: 1px solid rgba(124, 77, 255, 0.15);
background: linear-gradient(135deg, var(--color-brand), #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 14px var(--color-brand-glow);
}
.avatar-text {
font-size: 20px;
color: white;
}
.mini-avatar {
width: 32px;
height: 32px;
border-radius: 10px;
font-size: 14px;
}
.chat-avatar { width: 42px; height: 42px; }
.mini-avatar { width: 28px; height: 28px; border-radius: 9px; }
.chat-info { flex: 1; }
.chat-name {
font-size: 16px;
color: var(--text-primary);
font-weight: 600;
display: block;
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.chat-status {
display: block;
font-size: 11px;
color: var(--text-tertiary);
display: block;
margin-top: 2px;
}
// 配额徽章
.quota-badge {
display: flex;
align-items: center;
gap: 4px;
gap: 5px;
padding: 6px 12px;
background: rgba(124, 77, 255, 0.1);
border: 1px solid rgba(124, 77, 255, 0.2);
border-radius: 16px;
border-radius: var(--radius-full);
background: var(--color-brand-bg);
border: 1px solid var(--glass-border);
.quota-text {
font-size: 12px;
font-weight: 700;
color: var(--color-brand);
}
}
.quota-text {
font-size: 12px;
color: var(--dim1-color);
font-weight: 600;
.msg-list {
flex: 1;
min-height: 0;
}
.quota-icon { font-size: 14px; }
.list-bottom { height: 16px; }
// 未登录输入区占位
.login-input-placeholder {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background: var(--glass-bg);
border: 1px dashed var(--glass-border);
border-radius: 14px;
margin-bottom: 30px;
cursor: pointer;
}
.login-input-text {
font-size: 14px;
color: var(--text-tertiary);
}
.login-input-btn {
padding: 6px 16px;
background: var(--color-brand);
border-radius: 8px;
}
.login-input-btn text {
font-size: 13px;
color: white;
font-weight: 600;
}
// 欢迎卡片
.welcome-card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 14px;
border-radius: var(--radius-lg);
padding: 16px;
margin-bottom: 16px;
box-shadow: var(--card-shadow, none);
}
.welcome-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
.welcome-icon {
font-size: 20px;
display: block;
margin-bottom: 6px;
.welcome-text {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.5;
}
}
.welcome-text {
font-size: 13px;
color: var(--text-tertiary);
display: block;
margin-bottom: 10px;
}
.suggest-list {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
}
.suggest-item {
padding: 8px 12px;
background: rgba(124, 77, 255, 0.08);
border-radius: 8px;
font-size: 12px;
padding: 11px 14px;
border-radius: var(--radius-md);
background: var(--bg-primary);
border: 1px solid var(--glass-border);
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
transition: all 0.2s ease;
}
// 消息列表
.msg-list {
flex: 1;
overflow-y: auto;
padding-bottom: 10px;
}
.suggest-item:active { border-color: var(--color-brand); color: var(--color-brand); }
.chat-msg {
display: flex;
gap: 10px;
gap: 8px;
margin-bottom: 14px;
}
.chat-msg.user {
flex-direction: row-reverse;
}
&.assistant .msg-content { align-items: flex-start; }
.chat-msg.assistant {
flex-direction: row;
}
.msg-content {
max-width: 82%;
display: flex;
flex-direction: column;
}
.msg-content {
max-width: 82%;
&.user {
justify-content: flex-end;
.msg-content { align-items: flex-end; }
.bubble {
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
color: #fff;
border-radius: 16px 16px 4px 16px;
}
}
&.assistant .bubble {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
color: var(--text-primary);
border-radius: 16px 16px 16px 4px;
}
}
.bubble {
padding: 12px 16px;
border-radius: 16px;
padding: 12px 14px;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
line-height: 1.7;
word-break: break-word;
}
.chat-msg.assistant .bubble {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
color: var(--text-primary);
border-bottom-left-radius: 4px;
}
.chat-msg.user .bubble {
background: linear-gradient(135deg, rgba(124, 77, 255, 0.3), rgba(124, 77, 255, 0.15));
border: 1px solid rgba(124, 77, 255, 0.15);
color: white;
border-bottom-right-radius: 4px;
}
// 加载动画
.loading-dots {
display: flex;
gap: 4px;
padding: 14px 18px;
gap: 5px;
align-items: center;
padding: 16px 14px;
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-brand);
animation: dotBounce 1.2s infinite ease-in-out;
&:nth-child(2) { animation-delay: 0.15s; }
&:nth-child(3) { animation-delay: 0.3s; }
}
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
animation: blink 1.4s infinite;
@keyframes dotBounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-5px); opacity: 1; }
}
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes blink {
0%, 20% { opacity: 0; transform: scale(0.6); }
50% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(0.6); }
}
// 输入区
/* 输入区 */
.chat-input-area {
padding: 12px 0 30px;
background: var(--bg-primary);
flex-shrink: 0;
padding: 10px 0 12px;
}
.guest-hint {
display: block;
text-align: center;
font-size: 11px;
color: var(--text-muted);
margin-top: 8px;
}
.chat-input {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 14px;
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-full);
padding: 6px 6px 6px 18px;
}
.input-field {
flex: 1;
height: 24px;
height: 38px;
font-size: 14px;
color: var(--text-primary);
background: none;
border: none;
outline: none;
}
.input-field::placeholder {
color: var(--text-muted);
background: transparent;
}
.send-btn {
width: 36px;
height: 36px;
width: 38px;
height: 38px;
border-radius: 50%;
background: linear-gradient(135deg, #7c4dff, #b388ff);
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 0 20px rgba(124, 77, 255, 0.2);
flex-shrink: 0;
transition: opacity 0.2s ease;
&.disabled { opacity: 0.45; }
}
.send-btn text {
font-size: 16px;
/* 用完额度引导 */
.login-guide {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-radius: var(--radius-lg);
background: linear-gradient(135deg, var(--color-brand-bg), rgba(139, 92, 246, 0.06));
border: 1px solid var(--glass-hover-border);
.guide-text {
flex: 1;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.6;
}
.guide-btn {
padding: 8px 14px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
color: #fff;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
}
</style>
+396 -274
View File
@@ -1,29 +1,35 @@
<template>
<view class="detail-page page-layout">
<!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view>
<!-- 顶部导航 -->
<view class="nav-bar">
<view class="nav-back" @click="goBack">‹</view>
<view class="nav-back" @click="goBack">
<AppIcon name="back-gray" :size="18" />
</view>
<text class="nav-title">AI 知识</text>
<view class="nav-collect" @click="toggleCollect">
<text v-if="isCollected" class="icon icon-collected">★</text>
<text v-else class="icon">☆</text>
<AppIcon v-if="isCollected" name="star-fill" :size="20" />
<AppIcon v-else name="star-gray" :size="20" />
</view>
</view>
<!-- 知识头部 -->
<view class="detail-header">
<view class="dim-tag" :style="{ background: dimColor }">
<text class="dim-name">{{ dimensionName }}</text>
<view class="dim-tag" :style="{ background: dimInfo.bg, color: dimInfo.color }">
{{ dimInfo.name }}
</view>
<text class="detail-title">{{ detail?.title || '加载中...' }}</text>
<view class="meta-row">
<text class="meta-item">📖 {{ viewCountText }}</text>
<text class="meta-item">⭐ {{ collectionCount }}</text>
<text class="meta-item" v-if="detail?.category">
{{ categoryLabel }}
</text>
<view class="meta-item">
<AppIcon name="search-gray" :size="12" />
<text>{{ viewCountText }} 次阅读</text>
</view>
<view class="meta-item">
<AppIcon name="star-gray" :size="12" />
<text>{{ collectionCount }} 人收藏</text>
</view>
<text class="meta-item" v-if="readMinutes">{{ readMinutes }} 分钟读完</text>
</view>
</view>
@@ -32,83 +38,140 @@
<text class="tag" v-for="tag in detail.tags" :key="tag">#{{ tag }}</text>
</view>
<!-- 内容区域 -->
<scroll-view class="content-scroll" scroll-y>
<!-- 分段内容(如果存在 sections) -->
<view class="content-section" v-if="detail?.sections?.length">
<view class="section-block" v-for="(section, idx) in detail.sections" :key="idx">
<text class="section-title">{{ section.title }}</text>
<text class="section-content">{{ section.content }}</text>
<view class="section-images" v-if="section.images?.length">
<image
class="section-img"
v-for="(img, i) in section.images"
:key="i"
:src="img"
mode="aspectFill"
/>
</view>
</view>
<!-- 正文 -->
<view class="content-card">
<view v-if="!detail" class="loading-block">
<text>加载中...</text>
</view>
<!-- 普通内容 -->
<view class="content-block" v-else>
<text class="content-text">{{ detail?.content || '暂无内容' }}</text>
<template v-else-if="paragraphs.length">
<text
v-for="(p, i) in paragraphs"
:key="i"
class="para"
:class="{ 'para-lead': i === 0 }"
>{{ p }}</text>
</template>
<view v-else class="loading-block">
<text>暂无内容</text>
</view>
<!-- 问答区域 -->
<view class="qa-section" v-if="detail?.category === 'question' && detail?.question">
<view class="qa-question">
<text class="qa-label">问题</text>
<text class="qa-text">{{ detail.question }}</text>
</view>
<view class="qa-options" v-if="detail?.options?.length">
<view
class="qa-option"
v-for="(opt, idx) in detail.options"
:key="idx"
:class="{ correct: selectedOption === idx && opt.isCorrect }"
@click="selectedOption = idx"
>
<text class="option-letter">{{ String.fromCharCode(65 + idx) }}</text>
<text class="option-text">{{ opt.text }}</text>
</view>
</view>
<view class="qa-answer" v-if="selectedOption !== null">
<text class="answer-label">
{{ detail.options?.[selectedOption]?.isCorrect ? '✅ 正确' : '❌ 错误' }}
</text>
<text class="answer-text" v-if="detail.answer">{{ detail.answer }}</text>
</view>
</view>
</scroll-view>
<!-- 底部 AI 问答入口 -->
<view class="ai-banner" @click="goToAI">
<text class="ai-icon">🤖</text>
<text class="ai-text">还有疑问?问问 AI 助手</text>
<text class="ai-arrow">→</text>
</view>
<!-- 检测题(读后测,答对点亮星球) -->
<view class="quiz-card" v-if="detail?.question && detail?.options?.length">
<view class="quiz-head">
<AppIcon name="sparkle" :size="16" />
<text class="quiz-title">读后检测 · 答对点亮这颗星球</text>
</view>
<text class="quiz-question">{{ detail.question }}</text>
<template v-if="!quizResult">
<view
v-for="(opt, idx) in detail.options"
:key="idx"
class="quiz-option"
:class="{ selected: selectedOption === idx }"
@click="selectedOption = idx"
>
<text class="option-letter">{{ optionLetters[idx] }}</text>
<text class="option-text">{{ opt.text }}</text>
</view>
<view
class="quiz-submit"
:class="{ disabled: selectedOption === null }"
@click="submitQuiz"
>
<text>{{ selectedOption === null ? '选择你的答案' : '确认作答' }}</text>
</view>
</template>
<template v-else>
<view class="quiz-result" :class="quizResult.correct ? 'ok' : 'bad'">
<view class="result-icon">
<AppIcon :name="quizResult.correct ? 'check-green' : 'lock-gray'" :size="18" />
</view>
<view class="result-text">
<text class="result-title">{{ quizResult.correct ? resultOkTitle : resultBadTitle }}</text>
<text class="result-desc" v-if="quizResult.answer">{{ quizResult.answer }}</text>
</view>
</view>
<view class="quiz-actions">
<view v-if="!quizResult.correct" class="quiz-retry" @click="retryQuiz">
<text>再试一次</text>
</view>
<view class="quiz-next" @click="goChat">
<text>还有疑问?问维度精灵</text>
</view>
</view>
</template>
</view>
<!-- 底部 AI 导师入口 -->
<view class="ai-banner" @click="goChat">
<AppIcon name="bulb-white" :size="16" />
<text class="ai-text">读完想深入聊聊?问维度精灵</text>
<AppIcon name="arrow-white" :size="12" />
</view>
<view class="bottom-spacer"></view>
</view>
</template>
<script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, onMounted } from 'vue'
import { knowledgeApi } from '@/utils/api'
import { useUserStore } from '@/stores/user'
import { DIMENSIONS } from '@/utils/dimensions'
import AppIcon from '@/components/AppIcon.vue'
const RESULT_OK_TITLE = '回答正确,星球已点亮'
const RESULT_OK_GUEST = '回答正确!登录后可永久记录点亮'
const RESULT_BAD_TITLE = '差一点点,再想想'
const detailId = ref('')
const detail = ref(null)
const isCollected = ref(false)
const selectedOption = ref(null)
const quizResult = ref(null)
const userStore = useUserStore()
// 从路由参数获取 ID
const optionLetters = ['A', 'B', 'C', 'D', 'E']
// 正文按段落渲染(兼容全部存量数据:content 字符串)
const paragraphs = computed(() => {
const raw = detail.value?.content
if (!raw) return []
return String(raw)
.split(/\n+/)
.map(s => s.trim())
.filter(Boolean)
})
const readMinutes = computed(() => {
const len = detail.value?.content?.length || 0
return len ? Math.max(1, Math.ceil(len / 300)) : 0
})
const dimInfo = computed(() => {
const dim = detail.value?.dim
return DIMENSIONS.find(d => d.id === dim) || DIMENSIONS[0]
})
const viewCountText = computed(() => {
const count = detail.value?.viewCount || 0
return count >= 10000 ? (count / 10000).toFixed(1) + '万' : String(count)
})
const collectionCount = computed(() => detail.value?.collectionCount || 0)
const resultOkTitle = computed(() =>
quizResult.value?.lit ? RESULT_OK_TITLE : RESULT_OK_GUEST
)
const resultBadTitle = RESULT_BAD_TITLE
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const query = currentPage.$page?.query || {}
const query = currentPage.$page?.query || currentPage.options || {}
detailId.value = query.id || ''
if (detailId.value) {
@@ -116,41 +179,19 @@ onMounted(() => {
}
})
// 维度信息
const dimColor = computed(() => {
const dim = detail.value?.dim
if (!dim) return 'var(--dim1-color)'
const dimInfo = DIMENSIONS.find(d => d.id === dim)
return dimInfo ? dimInfo.color : 'var(--dim1-color)'
})
const dimensionName = computed(() => {
const dim = detail.value?.dim
if (!dim) return ''
const dimInfo = DIMENSIONS.find(d => d.id === dim)
return dimInfo ? dimInfo.name : ''
})
// 分类标签
const categoryLabel = computed(() => {
const map = {
concept: '📚 概念',
history: '📜 历史',
application: '🛠️ 应用',
question: '❓ 问答'
}
return map[detail.value?.category] || ''
})
const viewCountText = computed(() => {
const count = detail.value?.viewCount || 0
return count >= 10000 ? (count / 10000).toFixed(1) + '万' : count
})
const collectionCount = computed(() => detail.value?.collectionCount || 0)
async function fetchDetail() {
try {
detail.value = await knowledgeApi.detail(detailId.value)
// 检查是否已收藏
// 记录"继续探索"
try {
uni.setStorageSync('last_read', {
id: detailId.value,
title: detail.value.title || '',
dim: detail.value.dim
})
} catch (e) {}
if (userStore.isLoggedIn) {
const collected = await knowledgeApi.collected(userStore.token)
isCollected.value = collected.some(item => item.id === detailId.value)
@@ -161,10 +202,49 @@ async function fetchDetail() {
}
}
async function submitQuiz() {
if (selectedOption.value === null || !detailId.value) return
try {
const res = await knowledgeApi.quiz(
detailId.value,
selectedOption.value,
userStore.isLoggedIn ? userStore.token : ''
)
quizResult.value = res
if (res.correct) {
uni.setStorageSync('lit_today', new Date().toDateString())
if (res.lit) {
uni.showToast({ title: '星球已点亮', icon: 'success' })
}
}
} catch (err) {
uni.showToast({ title: err.message || '提交失败', icon: 'none' })
}
}
function retryQuiz() {
selectedOption.value = null
quizResult.value = null
}
async function toggleCollect() {
if (!userStore.isLoggedIn) {
uni.navigateTo({ url: '/pages/login/login' })
return
// 静默登录(微信 code 换 token,用户无感);失败再显式引导
const ok = await userStore.ensureLogin()
if (!ok) {
uni.showModal({
title: '登录后收藏',
content: '登录后即可收藏喜欢的 AI 知识',
confirmText: '去登录',
cancelText: '暂不',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/login' })
}
}
})
return
}
}
try {
if (isCollected.value) {
@@ -185,36 +265,45 @@ function goBack() {
uni.navigateBack()
}
function goToAI() {
function goChat() {
uni.switchTab({ url: '/pages/chat/chat' })
}
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: (detail.value?.title ? detail.value.title + ' · AI 维度' : 'AI 维度 · 知识星球'),
path: `/pages/detail/detail?id=${detailId.value}`
}))
onShareTimeline(() => ({
title: detail.value?.title || 'AI 维度 · 知识星球'
}))
// #endif
</script>
<style lang="scss" scoped>
.detail-page {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 0 20px 30px;
background: var(--bg-primary);
}
// 顶部导航
.status-bar-placeholder { height: 44px; }
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 50px 20px 12px;
flex-shrink: 0;
padding: 0 0 14px;
.nav-back {
width: 30px;
height: 30px;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: var(--text-primary);
}
.nav-title {
font-size: 16px;
@@ -222,226 +311,259 @@ function goToAI() {
color: var(--text-primary);
}
.nav-collect {
width: 30px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
.icon {
font-size: 18px;
color: var(--text-secondary);
}
.icon-collected {
color: var(--dim5-color);
}
}
}
// 知识头部
.detail-header {
padding: 0 20px 16px;
flex-shrink: 0;
padding: 4px 0 14px;
.dim-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 600;
color: white;
margin-bottom: 10px;
opacity: 0.9;
.dim-name { color: white; }
}
.detail-title {
display: block;
font-size: 20px;
font-size: 21px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.4;
line-height: 1.45;
margin-bottom: 10px;
}
.meta-row {
display: flex;
gap: 16px;
gap: 14px;
font-size: 12px;
color: var(--text-secondary);
color: var(--text-tertiary);
.meta-item { display: flex; align-items: center; }
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
}
}
// 标签
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 20px 16px;
flex-shrink: 0;
padding: 0 0 14px;
.tag {
font-size: 12px;
color: var(--dim1-color);
background: rgba(179, 136, 255, 0.1);
font-size: 11px;
color: var(--color-brand);
background: var(--color-brand-bg);
padding: 4px 10px;
border-radius: 10px;
border-radius: var(--radius-full);
}
}
// 内容滚动区
.content-scroll {
flex: 1;
padding: 0 20px;
overflow-y: auto;
padding-bottom: 80px;
.content-card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 18px 16px;
margin-bottom: 14px;
box-shadow: var(--card-shadow, none);
}
.content-section,
.content-block,
.qa-section {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
.loading-block {
text-align: center;
padding: 40px 0;
color: var(--text-muted);
font-size: 13px;
}
.para {
display: block;
font-size: 15px;
color: var(--text-primary);
line-height: 1.9;
margin-bottom: 14px;
letter-spacing: 0.3px;
&:last-child { margin-bottom: 0; }
}
.para-lead {
color: var(--text-primary);
}
/* 检测题 */
.quiz-card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 16px;
margin-bottom: 14px;
box-shadow: var(--card-shadow, none);
}
.quiz-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
.quiz-title {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
}
}
.quiz-question {
display: block;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.6;
margin-bottom: 12px;
}
.quiz-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-primary);
border: 1.5px solid var(--glass-border);
border-radius: var(--radius-md);
margin-bottom: 8px;
transition: all 0.2s ease;
&.selected {
border-color: var(--color-brand);
background: var(--color-brand-bg);
}
.section-block {
margin-bottom: 16px;
&:last-child { margin-bottom: 0; }
.section-title {
display: block;
font-size: 15px;
font-weight: 600;
color: var(--dim1-color);
margin-bottom: 8px;
}
.section-content {
display: block;
font-size: 14px;
color: var(--text-primary);
line-height: 1.8;
}
.section-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
.section-img {
width: 100%;
height: 200px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
}
}
.option-letter {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
border-radius: 50%;
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
flex-shrink: 0;
}
.content-text {
.option-text {
font-size: 14px;
color: var(--text-primary);
line-height: 1.8;
line-height: 1.5;
}
}
// 问答区域
.qa-section {
.qa-question {
.qa-label {
display: block;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.qa-text {
display: block;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.6;
}
.quiz-submit {
margin-top: 6px;
height: 44px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 600;
&.disabled {
opacity: 0.5;
}
}
.quiz-result {
display: flex;
gap: 12px;
padding: 14px;
border-radius: var(--radius-md);
&.ok {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
}
&.bad {
background: rgba(229, 28, 28, 0.07);
border: 1px solid rgba(229, 28, 28, 0.2);
}
.qa-options {
.result-icon {
flex-shrink: 0;
margin-top: 2px;
}
.result-title {
display: block;
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.result-desc {
display: block;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
}
}
.quiz-actions {
display: flex;
gap: 10px;
margin-top: 12px;
.quiz-retry {
flex: 1;
height: 42px;
border-radius: var(--radius-md);
border: 1.5px solid var(--glass-border);
background: var(--glass-bg);
display: flex;
flex-direction: column;
gap: 8px;
.qa-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 10px;
transition: all 0.3s;
&.correct {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.4);
}
.option-letter {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.option-text {
font-size: 14px;
color: var(--text-primary);
}
}
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.qa-answer {
margin-top: 12px;
padding: 12px;
background: rgba(179, 136, 255, 0.1);
border-radius: 10px;
.answer-label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.answer-text {
display: block;
font-size: 13px;
color: var(--text-primary);
line-height: 1.6;
}
.quiz-next {
flex: 1.6;
height: 42px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: #fff;
}
}
// AI 底部入口
/* AI 入口 */
.ai-banner {
position: fixed;
bottom: 20px;
left: 20px;
right: 20px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: linear-gradient(135deg, var(--dim1-color), var(--dim3-color));
border-radius: 12px;
color: white;
box-shadow: 0 4px 16px rgba(179, 136, 255, 0.3);
height: 48px;
background: linear-gradient(135deg, var(--color-brand-dark), var(--color-brand));
border-radius: var(--radius-md);
color: #fff;
box-shadow: 0 4px 16px var(--color-brand-glow);
.ai-icon { font-size: 18px; }
.ai-text { font-size: 14px; font-weight: 600; }
.ai-arrow { font-size: 14px; }
.ai-text { font-size: 14px; font-weight: 600; color: #fff; }
}
.bottom-spacer { height: 24px; }
</style>
+203 -183
View File
@@ -2,70 +2,96 @@
<view class="page-dim page-layout">
<view class="status-bar-placeholder"></view>
<view class="back-btn" @tap="goBack">
<text>← 返回</text>
<view class="nav-row">
<view class="back-btn" @tap="goBack">
<AppIcon name="back-gray" :size="16" />
<text>返回</text>
</view>
</view>
<!-- 维度横幅 -->
<!-- 星域横幅 -->
<view class="dim-banner" :style="{ borderColor: currentDim?.border }">
<text class="dim-banner-icon">{{ currentDim?.icon }}</text>
<text class="dim-banner-title">{{ currentDim?.name }}</text>
<text class="dim-banner-desc">{{ bannerDesc }}</text>
<view class="progress-track">
<view class="progress-fill" :style="{ width: progress + '%' }"></view>
<view class="banner-top">
<view class="dim-icon" :style="{ background: currentDim?.iconBg }">
<AppIcon :name="currentDim?.icon || 'planet'" :size="20" />
</view>
<view class="banner-info">
<text class="dim-banner-title">{{ currentDim?.name }}</text>
<text class="dim-banner-desc">{{ currentDim?.tagline }}</text>
</view>
<ProgressRing
:size="46"
:num-size="10"
:percent="litPercent"
:color="currentDim?.color"
/>
</view>
<text class="progress-label">探索进度 {{ progress }}% · 已完成 {{ readCount }}/{{ totalCount }} 篇</text>
<text class="progress-label">{{ litCount }}/{{ totalCount }} 颗星球已点亮 · {{ litPercent }}%</text>
</view>
<!-- 知识列表 -->
<view v-for="item in knowledgeList" :key="item._id" class="knowledge-item" @tap="goDetail(item)">
<view class="k-icon" :style="getItemStyle(item)">
<text>{{ item.icon || '📄' }}</text>
<!-- 星球列表 -->
<view
v-for="item in knowledgeList"
:key="item._id"
class="planet-item"
@tap="goDetail(item)"
>
<view class="p-icon" :class="{ lit: item.isLit }" :style="item.isLit ? litStyle : null">
<AppIcon :name="item.isLit ? 'star-fill-white' : 'star-gray'" :size="16" />
</view>
<view class="k-info">
<text class="k-title">{{ item.title }}</text>
<text class="k-desc">{{ item.desc }}</text>
<view class="k-meta">
<text>📖 {{ item.readTime || 5 }} 分钟</text>
<text v-if="item.collected">⭐ 已收藏</text>
<text v-else>🔖 {{ item.collectCount || 0 }} 人收藏</text>
<text class="k-badge" :class="item.isPremium ? 'pro' : 'free'">
{{ item.isPremium ? 'Pro' : '免费' }}
</text>
<view class="p-info">
<text class="p-title">{{ item.title }}</text>
<text class="p-desc">{{ item.desc }}</text>
<view class="p-meta">
<view class="p-lit" v-if="item.isLit" :style="{ color: currentDim?.color, background: currentDim?.bg }">已点亮</view>
<text class="p-read">{{ item.readTime }} 分钟</text>
<text class="p-quiz" v-if="item.hasQuiz">含检测题</text>
</view>
</view>
<AppIcon name="arrow-gray" :size="13" />
</view>
<!-- 空态 -->
<view v-if="!loading && !knowledgeList.length" class="empty-state">
<AppIcon name="planet-gray" :size="40" />
<text class="empty-title">这片星域还没有星球</text>
<text class="empty-desc">内容正在扩充中,先去别的星域逛逛</text>
</view>
<!-- 加载状态 -->
<view v-if="loading" class="loading-state">
<text>加载中...</text>
</view>
<view class="bottom-spacer"></view>
</view>
</template>
<script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, onMounted } from 'vue'
import { getDimension } from '@/utils/dimensions'
import { knowledgeApi } from '@/utils/api'
import { useUserStore } from '@/stores/user'
import AppIcon from '@/components/AppIcon.vue'
import ProgressRing from '@/components/ProgressRing.vue'
const userStore = useUserStore()
const dimId = ref(1)
const currentDim = computed(() => getDimension(dimId.value))
const knowledgeList = ref([])
const loading = ref(false)
const progress = ref(35)
const readCount = ref(4)
const totalCount = ref(12)
const litIds = ref([])
const bannerDesc = computed(() => {
const descs = {
1: '从 1950 年图灵测试到 2026 年通用人工智能,追溯 70 年演进之路',
2: '从深度学习到 Transformer,从 MoE 到多模态,理解每一次技术跃迁',
3: '大模型格局、Agent 生态、具身智能——2026 年 AI 世界全景图',
4: '提示词工程、RAG 系统、模型微调、工具链——从入门到上手',
5: '每日追踪 AI 前沿动态,不错过每一个重要时刻'
}
return descs[dimId.value] || '探索人工智能的精彩世界'
})
const totalCount = computed(() => knowledgeList.value.length)
const litCount = computed(() => knowledgeList.value.filter(i => i.isLit).length)
const litPercent = computed(() =>
totalCount.value ? Math.round((litCount.value / totalCount.value) * 100) : 0
)
const litStyle = computed(() => ({
background: currentDim.value?.iconBg,
borderColor: currentDim.value?.border
}))
onMounted(() => {
const pages = getCurrentPages()
@@ -79,91 +105,44 @@ onMounted(() => {
async function loadKnowledge() {
loading.value = true
try {
const data = await knowledgeApi.list({ dim: dimId.value })
knowledgeList.value = data.map(item => ({
const [data, stats] = await Promise.all([
knowledgeApi.list({ dim: dimId.value, limit: 100 }),
knowledgeApi.stats(userStore.isLoggedIn ? userStore.token : '').catch(() => null)
])
litIds.value = stats?.user?.litIds || []
knowledgeList.value = (data.items || []).map(item => ({
...item,
icon: getDimIcon(item.dim),
desc: item.content?.substring(0, 40) + '...' || '',
readTime: Math.ceil((item.content?.length || 300) / 200),
isPremium: item.isPremium,
collected: false,
collectCount: item.collectionCount
desc: item.content ? item.content.substring(0, 42) + '...' : '',
readTime: Math.max(1, Math.ceil((item.content?.length || 300) / 300)),
hasQuiz: !!(item.question && (item.options || []).length),
isLit: litIds.value.includes(String(item._id))
}))
} catch (e) {
// 用示例数据填充
knowledgeList.value = getSampleData(dimId.value)
knowledgeList.value = []
uni.showToast({ title: '加载失败,请下拉重试', icon: 'none' })
} finally {
loading.value = false
}
}
function getItemStyle(item) {
const colors = [
{ bg: 'rgba(124,77,255,0.1)', color: '#b388ff' },
{ bg: 'rgba(0,188,212,0.1)', color: '#4dd0e1' },
{ bg: 'rgba(0,200,150,0.1)', color: '#4db6ac' },
{ bg: 'rgba(255,193,7,0.1)', color: '#ffd54f' },
{ bg: 'rgba(239,83,80,0.1)', color: '#ef5350' }
]
const c = colors[(item.dim || 1) - 1] || colors[0]
return {
background: c.bg,
color: c.color,
borderColor: c.bg
}
}
function getDimIcon(dim) {
const icons = ['📜', '🏛', '♟', '🧠', '⚡']
return icons[(dim || 1) - 1]
}
function getSampleData(dim) {
const samples = {
1: [
{ title: '图灵测试:人工智能的哲学起点', content: '1950 年,艾伦·图灵提出"机器能思考吗?"' },
{ title: '达特茅斯会议:AI 的诞生时刻', content: '1956 年,一群科学家正式命名了"人工智能"' },
{ title: '深蓝 vs 卡斯帕罗夫', content: '1997 年,IBM 深蓝首次击败国际象棋世界冠军' },
],
2: [
{ title: '深度学习革命', content: '从神经网络到深度学习的演进之路' },
{ title: 'Transformer 架构详解', content: '2017 年 Google 提出的革命性架构' },
],
3: [
{ title: '2026 年大模型格局', content: 'GPT、Claude、Gemini、DeepSeek 全面对比' },
{ title: 'Agent 智能体生态', content: '从 Chatbot 到自主 Agent 的进化' },
],
4: [
{ title: '提示词工程入门', content: '如何写出高质量的 prompt' },
{ title: 'RAG 系统搭建指南', content: '检索增强生成从零到一' },
],
5: [
{ title: 'DeepSeek 自研推理芯片', content: '降低对英伟达依赖的重要一步' },
{ title: 'WAIC 2026 即将开幕', content: '7 月 17 日上海,1100+ 企业参展' },
]
}
const data = samples[dim] || samples[1]
return data.map((item, i) => ({
...item,
_id: `sample-${dim}-${i}`,
dim,
icon: getDimIcon(dim),
readTime: 5 + i * 2,
isPremium: i >= 3,
collected: i === 0,
collectionCount: 10 - i * 2
}))
}
function goBack() {
uni.navigateBack()
}
function goDetail(item) {
uni.navigateTo({
url: `/pages/detail/detail?id=${item._id}`
})
uni.navigateTo({ url: `/pages/detail/detail?id=${item._id}` })
}
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: (currentDim.value?.name || 'AI 维度') + '星域 · 每颗星球都是一则 AI 知识',
path: `/pages/dimension/dimension?id=${dimId.value}`
}))
onShareTimeline(() => ({
title: (currentDim.value?.name || 'AI 维度') + '星域 · AI 维度'
}))
// #endif
</script>
<style lang="scss" scoped>
@@ -175,107 +154,148 @@ function goDetail(item) {
.status-bar-placeholder { height: 44px; }
.nav-row { padding-bottom: 12px; }
.back-btn {
color: rgba(255,255,255,0.4);
font-size: 14px;
display: flex;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 0 14px;
gap: 4px;
padding: 8px 14px 8px 10px;
border-radius: var(--radius-full);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
font-size: 13px;
color: var(--text-secondary);
}
.dim-banner {
border-radius: 18px;
padding: 24px 20px;
margin-bottom: 20px;
background: linear-gradient(135deg, rgba(124,77,255,0.1), rgba(0,0,0,0.3));
border: 1px solid rgba(124,77,255,0.12);
position: relative;
overflow: hidden;
}
.dim-banner-icon { font-size: 28px; display: block; margin-bottom: 8px; }
.dim-banner-title { font-size: 22px; color: var(--text-primary); display: block; margin-bottom: 6px; }
.dim-banner-desc {
font-size: 12px;
color: rgba(255,255,255,0.4);
line-height: 1.6;
display: block;
border-radius: var(--radius-lg);
padding: 18px 16px;
margin-bottom: 16px;
background: var(--glass-bg);
border: 1.5px solid var(--glass-border);
box-shadow: var(--card-shadow, none);
}
.progress-track {
height: 3px;
background: var(--glass-bg);
border-radius: 2px;
margin-top: 16px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #b388ff, #7c4dff);
border-radius: 2px;
}
.progress-label {
font-size: 11px;
color: rgba(255,255,255,0.2);
margin-top: 8px;
display: block;
}
.knowledge-item {
border-radius: 14px;
padding: 16px;
margin-bottom: 10px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
.banner-top {
display: flex;
gap: 14px;
align-items: flex-start;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.k-icon {
.dim-icon {
width: 44px; height: 44px;
border-radius: 12px;
flex-shrink: 0;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
border: 1px solid var(--glass-border);
flex-shrink: 0;
}
.k-info { flex: 1; }
.k-title {
font-size: 14px;
color: rgba(255,255,255,0.85);
font-weight: 500;
.banner-info { flex: 1; min-width: 0; }
.dim-banner-title {
display: block;
margin-bottom: 4px;
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 3px;
}
.k-desc {
font-size: 11px;
color: rgba(255,255,255,0.3);
.dim-banner-desc {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
margin-bottom: 8px;
display: block;
}
.k-meta {
.progress-label {
font-size: 11px;
color: var(--text-tertiary);
}
.planet-item {
display: flex;
align-items: center;
gap: 12px;
border-radius: var(--radius-lg);
padding: 14px;
margin-bottom: 10px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
box-shadow: var(--card-shadow, none);
transition: transform 0.2s ease;
}
.planet-item:active { transform: scale(0.98); }
.p-icon {
width: 40px; height: 40px;
border-radius: 13px;
border: 1.5px dashed var(--glass-border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.lit { border-style: solid; }
}
.p-info { flex: 1; min-width: 0; }
.p-title {
display: block;
font-size: 14px;
color: var(--text-primary);
font-weight: 600;
line-height: 1.5;
margin-bottom: 3px;
}
.p-desc {
display: block;
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.5;
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.p-meta {
display: flex;
gap: 8px;
align-items: center;
font-size: 10px;
color: rgba(255,255,255,0.15);
color: var(--text-muted);
}
.k-badge {
font-size: 10px;
padding: 2px 10px;
border-radius: 6px;
font-weight: 500;
.p-lit {
font-weight: 600;
padding: 2px 8px;
border-radius: var(--radius-full);
}
.p-quiz {
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--bg-tertiary);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 50px 0 30px;
gap: 8px;
.empty-title {
font-size: 15px;
font-weight: 600;
color: var(--text-secondary);
margin-top: 8px;
}
.empty-desc {
font-size: 12px;
color: var(--text-muted);
}
}
.k-badge.free { background: rgba(76,175,80,0.12); color: #66bb6a; }
.k-badge.pro { background: rgba(255,152,0,0.12); color: #ffa726; }
.loading-state {
text-align: center;
padding: 40px 0;
color: rgba(255,255,255,0.2);
color: var(--text-muted);
font-size: 13px;
}
</style>
.bottom-spacer { height: 20px; }
</style>
+18 -7
View File
@@ -19,9 +19,9 @@
<!-- 维度标签 -->
<view
class="dim-tag"
:style="{ color: dimColor(item.dimensionId) }"
:style="{ color: dimColor(item.dimension) }"
>
{{ dimName(item.dimensionId) }}
{{ dimName(item.dimension) }}
</view>
<!-- 标题 -->
<text class="fav-title">{{ item.title }}</text>
@@ -34,9 +34,10 @@
<!-- 空状态 -->
<view class="empty-state" v-else>
<text class="empty-icon">⭐</text>
<text class="empty-text">你还没有收藏任何内容</text>
<text class="empty-sub">去首页发现有趣的 AI 知识吧</text>
<text class="empty-icon">还没有收藏</text>
<text class="empty-text" v-if="userStore.isLoggedIn">你还没有收藏任何内容</text>
<text class="empty-text" v-else>登录后可收藏喜欢的 AI 知识</text>
<text class="empty-sub" v-if="userStore.isLoggedIn">去首页发现有趣的 AI 知识吧</text>
<view class="empty-btn" @tap="goHome">
<text>开始探索</text>
</view>
@@ -45,6 +46,8 @@
</template>
<script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, onMounted } from 'vue'
import { knowledgeApi } from '@/utils/api'
import { getDimension } from '@/utils/dimensions'
@@ -56,8 +59,8 @@ const loading = ref(true)
onMounted(() => {
if (!userStore.isLoggedIn) {
uni.showToast({ title: '请先登录', icon: 'none' })
uni.navigateTo({ url: '/pages/login/login' })
// 未登录时显示空状态提示,不强制跳转登录
loading.value = false
return
}
loadFavorites()
@@ -112,6 +115,14 @@ async function removeFav(id, idx) {
function goHome() {
uni.switchTab({ url: '/pages/index/index' })
}
// 微信小程序分享
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手',
path: '/pages/index/index'
}))
// #endif
</script>
<style lang="scss" scoped>
+12 -2
View File
@@ -101,6 +101,8 @@
</template>
<script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, onMounted } from 'vue'
import { feedbackApi } from '@/utils/api'
import { useUserStore } from '@/stores/user'
@@ -110,7 +112,7 @@ const userStore = useUserStore()
// 反馈类型
const feedbackTypes = [
{ value: 'bug', name: '问题报告', icon: '🐛' },
{ value: 'feature', name: '功能建议', icon: '💡' },
{ value: 'feature', name: '功能建议', icon: '★' },
{ value: 'content', name: '内容纠错', icon: '📝' },
{ value: 'other', name: '其他', icon: '💬' }
]
@@ -145,7 +147,7 @@ function statusLabel(status) {
function typeLabel(type) {
const map = {
bug: '🐛 问题报告',
feature: '💡 功能建议',
feature: '功能建议',
content: '📝 内容纠错',
other: '💬 其他'
}
@@ -226,6 +228,14 @@ onMounted(() => {
function goBack() {
uni.navigateBack()
}
// 微信小程序分享
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: '帮AI维度变得更好 · 意见反馈',
path: '/pages/index/index'
}))
// #endif
</script>
<style lang="scss" scoped>
+291 -177
View File
@@ -1,75 +1,92 @@
<template>
<view class="page-home page-layout">
<!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view>
<!-- 顶部导航 -->
<view class="header">
<view class="brand">
<view class="brand-icon">✦</view>
<view class="brand-icon">
<AppIcon name="planet-white" :size="20" />
</view>
<view class="brand-text">
<text class="title">AI 维度</text>
<text class="subtitle">宇之然 · 探索</text>
<text class="subtitle">宇之然 · 知识星图</text>
</view>
</view>
<view class="header-actions">
<view class="icon-btn" @tap="onNotify">
<text>🔔</text>
</view>
<view class="icon-btn" @tap="goProfile">
<text>👤</text>
<AppIcon name="user-gray" :size="18" />
</view>
</view>
</view>
<!-- 问候语 -->
<view class="greeting">
<text class="greeting-title">探索 </text>
<text class="greeting-highlight">人工智能</text>
<text class="greeting-desc">从 5 个维度,系统理解 AI 的过去、现在与未来</text>
<!-- 连续点亮 streak -->
<view class="streak-bar" v-if="streak > 0" @tap="goFirstUnlit">
<AppIcon name="flame" :size="16" />
<text class="streak-text">已连续点亮 {{ streak }} 天</text>
<text class="streak-sub">{{ todayLit ? '今日已点亮,继续保持' : '今日还没点亮,去逛逛' }}</text>
<view class="streak-go">
<AppIcon name="arrow-white" :size="12" />
</view>
</view>
<!-- 继续探索 / 今日推荐(二合一:有上次阅读显示继续,没有则推荐今日一条) -->
<view class="resume-card" v-if="lastRead || todayKnowledge" @tap="resumeRead">
<view class="resume-icon">
<AppIcon :name="lastRead ? 'clock' : 'book'" :size="18" />
</view>
<view class="resume-info">
<text class="resume-label">{{ lastRead ? '继续探索' : '今日推荐' }}</text>
<text class="resume-title">{{ (lastRead || todayKnowledge)?.title }}</text>
</view>
<AppIcon name="arrow-gray" :size="14" />
</view>
<!-- 搜索栏 -->
<view class="search-bar" @tap="onSearch">
<text class="search-icon">🔍</text>
<AppIcon name="search-gray" :size="16" />
<text class="search-placeholder">搜搜你想了解的 AI 知识...</text>
</view>
<!-- 五维度网格 -->
<!-- 五大星域 -->
<view class="section-header">
<text class="section-title">✦ 五个维度</text>
<text class="section-title">五大星域</text>
<text class="section-more" v-if="userStats">{{ userStats.totalLit }}/{{ statsTotal }} 颗已点亮</text>
</view>
<view class="dim-grid">
<view
v-for="dim in dimensions"
:key="dim.id"
class="dim-card"
:class="{ 'dim-full': dim.isFull }"
:style="{ background: dim.bg }"
@tap="goDimension(dim)"
>
<view class="dim-icon" :style="{ background: dim.iconBg, color: dim.color }">
<text>{{ dim.icon }}</text>
<view class="dim-top">
<view class="dim-icon" :style="{ background: dim.iconBg }">
<AppIcon :name="dim.icon" :size="18" />
</view>
<ProgressRing
:size="34"
:num-size="9"
:percent="dimPercent(dim.id)"
:color="dim.color"
/>
</view>
<text class="dim-name">{{ dim.name }}</text>
<text class="dim-desc">{{ dim.tagline }}</text>
<view class="dim-foot">
<view class="dim-lit" :style="{ background: dim.bg, color: dim.color }">
{{ dimLitText(dim.id) }}
</view>
<text class="dim-count">{{ dimCountText(dim.id) }}</text>
</view>
<text class="dim-name" :style="{ color: dim.color }">{{ dim.name }}</text>
<text class="dim-desc">{{ dim.desc }}</text>
<text class="dim-count">
{{ dim.isFull ? '今日已更新 8 条' : `${dim.articleCount} 篇 · ${dim.quizCount} 个问答` }}
</text>
</view>
</view>
<!-- 今日快讯 -->
<view class="section-header">
<text class="section-title">📡 今日快讯</text>
<text class="section-more" @tap="goTrend">查看全部 →</text>
<text class="section-title">今日快讯</text>
<text class="section-more" @tap="goTrend">查看全部</text>
</view>
<view
v-for="(item, idx) in newsList"
:key="idx"
class="news-card"
@tap="goNewsDetail(item)"
>
<view v-for="(item, idx) in newsList" :key="idx" class="news-card" @tap="goNewsDetail(item)">
<view class="news-tag" :class="item.tagClass">{{ item.tag }}</view>
<text class="news-title">{{ item.title }}</text>
<view class="news-meta">
@@ -83,24 +100,80 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { onPullDownRefresh, onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'
import { DIMENSIONS } from '@/utils/dimensions'
import { trendApi } from '@/utils/api'
import { knowledgeApi, trendApi } from '@/utils/api'
import { useUserStore } from '@/stores/user'
import AppIcon from '@/components/AppIcon.vue'
import ProgressRing from '@/components/ProgressRing.vue'
const userStore = useUserStore()
const dimensions = ref(DIMENSIONS)
const newsList = ref([])
const loading = ref(true)
const byDim = ref({}) // 各星域知识总数
const todayKnowledge = ref(null)
onMounted(() => {
fetchNews()
const todayDimName = computed(() => {
const d = DIMENSIONS.find(x => x.id === todayKnowledge.value?.dim)
return d ? d.name : ''
})
async function fetchNews() {
function goTodayKnowledge() {
if (todayKnowledge.value?._id) {
uni.navigateTo({ url: `/pages/detail/detail?id=${todayKnowledge.value._id}` })
}
}
const userStats = ref(null) // { totalLit, litByDim, streak }
const lastRead = ref(null)
const TAG_HOT = '热门'
const TAG_PICK = '精选'
const TAG_NEW = '最新'
const statsTotal = computed(() => Object.values(byDim.value).reduce((a, b) => a + b, 0))
const streak = computed(() => userStats.value?.streak || 0)
const todayLit = computed(() => !!uni.getStorageSync('lit_today'))
onShow(() => {
loadStats()
loadNews()
loadTodayKnowledge()
lastRead.value = uni.getStorageSync('last_read') || null
})
// 今日知识卡:按日期取当日第 N 条(同一天所有人看到同一条,形成"每日一词"共识感)
async function loadTodayKnowledge() {
try {
const data = await trendApi.list({ limit: 5 })
newsList.value = data.list.map(item => ({
tag: item.hot ? '🔥 热门' : item.picked ? '📌 精选' : '🚀 最新',
const data = await knowledgeApi.list({ limit: 50, page: 1 })
const items = (data.items || []).filter((x) => x.content)
if (!items.length) return
const dayIndex = Math.floor(Date.now() / 86400000)
todayKnowledge.value = items[dayIndex % items.length]
} catch (e) { /* 静默 */ }
}
onPullDownRefresh(async () => {
await Promise.all([loadStats(), loadNews()])
uni.stopPullDownRefresh()
})
async function loadStats() {
try {
const data = await knowledgeApi.stats(userStore.isLoggedIn ? userStore.token : '')
byDim.value = data.byDim || {}
userStats.value = data.user || null
} catch (e) {
// 静默降级:环形显示 0%
}
}
async function loadNews() {
try {
const data = await trendApi.list({ limit: 3 })
newsList.value = (data.list || []).slice(0, 3).map(item => ({
tag: item.hot ? TAG_HOT : item.picked ? TAG_PICK : TAG_NEW,
tagClass: item.hot ? 'hot' : item.picked ? 'pick' : 'new',
title: item.title,
source: item.source,
@@ -108,17 +181,27 @@ async function fetchNews() {
_id: item._id
}))
} catch (e) {
// 降级:本地数据
newsList.value = [
{ tag: '🔥 热门', tagClass: 'hot', title: 'DeepSeek 被曝自研推理芯片', source: '量子位', time: '2 小时前' },
{ tag: '🚀 最新', tagClass: 'new', title: '蚂蚁灵波开源 LingBot-World 2.0', source: '量子位', time: '4 小时前' },
{ tag: '📌 精选', tagClass: 'pick', title: '阿里获 ACL 2026 最佳资源论文奖', source: '机器之心', time: '6 小时前' }
]
} finally {
loading.value = false
newsList.value = []
}
}
function dimPercent(dimId) {
const total = byDim.value[dimId] || 0
if (!total) return 0
const lit = userStats.value?.litByDim?.[dimId] || 0
return Math.round((lit / total) * 100)
}
function dimLitText(dimId) {
const lit = userStats.value?.litByDim?.[dimId] || 0
return `${lit} 颗已点亮`
}
function dimCountText(dimId) {
const total = byDim.value[dimId] || 0
return `共 ${total} 颗星球`
}
function formatTime(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime()
const hours = Math.floor(diff / 3600000)
@@ -131,6 +214,18 @@ function goDimension(dim) {
uni.navigateTo({ url: `/pages/dimension/dimension?id=${dim.id}&name=${dim.name}` })
}
function goFirstUnlit() {
uni.navigateTo({ url: '/pages/dimension/dimension?id=1' })
}
function resumeRead() {
if (lastRead.value?.id) {
uni.navigateTo({ url: `/pages/detail/detail?id=${lastRead.value.id}` })
} else if (todayKnowledge.value?.id || todayKnowledge.value?._id) {
uni.navigateTo({ url: `/pages/detail/detail?id=${todayKnowledge.value.id || todayKnowledge.value._id}` })
}
}
function goTrend() {
uni.switchTab({ url: '/pages/trend/trend' })
}
@@ -143,14 +238,20 @@ function onSearch() {
uni.navigateTo({ url: '/pages/search/search' })
}
function onNotify() {
uni.showToast({ title: '暂无新消息', icon: 'none' })
}
function goNewsDetail(item) {
uni.setStorageSync('__news_item__', JSON.stringify(item))
uni.navigateTo({ url: '/pages/news/news' })
}
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: 'AI 维度 · 5 分钟点亮一颗 AI 知识星球',
path: '/pages/index/index'
}))
onShareTimeline(() => ({
title: 'AI 维度 · 5 分钟点亮一颗 AI 知识星球'
}))
// #endif
</script>
<style lang="scss" scoped>
@@ -160,15 +261,13 @@ function goNewsDetail(item) {
background: var(--bg-primary);
}
.status-bar-placeholder {
height: 44px;
}
.status-bar-placeholder { height: 44px; }
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0 20px;
padding: 6px 0 18px;
}
.brand {
@@ -178,38 +277,32 @@ function goNewsDetail(item) {
}
.brand-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, rgba(124,77,255,0.3), rgba(0,188,212,0.2));
border: 1px solid rgba(124,77,255,0.2);
width: 38px;
height: 38px;
border-radius: 12px;
background: linear-gradient(135deg, var(--color-brand), #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 0 20px rgba(124,77,255,0.1);
box-shadow: 0 4px 14px var(--color-brand-glow);
}
.brand-text {
display: flex;
flex-direction: column;
}
.brand-text { display: flex; flex-direction: column; }
.brand-text .title {
font-size: 20px;
color: var(--text-primary);
font-weight: 700;
letter-spacing: 1px;
line-height: 1.2;
}
.brand-text .subtitle {
font-size: 11px;
color: var(--text-muted);
letter-spacing: 2px;
color: var(--text-tertiary);
letter-spacing: 1.5px;
margin-top: 2px;
}
.header-actions {
display: flex;
gap: 10px;
}
.header-actions { display: flex; gap: 10px; }
.icon-btn {
width: 36px; height: 36px;
border-radius: 50%;
@@ -218,167 +311,188 @@ function goNewsDetail(item) {
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
}
.greeting {
margin-bottom: 20px;
padding: 24px 20px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(99,102,241,0.08), rgba(139,92,246,0.04));
border: 1px solid rgba(99,102,241,0.1);
/* streak */
.streak-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, rgba(229, 28, 28, 0.08), rgba(223, 119, 0, 0.06));
border: 1px solid rgba(229, 28, 28, 0.15);
margin-bottom: 12px;
}
.greeting-title {
font-size: 26px;
color: var(--text-primary);
font-weight: 700;
}
.greeting-highlight {
font-size: 26px;
font-weight: 700;
background: linear-gradient(135deg, #b388ff, #7c4dff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.greeting-desc {
display: block;
.streak-text {
font-size: 13px;
font-weight: 700;
color: var(--dim5-color);
}
.streak-sub {
flex: 1;
font-size: 11px;
color: var(--text-tertiary);
margin-top: 4px;
}
.streak-go {
width: 22px; height: 22px;
border-radius: 50%;
background: var(--dim5-color);
display: flex;
align-items: center;
justify-content: center;
}
.search-bar {
/* 继续探索 */
.resume-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: var(--radius-lg);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 14px;
padding: 12px 16px;
box-shadow: var(--card-shadow, none);
margin-bottom: 12px;
}
.resume-icon {
width: 38px; height: 38px;
border-radius: 12px;
background: var(--color-brand-bg);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.resume-info { flex: 1; min-width: 0; }
.resume-label {
font-size: 11px;
color: var(--text-tertiary);
display: block;
margin-bottom: 2px;
}
.resume-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
/* 搜索 */
.search-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 24px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
padding: 12px 16px;
border-radius: var(--radius-full);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
margin-bottom: 6px;
}
.search-icon { color: var(--text-muted); }
.search-placeholder { color: var(--text-muted); font-size: 14px; }
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
padding: 8px 12px;
border-radius: 10px;
background: rgba(99,102,241,0.04);
}
.section-title {
font-size: 17px;
color: var(--text-primary);
font-weight: 600;
letter-spacing: 0.5px;
}
.section-more {
font-size: 12px;
.search-placeholder {
font-size: 13px;
color: var(--text-muted);
}
/* 星域网格 */
.dim-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 28px;
}
.dim-card {
border-radius: 18px;
padding: 20px 16px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.dim-full {
grid-column: 1 / -1;
border-radius: var(--radius-lg);
padding: 14px;
box-shadow: var(--card-shadow, none);
transition: transform 0.2s ease;
}
.dim-card:active { transform: scale(0.97); }
.dim-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.dim-icon {
width: 42px; height: 42px;
border-radius: 12px;
width: 34px; height: 34px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
margin-bottom: 14px;
}
.dim-name {
font-size: 15px;
font-weight: 600;
margin-bottom: 4px;
letter-spacing: 0.5px;
display: block;
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 3px;
}
.dim-desc {
font-size: 12px;
display: block;
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.5;
display: block;
margin-bottom: 10px;
}
.dim-foot {
display: flex;
align-items: center;
justify-content: space-between;
}
.dim-lit {
font-size: 10px;
font-weight: 600;
padding: 3px 8px;
border-radius: var(--radius-full);
}
.dim-count {
font-size: 11px;
font-size: 10px;
color: var(--text-muted);
margin-top: 10px;
display: block;
}
/* 快讯 */
.news-card {
border-radius: 14px;
padding: 14px 16px;
margin-bottom: 10px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: var(--radius-md);
padding: 14px 16px;
margin-bottom: 10px;
box-shadow: var(--card-shadow, none);
}
.news-tag {
display: inline-block;
font-size: 10px;
padding: 2px 10px;
border-radius: 6px;
font-weight: 600;
padding: 2px 8px;
border-radius: var(--radius-full);
margin-bottom: 8px;
font-weight: 500;
}
.news-tag.hot {
background: rgba(239,83,80,0.12);
color: #ef5350;
}
.news-tag.new {
background: rgba(0,188,212,0.12);
color: #4dd0e1;
}
.news-tag.pick {
background: rgba(124,77,255,0.12);
color: #b388ff;
}
.news-tag.hot { background: var(--color-hot-bg); color: var(--color-hot); }
.news-tag.new { background: var(--color-new-bg); color: var(--color-new); }
.news-tag.pick { background: var(--color-pick-bg); color: var(--color-pick); }
.news-title {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.5;
margin-bottom: 8px;
font-weight: 500;
display: block;
}
.news-meta {
display: flex;
gap: 16px;
justify-content: space-between;
font-size: 11px;
color: var(--text-muted);
}
.bottom-spacer {
height: 40px;
}
</style>
.bottom-spacer { height: 30px; }
</style>
<style scoped>
</style>
+18 -9
View File
@@ -37,20 +37,20 @@
<!-- 头像 -->
<view class="avatar">
<image
v-if="item.avatarUrl"
:src="item.avatarUrl"
v-if="item.avatar"
:src="item.avatar"
class="avatar-img"
mode="aspectFill"
/>
<text v-else class="avatar-text">
{{ (item.nickName || item.username || '用').charAt(0) }}
{{ (item.nickname || item.username || '用').charAt(0) }}
</text>
</view>
<!-- 信息 -->
<view class="user-info">
<text class="user-name">{{ item.nickName || item.username || '用户' }}</text>
<text class="user-score">{{ item.score || 0 }} 分</text>
<text class="user-name">{{ item.nickname || item.username || '用户' }}</text>
<text class="user-score">{{ item.totalScore || 0 }} 分</text>
</view>
<!-- 已解锁维度 -->
@@ -81,6 +81,8 @@
</template>
<script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, onMounted } from 'vue'
import { userApi } from '@/utils/api'
import { getDimension } from '@/utils/dimensions'
@@ -102,8 +104,7 @@ const dimTabs = [
onMounted(() => {
if (!userStore.isLoggedIn) {
uni.showToast({ title: '请先登录', icon: 'none' })
uni.navigateTo({ url: '/pages/login/login' })
loading.value = false
return
}
loadBoard()
@@ -116,10 +117,10 @@ async function loadBoard() {
const data = await userApi.leaderboard(dimParam)
const list = Array.isArray(data) ? data : data.list || []
// 标记当前用户
const myId = userStore.userInfo?.id || userStore.userInfo?.userId || ''
const myId = userStore.userInfo?.openid || ''
board.value = list.map((item) => ({
...item,
isCurrentUser: String(item.userId || item.id || '') === String(myId)
isCurrentUser: item.openid === myId
}))
} catch (e) {
console.error('[leaderboard] load failed:', e)
@@ -133,6 +134,14 @@ function dimDotColor(id) {
const dim = getDimension(id)
return dim ? dim.color : 'rgba(255,255,255,0.2)'
}
// 微信小程序分享
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: 'AI学习排行榜 · 宇之然AI维度',
path: '/pages/index/index'
}))
// #endif
</script>
<style lang="scss" scoped>
+98 -13
View File
@@ -4,6 +4,14 @@
<view class="bg-orb orb-2"></view>
<view class="bg-orb orb-3"></view>
<!-- 顶部返回 -->
<view class="nav-bar">
<view class="nav-back" @click="goBack">
<text class="nav-back-icon">←</text>
<text class="nav-back-text">返回</text>
</view>
</view>
<view class="login-container">
<view class="logo-wrap">
<view class="logo-ring"></view>
@@ -28,13 +36,18 @@
<input class="form-input" v-model="loginForm.password" type="password" placeholder="请输入密码" />
</view>
<!-- 忘记密码 -->
<view class="forgot-row">
<text class="forgot-link" @click="goForgotPassword">忘记密码?</text>
</view>
<!-- 登录协议勾选 -->
<view class="agreement-row">
<view class="agreement-checkbox" :class="{ checked: loginAgreed }" @click="loginAgreed = !loginAgreed">
<text v-if="loginAgreed">✓</text>
</view>
<text class="agreement-text">
登录即表示同意
我已阅读并同意
<text class="agreement-link" @click.stop="openAgreement('terms')">《用户协议》</text>
和
<text class="agreement-link" @click.stop="openAgreement('privacy')">《隐私政策》</text>
@@ -45,12 +58,14 @@
<text>{{ loading ? '登录中...' : '登录' }}</text>
</view>
<!-- 微信小程序一键登录 -->
<view class="wx-login-row" v-if="isMiniApp">
<!-- #ifdef MP-WEIXIN -->
<view class="wx-login-row">
<view class="wx-login-btn" @click="wechatLogin">
<text class="wx-icon">💬</text>
<text>微信一键登录</text>
</view>
</view>
<!-- #endif -->
</view>
<!-- 注册表单 -->
@@ -59,6 +74,10 @@
<text class="form-label">邮箱 <text style="color:var(--color-vip);font-size:11px;">*</text></text>
<input class="form-input" v-model="registerForm.email" type="email" placeholder="请输入邮箱地址" @input="registerForm.email = $event.detail.value.trim(); verifyCodeSent = false" />
</view>
<view class="form-item">
<text class="form-label">用户名 <text style="color:var(--color-vip);font-size:11px;">*</text></text>
<input class="form-input" v-model="registerForm.username" placeholder="请输入用户名(用于登录)" @input="registerForm.username = $event.detail.value.trim()" />
</view>
<view class="form-item">
<text class="form-label">昵称 <text style="color:var(--text-muted);font-size:11px;">(选填)</text></text>
<input class="form-input" v-model="registerForm.nickName" placeholder="显示名称,留空自动生成" />
@@ -100,22 +119,22 @@
<view class="footer-info">
<text>© 2026 宇之然 · AI 维度</text>
</view>
<!-- 显著有效的「拒绝/暂不登录」入口,避免强制登录 -->
<view class="skip-login" @click="goBrowse">
<text>暂不登录,先体验</text>
</view>
</view>
</view>
</template>
<script setup>
import { askSubscribe } from '../../utils/notify'
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { useUserStore } from '@/stores/index'
const isMiniApp = ref(false)
try {
const sysInfo = uni.getSystemInfoSync()
if (sysInfo && sysInfo.version && sysInfo.platform) {
isMiniApp.value = true
}
} catch (e) {}
const activeTab = ref('login')
const loading = ref(false)
const agreed = ref(false)
@@ -127,7 +146,7 @@ let countdownTimer = null
const userStore = useUserStore()
const loginForm = ref({ email: '', password: '' })
const registerForm = ref({ email: '', password: '', nickName: '', verifyCode: '' })
const registerForm = ref({ email: '', username: '', password: '', nickName: '', verifyCode: '' })
const BASE_URL = 'https://wdkj.yuzhiran.com.cn'
@@ -189,9 +208,11 @@ const wechatLogin = async () => {
loading.value = true
await userStore.login(loginRes.code, {})
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => askSubscribe(), 800) // 登录成功后请求订阅授权(接受新版通知等)
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
} catch (err) {
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
const msg = (err && (err.message || err.errMsg)) || ''
uni.showToast({ title: msg ? `登录失败:${msg}` : '登录失败,请重试', icon: 'none' })
} finally {
loading.value = false
}
@@ -216,6 +237,7 @@ const handleLogin = async () => {
: { username: identifier, password: loginForm.value.password }
await userStore.h5Login(loginData)
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => askSubscribe(), 800) // 登录成功后请求订阅授权(接受新版通知等)
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
} catch (err) {
uni.showToast({ title: err.message || '登录失败', icon: 'none' })
@@ -241,6 +263,10 @@ const handleRegister = async () => {
uni.showToast({ title: '请输入密码', icon: 'none' })
return
}
if (!registerForm.value.username) {
uni.showToast({ title: '请输入用户名', icon: 'none' })
return
}
if (registerForm.value.password.length < 6) {
uni.showToast({ title: '密码至少 6 位', icon: 'none' })
return
@@ -255,7 +281,8 @@ const handleRegister = async () => {
registerForm.value.email,
registerForm.value.password,
registerForm.value.nickName || '',
registerForm.value.verifyCode
registerForm.value.verifyCode,
registerForm.value.username
)
uni.showToast({ title: '注册成功', icon: 'success' })
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
@@ -273,6 +300,27 @@ function openAgreement(type) {
}
uni.navigateTo({ url: pages[type] || pages.terms })
}
function goBack() {
uni.navigateBack({ fallback: () => uni.switchTab({ url: '/pages/index/index' }) })
}
/** 拒绝登录:返回首页以游客身份浏览,不强制登录 */
function goBrowse() {
uni.switchTab({ url: '/pages/index/index' })
}
function goForgotPassword() {
uni.showToast({ title: '请联系管理员重置密码', icon: 'none' })
}
// 微信小程序分享
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: '登录解锁AI学习完整功能 · 宇之然AI维度',
path: '/pages/index/index'
}))
// #endif
</script>
<style lang="scss" scoped>
@@ -297,6 +345,19 @@ function openAgreement(type) {
.orb-2 { width: 160px; height: 160px; top: 30%; right: -40px; background: var(--dim3-color); }
.orb-3 { width: 180px; height: 180px; bottom: -60px; left: 30%; background: var(--dim5-color); }
// 顶部导航栏
.nav-bar {
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
padding: 44px 20px 0;
.nav-back {
display: inline-flex; align-items: center; gap: 4px;
padding: 8px 12px; cursor: pointer;
&:active { opacity: 0.6; }
}
.nav-back-icon { font-size: 20px; color: var(--text-primary); }
.nav-back-text { font-size: 14px; color: var(--text-secondary); }
}
.login-container {
position: relative; z-index: 1;
width: 100%; max-width: 360px;
@@ -348,6 +409,15 @@ function openAgreement(type) {
}
}
// 忘记密码
.forgot-row {
display: flex; justify-content: flex-end; margin: -8px 0 0;
.forgot-link {
font-size: 12px; color: var(--color-brand); padding: 4px 0;
&:active { opacity: 0.6; }
}
}
// 验证码行
.verify-row {
display: flex; gap: 10px;
@@ -411,5 +481,20 @@ function openAgreement(type) {
}
.footer-info { margin-top: 40px; font-size: 11px; color: var(--text-muted); }
// 暂不登录入口(拒绝/返回,显著有效)
.skip-login {
margin-top: 24px;
padding: 14px 0;
width: 100%;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 12px;
cursor: pointer;
&:active { opacity: 0.7; }
}
}
</style>
+56 -20
View File
@@ -11,11 +11,6 @@
</view>
<scroll-view scroll-y class="content">
<!-- 资讯头图 -->
<view class="news-hero" :style="{background: heroBg}">
<text class="hero-emoji">{{ heroEmoji }}</text>
</view>
<!-- 分类标签 -->
<view class="category-tag">
<text class="tag-text">{{ categoryLabel }}</text>
@@ -39,7 +34,7 @@
<text class="meta-value">{{ news.viewCount || 0 }} 次</text>
</view>
<view class="meta-item" v-if="news.hot">
<text class="meta-label hot">🔥</text>
<text class="meta-label hot">热</text>
<text class="meta-value">{{ news.hotCount || 0 }}</text>
</view>
</view>
@@ -50,6 +45,14 @@
<text class="summary-text">{{ news.summary }}</text>
</view>
<!-- 人话解读 -->
<view class="summary-card plain" v-if="news.plainExplain">
<text class="summary-label">💡 一句话说清</text>
<text class="summary-text">{{ news.plainExplain }}</text>
<text class="summary-label" style="margin-top: 12px" v-if="news.takeAway">🎯 对你意味着</text>
<text class="summary-text" v-if="news.takeAway">{{ news.takeAway }}</text>
</view>
<!-- 正文 -->
<view class="news-body" v-if="newsBody">
<rich-text :nodes="newsBody"></rich-text>
@@ -90,6 +93,10 @@
</template>
<script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
import { ref, computed, onMounted } from 'vue'
import { trendApi, knowledgeApi } from '@/utils/api'
@@ -108,7 +115,8 @@ const heroBg = computed(() => {
})
const heroEmoji = computed(() => {
return { product: '🚀', paper: '📄', tool: '🔧', company: '🏢' }[news.value.category || 'product'] || '📰'
const map = { product: '产品', paper: '论文', tool: '工具', company: '公司' }
return map[news.value.category || 'product'] || '资讯'
})
const categoryLabel = computed(() => {
@@ -140,9 +148,14 @@ const relatedTags = computed(() => {
})
async function loadDetail() {
const id = uni.getStorageSync('__news_id__')
loading.value = false
// Try API detail first, fallback to local data from trend page
// 列表页通过 __news_item__ 传入条目(含 _id),优先拉取详情以刷新阅读量
const raw = uni.getStorageSync('__news_item__')
let item = null
if (raw) {
try { item = JSON.parse(raw) } catch (e) {}
}
const id = item?._id
if (id) {
try {
const data = await trendApi.detail(id)
@@ -150,17 +163,11 @@ async function loadDetail() {
isCollected.value = data.collected || false
return
} catch (e) {
// fall through to local
// 回退到本地缓存
}
}
// Fallback: read from trend page's stored item
try {
const raw = uni.getStorageSync('__news_item__')
if (raw) {
news.value = JSON.parse(raw)
}
} catch (e) {
// ignore
if (item) {
news.value = item
}
}
@@ -168,16 +175,37 @@ function goBack() {
uni.navigateBack()
}
function onFavorite() {
async function onFavorite() {
// 静默登录后提示(收藏态由详情页统一管理)
const ok = await userStore.ensureLogin()
if (!ok) {
uni.showModal({
title: '登录后收藏',
content: '登录后即可收藏资讯',
confirmText: '去登录',
cancelText: '暂不',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/login' }) }
})
return
}
isCollected.value = !isCollected.value
uni.showToast({ title: isCollected.value ? '已收藏' : '已取消收藏', icon: 'none' })
}
function onShare() {
// #ifdef MP-WEIXIN
uni.showModal({
title: '分享给朋友',
content: '点击右上角「···」→ 转发给朋友或分享到朋友圈',
showCancel: false,
})
// #endif
// #ifdef H5
uni.setClipboardData({
data: news.value.link || `https://ai-dimension.yuzhiran.com.cn/news/${uni.getStorageSync('__news_id__')}`,
data: news.value.sourceUrl || `https://wdkj.yuzhiran.com.cn/trends/${news.value._id || ''}`,
success: () => uni.showToast({ title: '链接已复制', icon: 'none' })
})
// #endif
}
function openSource() {
@@ -197,6 +225,14 @@ function openSource() {
onMounted(() => {
loadDetail()
})
// 微信小程序分享
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手',
path: '/pages/index/index'
}))
// #endif
</script>
<style lang="scss" scoped>
+260 -187
View File
@@ -30,12 +30,15 @@
<text class="plan-name">Pro</text>
<view class="plan-badge" v-if="selectedPlan === 'pro'">推荐</view>
</view>
<text class="plan-price">¥19.9</text>
<text class="plan-unit">/ 月</text>
<view class="plan-price-row">
<text class="plan-price">¥19.9</text>
<text class="plan-unit">/ 月</text>
</view>
<view class="plan-features">
<text class="plan-feature">✓ AI 问答无限次</text>
<text class="plan-feature">✓ 全量知识解锁</text>
<text class="plan-feature">✓ 收藏无限容量</text>
<text class="plan-feature">✓ 趋势优先推送</text>
</view>
</view>
<view
@@ -47,52 +50,15 @@
<text class="plan-name">VIP</text>
<view class="plan-badge vip-badge" v-if="selectedPlan === 'vip'">尊享</view>
</view>
<text class="plan-price vip-price">¥39.9</text>
<text class="plan-unit">/ 月</text>
<view class="plan-price-row">
<text class="plan-price vip-price">¥39.9</text>
<text class="plan-unit">/ 月</text>
</view>
<view class="plan-features">
<text class="plan-feature">✓ 含 Pro 全部权益</text>
<text class="plan-feature">✓ 专属客服支持</text>
<text class="plan-feature">✓ 专属会员标识</text>
</view>
</view>
</view>
<!-- 套餐时长 -->
<view class="duration-section">
<text class="section-title">选择套餐</text>
<view class="duration-list">
<view
class="duration-item"
:class="{ active: selectedDuration === item.key }"
v-for="item in durationOptions"
:key="item.key"
@click="selectedDuration = item.key"
>
<text class="d-label">{{ item.label }}</text>
<text class="d-price">¥{{ item.price }}</text>
<text class="d-save" v-if="item.save">省 {{ item.save }}</text>
</view>
</view>
</view>
<!-- 权益清单 -->
<view class="benefits-section">
<text class="section-title">权益对比</text>
<view class="benefits-card">
<view
class="benefit-row"
v-for="(item, idx) in benefitList"
:key="idx"
>
<view class="benefit-left">
<text class="benefit-icon">{{ item.icon }}</text>
<text class="benefit-text">{{ item.text }}</text>
</view>
<view class="benefit-check">
<text class="check-mark" :class="{ 'pro-only': item.proOnly, 'vip-only': !item.proOnly }">
{{ item.proOnly ? 'Pro' : (item.hasPro ? 'Pro+VIP' : 'VIP') }}
</text>
</view>
<text class="plan-feature">✓ 工具教程 + 学习路径</text>
</view>
</view>
</view>
@@ -107,49 +73,84 @@
>
<text class="pay-btn-text">立即支付 ¥{{ currentPrice }}</text>
</button>
<text class="pay-agreement">
点击支付即表示同意《用户协议》和《隐私政策》
</text>
<view class="pay-agreement-row">
<text class="pay-agreement-text">
会员服务说明:
<text class="pay-agreement-link" @click.stop="openAgreement('terms')">《用户协议》</text>
·
<text class="pay-agreement-link" @click.stop="openAgreement('privacy')">《隐私政策》</text>
</text>
</view>
</view>
<view class="footer-spacer"></view>
</scroll-view>
<!-- H5 支付失败/备选方案 -->
<view v-if="showH5Fallback" class="h5-fallback-mask">
<view class="h5-fallback-content">
<view class="fallback-icon">📱</view>
<text class="fallback-title">请使用微信完成支付</text>
<text class="fallback-desc">
订单号:{{ h5OrderNo }}
金额:¥{{ currentPrice }}
</text>
<view class="fallback-actions">
<button class="fallback-btn primary" @click="retryH5Pay">
再次跳转微信支付
</button>
<button class="fallback-btn secondary" @click="copyH5Url">
复制链接到微信打开
</button>
<button class="fallback-btn text-btn" @click="closeH5Fallback">
稍后再说
</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'
import { paymentApi } from '@/utils/api'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const selectedPlan = ref('pro')
const selectedDuration = ref('pro-monthly')
const selectedPlan = ref('pro') // 'pro' | 'vip'
const durationOptions = computed(() => {
const isPro = selectedPlan.value === 'pro'
return [
{ key: isPro ? 'pro-monthly' : 'vip-monthly', label: '月付', price: isPro ? 19.9 : 39.9 },
{ key: isPro ? 'pro-quarterly' : 'vip-quarterly', label: '季付', price: isPro ? 49.9 : 99.9, save: isPro ? '¥10' : '¥20' }
]
})
// 方案 → 对应数据库的商品 ID 和价格(单位:元)
const PLAN_MAP = {
pro: { productId: 'pro_monthly', price: 19.9 },
vip: { productId: 'vip_monthly', price: 39.9 }
}
const benefitList = ref([
{ icon: '🤖', text: 'AI 问答无限次', proOnly: true, hasPro: true },
{ icon: '📚', text: '全量知识内容解锁', proOnly: true, hasPro: true },
{ icon: '📡', text: '每日趋势优先推送', proOnly: true, hasPro: true },
{ icon: '⭐', text: '收藏夹无限容量', proOnly: true, hasPro: true },
{ icon: '💬', text: '专属客服支持', proOnly: false, hasPro: false },
{ icon: '🎁', text: '专属会员标识', proOnly: false, hasPro: false }
])
const currentPrice = computed(() => {
const item = durationOptions.value.find(d => d.key === selectedDuration.value)
return item ? item.price : 19.9
})
const currentPrice = computed(() => PLAN_MAP[selectedPlan.value].price)
const currentProductId = computed(() => PLAN_MAP[selectedPlan.value].productId)
const loading = ref(false)
// H5 支付相关状态
const h5PayUrl = ref('')
const showH5Fallback = ref(false)
const h5OrderNo = ref('')
// 检测是否运行在 H5 环境
function isH5() {
// #ifdef H5
return true
// #endif
// #ifdef MP-WEIXIN
return false
// #endif
return false
}
async function openAgreement(type) {
uni.navigateTo({ url: '/pages/agreement/agreement?type=' + (type || 'terms') })
}
async function handlePay() {
if (!userStore.isLoggedIn) {
@@ -157,14 +158,65 @@ async function handlePay() {
return
}
const productId = selectedDuration.value
const plan = PLAN_MAP[selectedPlan.value]
if (!plan || !plan.productId || plan.price <= 0) {
uni.showToast({ title: '请选择有效的套餐', icon: 'none' })
return
}
const productId = plan.productId
const amount = plan.price
loading.value = true
// ===== H5 环境:使用微信支付 H5 跳转链接 =====
if (isH5()) {
try {
const data = await paymentApi.createH5Order(productId, window.location.hostname, userStore.token)
h5OrderNo.value = data.orderNo
if (data.h5Url) {
h5PayUrl.value = data.h5Url
showH5Fallback.value = false
// 尝试直接跳转微信支付页面
// #ifdef H5
window.location.href = data.h5Url
// #endif
// H5 环境下跳转后不会返回,这里设置一个超时提示
setTimeout(() => {
showH5Fallback.value = true
}, 5000)
} else {
uni.showToast({ title: '获取支付链接失败', icon: 'none' })
loading.value = false
}
} catch (err) {
console.error('H5创建订单失败:', err)
uni.showToast({ title: err.message || '创建订单失败,请重试', icon: 'none' })
loading.value = false
}
return
}
// ===== 微信小程序环境:使用原生支付 =====
try {
const data = await paymentApi.createOrder(productId, userStore.token)
const payParam = data.payParams || data.wechatParam || data.paymentData
if (payParam) {
// 校验支付参数金额(分)与所选金额一致
// 微信返回的 amount 可能是 number 或 string
const payAmountFen = payParam.totalFee || payParam.amount || data.amount
const expectedFen = Math.round(amount * 100)
if (parseInt(payAmountFen, 10) !== expectedFen) {
console.error(`支付金额不匹配: 前端 ¥${amount} / 后端 ${payAmountFen} 分`)
uni.showToast({ title: '支付金额异常,请重试', icon: 'none' })
loading.value = false
return
}
uni.requestPayment({
provider: 'wxpay',
timeStamp: payParam.timeStamp || payParam.timestamp,
@@ -173,7 +225,7 @@ async function handlePay() {
signType: payParam.signType || 'MD5',
paySign: payParam.paySign,
success: () => {
uni.showToast({ title: '支付成功!', icon: 'success' })
uni.showToast({ title: `支付成功!金额 ¥${amount}`, icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
@@ -197,9 +249,52 @@ async function handlePay() {
}
}
/** H5 支付:复制链接到微信打开 */
function copyH5Url() {
// #ifdef H5
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(h5PayUrl.value).then(() => {
uni.showToast({ title: '链接已复制,请到微信中打开', icon: 'none' })
}).catch(() => {
// fallback
const textarea = document.createElement('textarea')
textarea.value = h5PayUrl.value
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
uni.showToast({ title: '链接已复制,请到微信中打开', icon: 'none' })
})
}
// #endif
}
/** H5 支付:再次跳转 */
function retryH5Pay() {
if (h5PayUrl.value) {
// #ifdef H5
window.location.href = h5PayUrl.value
// #endif
}
}
/** H5 支付:关闭窗口返回 */
function closeH5Fallback() {
showH5Fallback.value = false
loading.value = false
}
function goBack() {
uni.navigateBack()
}
// 微信小程序分享
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: 'AI知识社区 · 宇之然AI维度',
path: '/pages/index/index'
}))
// #endif
</script>
<style lang="scss" scoped>
@@ -341,7 +436,12 @@ function goBack() {
font-size: 28px;
font-weight: 700;
color: var(--dim1-color);
display: inline;
}
.plan-price-row {
display: flex;
align-items: baseline;
margin-bottom: 12px;
}
.plan-card.vip .plan-price {
@@ -367,120 +467,6 @@ function goBack() {
line-height: 1.5;
}
// 套餐时长
.duration-section {
margin-bottom: 24px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
display: block;
margin-bottom: 12px;
}
.duration-list {
display: flex;
gap: 10px;
}
.duration-item {
flex: 1;
padding: 14px 12px;
background: var(--glass-bg);
border: 1.5px solid var(--glass-border);
border-radius: 14px;
text-align: center;
transition: all 0.3s;
&.active {
border-color: var(--dim1-color);
background: linear-gradient(135deg, rgba(99,102,241,0.1), rgba(99,102,241,0.05));
}
}
.d-label {
font-size: 13px;
color: var(--text-secondary);
display: block;
margin-bottom: 6px;
}
.d-price {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
display: block;
}
.d-save {
font-size: 11px;
font-weight: 600;
color: #4db6ac;
display: block;
margin-top: 4px;
}
// 权益对比
.benefits-section {
margin-bottom: 24px;
}
.benefits-card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 14px;
overflow: hidden;
}
.benefit-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
&:not(:last-child) {
border-bottom: 1px solid var(--glass-border);
}
}
.benefit-left {
display: flex;
align-items: center;
gap: 10px;
}
.benefit-icon {
font-size: 16px;
width: 24px;
text-align: center;
}
.benefit-text {
font-size: 13px;
color: var(--text-secondary);
}
.benefit-check {
.check-mark {
font-size: 11px;
font-weight: 600;
padding: 2px 10px;
border-radius: 8px;
&.pro-only {
background: rgba(99,102,241,0.1);
color: var(--dim1-color);
}
&.vip-only {
background: rgba(255,167,38,0.1);
color: #ffa726;
}
}
}
// 支付
.pay-section {
margin-bottom: 24px;
@@ -517,4 +503,91 @@ function goBack() {
}
.footer-spacer { height: 40px; }
</style>
// ===== H5 支付备选方案 =====
.h5-fallback-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.h5-fallback-content {
background: var(--bg-primary);
border-radius: 20px;
padding: 32px 24px;
width: 85%;
max-width: 380px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.fallback-icon {
font-size: 48px;
margin-bottom: 8px;
}
.fallback-title {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.fallback-desc {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.8;
margin-bottom: 8px;
white-space: pre-line;
}
.fallback-actions {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
margin-top: 8px;
}
.fallback-btn {
padding: 12px 20px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
border: none;
cursor: pointer;
&.primary {
background: linear-gradient(135deg, #07C160, #06AE56);
color: white;
box-shadow: 0 4px 16px rgba(7, 193, 96, 0.3);
}
&.secondary {
background: var(--glass-bg);
border: 1.5px solid var(--glass-border);
color: var(--text-primary);
}
&.text-btn {
background: transparent;
color: var(--text-tertiary);
font-size: 13px;
padding: 8px;
}
}
</style>
<style scoped>
.pay-agreement-row { display: flex; align-items: center; justify-content: center; gap: 6px; margin-top: 10px; }
.pay-agreement-text { font-size: 11px; color: var(--text-tertiary); }
.pay-agreement-link { color: var(--color-brand); }
</style>
File diff suppressed because it is too large Load Diff
+16 -10
View File
@@ -17,7 +17,7 @@
</view>
<view class="stats-row">
<view class="stat-item">
<text class="stat-value">{{ stats?.totalPoints || 0 }}</text>
<text class="stat-value">{{ stats?.totalScore || 0 }}</text>
<text class="stat-label">累计积分</text>
</view>
<view class="stat-divider"></view>
@@ -122,6 +122,8 @@
</template>
<script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, onMounted } from 'vue'
import { knowledgeApi, userApi } from '@/utils/api'
import { useUserStore } from '@/stores/user'
@@ -134,7 +136,7 @@ const loading = ref(true)
// 学习时长(小时)
const studyHours = computed(() => {
const hours = stats.value?.studyHours || 0
const hours = stats.value?.totalPlayTime || 0
if (hours < 1) return `${Math.round(hours * 60)}分`
return hours.toFixed(1) + 'h'
})
@@ -156,7 +158,7 @@ function initDimData() {
color: dim.color,
bg: dim.bg,
total: 0,
done: exploreData[dim.id] || 0,
done: exploreData['dim' + dim.id]?.completed ? 1 : 0,
percent: 0,
expanded: false
}))
@@ -167,11 +169,7 @@ async function fetchDimTotals() {
for (const dim of dimProgress.value) {
try {
const data = await knowledgeApi.list({ dim: dim.id, limit: 1 })
dim.total = data?.total || data?.length || (Array.isArray(data) ? data.length : 0)
// 如果 list 返回的是数组而不是带 total 的对象,直接用数组长度
if (Array.isArray(data) && dim.total === 0) {
dim.total = data.length
}
dim.total = data?.pagination?.total || data?.items?.length || 0
// 计算百分比
if (dim.total > 0) {
dim.percent = Math.round((dim.done / dim.total) * 100)
@@ -200,10 +198,10 @@ async function loadData() {
userApi.progress(userStore.token).catch(() => ({ exploreData: {} }))
])
// 合并 exploreData
// 合并 exploreData(进度接口返回 {isNewUser, data: user},exploreData 在 data 下)
stats.value = {
...statsData,
exploreData: { ...statsData?.exploreData, ...progressData?.exploreData }
exploreData: progressData?.data?.exploreData || {}
}
dimProgress.value = initDimData()
@@ -223,6 +221,14 @@ onMounted(() => {
function goBack() {
uni.navigateBack()
}
// 微信小程序分享
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: '我的AI学习进度 · 宇之然AI维度',
path: '/pages/index/index'
}))
// #endif
</script>
<style lang="scss" scoped>
+39 -6
View File
@@ -21,7 +21,7 @@
@confirm="doSearch"
confirm-type="search"
/>
<view class="search-icon">🔍</view>
<view class="search-icon"></view>
<view v-if="searchText" class="clear-btn" @click="clearSearch">✕</view>
</view>
@@ -31,11 +31,13 @@
<text class="loading-text">搜索中...</text>
</view>
<!-- 空搜索提示 -->
<!-- 未搜索:热门词快捷入口 -->
<view class="empty-state" v-else-if="!hasSearched">
<text class="empty-icon">🔍</text>
<text class="empty-text">输入关键词搜索 AI 知识</text>
<text class="empty-hint">支持按标题、摘要、标签搜索</text>
<view class="hot-words">
<text v-for="w in HOT_WORDS" :key="w" class="hot-word" @click="quickSearch(w)">{{ w }}</text>
</view>
</view>
<!-- 无结果提示 -->
@@ -64,8 +66,8 @@
<text class="result-title">{{ item.title }}</text>
<!-- 摘要 -->
<text class="result-summary" v-if="item.summary">
{{ item.summary }}
<text class="result-summary" v-if="item.content">
{{ item.content }}
</text>
<!-- 标签与分类 -->
@@ -85,11 +87,18 @@
</template>
<script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { knowledgeApi } from '@/utils/api'
import { DIMENSIONS } from '@/utils/dimensions'
const searchText = ref('')
const HOT_WORDS = ['大模型', 'Transformer', 'DeepSeek', '提示词', 'AI绘画', '智能体', 'RAG', '具身智能']
function quickSearch(w) {
searchText.value = w
doSearch()
}
const results = ref([])
const loading = ref(false)
const hasSearched = ref(false)
@@ -186,6 +195,14 @@ onMounted(() => {
onUnmounted(() => {
if (debounceTimer) clearTimeout(debounceTimer)
})
// 微信小程序分享
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: '搜一搜想了解的AI知识 · 宇之然AI维度',
path: '/pages/index/index'
}))
// #endif
</script>
<style lang="scss" scoped>
@@ -385,4 +402,20 @@ onUnmounted(() => {
font-size: 11px;
color: var(--text-secondary);
}
</style>
.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>
+62 -9
View File
@@ -1,13 +1,17 @@
<template>
<view class="page-settings page-layout">
<view class="status-bar-placeholder"></view>
<text class="page-title">设置</text>
<view class="nav-bar">
<view class="nav-back" @click="goBack">‹</view>
<text class="page-title">设置</text>
<view class="nav-placeholder"></view>
</view>
<!-- 偏好设置 -->
<view class="section-title">偏好设置</view>
<view class="card">
<view class="setting-item" @click="togglePush">
<view class="s-icon" style="background:rgba(255,213,79,0.1);color:#ffd54f">🔔</view>
<view class="s-icon" style="background:rgba(91,95,248,0.1)">铃</view>
<view class="s-text">
<text class="s-label">推送通知</text>
<text class="s-desc">接收每日 AI 趋势推送</text>
@@ -16,6 +20,7 @@
<view class="s-switch-knob" :class="{ on: pushOn }"></view>
</view>
</view>
<!-- #ifdef H5 -->
<view class="setting-item" @click="toggleDarkMode">
<view class="s-icon" style="background:rgba(77,208,225,0.1);color:#4dd0e1">🌙</view>
<view class="s-text">
@@ -26,6 +31,17 @@
<view class="s-switch-knob" :class="{ on: darkMode }"></view>
</view>
</view>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<view class="setting-item" @click="showDarkTip">
<view class="s-icon" style="background:rgba(77,208,225,0.1);color:#4dd0e1">🌙</view>
<view class="s-text">
<text class="s-label">深色模式</text>
<text class="s-desc">跟随系统设置自动切换</text>
</view>
<text class="s-arrow">›</text>
</view>
<!-- #endif -->
</view>
<!-- 其他 -->
@@ -55,11 +71,14 @@
</view>
</view>
<text class="version">宇之然 AI 维度 v1.0.0</text>
<text class="version">宇之然 AI 维度 v{{ appVersion }}</text>
</view>
</template>
<script setup>
import { APP_VERSION as appVersion } from '@/utils/version'
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, onMounted } from 'vue'
const pushOn = ref(true)
@@ -78,6 +97,14 @@ function togglePush() {
})
}
function showDarkTip() {
uni.showModal({
title: '深色模式',
content: '小程序深色模式跟随系统:在手机的 系统设置 → 显示与亮度 中切换深色/浅色,小程序将自动适配。',
showCancel: false,
})
}
function toggleDarkMode() {
darkMode.value = !darkMode.value
const theme = darkMode.value ? 'dark' : 'light'
@@ -117,7 +144,7 @@ function applyTabBarTheme(theme) {
function showAbout() {
uni.showModal({
title: '关于「宇之然AI维度」',
content: '宇之然 AI 维度 v1.0.0\n从 5 个维度系统理解人工智能\n© 2026 宇之然',
content: `宇之然AI维度 v${appVersion}\n从 5 个维度系统理解人工智能\n© 2026 宇之然`,
showCancel: false
})
}
@@ -131,12 +158,20 @@ function showContact() {
}
function showAgreement() {
uni.showModal({
title: '用户协议 / 隐私政策',
content: '暂无内容',
showCancel: false
})
uni.navigateTo({ url: '/pages/agreement/agreement?type=terms' })
}
function goBack() {
uni.navigateBack()
}
// 微信小程序分享
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: 'AI知识社区 · 宇之然AI维度',
path: '/pages/index/index'
}))
// #endif
</script>
<style lang="scss" scoped>
@@ -146,6 +181,24 @@ function showAgreement() {
background: var(--bg-primary);
}
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
}
.nav-back {
font-size: 28px;
color: var(--text-primary);
padding: 0 8px;
line-height: 1;
}
.nav-placeholder {
width: 30px;
}
.status-bar-placeholder { height: 44px; }
.page-title {
+104 -74
View File
@@ -1,5 +1,5 @@
<template>
<view class="page-trend page-layout">
<view class="page-trend">
<view class="status-bar-placeholder"></view>
<!-- 顶部 -->
@@ -8,7 +8,7 @@
<text class="header-desc">每天精选 AI 行业最新动态</text>
</view>
<!-- 分类筛选 -->
<!-- 分类筛选:紧凑胶囊单行 -->
<view class="filter-bar">
<view
class="filter-item"
@@ -17,7 +17,8 @@
:key="idx"
@click="activeFilter = idx"
>
<text>{{ tag }}</text>
<AppIcon :name="tag.icon" :size="12" />
<text class="filter-label">{{ tag.text }}</text>
</view>
</view>
@@ -40,11 +41,23 @@
{{ item.summary }}
</text>
<!-- 人话解读:一句话说清 + 对你意味着什么 -->
<view class="card-plain" v-if="item.plainExplain">
<view class="plain-line">
<text class="plain-tag">人话</text>
<text class="plain-text">{{ item.plainExplain }}</text>
</view>
<view class="plain-line" v-if="item.takeAway">
<text class="plain-tag alt">对你</text>
<text class="plain-text">{{ item.takeAway }}</text>
</view>
</view>
<!-- 底部信息 -->
<view class="card-footer">
<text class="card-date">{{ formatDate(item.newsDate) }}</text>
<view class="card-stats">
<text class="stat-item" v-if="item.hot">🔥 {{ item.hotCount }}</text>
<text class="stat-item" v-if="item.hot">{{ item.hotCount }} 人觉得热</text>
<text class="stat-item">👁️ {{ item.viewCount || 0 }}</text>
</view>
</view>
@@ -55,7 +68,7 @@
<text>加载中...</text>
</view>
<view v-else-if="displayItems.length === 0" class="empty-state">
<text class="empty-icon">📡</text>
<text class="empty-icon">暂时没有快讯</text>
<text class="empty-text">暂无趋势资讯</text>
<text class="empty-sub">稍后再来看最新 AI 动态</text>
</view>
@@ -64,21 +77,38 @@
</template>
<script setup>
import { onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, onMounted } from 'vue'
import { trendApi } from '@/utils/api'
import AppIcon from '@/components/AppIcon.vue'
const filters = ref(['全部', '🚀 产品', '📄 论文', '🔧 工具', '🏢 公司'])
const filters = ref([
{ icon: 'sparkle', text: '全部' },
{ icon: 'rocket', text: '产品' },
{ icon: 'book', text: '论文' },
{ icon: 'settings', text: '工具' },
{ icon: 'user', text: '公司' }
])
const activeFilter = ref(0)
const items = ref([])
const loading = ref(false)
onPullDownRefresh(async () => {
try {
const data = await trendApi.list({})
if (typeof setList === "function") setList(data)
} catch (e) {}
uni.stopPullDownRefresh()
})
onMounted(() => {
fetchTrends()
})
const displayItems = computed(() => {
if (activeFilter.value === 0) return items.value
const categoryMap = { 1: 'product', 2: 'paper', 3: 'tool', 4: 'company' }
const categoryMap = { 1: '产品', 2: '论文', 3: '工具', 4: '公司' }
const category = categoryMap[activeFilter.value]
if (!category) return items.value
return items.value.filter(item => item.category === category)
@@ -91,29 +121,28 @@ async function fetchTrends() {
items.value = data.list || data.items || []
} catch (err) {
console.error('获取趋势失败:', err)
// 降级:本地示例
items.value = getSampleData()
uni.showToast({ title: '加载失败,下拉重试', icon: 'none' })
} finally {
loading.value = false
}
}
function getTagText(item) {
if (item.hot) return '🔥 热门'
if (item.picked) return '📌 精选'
if (item.category === 'product') return '🚀 产品'
if (item.category === 'paper') return '📄 论文'
if (item.category === 'tool') return '🔧 工具'
if (item.category === 'company') return '🏢 公司'
if (item.hot) return '热门'
if (item.picked) return '精选'
if (item.category === '产品') return '产品'
if (item.category === '论文') return '📄 论文'
if (item.category === '工具') return '🔧 工具'
if (item.category === '公司') return '🏢 公司'
return '📰 资讯'
}
function getTagClass(item) {
if (item.hot) return 'hot'
if (item.picked) return 'pick'
if (item.category === 'product') return 'product'
if (item.category === 'paper') return 'paper'
if (item.category === 'tool') return 'tool'
if (item.category === '产品') return 'product'
if (item.category === '论文') return 'paper'
if (item.category === '工具') return 'tool'
return ''
}
@@ -129,56 +158,21 @@ function formatDate(dateStr) {
}
function goDetail(item) {
// 如果有原文链接,跳转外部
if (item.link) {
// 微信小程序不支持直接打开外部链接,复制链接提示
uni.setClipboardData({
data: item.link,
success: () => {
uni.showModal({
title: '打开链接',
content: '链接已复制,请在浏览器中打开',
showCancel: false
})
}
})
return
}
// 始终进入站内详情页(人话解读/阅读量统计/收藏/分享);原文链接在详情页内提供
uni.setStorageSync('__news_item__', JSON.stringify(item))
uni.navigateTo({ url: '/pages/news/news' })
}
function getSampleData() {
return [
{
title: 'DeepSeek 发布 R1 推理模型',
summary: 'DeepSeek R1 在多项基准测试中超越 GPT-4o,引发全球关注',
source: '量子位',
newsDate: new Date(Date.now() - 2 * 3600000).toISOString(),
category: 'product',
hot: true,
hotCount: 2000,
viewCount: 15000
},
{
title: 'Google Gemini 1.5 Pro 发布',
summary: '支持 200 万 token 上下文窗口,多模态能力大幅提升',
source: '机器之心',
newsDate: new Date(Date.now() - 6 * 3600000).toISOString(),
category: 'product',
viewCount: 8000
},
{
title: 'Transformer 论文作者新方向',
summary: '从注意力机制到状态空间模型,作者分享最新研究进展',
source: 'PaperWeekly',
newsDate: new Date(Date.now() - 12 * 3600000).toISOString(),
category: 'paper',
picked: true,
viewCount: 5000
// 微信小程序分享
// #ifdef MP-WEIXIN
onShareAppMessage(() => {
const latest = (items.value || [])[0]
return {
title: latest ? `今日AI速递:${String(latest.title).slice(0, 24)}… · AI维度` : 'AI 维度 · 每天 5 分钟看懂 AI 大事',
path: '/pages/trend/trend'
}
]
}
})
// #endif
</script>
<style lang="scss" scoped>
@@ -214,23 +208,33 @@ function getSampleData() {
.filter-bar {
padding: 0 20px 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
flex-wrap: nowrap;
gap: 6px;
}
.filter-item {
padding: 6px 14px;
flex-shrink: 0;
padding: 6px 11px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 16px;
font-size: 13px;
border-radius: 999px;
color: var(--text-secondary);
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
}
.filter-label {
font-size: 12px;
line-height: 1;
white-space: nowrap;
}
.filter-item.active {
background: rgba(124, 77, 255, 0.15);
border-color: rgba(124, 77, 255, 0.3);
color: var(--dim1-color);
background: var(--color-brand-bg);
border-color: var(--color-brand);
color: var(--color-brand);
}
// 列表
@@ -239,7 +243,7 @@ function getSampleData() {
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
padding: 0 0 16px;
padding: 0 20px 16px;
}
.trend-card {
@@ -271,7 +275,7 @@ function getSampleData() {
.card-tag.pick {
background: rgba(124, 77, 255, 0.12);
color: #b388ff;
color: var(--color-brand);
}
.card-tag.product {
@@ -281,7 +285,7 @@ function getSampleData() {
.card-tag.paper {
background: rgba(179, 136, 255, 0.12);
color: #b388ff;
color: var(--color-brand);
}
.card-tag.tool {
@@ -364,3 +368,29 @@ function getSampleData() {
display: block;
}
</style>
<style scoped>
.card-plain {
margin-top: 8px;
background: var(--color-brand-bg);
border: 1px solid var(--glass-hover-border);
border-radius: 10px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 5px;
}
.plain-line { display: flex; align-items: flex-start; gap: 6px; }
.plain-tag {
flex-shrink: 0;
font-size: 10px;
font-weight: 700;
color: #fff;
background: var(--color-brand);
border-radius: 5px;
padding: 1px 6px;
margin-top: 1px;
}
.plain-tag.alt { background: var(--dim3-color); }
.plain-text { font-size: 12px; color: var(--text-primary); line-height: 1.6; }
</style>
+95
View File
@@ -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>
+191
View File
@@ -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>
+6
View File
@@ -0,0 +1,6 @@
{
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
"rules": [
{ "action": "allow", "page": "*" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1007 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Some files were not shown because too many files have changed in this diff Show More