Compare commits

..

100 Commits

Author SHA1 Message Date
BTMuli
dc03edd30b 🚀 v0.8.8 2025-12-03 20:30:48 +08:00
BTMuli
d18b6bb898 ♻️ 代码重构提取 2025-12-03 20:30:28 +08:00
BTMuli
0f107abde6 🐛 重构管理员权限重启逻辑 2025-12-03 19:58:03 +08:00
BTMuli
46cf40734f 🐛 修复成就数据读取异常 2025-12-03 18:53:35 +08:00
BTMuli
676ef8e8ee 🚀 v0.8.7 2025-12-03 10:24:27 +08:00
BTMuli
5d8ea639b8 🍱 更新成就&卡池数据 2025-12-03 10:22:07 +08:00
BTMuli
bc64e20ebd 🚸 优化字典处理逻辑,确保字典至少包含两个键 2025-12-02 23:49:05 +08:00
BTMuli
98efd557d6 🚸 完善奇偶处理 2025-12-02 23:04:10 +08:00
BTMuli
b0b3120b7b 🚸 完善名片解析处理 2025-12-02 22:55:07 +08:00
BTMuli
c2ea3cf026 🐛 修复左侧列表顺序异常 2025-12-02 22:51:20 +08:00
BTMuli
90d71be17e 🐛 修复mac编译异常 2025-12-02 22:39:04 +08:00
BTMuli
9435622a6d 🍱 更新部分新版本资源 2025-12-02 22:36:31 +08:00
BTMuli
5ac9c24379 🐛 修复macOS编译异常 2025-12-02 22:25:44 +08:00
BTMuli
9e359b9621 ⬆️ 更新依赖 2025-12-02 21:59:24 +08:00
BTMuli
cde9149bbd 🐛 修复mac编译异常 2025-12-02 21:49:59 +08:00
BTMuli
b38c3f9fbe 🐛 修复写入文件异常 2025-12-02 21:29:14 +08:00
BTMuli
92ad548061 🐛 修复 Windows 平台相关的条件编译和文档注释 2025-12-02 21:16:59 +08:00
BTMuli
37cea99bbd 侧边栏添加启动游戏入口 2025-12-02 11:58:13 +08:00
BTMuli
b267599039 🐛 修复重启异常 2025-12-02 11:37:37 +08:00
BTMuli
1d204c8284 🚸 调整回复按钮展示判断 2025-12-02 11:11:54 +08:00
BTMuli
51ce0217f0 💩 release模式下重启不一定成功 2025-12-02 02:03:25 +08:00
BTMuli
fac394be8b 🚨 修复内存分配和句柄关闭错误 2025-12-02 00:37:00 +08:00
BTMuli
a12a12e786 🚨 修复部分异常 2025-12-02 00:37:00 +08:00
BTMuli
2d1890645d 🚨 修复编译器异常,移除多余依赖 2025-12-02 00:37:00 +08:00
BTMuli
38f3301664 完成成就导入 2025-12-02 00:37:00 +08:00
BTMuli
14c47369e7 🌱 尝试检测管理员&以管理员模式重启 2025-12-02 00:37:00 +08:00
BTMuli
93be279cbb 👷 完善构建 2025-12-02 00:37:00 +08:00
BTMuli
aca47f822b 实现成就数据读取 2025-12-02 00:37:00 +08:00
BTMuli
ccb4730c82 🐛 修复pipe broken 2025-12-02 00:37:00 +08:00
BTMuli
670e9deba3 ⬆️ 更新依赖 2025-12-02 00:37:00 +08:00
BTMuli
93cca5f715 🌱 初步建立pipe&成功调用dll 2025-12-02 00:37:00 +08:00
BTMuli
d787b8dc8b ️ 添加条件判断以控制调试和发布构建 2025-12-01 13:00:10 +08:00
BTMuli
6b90dde0ab ♻️ 调整命名 2025-12-01 00:55:51 +08:00
BTMuli
323b951c10 🎨 微调 2025-12-01 00:43:31 +08:00
BTMuli
4c3648481e 🐛 修复自定义表情格式解析异常,增加文本清晰度 2025-12-01 00:33:56 +08:00
BTMuli
afcba5ec1a 💄 调整帖子回复浮窗UI,完善类型 2025-11-30 16:48:02 +08:00
BTMuli
725d62b755 👽️ 完善前瞻识别规则,增加空列表处理 2025-11-29 20:04:31 +08:00
BTMuli
e72d6b1b9f 💄 补充遗漏文本,调整交互逻辑 2025-11-27 17:17:37 +08:00
BTMuli
482c7fb1c9 🚸 移除确认弹窗
close #170
2025-11-27 16:12:01 +08:00
BTMuli
d84d68607b 简化账号切换,逻辑移至侧边栏
close#170
2025-11-27 16:04:16 +08:00
BTMuli
758f0d519f 🌱 登录移至侧栏
#170
2025-11-27 14:41:37 +08:00
BTMuli
72b7dc5405 优化组件响应式处理 2025-11-27 11:38:42 +08:00
BTMuli
50a528d25b 🚸 优化滚动处理,移除不必要的async/await 2025-11-25 21:18:09 +08:00
Copilot
4d937b365b 🚸 优化回复浮窗处理 (#169)
* Initial plan

* Fix secondary reply scroll position issue by adding scroll-strategy="close" to submenu

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Add auto-load on scroll for reply and sub-reply lists

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Fix sub-reply scroll issues with custom event-based solution

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Fix sub-reply initialization to use embedded sub_replies data

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* 🎨 codeStyle

* Fix duplicate sub-reply data by filtering existing reply IDs

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Use persistent Set for existingIds to improve duplicate filtering efficiency

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>
Co-authored-by: BTMuli <bt-muli@outlook.com>
2025-11-25 18:26:59 +08:00
BTMuli
1124927c0e 💄 修复UI错位 2025-11-25 16:45:14 +08:00
BTMuli
b3c42428e9 🚸 添加BETA提示 2025-11-25 16:44:57 +08:00
BTMuli
db03f211d4 窄视图 2025-11-25 15:13:21 +08:00
BTMuli
5df5868549 🙈 忽略测试文件 2025-11-23 17:31:38 +08:00
BTMuli
b5e4b013c9 🚸 调整hint 2025-11-22 17:35:08 +08:00
BTMuli
6af43bf957 🧑‍💻 调整构建 2025-11-22 13:48:49 +08:00
BTMuli
256b529b16 🧑‍💻 选择构建,尝试减少层数 2025-11-22 13:35:37 +08:00
BTMuli
c521f3cc26 🚸 优化图表下载交互 2025-11-22 13:00:19 +08:00
BTMuli
dcc0d7d052 💄 增加浅色模式下的可见度 2025-11-22 01:15:25 +08:00
BTMuli
3a542ead17 🐛 修复主题切换异常 2025-11-22 01:08:09 +08:00
BTMuli
dce90b64a6 💄 增加浅色模式下的可见度 2025-11-22 01:07:49 +08:00
BTMuli
2fdb2e7b51 🏷️ 修正类型 2025-11-22 00:14:53 +08:00
Copilot
586b506fca ♻️ 重构祈愿图表 close#166
* Initial plan

* Refactor wish calendar - split charts into separate components with scrollbar support

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Address code review feedback - improve error handling and comments

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Move chart logic to components and remove v-if guards

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Fix UID switching reactivity, add scrollbar spacing, and fix download functionality

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

* Use proper ECharts ComposeOption type declarations

Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: BTMuli <72692909+BTMuli@users.noreply.github.com>
2025-11-22 00:11:37 +08:00
BTMuli
45ff02b998 🔧 使用https协议 2025-11-21 23:53:09 +08:00
BTMuli
955a1a3c54 🔥 移除无用调整 T_T 2025-11-21 19:07:46 +08:00
BTMuli
5b5589d213 🐛 修复数据刷新异常
close #163
2025-11-21 19:03:57 +08:00
BTMuli
759804a99a 🚸 导入后刷新页面 2025-11-21 19:03:28 +08:00
BTMuli
310c1c91cf 🧪 尝试替换 2025-11-21 18:22:27 +08:00
BTMuli
954fb1a1e8 🧪 完善类型 2025-11-21 18:15:20 +08:00
BTMuli
17f5a87e31 🧪 尝试允许geetest域名 2025-11-21 17:40:57 +08:00
BTMuli
7a9ef78376 🔨 macos-latest 2025-11-21 17:31:22 +08:00
BTMuli
aa5ef06ffd 🧪 尝试调整极验sdk引用 2025-11-21 17:19:23 +08:00
BTMuli
260e9ce4dd 🐛 修复无法手动关闭验证弹窗 2025-11-21 17:17:49 +08:00
BTMuli
e45ebff0fc 💄 增加浅色模式下的可见度 2025-11-21 17:15:22 +08:00
BTMuli
04c1bd0446 🧑‍💻 微调构建 2025-11-21 16:50:21 +08:00
BTMuli
239196149e 🧪 使用v4 2025-11-21 16:39:52 +08:00
BTMuli
77da679a70 🧪 添加上传 2025-11-21 16:33:19 +08:00
BTMuli
50383c2365 🧪 尝试macos-latest构建arm 2025-11-21 16:24:22 +08:00
BTMuli
a099e4e413 👷 新建debug构建流程 2025-11-21 16:11:57 +08:00
BTMuli
df8af9eecd 👽️ 更新Q群链接 2025-11-21 15:53:42 +08:00
BTMuli
0ba4690085 🚸 调整UI显示 2025-11-21 14:59:43 +08:00
BTMuli
98c911469a Revert "🐛 修复窗口适配异常"
This reverts commit 999ddc708c.
2025-11-20 18:18:48 +08:00
BTMuli
999ddc708c 🐛 修复窗口适配异常 2025-11-20 18:03:40 +08:00
BTMuli
5298ecdd0a 👽️ 支持Gt4验证
close #162
2025-11-20 00:30:12 +08:00
BTMuli
dc18cd75a7 搜索新增“最新”“最热”排序 2025-11-19 22:06:15 +08:00
BTMuli
1f4248bde8 ✏️ 修正文本错误 2025-11-19 16:32:02 +08:00
BTMuli
ae7b4acb88 🚸 执行脚本时不允许切换账号 2025-11-19 14:33:20 +08:00
BTMuli
2ab31d8f5c 🚀 v0.8.6 2025-11-19 14:09:48 +08:00
BTMuli
1af990512d 📝 更新README 2025-11-19 13:55:53 +08:00
BTMuli
d96d451156 👽️ 调整读取格式 2025-11-19 13:50:47 +08:00
BTMuli
f029306ebb 🚸 添加跳转视频链接 2025-11-19 13:40:25 +08:00
BTMuli
d3c5baa0c2 📝 更新资源说明文档 2025-11-19 13:22:01 +08:00
BTMuli
ba0802752c 🔊 完善log 2025-11-19 00:51:49 +08:00
BTMuli
ff94e12ff5 🚸 调整默认文本 2025-11-19 00:07:13 +08:00
BTMuli
0fbf1f7c2a 🚸 添加AIGC相关注释 2025-11-18 23:01:51 +08:00
BTMuli
68809a93c6 支撑导入剧诗数据 2025-11-18 22:51:32 +08:00
BTMuli
0edcadef63 👽️ 移除剧诗概览,支撑导入剧诗数据 2025-11-18 22:42:46 +08:00
BTMuli
9f9c30914f 🔥 移除胡桃深渊统计页面 2025-11-18 22:29:24 +08:00
BTMuli
04cf372798 🎨 路由重定向 2025-11-18 22:27:27 +08:00
BTMuli
6617a26c90 👽️ 移除深渊上传,支撑导入胡桃深渊数据 2025-11-18 22:20:58 +08:00
BTMuli
d244423800 🚸 调整导入浮窗ui,显示导入进度 2025-11-18 22:02:07 +08:00
BTMuli
3366efaadd 🐛 处理拓展解析异常 2025-11-15 20:36:50 +08:00
BTMuli
d74e7a7a31 🥅 处理异常,清除缓存后重启 2025-11-15 14:54:06 +08:00
BTMuli
2d0b409813 🐛 修复图片渲染异常 2025-11-15 14:37:05 +08:00
BTMuli
942068faea 🚀 v0.8.5 2025-11-10 16:34:14 +08:00
BTMuli
0f0f7684d2 🍱 更新下半数据 2025-11-10 16:31:01 +08:00
141 changed files with 6635 additions and 2533 deletions

View File

@@ -1,42 +0,0 @@
name: 原神游戏资源更新(仅供开发者使用)
description: 版本前瞻后的例行资源更新
title: "[Update] "
labels:
- 资源
body:
- type: checkboxes
attributes:
label: Issue Check
options:
- label: 个人明确了解该模板仅供开发者使用
required: true
- type: input
id: version
attributes:
label: 游戏版本
description: 请填写游戏版本
placeholder: 如 4.6
- type: checkboxes
id: resources
attributes:
label: 包括的资源
options:
- label: 角色&名片,有新角色时选择
required: false
- label: 武器,有新武器时选择
required: false
- label: 成就,有新成就时选择
required: false
- label: 材料,有新材料时选择
required: false
- type: textarea
id: detail
attributes:
label: 详情
description: 对上述内容进行详细说明
- type: textarea
id: additional
attributes:
label: 其他信息
description: 请填写其他信息
placeholder: 请填写其他信息

View File

@@ -23,7 +23,7 @@ jobs:
- platform: macos-latest
args: "--target x86_64-apple-darwin"
target: "macos-intel"
- platform: macos-15-intel
- platform: macos-latest
args: "--target aarch64-apple-darwin"
target: "macos-arm"
runs-on: ${{ matrix.settings.platform }}
@@ -58,7 +58,8 @@ jobs:
run: rustup target add aarch64-apple-darwin
- name: Output toolchain
run: rustup show
- name: Add Offset Conf
run: echo '${{ secrets.YAE_CONF }}' | jq -c . > ./src-tauri/lib/conf.json
- name: setup node
uses: actions/setup-node@v3
with:
@@ -66,7 +67,7 @@ jobs:
- name: setup pnpm
uses: pnpm/action-setup@v2
with:
version: 10.16.1
version: 10.23.0
- name: Install frontend dependencies
run: pnpm install
@@ -81,7 +82,8 @@ jobs:
releaseBody: |
> [!TIP]
> Windows 平台用户建议通过微软应用商店下载macOS 平台仅在此发布Linux 平台暂不支持。
> 如有使用问题可加入 [反馈QQ群](https://h5.qun.qq.com/s/3cgX0hJ4GA)
> 如有使用问题可加入 [反馈QQ群](https://qm.qq.com/q/hUxIfSWluo)
> MacOS 用户参考 [安装指南](https://github.com/BTMuli/TeyvatGuide/blob/master/docs/macos-gatekeeper/README.md)
<a href="https://apps.microsoft.com/store/detail/9NLBNNNBNSJN?launch=true&cid=BTMuli&mode=mini">
<img src="https://get.microsoft.com/images/zh-cn%20dark.svg" alt="download"/>

132
.github/workflows/debug.yml vendored Normal file
View File

@@ -0,0 +1,132 @@
name: Build Debug for Mac
on:
workflow_dispatch:
inputs:
build-debug:
description: "Build debug version"
required: true
default: true
type: boolean
build-release:
description: "Build release version"
required: true
default: false
type: boolean
jobs:
build-debug-mac:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
settings:
- platform: macos-latest
args: "--target x86_64-apple-darwin"
target: "macos-intel"
artifact: "debug-build-macos-intel"
- platform: macos-latest
args: "--target aarch64-apple-darwin"
target: "macos-arm"
artifact: "debug-build-macos-arm"
runs-on: ${{ matrix.settings.platform }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" | tr -d '\r' > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
- name: Add Github RSA
run: |
echo "${{ secrets.KNOWN_GITHUB_RSA }}" >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Test SSH connection
run: ssh -T git@github.com || true
- name: Rust setup
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./src-tauri -> target"
- name: Add Rust targets(macOS Intel)
if: matrix.settings.target == 'macos-intel'
run: rustup target add x86_64-apple-darwin
- name: Add Rust targets(macOS ARM)
if: matrix.settings.target == 'macos-arm'
run: rustup target add aarch64-apple-darwin
- name: Output toolchain
run: rustup show
- name: Add Offset Conf
run: echo '${{ secrets.YAE_CONF }}' | jq -c . > ./src-tauri/lib/conf.json
- name: setup node
uses: actions/setup-node@v3
with:
node-version: 24.8.0
- name: setup pnpm
uses: pnpm/action-setup@v2
with:
version: 10.23.0
- name: Install frontend dependencies
run: pnpm install
# 获取commit hash后续用这个做文件命名
- name: Get Commit Hash
id: get_commit_hash
run: echo "COMMIT_HASH=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
# Build Debug
- name: Build debug app
if: github.event.inputs.build-debug == 'true'
uses: tauri-apps/tauri-action@dev
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: ${{ matrix.settings.args }} --debug
- name: Move Debug Intel
if: matrix.settings.target == 'macos-intel' && github.event.inputs.build-debug == 'true'
run: mv src-tauri/target/x86_64-apple-darwin/debug/bundle/dmg/*.dmg TeyvatGuide_${{ env.COMMIT_HASH }}_intel-debug.dmg
- name: Move Debug ARM
if: matrix.settings.target == 'macos-arm' && github.event.inputs.build-debug == 'true'
run: mv src-tauri/target/aarch64-apple-darwin/debug/bundle/dmg/*.dmg TeyvatGuide_${{ env.COMMIT_HASH }}_arm-debug.dmg
- name: Upload Debug Intel
if: matrix.settings.target == 'macos-intel' && github.event.inputs.build-debug == 'true'
uses: actions/upload-artifact@v4
with:
name: debug-macos-intel
path: TeyvatGuide_${{ env.COMMIT_HASH }}_intel-debug.dmg
- name: Upload Debug ARM
if: matrix.settings.target == 'macos-arm' && github.event.inputs.build-debug == 'true'
uses: actions/upload-artifact@v4
with:
name: debug-macos-arm
path: TeyvatGuide_${{ env.COMMIT_HASH }}_arm-debug.dmg
# Build Release
- name: Build app
if: github.event.inputs.build-release == 'true'
uses: tauri-apps/tauri-action@dev
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: ${{ matrix.settings.args }}
- name: Move Release Intel
if: matrix.settings.target == 'macos-intel' && github.event.inputs.build-release == 'true'
run: mv src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg TeyvatGuide_${{ env.COMMIT_HASH }}_intel-release.dmg
- name: Move Release ARM
if: matrix.settings.target == 'macos-arm' && github.event.inputs.build-release == 'true'
run: mv src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg TeyvatGuide_${{ env.COMMIT_HASH }}_arm-release.dmg
- name: Upload Release Intel
if: matrix.settings.target == 'macos-intel' && github.event.inputs.build-release
uses: actions/upload-artifact@v4
with:
name: release-macos-intel
path: TeyvatGuide_${{ env.COMMIT_HASH }}_intel-release.dmg
- name: Upload Release ARM
if: matrix.settings.target == 'macos-arm' && github.event.inputs.build-release == 'true'
uses: actions/upload-artifact@v4
with:
name: release-macos-arm
path: TeyvatGuide_${{ env.COMMIT_HASH }}_arm-release.dmg

View File

@@ -2,12 +2,61 @@
Author: 目棃
Description: CHANGELOG
Date: 2025-09-09
Update: 2025-10-27
Update: 2025-12-03
---
> 本文档 [`Frontmatter`](https://github.com/BTMuli/MuCli#Frontmatter) 由 [MuCli](https://github.com/BTMuli/Mucli) 自动生成于 `2025-09-09 14:30:56`
>
> 更新于 `2025-10-27 19:48:23`
> 更新于 `2025-12-03 20:02:17`
## [0.8.8](https://github.com/BTMuli/TeyvatGuide/releases/v0.8.8) (2025-12-03)
- 🐛 修复成就数据读取异常
- 🐛 重构管理员权限重启逻辑
## [0.8.7](https://github.com/BTMuli/TeyvatGuide/releases/v0.8.7) (2025-12-03)
- 🍱 更新 6.2 版本资源
- ✨ 帖子搜索支持“最新”“最热”排序
- ✨ 登录支持 Gt4 验证 [`#162`](https://github.com/BTMuli/TeyvatGuide/issues/162)
- ✨ 帖子视图支持窄视图模式,**未完全适配所有组件,可能存在显示异常**
- ✨ 支持通过内置 Yae 自动获取成就数据 [`#142`](https://github.com/BTMuli/TeyvatGuide/issues/142)
- 🐛 修复无法手动关闭极验验证弹窗
- 🐛 修复数据刷新后渲染异常 [`#163`](https://github.com/BTMuli/TeyvatGuide/issues/163)
- 🐛 重构祈愿图表,修复祈愿日历没有下拉条 [`#165`](https://github.com/BTMuli/TeyvatGuide/issues/165)
- 🐛 修复 MacOS 下极验验证浮窗加载异常 [`#164`](https://github.com/BTMuli/TeyvatGuide/issues/164)
- 🐛 重构回复浮窗处理,调整 UI ,修复滚动异常 [`#168`](https://github.com/BTMuli/TeyvatGuide/issues/168)
- 🐛 修复自定义表情格式解析异常,增加文本清晰度
- 🐛 调整回复按钮展示判断,修复特定条件下的数据对应异常
- 🐛 修复角色 Wiki 左侧列表顺序概率异常
- ✏️ 修正通过 Yae 导入成就的文本错误
- ✏️ 修正清除缓存后的提示文本
- 🚸 执行脚本时不允许切换账号
- 🚸 调整外部导入祈愿记录时进度显示逻辑,导入后刷新页面
- 🚸 增加部分 UI 在浅色模式下的可见度
- 🚸 账号相关操作(添加,切换)移至侧栏 [`#170`](https://github.com/BTMuli/TeyvatGuide/issues/170)
- 🚸 侧栏添加启动入口,满足条件时显示
- 🚸 完善角色 Wiki 侧边栏奇偶点击处理
- 👽️ 完善前瞻识别规则,增加空列表处理
- 📝 更新Q群链接
## [0.8.6](https://github.com/BTMuli/TeyvatGuide/releases/v0.8.6) (2025-11-19)
> 关于胡桃数据库导入功能的说明请参考 [导入胡桃数据库](https://app.btmuli.ink/docs/TeyvatGuide/import-hutao-db.html)
- 👽️ 移除剧诗概览,支持导入胡桃剧诗数据
- 👽️ 移除深渊上传,支持导入胡桃深渊数据
- 🔥 移除胡桃深渊统计页面
- 🚸 调整导入祈愿记录浮窗ui显示导入进度
- 🐛 修复图片渲染异常
- 🥅 处理清除缓存异常,清除缓存后重启
- 🚸 帖子详情添加AIGC相关注释
- 🚸 添加跳转视频链接
- 📝 更新相关文档
## [0.8.5](https://github.com/BTMuli/TeyvatGuide/releases/v0.8.5) (2025-11-10)
- 🍱 更新下半数据
## [0.8.4](https://github.com/BTMuli/TeyvatGuide/releases/v0.8.4) (2025-10-27)

View File

@@ -2,16 +2,20 @@
Author: 目棃
Description: 说明文档
Date: 2023-03-05
Update: 2025-10-27
Update: 2025-12-03
---
> 本文档 [`Frontmatter`](https://github.com/BTMuli/MuCli#Frontmatter) 由 [MuCli](https://github.com/BTMuli/Mucli) 自动生成于 `2023-03-05 14:41:55`
>
> 更新于 `2025-10-27 19:46:04`
> 更新于 `2025-12-03 10:22:51`
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/BTMuli/TeyvatGuide) ![](https://img.shields.io/github/last-commit/BTMuli/TeyvatGuide) ![](https://img.shields.io/github/commits-since/BTMuli/TeyvatGuide/latest?include_prereleases)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/BTMuli/TeyvatGuide)
![](https://img.shields.io/badge/UIAF-v1.1-orange?style=for-the-badge) ![](https://img.shields.io/badge/UIGF-v3.0-red?style=for-the-badge) ![](https://img.shields.io/badge/UIGF-v4.1-red?style=for-the-badge) ![](https://img.shields.io/github/license/BTMuli/TeyvatGuide?style=for-the-badge)
[![](https://img.shields.io/github/last-commit/BTMuli/TeyvatGuide)](https://github.com/BTMuli/TeyvatGuide/commits) [![](https://img.shields.io/github/commits-since/BTMuli/TeyvatGuide/latest?include_prereleases)](https://github.com/BTMuli/TeyvatGuide/commits)
[![](https://img.shields.io/badge/UIAF-v1.1-orange?style=for-the-badge)](./docs/standards/UIAF.md) [![](https://img.shields.io/badge/UIGF-v3.0-red?style=for-the-badge)](./docs/standards/UIGF3.md) [![](https://img.shields.io/badge/UIGF-v4.1-red?style=for-the-badge)](./docs/standards/UIGF.md)
[![](https://img.shields.io/github/license/BTMuli/TeyvatGuide?style=for-the-badge)](./LICENSE)
<div style="width: 100%; text-align: center; margin: 0 auto;">
<img alt="icon" src="https://s2.loli.net/2023/10/19/Y5DpBQRy3usLHEb.png" />
@@ -49,7 +53,7 @@ Game Tool for Genshin Impact player, supports Windows and macOS.
- [x] 米游社官方帖获取(支持通过 ID 获取)
- [x] 米游社各分区帖子获取(支持通过 ID 获取)
- [x] 米游社话题帖子获取(通过话题点击跳转)
- [x] 成就管理UIAF v1.1),支持 [`Yae`](https://github.com/HolographicHat/Yae) 导入
- [x] 成就管理UIAF v1.1),支持 [`Yae`](https://github.com/HolographicHat/Yae) 导入 & 自动导入内置Yae
- [x] 祈愿管理UIGF v3.0UIGF v4.1
- [x] 留影叙佳期画片查看
- [x] 帖子收藏
@@ -72,7 +76,6 @@ Game Tool for Genshin Impact player, supports Windows and macOS.
- [x] 一键完成游戏签到
- Wiki 功能:
- [x] 深渊数据库Hutao API
- [x] 角色图鉴
- [x] 武器图鉴
- [x] 名片图鉴
@@ -91,7 +94,7 @@ Game Tool for Genshin Impact player, supports Windows and macOS.
## UI 参考 / UI Reference
- [Snap.Hutao](https://github.com/DGP-Studio/Snap.Hutao)
- ~~[Snap.Hutao](https://github.com/DGP-Studio/Snap.Hutao)~~
- [Starward](https://github.com/Scighost/Starward)
- [米游社](https://www.miyoushe.com/ys/)
- [原神](https://yuanshen.com/)
@@ -103,6 +106,7 @@ Game Tool for Genshin Impact player, supports Windows and macOS.
- UIAF[UIAF v1.1](docs/standards/UIAF.md)
- UIGF[UIGF v3.0](docs/standards/UIGF3.md)[UIGF v4.0](docs/standards/UIGF.md)
- [macOS 平台门禁属性导致应用无法打开应用的修复指引](docs/macos-gatekeeper/README.md)
- [如何导入胡桃数据库](https://app.btmuli.ink/docs/TeyvatGuide/import-hutao-db.html)
## 特定项目 / Special Project
@@ -137,7 +141,7 @@ Game Tool for Genshin Impact player, supports Windows and macOS.
本项目在开发过程中参考了诸多相关开源项目,特此鸣谢。
- [UIGF Organization](https://github.com/UIGF-org)
- [Snap.Hutao](https://github.com/DGP-Studio/Snap.Hutao)
- ~~[Snap.Hutao](https://github.com/DGP-Studio/Snap.Hutao)~~
- [StarWard](https://github.com/Scighost/Starward)
- [xunkong](https://github.com/xunkong/xunkong)
- [gs-helper](https://github.com/vikiboss/gs-helper)
@@ -146,5 +150,6 @@ Game Tool for Genshin Impact player, supports Windows and macOS.
- [amos-data](https://github.com/yuehaiteam/amos-data)
- [MihoyoBBSTools](https://github.com/Womsxd/MihoyoBBSTools)
- [nonebot-plugin-mystool](https://github.com/Ljzd-PRO/nonebot-plugin-mystool)
- [Yae](https://github.com/HolographicHat/Yae)
[![Star History Chart](https://api.star-history.com/svg?repos=BTMuli/TeyvatGuide&type=Timeline)](https://star-history.com/#BTMuli/TeyvatGuide&Timeline)

View File

@@ -2,12 +2,12 @@
Author: 目棃
Description: 项目资源说明
Date: 2023-03-10
Update: 2025-02-28
Update: 2025-11-19
---
> 本文档 [`Frontmatter`](https://github.com/BTMuli/MuCli#Frontmatter) 由 [MuCli](https://github.com/BTMuli/Mucli) 自动生成于 `2023-03-10 22:05:44`
>
> 更新于 `2025-02-28 09:40:33`
> 更新于 `2025-11-19 12:31:22`
## 说明
@@ -40,8 +40,8 @@ Update: 2025-02-28
相关仓库:
- [TGAssistant](https://github.com/BTMuli/TGAssistant):项目下游仓库,用于处理项目数据。
- [Snap.Metadata](https://github.com/DGP-Studio/Snap.Metadata):胡桃元数据仓库,项目大部分数据来源于此。
- [Snap.Static](https://github.com/DGP-Studio/Snap.Static):胡桃静态资源仓库,项目部分图像资源来源于此。
- ~~[Snap.Metadata](https://github.com/DGP-Studio/Snap.Metadata)~~:胡桃元数据仓库,项目大部分数据来源于此。
- ~~[Snap.Static](https://github.com/DGP-Studio/Snap.Static)~~:胡桃静态资源仓库,项目部分图像资源来源于此。
- [amos-data](https://github.com/yuehaiteam/amos-data):成就数据仓库,成就数据的详细信息来源于此。
## 字体

View File

@@ -1,9 +1,9 @@
{
"name": "teyvatguide",
"version": "0.8.4",
"version": "0.8.8",
"description": "Game Tool for GenshinImpact player",
"private": true,
"packageManager": "pnpm@10.19.0",
"packageManager": "pnpm@10.24.0",
"type": "module",
"scripts": {
"build": "tauri build",
@@ -71,11 +71,11 @@
},
"dependencies": {
"@mdi/font": "7.4.47",
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-deep-link": "^2.4.5",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.4.4",
"@tauri-apps/plugin-http": "^2.5.4",
"@tauri-apps/plugin-http": "github:tauri-apps/tauri-plugin-http",
"@tauri-apps/plugin-log": "^2.7.1",
"@tauri-apps/plugin-opener": "^2.5.2",
"@tauri-apps/plugin-os": "^2.3.2",
@@ -84,53 +84,53 @@
"@tauri-apps/plugin-sql": "^2.3.1",
"ajv": "^8.17.1",
"artplayer": "^5.3.0",
"color-convert": "^3.1.2",
"color-convert": "^3.1.3",
"echarts": "^6.0.0",
"html2canvas": "^1.4.1",
"js-md5": "^0.8.3",
"jsencrypt": "^3.5.4",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"qrcode.vue": "^3.6.0",
"sass-embedded": "^1.93.2",
"sass-embedded": "^1.93.3",
"swiper": "^12.0.3",
"uuid": "^13.0.0",
"vue": "^3.5.22",
"vue": "^3.5.25",
"vue-echarts": "^8.0.1",
"vue-json-pretty": "^2.5.0",
"vue-json-pretty": "^2.6.0",
"vue-router": "^4.6.3",
"vuetify": "^3.10.7",
"vuetify": "^3.11.0",
"wcag-color": "^1.1.1",
"xml-js": "^1.6.11"
},
"devDependencies": {
"@btmuli/stylelint-plugin-color": "^0.1.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.38.0",
"@tauri-apps/cli": "2.9.1",
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.1",
"@tauri-apps/cli": "2.9.5",
"@types/color-convert": "^2.0.4",
"@types/fs-extra": "^11.0.4",
"@types/js-md5": "^0.8.0",
"@types/node": "^24.9.1",
"@typescript-eslint/parser": "^8.46.2",
"@typescript/native-preview": "7.0.0-dev.20251027.1",
"@vitejs/plugin-vue": "^6.0.1",
"@types/node": "^24.10.1",
"@typescript-eslint/parser": "^8.48.1",
"@typescript/native-preview": "7.0.0-dev.20251202.1",
"@vitejs/plugin-vue": "^6.0.2",
"app-root-path": "^3.1.0",
"concurrently": "^9.2.1",
"eslint": "^9.38.0",
"eslint": "^9.39.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsonc": "^2.21.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "^10.5.1",
"eslint-plugin-vue": "^10.6.2",
"eslint-plugin-yml": "^1.19.0",
"fs-extra": "^11.3.2",
"globals": "^16.4.0",
"globals": "^16.5.0",
"husky": "^9.1.7",
"jsonc-eslint-parser": "^2.4.1",
"lint-staged": "^16.2.6",
"oxlint": "^1.24.0",
"prettier": "3.6.2",
"stylelint": "^16.25.0",
"lint-staged": "^16.2.7",
"oxlint": "^1.31.0",
"prettier": "3.7.3",
"stylelint": "^16.26.1",
"stylelint-config-idiomatic-order": "^10.0.0",
"stylelint-config-standard-scss": "^16.0.0",
"stylelint-config-standard-vue": "^1.0.0",
@@ -139,14 +139,14 @@
"stylelint-order": "^7.0.0",
"stylelint-prettier": "^5.0.3",
"stylelint-scss": "^6.12.1",
"tsx": "^4.20.6",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.2",
"vite": "npm:rolldown-vite@^7.1.20",
"vite-plugin-vue-devtools": "^8.0.3",
"typescript-eslint": "^8.48.1",
"vite": "npm:rolldown-vite@^7.2.9",
"vite-plugin-vue-devtools": "^8.0.5",
"vite-plugin-vuetify": "^2.1.2",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.1.2",
"yaml-eslint-parser": "^1.3.0"
"vue-tsc": "^3.1.5",
"yaml-eslint-parser": "^1.3.1"
}
}

2340
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -5,3 +5,6 @@
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
# secret
/lib/*.json

803
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "TeyvatGuide"
version = "0.8.4"
version = "0.8.8"
description = "Game Tool for Genshin Impact player"
authors = ["BTMuli <bt-muli@outlook.com>"]
license = "MIT"
@@ -17,18 +17,33 @@ name = "teyvat_guide_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.1", features = [] }
tauri-build = { version = "2.5.3", features = [] }
[dependencies]
chrono = "0.4.42"
log = "0.4.28"
log = "0.4.29"
prost = "0.14.1"
prost-types = "0.14.1"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
tauri = { version = "2.9.1", features = [] }
tauri-utils = "2.8.0"
tauri = { version = "2.9.4", features = [] }
tauri-utils = "2.8.1"
url = "2.5.7"
walkdir = "2.5.0"
[target.'cfg(windows)'.dependencies.windows-sys]
version = "0.61.2"
features = [
"Win32_System_Diagnostics",
"Win32_System_Diagnostics_Debug",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_LibraryLoader",
"Win32_System_Memory",
"Win32_System_Pipes",
"Win32_System_Threading",
"Win32_System_WindowsProgramming",
]
# deep link 插件
[dependencies.tauri-plugin-deep-link]
git = "ssh://git@github.com/tauri-apps/plugins-workspace.git"

Binary file not shown.

View File

@@ -1,6 +1,5 @@
//! @file src/commands.rs
//! @desc 命令模块,负责处理命令
//! @since Beta v0.7.4
//! 命令模块,负责处理命令
//! @since Beta v0.8.8
use tauri::{AppHandle, Emitter, Manager, WebviewWindowBuilder};
use tauri_utils::config::{WebviewUrl, WindowConfig};
@@ -70,3 +69,61 @@ pub async fn get_dir_size(path: String) -> u64 {
}
size
}
// 判断是否是管理员权限
#[tauri::command]
pub fn is_in_admin() -> bool {
#[cfg(not(target_os = "windows"))]
{
return false;
}
#[cfg(target_os = "windows")]
{
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
use windows_sys::Win32::Security::{
AllocateAndInitializeSid, CheckTokenMembership, FreeSid, SID_IDENTIFIER_AUTHORITY,
TOKEN_QUERY,
};
use windows_sys::Win32::System::SystemServices::{
DOMAIN_ALIAS_RID_ADMINS, SECURITY_BUILTIN_DOMAIN_RID,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
unsafe {
let mut token_handle: HANDLE = std::ptr::null_mut();
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token_handle) == 0 {
return false;
}
let nt_authority = SID_IDENTIFIER_AUTHORITY { Value: [0, 0, 0, 0, 0, 5] };
let mut admin_group = std::ptr::null_mut();
let success = AllocateAndInitializeSid(
&nt_authority,
2,
SECURITY_BUILTIN_DOMAIN_RID.try_into().unwrap(),
DOMAIN_ALIAS_RID_ADMINS.try_into().unwrap(),
0,
0,
0,
0,
0,
0,
&mut admin_group,
);
if success == 0 {
CloseHandle(token_handle);
return false;
}
let mut is_admin = 0i32;
let result = CheckTokenMembership(std::ptr::null_mut(), admin_group, &mut is_admin);
FreeSid(admin_group);
CloseHandle(token_handle);
result != 0 && is_admin != 0
}
}
}

View File

@@ -1,16 +1,19 @@
//! @file src/lib.rs
//! @desc 主模块,用于启动应用
//! @since Beta v0.7.2
//! 主模块,用于启动应用
//! @since Beta v0.8.8
mod client;
mod commands;
mod plugins;
mod utils;
#[cfg(target_os = "windows")]
mod watchdog;
#[cfg(target_os = "windows")]
mod yae;
use crate::client::create_mhy_client;
use crate::commands::{create_window, execute_js, get_dir_size, init_app};
use crate::commands::{create_window, execute_js, get_dir_size, init_app, is_in_admin};
use crate::plugins::{build_log_plugin, build_si_plugin};
use tauri::{generate_context, generate_handler, Builder, Manager, Window, WindowEvent};
use tauri::{generate_context, generate_handler, Manager, Window, WindowEvent};
// 窗口事件处理
fn window_event_handler(app: &Window, event: &WindowEvent) {
@@ -35,9 +38,35 @@ fn window_event_handler(app: &Window, event: &WindowEvent) {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
Builder::default()
#[cfg(target_os = "windows")]
{
let args: Vec<String> = std::env::args().collect();
let is_watchdog = args.iter().any(|a| a == "--watchdog");
// 看门狗模式:不初始化 Tauri不加载单例纯等待 + 提权启动
if is_watchdog {
// 解析父进程 PID
let mut ppid: u32 = 0;
for a in &args {
if let Some(rest) = a.strip_prefix("--ppid=") {
if let Ok(v) = rest.parse::<u32>() {
ppid = v;
}
}
}
// 等父进程退出后再 runas 启动管理员实例,传入 --elevated 标志
let _ = watchdog::run_watchdog(ppid, "--elevated");
// 看门狗退出
return;
}
}
// 正常应用实例:加载单例插件,防止多实例
let mut builder = tauri::Builder::default();
// 只有在正常/管理员实例下才加载单例插件;看门狗不加载
builder = builder.plugin(build_si_plugin());
builder
.on_window_event(move |app, event| window_event_handler(app, event))
.plugin(build_si_plugin())
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
@@ -61,7 +90,12 @@ pub fn run() {
create_window,
execute_js,
get_dir_size,
create_mhy_client
create_mhy_client,
is_in_admin,
#[cfg(target_os = "windows")]
yae::call_yae_dll,
#[cfg(target_os = "windows")]
watchdog::run_with_admin
])
.run(generate_context!())
.expect("error while running tauri application");

View File

@@ -1,6 +1,5 @@
//! @file src/plugins.rs
//! @desc 插件模块,用于注册插件
//! @since Beta v0.6.2
//! 插件模块,用于注册插件
//! @since Beta v0.7.8
use crate::utils::get_current_date;
use log::LevelFilter;
@@ -11,7 +10,23 @@ use tauri_plugin_single_instance::init;
// 单例插件
pub fn build_si_plugin<R: Runtime>() -> TauriPlugin<R> {
init(move |app, argv, _cwd| app.emit("active_deep_link", argv).unwrap())
init(move |app, argv, _cwd| {
// 把 argv 转成 Vec<String>
// let args: Vec<String> = argv.iter().map(|s| s.to_string()).collect();
// 如果包含提升约定参数,发出专门事件并短路退出
// if args.iter().any(|a| a == "--elevated") {
// 提升实例通常只负责传参或执行一次性任务,退出避免与主实例冲突
// std::process::exit(0);
// }
// 非提升启动:按原逻辑广播 deep link
if let Err(e) = app.emit("active_deep_link", argv) {
eprintln!("emit active_deep_link failed: {}", e);
}
// 回调必须返回 unit直接结束即可
})
}
// 日志插件

79
src-tauri/src/watchdog.rs Normal file
View File

@@ -0,0 +1,79 @@
//! 重启提权相关处理
//! @since Beta v0.8.7
#![cfg(target_os = "windows")]
use std::ffi::OsStr;
use std::iter::once;
use std::os::windows::ffi::OsStrExt;
use std::ptr::null_mut;
use std::time::Duration;
use tauri::AppHandle;
use windows_sys::Win32::Foundation::{HANDLE, HWND};
use windows_sys::Win32::Storage::FileSystem::SYNCHRONIZE;
use windows_sys::Win32::System::Threading::{OpenProcess, WaitForSingleObject, INFINITE};
use windows_sys::Win32::UI::Shell::ShellExecuteW;
use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
// 带参数启动
fn shell_runas_with_args(args: &str) -> Result<(), String> {
fn to_wide(s: &OsStr) -> Vec<u16> {
s.encode_wide().chain(once(0)).collect()
}
let exe_path = std::env::current_exe().map_err(|e| e.to_string())?;
let exe_wide = to_wide(exe_path.as_os_str());
let args_wide = to_wide(OsStr::new(args));
let cwd_wide =
exe_path.parent().map(|p| to_wide(p.as_os_str())).unwrap_or_else(|| to_wide(OsStr::new("")));
unsafe {
let result = ShellExecuteW(
0 as HWND,
to_wide(OsStr::new("runas")).as_ptr(),
exe_wide.as_ptr(),
args_wide.as_ptr(),
if cwd_wide.len() > 1 { cwd_wide.as_ptr() } else { null_mut() },
SW_SHOWNORMAL,
);
if (result as usize) > 32 {
Ok(())
} else {
Err("Failed to ShellExecuteW runas".into())
}
}
}
// 等待父进程退出(释放单例锁)后,再以管理员身份启动新实例
pub fn run_watchdog(parent_pid: u32, args_to_pass: &str) -> Result<(), String> {
// 打开父进程句柄用于等待
let handle: HANDLE = unsafe { OpenProcess(SYNCHRONIZE, 0, parent_pid) };
if handle == std::ptr::null_mut() {
// 如果拿不到句柄,可能父进程已退出,稍作等待后继续
std::thread::sleep(Duration::from_millis(300));
} else {
unsafe {
WaitForSingleObject(handle, INFINITE);
}
}
// 父进程已退出 → 触发 UAC 提权启动新实例
shell_runas_with_args(args_to_pass)
}
// 以管理员权限重启应用
#[tauri::command]
pub fn run_with_admin(app_handle: AppHandle) -> Result<(), String> {
let parent_pid = std::process::id();
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let mut cmd = std::process::Command::new(exe);
cmd
.arg("--watchdog")
.arg(format!("--ppid={}", parent_pid))
// 看门狗不加载单例插件(通过参数决定 main 的初始化)
.spawn()
.map_err(|e| format!("spawn watchdog failed: {e}"))?;
// 立即退出:单例锁释放
app_handle.exit(0);
Ok(())
}

191
src-tauri/src/yae/inject.rs Normal file
View File

@@ -0,0 +1,191 @@
//! DLL 注入相关功能
//! @since Beta v0.7.8
#![cfg(target_os = "windows")]
use std::ffi::OsStr;
use std::iter::once;
use std::os::windows::ffi::OsStrExt;
use std::ptr;
use windows_sys::Win32::Foundation::{CloseHandle, FreeLibrary, HANDLE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Storage::FileSystem::PIPE_ACCESS_DUPLEX;
use windows_sys::Win32::System::Diagnostics::Debug::WriteProcessMemory;
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Module32FirstW, Module32NextW, MODULEENTRY32W, TH32CS_SNAPMODULE,
};
use windows_sys::Win32::System::LibraryLoader::{
GetModuleHandleA, GetProcAddress, LoadLibraryExW, DONT_RESOLVE_DLL_REFERENCES,
};
use windows_sys::Win32::System::Memory::{VirtualAllocEx, MEM_COMMIT, PAGE_READWRITE};
use windows_sys::Win32::System::Pipes::{
CreateNamedPipeW, PIPE_READMODE_MESSAGE, PIPE_TYPE_MESSAGE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT,
};
use windows_sys::Win32::System::Threading::{
CreateProcessW, CreateRemoteThread, WaitForSingleObject, INFINITE, PROCESS_INFORMATION,
STARTUPINFOW,
};
/// 转为宽字符串
pub fn to_wide_string(s: &str) -> Vec<u16> {
OsStr::new(s).encode_wide().chain(once(0)).collect()
}
/// 创建命名管道
pub fn create_named_pipe(pipe_name: &str) -> HANDLE {
let full_pipe_name = format!(r"\\.\pipe\{}", pipe_name);
let wide: Vec<u16> = to_wide_string(&full_pipe_name);
unsafe {
let handle = CreateNamedPipeW(
wide.as_ptr(),
PIPE_ACCESS_DUPLEX,
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
PIPE_UNLIMITED_INSTANCES,
512,
512,
0,
ptr::null_mut(),
);
if handle == INVALID_HANDLE_VALUE {
panic!("CreateNamedPipeW failed");
}
handle
}
}
/// 启动目标进程附加cwd
pub fn spawn_process(path: &str) -> PROCESS_INFORMATION {
let wide_path: Vec<u16> = to_wide_string(path);
let cwd = std::path::Path::new(path).parent().unwrap().to_str().unwrap();
let wide_cwd: Vec<u16> = to_wide_string(cwd);
unsafe {
let mut si: STARTUPINFOW = std::mem::zeroed();
si.cb = std::mem::size_of::<STARTUPINFOW>() as u32;
let mut pi: PROCESS_INFORMATION = std::mem::zeroed();
let success = CreateProcessW(
wide_path.as_ptr(),
ptr::null_mut(),
ptr::null_mut(),
ptr::null_mut(),
0,
0,
ptr::null_mut(),
wide_cwd.as_ptr(),
&mut si,
&mut pi,
);
if success == 0 {
panic!("CreateProcessW failed");
}
pi
}
}
/// 注入 DLL
pub fn inject_dll(pi: &PROCESS_INFORMATION, dll_path: &str) {
let dll_utf16: Vec<u16> = to_wide_string(dll_path);
let size = dll_utf16.len() * 2;
unsafe {
let addr = VirtualAllocEx(pi.hProcess, ptr::null_mut(), size, MEM_COMMIT, PAGE_READWRITE);
if addr.is_null() {
panic!("VirtualAllocEx failed");
}
let success =
WriteProcessMemory(pi.hProcess, addr, dll_utf16.as_ptr() as *const _, size, ptr::null_mut());
if success == 0 {
panic!("WriteProcessMemory failed");
}
let k32 = GetModuleHandleA(b"kernel32.dll\0".as_ptr());
if k32 == std::ptr::null_mut() {
panic!("GetModuleHandleA failed");
}
let loadlib = GetProcAddress(k32, b"LoadLibraryW\0".as_ptr());
if loadlib.is_none() {
panic!("GetProcAddress failed");
}
let thread = CreateRemoteThread(
pi.hProcess,
ptr::null_mut(),
0,
Some(std::mem::transmute(loadlib)),
addr,
0,
ptr::null_mut(),
);
if thread.is_null() {
panic!("CreateRemoteThread failed");
}
WaitForSingleObject(thread, INFINITE);
}
}
/// 枚举模块,找到 DLL 基址
pub fn find_module_base(pid: u32, dll_name: &str) -> Option<usize> {
unsafe {
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, pid);
if snapshot == INVALID_HANDLE_VALUE {
return None;
}
let mut me32 =
MODULEENTRY32W { dwSize: std::mem::size_of::<MODULEENTRY32W>() as u32, ..Default::default() };
if Module32FirstW(snapshot, &mut me32) != 0 {
loop {
let name = String::from_utf16_lossy(&me32.szModule);
if name.contains(dll_name) {
CloseHandle(snapshot);
return Some(me32.modBaseAddr as usize);
}
if Module32NextW(snapshot, &mut me32) == 0 {
break;
}
}
}
CloseHandle(snapshot);
}
None
}
/// 执行 YaeMain
pub fn call_yaemain(pi: &PROCESS_INFORMATION, base: usize, dll_path: &str) {
let dll_path_wide: Vec<u16> = to_wide_string(dll_path);
unsafe {
let local =
LoadLibraryExW(dll_path_wide.as_ptr(), std::ptr::null_mut(), DONT_RESOLVE_DLL_REFERENCES);
if local == std::ptr::null_mut() {
panic!("LoadLibraryExW failed");
}
let proc = GetProcAddress(local, b"YaeMain\0".as_ptr()).expect("无法找到 YaeMain");
// Option<unsafe extern "system" fn() -> isize>
let proc_addr = proc as *const () as usize;
let rva = proc_addr - local as usize;
println!("YaeMain RVA: {:#x}", rva);
FreeLibrary(local);
let remote_yaemain = (base + rva) as *mut std::ffi::c_void;
// 在远程进程里调用 YaeMain(hModule)
CreateRemoteThread(
pi.hProcess,
std::ptr::null_mut(),
0,
Some(std::mem::transmute(remote_yaemain)),
base as *mut _,
0,
std::ptr::null_mut(),
);
}
}

204
src-tauri/src/yae/mod.rs Normal file
View File

@@ -0,0 +1,204 @@
//! Yae 相关处理
//! @since Beta v0.8.7
#![cfg(target_os = "windows")]
pub mod inject;
pub mod proto;
use inject::{call_yaemain, create_named_pipe, find_module_base, inject_dll, spawn_process};
use proto::parse_achi_list;
use serde_json::Value;
use std::fs::File;
use std::io::{self, Read, Write};
use std::os::windows::io::{FromRawHandle, RawHandle};
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Pipes::ConnectNamedPipe;
// 读取配置值
fn read_rva(key: &str) -> i32 {
let path = format!("nativeConfig.methodRva.chinese.{}", key);
read_conf(&path)
}
// 读取配置文件
pub fn read_conf(path: &str) -> i32 {
// 编译时嵌入 JSON 文件值都是32位整数
let data = include_str!("../../lib/conf.json");
let json: Value = serde_json::from_str(data).expect("Invalid JSON");
// 按 '.' 分割 key
let mut current = &json;
for key in path.split('.') {
match current.get(key) {
Some(value) => current = value,
None => return 0, // 如果找不到 key返回默认值 0
}
}
current.as_i64().unwrap_or(0) as i32
}
fn read_u32_le<R: Read>(r: &mut R) -> io::Result<u32> {
let mut buf = [0u8; 4];
match r.read_exact(&mut buf) {
Ok(_) => Ok(u32::from_le_bytes(buf)),
Err(e) => Err(e),
}
}
fn read_f64_le<R: Read>(r: &mut R) -> io::Result<f64> {
let mut buf = [0u8; 8];
match r.read_exact(&mut buf) {
Ok(_) => Ok(f64::from_le_bytes(buf)),
Err(e) => Err(e),
}
}
fn read_exact_vec<R: Read>(r: &mut R, len: usize) -> io::Result<Vec<u8>> {
let mut v = vec![0u8; len];
match r.read_exact(&mut v) {
Ok(_) => Ok(v),
Err(e) => Err(e),
}
}
/// 调用 dll
#[tauri::command]
pub fn call_yae_dll(app_handle: AppHandle, game_path: String) -> Result<(), String> {
let dll_path = app_handle.path().resource_dir().unwrap().join("resources/YaeAchievementLib.dll");
dbg!(&dll_path);
// 0. 创建 YaeAchievementPipe 的 命名管道,获取句柄
dbg!("开始启动 YaeAchievementPipe 命名管道");
let _pipe_handle = create_named_pipe("YaeAchievementPipe");
// 1. 启动游戏进程
let pi = spawn_process(&game_path);
dbg!("游戏进程启动完成");
// 2. 注入 DLL
inject_dll(&pi, dll_path.to_str().unwrap());
dbg!("DLL 注入完成");
// 3. 找到 DLL 基址
let base = find_module_base(pi.dwProcessId, "YaeAchievementLib.dll").expect("找不到 DLL 基址");
dbg!("找到 DLL 基址: {:X}", base);
// 4. 调用 YaeMain
call_yaemain(&pi, base, dll_path.to_str().unwrap());
dbg!("YaeMain 调用完成");
// 根据句柄等待命名管道连接
let retry_count = 50;
for _ in 0..retry_count {
let result = unsafe { ConnectNamedPipe(_pipe_handle, std::ptr::null_mut()) };
if result != 0 {
dbg!("命名管道连接成功");
break;
} else {
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
// 5. 读取命名管道数据,循环读取,根据接受字节进行通信
let file = unsafe { File::from_raw_handle(_pipe_handle as RawHandle) };
let file = Arc::new(file);
// No raw HANDLE captured in the closure. Only Arc<File>.
std::thread::spawn({
let file = file.clone();
move || {
let mut file = file.try_clone().expect("Failed to clone pipe file");
let mut cmd = [0u8; 1];
loop {
match file.read_exact(&mut cmd) {
// 输出命令字节
Ok(_) => {
println!("收到命令: {}", cmd[0]);
match cmd[0] {
0x01 => {
match read_u32_le(&mut file) {
Ok(len) => {
// 再读数据
match read_exact_vec(&mut file, len as usize) {
Ok(data) => {
println!("长度: {}", len);
// 解码成 AchievementInfo
match parse_achi_list(&data) {
Ok(list) => {
println!("解码成功,成就列表长度: {}", list.len());
let json = serde_json::to_string_pretty(&list).unwrap();
let _ = app_handle.emit("yae_achi_list", json);
}
Err(e) => println!("解析失败: {:?}", e),
}
}
Err(e) => println!("读取数据失败: {:?}", e),
}
}
Err(e) => println!("读取长度失败: {:?}", e),
}
}
0x02 => {
println!("PlayerStoreNotify");
// 读取剩余数据
match read_u32_le(&mut file) {
Ok(len) => match read_exact_vec(&mut file, len as usize) {
Ok(_data) => println!("长度: {}", len),
Err(e) => println!("读取数据失败: {:?}", e),
},
Err(e) => println!("读取长度失败: {:?}", e),
}
}
0x03 => {
println!("PlayerPropNotify");
// 读取剩余数据
match read_u32_le(&mut file) {
Ok(prop_type) => match read_f64_le(&mut file) {
Ok(value) => println!("Prop 类型: {}, 值: {}", prop_type, value),
Err(e) => println!("读取值失败: {:?}", e),
},
Err(e) => println!("读取类型失败: {:?}", e),
}
}
0xFC => {
let _ = file.write_all(&read_conf("nativeConfig.achievementCmdId").to_le_bytes());
let _ = file.write_all(&read_conf("nativeConfig.storeCmdId").to_le_bytes());
}
0xFD => {
for key in [
"doCmd",
"updateNormalProp",
"newString",
"findGameObject",
"eventSystemUpdate",
"simulatePointerClick",
"toInt32",
"tcpStatePtr",
"sharedInfoPtr",
"decompress",
] {
let _ = file.write_all(&read_rva(key).to_le_bytes());
}
}
0xFF => {
let _ = file.write_all(&[1]);
break;
}
_ => println!("收到未知命令: {}", cmd[0]),
}
}
Err(e) => {
println!("读取失败: {:?}", e);
break;
}
}
}
}
});
unsafe {
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}
Ok(())
}

170
src-tauri/src/yae/proto.rs Normal file
View File

@@ -0,0 +1,170 @@
//! Yae 成就信息的 Protobuf 定义
//! @since Beta v0.7.9
#![cfg(target_os = "windows")]
use crate::yae::read_conf;
use prost::encoding::{decode_key, WireType};
use prost::DecodeError;
use prost::Message;
use serde::Serialize;
use std::collections::HashMap;
use std::io::{Cursor, Read};
#[allow(dead_code)]
#[derive(Clone, PartialEq, Message, Serialize)]
pub struct AchievementProtoFieldInfo {
#[prost(uint32, tag = "1")]
pub id: u32,
#[prost(uint32, tag = "2")]
pub status: u32,
#[prost(uint32, tag = "3")]
pub total_progress: u32,
#[prost(uint32, tag = "4")]
pub current_progress: u32,
#[prost(uint32, tag = "5")]
pub finish_timestamp: u32,
}
#[allow(dead_code)]
#[derive(Clone, PartialEq, Message, Serialize)]
pub struct AchievementItem {
#[prost(uint32, tag = "1")]
pub pre: u32,
#[prost(uint32, tag = "2")]
pub group: u32,
#[prost(string, tag = "3")]
pub name: String,
#[prost(string, tag = "4")]
pub description: String,
}
#[allow(dead_code)]
#[derive(Clone, PartialEq, Message, Serialize)]
pub struct MethodRvaConfig {
#[prost(uint32, tag = "1")]
pub do_cmd: u32,
#[prost(uint32, tag = "3")]
pub update_normal_prop: u32,
#[prost(uint32, tag = "4")]
pub new_string: u32,
#[prost(uint32, tag = "5")]
pub find_game_object: u32,
#[prost(uint32, tag = "6")]
pub event_system_update: u32,
#[prost(uint32, tag = "7")]
pub simulate_pointer_click: u32,
#[prost(uint32, tag = "8")]
pub to_int32: u32,
#[prost(uint32, tag = "9")]
pub tcp_state_ptr: u32,
#[prost(uint32, tag = "10")]
pub shared_info_ptr: u32,
#[prost(uint32, tag = "11")]
pub decompress: u32,
}
#[allow(dead_code)]
#[derive(Clone, PartialEq, Message, Serialize)]
pub struct NativeLibConfig {
#[prost(uint32, tag = "1")]
pub store_cmd_id: u32,
#[prost(uint32, tag = "2")]
pub achievement_cmd_id: u32,
#[prost(map = "uint32, message", tag = "10")]
pub method_rva: HashMap<u32, MethodRvaConfig>,
}
#[allow(dead_code)]
#[derive(Clone, PartialEq, Message, Serialize)]
pub struct AchievementInfo {
#[prost(string, tag = "1")]
pub version: String,
#[prost(map = "uint32, string", tag = "2")]
pub group: HashMap<u32, String>,
#[prost(map = "uint32, message", tag = "3")]
pub items: HashMap<u32, AchievementItem>,
#[prost(message, tag = "4")]
pub pb_info: Option<AchievementProtoFieldInfo>,
#[prost(message, tag = "5")]
pub native_config: Option<NativeLibConfig>,
}
#[derive(Debug, Default, Serialize)]
pub struct UiafAchiItem {
pub id: u32,
pub current: u32,
pub timestamp: u32,
pub status: u32,
}
pub fn parse_achi_list(bytes: &[u8]) -> Result<Vec<UiafAchiItem>, DecodeError> {
let mut cursor = Cursor::new(bytes);
let mut dicts: Vec<HashMap<u32, u32>> = Vec::new();
while let Ok((_, wire_type)) = decode_key(&mut cursor) {
if wire_type == WireType::LengthDelimited {
let len = prost::encoding::decode_varint(&mut cursor)? as usize;
let mut buf = vec![0u8; len];
if cursor.read_exact(&mut buf).is_err() {
continue;
}
let mut inner = Cursor::new(&buf);
let mut dict = HashMap::new();
while let Ok((tag, wire_type)) = decode_key(&mut inner) {
if wire_type != WireType::Varint {
break;
}
let value = prost::encoding::decode_varint(&mut inner)? as u32;
dict.insert(tag, value);
}
// 输出 dict
println!("{:?}", dict);
// dict 至少需要两个 key
if dict.len() > 2 {
dicts.push(dict)
}
}
}
let _id = read_conf("id") as u32;
let _status = read_conf("status") as u32;
let _cur = read_conf("currentProgress") as u32;
let _ts = read_conf("finishTimestamp") as u32;
let achievements = dicts
.into_iter()
.map(|d| UiafAchiItem {
id: d.get(&_id).copied().unwrap_or(0),
status: d.get(&_status).copied().unwrap_or(0),
current: d.get(&_cur).copied().unwrap_or(0),
timestamp: d.get(&_ts).copied().unwrap_or(0),
})
.filter(|a| a.status != 0)
.collect();
Ok(achievements)
}

View File

@@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "TeyvatGuide",
"identifier": "TeyvatGuide",
"version": "0.8.4",
"version": "0.8.8",
"build": {
"beforeDevCommand": "pnpm vite:dev",
"beforeBuildCommand": "pnpm vite:build",
@@ -30,7 +30,8 @@
],
"targets": ["msi", "app", "dmg"],
"windows": { "wix": { "language": "zh-CN" } },
"macOS": {}
"macOS": {},
"resources": { "lib/YaeAchievementLib.dll": "resources/YaeAchievementLib.dll" }
},
"app": {
"withGlobalTauri": true,

View File

@@ -1,3 +1,4 @@
<!--主界面 -->
<template>
<v-app v-model:theme="vuetifyTheme">
<TSidebar v-if="isMain" />
@@ -164,7 +165,7 @@ async function getDeepLink(): Promise<UnlistenFn> {
const windowGet = new webviewWindow.WebviewWindow("TeyvatGuide");
if (await windowGet.isMinimized()) await windowGet.unminimize();
await windowGet.setFocus();
const payload = parseDeepLink(e.payload);
const payload = await parseDeepLink(e.payload);
if (payload === false) {
showSnackbar.error("无效的 deep link", 3000);
await TGLogger.Error(`[App][getDeepLink] 无效的 deep link ${JSON.stringify(e.payload)}`);
@@ -175,14 +176,14 @@ async function getDeepLink(): Promise<UnlistenFn> {
});
}
function parseDeepLink(payload: string | string[]): string | false {
async function parseDeepLink(payload: string | string[]): Promise<string | false> {
try {
if (typeof payload === "string") return payload;
if (payload.length < 2) return "teyvatguide://";
return payload[1];
} catch (e) {
if (e instanceof Error) {
TGLogger.Error(`[App][parseDeepLink] ${e.name}: ${e.message}`);
await TGLogger.Error(`[App][parseDeepLink] ${e.name}: ${e.message}`);
} else console.error(e);
return false;
}

View File

@@ -1,3 +1,4 @@
<!-- 版块小组件菜单 -->
<template>
<div class="tgn-container">
<div v-for="navItem in nav" :key="navItem.id" class="tgn-nav" @click="toNav(navItem)">
@@ -5,13 +6,14 @@
<span>{{ navItem.name }}</span>
</div>
<div v-if="hasNav" class="tgn-nav">
<v-icon size="25" @click="tryGetCode" title="查看兑换码">mdi-code-tags-check</v-icon>
<v-icon size="25" @click="tryGetCode" title="查看兑换码" color="var(--tgc-od-orange)">
mdi-code-tags-check
</v-icon>
</div>
<ToLivecode v-model="showOverlay" :gid="model" :data="codeData" :actId="actId" />
</div>
</template>
<script lang="ts" setup>
import TMiImg from "@comp/app/t-mi-img.vue";
import showDialog from "@comp/func/dialog.js";
import showSnackbar from "@comp/func/snackbar.js";
import ApiHubReq from "@req/apiHubReq.js";
@@ -25,18 +27,19 @@ import { createPost } from "@utils/TGWindow.js";
import { storeToRefs } from "pinia";
import { computed, onMounted, ref, shallowRef, watch } from "vue";
import TMiImg from "./t-mi-img.vue";
import ToLivecode from "./to-livecode.vue";
const { isLogin } = storeToRefs(useAppStore());
const model = defineModel<number>({ default: 2 });
const { isLogin } = storeToRefs(useAppStore());
const actId = ref<string>();
const showOverlay = ref<boolean>(false);
const nav = shallowRef<TGApp.BBS.Navigator.Navigator[]>([]);
const codeData = shallowRef<TGApp.BBS.Navigator.CodeData[]>([]);
const showOverlay = ref<boolean>(false);
const actId = ref<string>();
const hasNav = computed<TGApp.BBS.Navigator.Navigator | undefined>(() => {
const liveNames = ["前瞻直播", "前瞻节目", "直播兑换码"];
const liveNames = ["前瞻直播", "前瞻节目", "直播兑换码", "特别节目"];
const find = nav.value.find((item) => liveNames.includes(item.name));
if (find) return find;
return nav.value.find((item) => item.name.includes("前瞻"));
@@ -49,34 +52,55 @@ watch(
async () => await loadNav(),
);
/**
* 加载组件数据
* @returns {Promise<void>}
*/
async function loadNav(): Promise<void> {
nav.value = await ApiHubReq.home(model.value);
try {
nav.value = await ApiHubReq.home(model.value);
console.debug(`[TGameNav][loadNav] 组件数据:`, nav.value);
} catch (e) {
await TGLogger.Error(`[TGameNav][loadNav] 加载组件数据失败:${e}`);
}
}
/**
* 获取兑换码
* @returns {Promise<void>}
*/
async function tryGetCode(): Promise<void> {
if (!hasNav.value) return;
const actIdFind = new URL(hasNav.value.app_path).searchParams.get("act_id");
if (!actIdFind) {
showSnackbar.warn("未找到活动ID");
await TGLogger.Warn(`[TGameNav][tryGetCode] 未找到活动ID链接${hasNav.value.app_path}`);
return;
}
actId.value = actIdFind;
const res = await OtherApi.code(actIdFind);
if (!Array.isArray(res)) {
showSnackbar.warn(`[${res.retcode}] ${res.message}`);
await TGLogger.Warn(`[TGameNav][tryGetCode] 获取兑换码失败:${JSON.stringify(res)}`);
return;
}
codeData.value = res;
console.debug(`[TGameNave][tryGetCode] 兑换码数据:`, codeData.value);
showSnackbar.success("获取兑换码成功");
await TGLogger.Info(JSON.stringify(res));
showOverlay.value = true;
}
/**
* 跳转到活动页面
* @param {TGApp.BBS.Navigator.Navigator} item 导航项
* @returns {Promise<void>}
*/
async function toNav(item: TGApp.BBS.Navigator.Navigator): Promise<void> {
if (!isLogin.value) {
showSnackbar.warn("请先登录");
return;
}
console.debug(`[TGameNav][toNav] 跳转到活动页面:`, item);
await TGLogger.Info(`[TGameNav][toNav] 打开网页活动 ${item.name}`);
await TGLogger.Info(`[TGameNav][toNav] ${item.app_path}`);
const link = new URL(item.app_path);
@@ -110,7 +134,11 @@ async function toNav(item: TGApp.BBS.Navigator.Navigator): Promise<void> {
else await TGClient.open("web_act", item.app_path);
}
// 处理 protocol
/**
* 处理米游社论坛链接
* @param {URL} link 链接
* @returns {Promise<void>}
*/
async function toBBS(link: URL): Promise<void> {
if (link.protocol == "mihoyobbs:") {
if (link.hostname === "article") {
@@ -148,25 +176,26 @@ async function toBBS(link: URL): Promise<void> {
border-radius: 4px;
color: var(--tgc-white-1);
cursor: pointer;
img {
width: 28px;
height: 28px;
}
span {
display: none;
margin-left: 4px;
color: var(--common-text-title);
font-family: var(--font-title);
font-size: 16px;
}
&:hover span {
display: block;
}
}
.dark .tgn-nav {
@include github-styles.github-card("dark");
}
.tgn-nav img {
width: 28px;
height: 28px;
}
.tgn-nav span {
display: none;
color: var(--common-text-title);
font-family: var(--font-title);
font-size: 16px;
}
.tgn-nav:hover span {
display: block;
}
</style>

View File

@@ -0,0 +1,95 @@
<!-- 改变帖子视图 -->
<template>
<div
class="tpw2-box"
:class="postViewWide ? '' : 'active'"
:title="postViewWide ? '切换为窄屏视图' : '切换为宽屏视图'"
data-html2canvas-ignore
>
<div class="tpw2-btn" @click="switchPostWidth()">
<v-icon size="20">
{{ postViewWide ? "mdi-arrow-collapse-horizontal" : "mdi-arrow-expand-horizontal" }}
</v-icon>
</div>
<div class="tpw2-beta-hint" title="测试功能,可能存在适配问题">β</div>
</div>
</template>
<script lang="ts" setup>
import useAppStore from "@store/app.js";
import { storeToRefs } from "pinia";
const { postViewWide } = storeToRefs(useAppStore());
function switchPostWidth(): void {
postViewWide.value = !postViewWide.value;
}
</script>
<style lang="scss" scoped>
@use "@styles/github.styles.scss" as github-styles;
.tpw2-box {
@include github-styles.github-card;
position: fixed;
top: 112px;
left: 16px;
display: flex;
width: 36px;
height: 36px;
box-sizing: border-box;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
&.active {
background: var(--tgc-btn-1);
box-shadow: 1px 3px 6px var(--common-shadow-2);
color: var(--btn-text);
}
&:hover:not(.active) {
background: var(--common-shadow-1);
}
}
.dark .tpw2-box {
border: 1px solid var(--common-shadow-1);
box-shadow: 1px 3px 6px var(--common-shadow-t-2);
&:not(.active) {
@include github-styles.github-card("dark");
&:hover {
background: var(--common-shadow-6);
}
}
}
.tpw2-btn {
position: relative;
z-index: 1;
display: flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
}
.tpw2-beta-hint {
position: absolute;
right: -5px;
bottom: -5px;
display: flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--tgc-od-green);
box-shadow: 1px 3px 6px var(--common-shadow-2);
color: var(--tgc-blue-1);
font-size: 10px;
font-weight: bold;
}
</style>

View File

@@ -1,150 +1,147 @@
<template>
<v-navigation-drawer :permanent="true" :rail="rail" class="tsb-box">
<v-list class="side-list" density="compact" :nav="true">
<v-list :nav="true" class="side-list" density="compact">
<v-list-item
@click="rail = !rail"
:prepend-icon="rail ? 'mdi-chevron-right' : undefined"
:append-icon="!rail ? 'mdi-chevron-left' : undefined"
:prepend-icon="rail ? 'mdi-chevron-right' : undefined"
@click="rail = !rail"
/>
<!-- 菜单项 -->
<v-list-item :title.attr="'首页'" :link="true" href="/">
<v-list-item :link="true" :title.attr="'首页'" href="/">
<template #title>首页</template>
<template #prepend>
<img src="/source/UI/paimon.webp" alt="homeIcon" class="side-icon paimon" />
<img alt="homeIcon" class="side-icon paimon" src="/source/UI/paimon.webp" />
</template>
</v-list-item>
<v-list-item title.attr="'公告'" :link="true" href="/announcements">
<v-list-item :link="true" href="/announcements" title.attr="'公告'">
<template #title>公告</template>
<template #prepend>
<img src="@/assets/icons/board.svg" alt="annoIcon" class="side-icon" />
<img alt="annoIcon" class="side-icon" src="@/assets/icons/board.svg" />
</template>
</v-list-item>
<v-list-item :title.attr="'咨讯'" :link="true" :href="`/news/2/${recentNewsType}`">
<v-list-item :href="`/news/2/${recentNewsType}`" :link="true" :title.attr="'咨讯'">
<template #title>咨讯</template>
<template #prepend>
<img src="/platforms/mhy/mys.webp" alt="mihoyo" class="side-icon" />
<img alt="mihoyo" class="side-icon" src="/platforms/mhy/mys.webp" />
</template>
</v-list-item>
<v-list-item :title.attr="'帖子'" :link="true" href="/posts/forum">
<v-list-item :link="true" :title.attr="'帖子'" href="/posts/forum">
<template #title>帖子</template>
<template #prepend>
<img src="/source/UI/posts.webp" alt="posts" class="side-icon" />
<img alt="posts" class="side-icon" src="/source/UI/posts.webp" />
</template>
</v-list-item>
<v-list-item :title.attr="'成就'" :link="true" href="/achievements">
<v-list-item :link="true" :title.attr="'成就'" href="/achievements">
<template #title>成就</template>
<template #prepend>
<img src="@/assets/icons/achievements.svg" alt="achievementsIcon" class="side-icon" />
<img alt="achievementsIcon" class="side-icon" src="@/assets/icons/achievements.svg" />
</template>
</v-list-item>
<v-divider />
<v-list-item :title.attr="'原神战绩'" :link="true" href="/user/record">
<v-list-item :link="true" :title.attr="'原神战绩'" href="/user/record">
<template #title>原神战绩</template>
<template #prepend>
<img src="/source/UI/userRecord.webp" alt="record" class="side-icon" />
<img alt="record" class="side-icon" src="/source/UI/userRecord.webp" />
</template>
</v-list-item>
<v-list-item :title.attr="'我的角色'" :link="true" href="/user/characters">
<v-list-item :link="true" :title.attr="'我的角色'" href="/user/characters">
<template #title>我的角色</template>
<template #prepend>
<img src="/source/UI/userAvatar.webp" alt="characters" class="side-icon" />
<img alt="characters" class="side-icon" src="/source/UI/userAvatar.webp" />
</template>
</v-list-item>
<v-menu :open-on-click="true" location="end" :offset="[8, 0]">
<v-menu :offset="[8, 0]" :open-on-click="true" location="end">
<template #activator="{ props }">
<v-list-item :title.attr="'高难挑战'" v-bind="props">
<template #title>高难挑战</template>
<template #prepend>
<img src="/source/UI/userAbyssLab.webp" alt="abyssLab" class="side-icon" />
<img alt="abyssLab" class="side-icon" src="/source/UI/userAbyssLab.webp" />
</template>
</v-list-item>
</template>
<v-list class="side-list-menu sub" density="compact" :nav="true">
<v-list-item class="side-item-menu" title="深境螺旋" :link="true" href="/user/abyss">
<v-list :nav="true" class="side-list-menu sub" density="compact">
<v-list-item :link="true" class="side-item-menu" href="/user/abyss" title="深境螺旋">
<template #prepend>
<img src="/source/UI/userAbyss.webp" alt="abyss" class="side-icon-menu" />
<img alt="abyss" class="side-icon-menu" src="/source/UI/userAbyss.webp" />
</template>
</v-list-item>
<v-list-item class="side-item-menu" title="真境剧诗" :link="true" href="/user/combat">
<v-list-item :link="true" class="side-item-menu" href="/user/combat" title="真境剧诗">
<template #prepend>
<img src="/source/UI/userCombat.webp" alt="combat" class="side-icon-menu" />
<img alt="combat" class="side-icon-menu" src="/source/UI/userCombat.webp" />
</template>
</v-list-item>
<v-list-item class="side-item-menu" title="幽境危战" :link="true" href="/user/challenge">
<v-list-item :link="true" class="side-item-menu" href="/user/challenge" title="幽境危战">
<template #prepend>
<img src="/source/UI/userChallenge.webp" alt="challenge" class="side-icon-menu" />
<img alt="challenge" class="side-icon-menu" src="/source/UI/userChallenge.webp" />
</template>
</v-list-item>
</v-list>
</v-menu>
<v-list-item :title.attr="'祈愿记录'" :link="true" href="/user/gacha">
<v-list-item :link="true" :title.attr="'祈愿记录'" href="/user/gacha">
<template #title>祈愿记录</template>
<template #prepend>
<img src="/source/UI/userGacha.webp" alt="gacha" class="side-icon" />
<img alt="gacha" class="side-icon" src="/source/UI/userGacha.webp" />
</template>
</v-list-item>
<v-list-item :title.attr="'实用脚本'" :link="true" href="/user/scripts">
<v-list-item :link="true" :title.attr="'实用脚本'" href="/user/scripts">
<template #title>实用脚本</template>
<template #prepend>
<img src="/source/UI/toolbox.webp" alt="scripts" class="side-icon" />
<img alt="scripts" class="side-icon" src="/source/UI/toolbox.webp" />
</template>
</v-list-item>
<v-divider />
<v-list-item
v-show="isDevEnv"
:title.attr="'测试页面'"
:link="true"
:title.attr="'测试页面'"
href="/test"
prepend-icon="mdi-test-tube"
>
<template #title>测试页面</template>
</v-list-item>
<v-divider v-show="isDevEnv" />
<v-menu :open-on-click="true" location="end" :offset="[8, 0]">
<v-menu :offset="[8, 0]" :open-on-click="true" location="end">
<template #activator="{ props }">
<v-list-item :title.attr="'图鉴'" v-bind="props">
<template #title>图鉴</template>
<template #prepend>
<img src="/source/UI/wikiIcon.webp" alt="wikiIcon" class="side-icon" />
<img alt="wikiIcon" class="side-icon" src="/source/UI/wikiIcon.webp" />
</template>
</v-list-item>
</template>
<v-list class="side-list-menu sub" density="compact" :nav="true">
<v-list-item class="side-item-menu" title="深渊数据库" :link="true" href="/wiki/abyss">
<v-list :nav="true" class="side-list-menu sub" density="compact">
<v-list-item :link="true" class="side-item-menu" href="/wiki/character" title="角色图鉴">
<template #prepend>
<img src="/source/UI/wikiAbyss.webp" alt="abyssIcon" class="side-icon-menu" />
<img alt="characterIcon" class="side-icon-menu" src="/source/UI/wikiAvatar.webp" />
</template>
</v-list-item>
<v-list-item class="side-item-menu" title="角色图鉴" :link="true" href="/wiki/character">
<v-list-item :link="true" class="side-item-menu" href="/wiki/weapon" title="武器图鉴">
<template #prepend>
<img src="/source/UI/wikiAvatar.webp" alt="characterIcon" class="side-icon-menu" />
<img alt="weaponIcon" class="side-icon-menu" src="/source/UI/wikiWeapon.webp" />
</template>
</v-list-item>
<v-list-item class="side-item-menu" title="武器图鉴" :link="true" href="/wiki/weapon">
<template #prepend>
<img src="/source/UI/wikiWeapon.webp" alt="weaponIcon" class="side-icon-menu" />
</template>
</v-list-item>
<v-list-item class="side-item-menu" :link="true" href="/wiki/nameCard">
<v-list-item :link="true" class="side-item-menu" href="/wiki/nameCard">
<template #default>
<v-icon size="20" color="var(--tgc-yellow-2)">mdi-credit-card-outline</v-icon>
<v-icon color="var(--tgc-yellow-2)" size="20">mdi-credit-card-outline</v-icon>
<span style="margin-left: 10px; font-size: 0.8125rem">名片图鉴</span>
</template>
</v-list-item>
<v-list-item class="side-item-menu" title="材料图鉴" :link="true" href="/wiki/material">
<v-list-item :link="true" class="side-item-menu" href="/wiki/material" title="材料图鉴">
<template #prepend>
<img src="/source/UI/wikiGCG.webp" alt="gcgIcon" class="side-icon-menu" />
<img alt="gcgIcon" class="side-icon-menu" src="/source/UI/wikiGCG.webp" />
</template>
</v-list-item>
</v-list>
</v-menu>
<v-list-item :title.attr="'留影叙佳期'" :link="true" href="/archive/birthday">
<v-list-item :link="true" :title.attr="'留影叙佳期'" href="/archive/birthday">
<template #title>留影叙佳期</template>
<template #prepend>
<img src="/source/UI/act_birthday.webp" alt="archive_birthday_icon" class="side-icon" />
<img alt="archive_birthday_icon" class="side-icon" src="/source/UI/act_birthday.webp" />
</template>
</v-list-item>
<!-- 底部菜单 -->
<div class="bottom-menu">
<!-- 用户菜单 -->
<v-menu :open-on-click="true" location="end">
<template #activator="{ props }">
<v-list-item :title.attr="userInfo.nickname" v-bind="props">
@@ -154,69 +151,160 @@
</template>
</v-list-item>
</template>
<v-list class="side-list-menu sub" density="compact" :nav="true">
<v-list-item class="side-item-menu" title="签到" @click="openClient('sign_in')">
<v-list :nav="true" class="side-list-menu sub" density="compact">
<v-list-item
v-if="isLogin"
class="side-item-menu"
title="签到"
@click="openClient('sign_in')"
>
<template #prepend>
<img src="/source/UI/userGacha.webp" class="side-icon-menu" alt="sing_in" />
<img alt="sing_in" class="side-icon-menu" src="/source/UI/userGacha.webp" />
</template>
</v-list-item>
<v-list-item class="side-item-menu" title="战绩" @click="openClient('game_record')">
<v-list-item
v-if="isLogin"
class="side-item-menu"
title="战绩"
@click="openClient('game_record')"
>
<template #prepend>
<img src="/source/UI/userRecord.webp" class="side-icon-menu" alt="game_record" />
<img alt="game_record" class="side-icon-menu" src="/source/UI/userRecord.webp" />
</template>
</v-list-item>
<v-list-item class="side-item-menu" title="便笺" @click="openClient('daily_note')">
<v-list-item
v-if="isLogin"
class="side-item-menu"
title="便笺"
@click="openClient('daily_note')"
>
<template #prepend>
<img src="/icon/material/210.webp" class="side-icon-menu" alt="daily_note" />
<img alt="daily_note" class="side-icon-menu" src="/icon/material/210.webp" />
</template>
</v-list-item>
<v-list-item class="side-item-menu" title="收藏" :link="true" href="/collection">
<v-list-item :link="true" class="side-item-menu" href="/collection" title="收藏">
<template #prepend>
<img src="/source/UI/posts.webp" alt="collect" class="side-icon-menu" />
<img alt="collect" class="side-icon-menu" src="/source/UI/posts.webp" />
</template>
</v-list-item>
<v-list-item class="side-item-menu" title="关注" @click="showFollow = true">
<v-list-item
v-if="isLogin"
class="side-item-menu"
title="关注"
@click="showFollow = true"
>
<template #prepend>
<img src="/platforms/mhy/mys.webp" alt="follow" class="side-icon-menu" />
<img alt="follow" class="side-icon-menu" src="/platforms/mhy/mys.webp" />
</template>
</v-list-item>
<v-list-item
v-if="canLaunch"
class="side-item-menu"
title="启动"
@click="tryLaunchGame()"
>
<template #prepend>
<img alt="genshin" class="side-icon-menu" src="/icon/material/220120.webp" />
</template>
</v-list-item>
</v-list>
</v-menu>
<!-- 添加账号 -->
<v-menu :disabled="isTryLogin" :open-on-click="true" location="end">
<template #activator="{ props }">
<v-list-item :title.attr="'添加账号'" prepend-icon="mdi-account-plus" v-bind="props">
<template #title>添加账号</template>
</v-list-item>
</template>
<v-list :nav="true" class="side-list-menu" density="compact">
<v-list-item class="side-item-menu sub" @click="tryCaptchaLogin()">
<v-list-item-title>验证码登录推荐</v-list-item-title>
<v-list-item-subtitle>使用手机号登录</v-list-item-subtitle>
<template #prepend>
<v-icon class="side-icon-menu">mdi-shield-key-outline</v-icon>
</template>
</v-list-item>
<v-list-item class="side-item-menu sub" @click="tryCodeLogin()">
<v-list-item-title>扫码登录推荐</v-list-item-title>
<v-list-item-subtitle>使用米游社扫码登录</v-list-item-subtitle>
<template #prepend>
<img alt="launcher" class="side-icon-menu" src="/platforms/mhy/mys.webp" />
</template>
</v-list-item>
<v-list-item class="side-item-menu sub" @click="addByCookie()">
<v-list-item-title>手动添加</v-list-item-title>
<v-list-item-subtitle>手动输入Cookie</v-list-item-subtitle>
<template #prepend>
<v-icon class="side-icon-menu">mdi-cookie</v-icon>
</template>
</v-list-item>
</v-list>
</v-menu>
<!-- 切换账号 -->
<v-list-item
v-if="isLogin"
:disabled="isTryLogin"
:title.attr="'切换账号'"
prepend-icon="mdi-account-switch"
@click="trySwitchAccount()"
>
<v-list-item-title>切换账号</v-list-item-title>
</v-list-item>
<!-- 主题切换 -->
<v-list-item
:prepend-icon="theme === 'default' ? 'mdi-weather-night' : 'mdi-weather-sunny'"
:title.attr="themeTitle"
@click="switchTheme()"
:prepend-icon="theme === 'default' ? 'mdi-weather-night' : 'mdi-weather-sunny'"
>
<template #title>{{ themeTitle }}</template>
</v-list-item>
<v-list-item :title.attr="'设置'" value="config" :link="true" href="/config">
<!-- 设置页面 -->
<v-list-item :link="true" :title.attr="'设置'" href="/config" value="config">
<template #title>设置</template>
<template #prepend>
<img src="@/assets/icons/setting.svg" alt="setting" class="side-icon" />
<img alt="setting" class="side-icon" src="@/assets/icons/setting.svg" />
</template>
</v-list-item>
</div>
</v-list>
</v-navigation-drawer>
<vp-overlay-follow v-model="showFollow" />
<ToGameLogin v-model="showLoginQr" :launcher="false" @success="tryGetTokens" />
<ToSwitchAc v-model="showAcSwitch" />
</template>
<script lang="ts" setup>
import showDialog from "@comp/func/dialog.js";
import showGeetest from "@comp/func/geetest.js";
import showLoading from "@comp/func/loading.js";
import showSnackbar from "@comp/func/snackbar.js";
import ToGameLogin from "@comp/pageConfig/tco-gameLogin.vue";
import VpOverlayFollow from "@comp/viewPost/vp-overlay-follow.vue";
import bbsReq from "@req/bbsReq.js";
import passportReq from "@req/passportReq.js";
import takumiReq from "@req/takumiReq.js";
import TSUserAccount from "@Sqlm/userAccount.js";
import useAppStore from "@store/app.js";
import useUserStore from "@store/user.js";
import { event, webviewWindow } from "@tauri-apps/api";
import { event, path, webviewWindow } from "@tauri-apps/api";
import type { Event, UnlistenFn } from "@tauri-apps/api/event";
import { exists } from "@tauri-apps/plugin-fs";
import { Command } from "@tauri-apps/plugin-shell";
import mhyClient from "@utils/TGClient.js";
import TGLogger from "@utils/TGLogger.js";
import { storeToRefs } from "pinia";
import { computed, onMounted, onUnmounted, ref } from "vue";
const { sidebar, theme, isLogin, recentNewsType } = storeToRefs(useAppStore());
const { briefInfo } = storeToRefs(useUserStore());
import ToSwitchAc from "./to-switchAc.vue";
const { sidebar, theme, isLogin, recentNewsType, gameDir } = storeToRefs(useAppStore());
const { uid, briefInfo, cookie, account } = storeToRefs(useUserStore());
let themeListener: UnlistenFn | null = null;
// @ts-expect-error The import.meta meta-property is not allowed in files which will build into CommonJS output.
const isDevEnv = import.meta.env.MODE === "development";
const showFollow = ref<boolean>();
const showLoginQr = ref<boolean>(false);
const showAcSwitch = ref<boolean>(false);
const isTryLogin = ref<boolean>(false);
const rail = computed<boolean>({
get: () => sidebar.value.collapse,
set: (v) => (sidebar.value.collapse = v),
@@ -226,6 +314,11 @@ const userInfo = computed<TGApp.App.Account.BriefInfo>(() => {
return { nickname: "未登录", uid: "-1", desc: "请扫码登录", avatar: "/source/UI/lumine.webp" };
});
const themeTitle = computed<string>(() => (theme.value === "default" ? "深色模式" : "浅色模式"));
const canLaunch = computed<boolean>(() => {
if (!isLogin.value) return false;
if (!gameDir.value || gameDir.value === "未设置") return false;
return account.value.isOfficial === 1;
});
onMounted(async () => {
themeListener = await event.listen<string>("readTheme", (e: Event<string>) => {
@@ -234,6 +327,17 @@ onMounted(async () => {
if (webviewWindow.getCurrentWebviewWindow().label === "TeyvatGuide") await mhyClient.run();
});
onUnmounted(() => {
if (themeListener !== null) {
themeListener();
themeListener = null;
}
});
async function trySwitchAccount(): Promise<void> {
showAcSwitch.value = true;
}
async function switchTheme(): Promise<void> {
await event.emit("readTheme", theme.value === "default" ? "dark" : "default");
}
@@ -243,12 +347,341 @@ async function openClient(func: string): Promise<void> {
else showSnackbar.warn("请前往设置页面登录!");
}
onUnmounted(() => {
if (themeListener !== null) {
themeListener();
themeListener = null;
async function tryGetTokens(ck: TGApp.App.Account.Cookie): Promise<void> {
await showLoading.update("正在获取 LToken");
const ltokenRes = await passportReq.lToken.get(ck);
if (typeof ltokenRes !== "string") {
await showLoading.end();
showSnackbar.error(`[${ltokenRes.retcode}]${ltokenRes.message}`);
await TGLogger.Error(`获取LToken失败${ltokenRes.retcode}-${ltokenRes.message}`);
isTryLogin.value = false;
return;
}
});
showSnackbar.success("获取LToken成功");
ck.ltoken = ltokenRes;
await showLoading.update("正在获取 CookieToken");
const cookieTokenRes = await passportReq.cookieToken(ck);
if (typeof cookieTokenRes !== "string") {
await showLoading.end();
showSnackbar.error(`[${cookieTokenRes.retcode}]${cookieTokenRes.message}`);
await TGLogger.Error(
`获取CookieToken失败${cookieTokenRes.retcode}-${cookieTokenRes.message}`,
);
isTryLogin.value = false;
return;
}
showSnackbar.success("获取CookieToken成功");
ck.cookie_token = cookieTokenRes;
await showLoading.update("正在获取用户信息");
const briefRes = await bbsReq.userInfo(ck);
if ("retcode" in briefRes) {
await showLoading.end();
showSnackbar.error(`[${briefRes.retcode}]${briefRes.message}`);
await TGLogger.Error(`获取用户数据失败:${briefRes.retcode}-${briefRes.message}`);
isTryLogin.value = false;
return;
}
showSnackbar.success("获取用户信息成功");
const briefInfoGet: TGApp.App.Account.BriefInfo = {
nickname: briefRes.nickname,
uid: briefRes.uid,
avatar: briefRes.avatar_url,
desc: briefRes.introduce,
};
await showLoading.update("正在保存用户数据");
await TSUserAccount.account.saveAccount({
uid: briefInfoGet.uid,
cookie: ck,
brief: briefInfoGet,
updated: "",
});
uid.value = briefInfoGet.uid;
briefInfo.value = briefInfoGet;
cookie.value = ck;
isLogin.value = true;
await showLoading.update("正在获取游戏账号");
const gameRes = await takumiReq.bind.gameRoles(cookie.value);
if (!Array.isArray(gameRes)) {
await showLoading.end();
showSnackbar.error(`[${gameRes.retcode}]${gameRes.message}`);
await TGLogger.Error(`获取游戏账号失败:${gameRes.retcode}-${gameRes.message}`);
isTryLogin.value = false;
return;
}
showSnackbar.success("获取游戏账号成功");
await TSUserAccount.game.saveAccounts(briefInfoGet.uid, gameRes);
const curAccount = await TSUserAccount.game.getCurAccount(briefInfoGet.uid);
if (!curAccount) {
showSnackbar.warn("未检测到游戏账号,请重新刷新");
await showLoading.end();
isTryLogin.value = false;
return;
}
account.value = curAccount;
await showLoading.end();
showSnackbar.success("成功登录!");
isTryLogin.value = false;
}
/**
* 验证码登录
* @since Beta v0.8.7
* @returns {Promise<void>}
*/
async function tryCaptchaLogin(): Promise<void> {
if (showLoginQr.value) showLoginQr.value = false;
isTryLogin.value = true;
const phone = await showDialog.input("请输入手机号", "+86");
if (!phone) {
showSnackbar.cancel("已取消验证码登录");
isTryLogin.value = false;
return;
}
const phoneReg = /^1[3-9]\d{9}$/;
if (!phoneReg.test(phone)) {
showSnackbar.warn("请输入正确的手机号");
isTryLogin.value = false;
return;
}
const actionType = await tryGetCaptcha(phone);
if (!actionType) {
showSnackbar.warn("获取验证码失败");
isTryLogin.value = false;
return;
}
showSnackbar.success(`已发送验证码到 ${phone}`);
const captcha = await showDialog.input("请输入验证码", "验证码:", undefined, false);
if (!captcha) {
showSnackbar.warn("输入验证码为空");
isTryLogin.value = false;
return;
}
const loginResp = await tryLoginByCaptcha(phone, captcha, actionType);
if (!loginResp) {
showSnackbar.warn("验证码登录失败");
isTryLogin.value = false;
return;
}
await showLoading.start("正在尝试登录...");
const ck: TGApp.App.Account.Cookie = {
account_id: loginResp.user_info.aid,
ltuid: loginResp.user_info.aid,
stuid: loginResp.user_info.aid,
mid: loginResp.user_info.mid,
cookie_token: "",
stoken: loginResp.token.token,
ltoken: "",
};
await tryGetTokens(ck);
}
/**
* 扫码登录
* @since Beta v0.8.7
* @returns {Promise<void>}
*/
async function tryCodeLogin(): Promise<void> {
showLoginQr.value = true;
}
/**
* 尝试获取验证码
* @since Beta v0.8.7
* @param {string} phone 手机号
* @param {string} [aigis] aegis参数
* @returns {Promise<string | null>} 返回 action_type 或 null
*/
async function tryGetCaptcha(phone: string, aigis?: string): Promise<string | false> {
const captchaResp = await passportReq.captcha.create(phone, aigis);
if ("retcode" in captchaResp) {
if (!captchaResp.data || captchaResp.data === "") {
showSnackbar.error(`[${captchaResp.retcode}] ${captchaResp.message}`);
await TGLogger.Error(
`[tc-userBadge][tryGetCaptcha] ${captchaResp.retcode} ${captchaResp.message}`,
);
return false;
}
const aigisResp: TGApp.BBS.CaptchaLogin.CaptchaAigis = JSON.parse(captchaResp.data);
const resp = await showGeetest(JSON.parse(aigisResp.data), aigisResp);
const aigisStr = `${aigisResp.session_id};${btoa(JSON.stringify(resp))}`;
return await tryGetCaptcha(phone, aigisStr);
}
return captchaResp.action_type;
}
/**
* 尝试通过验证码登录
* @since Beta v0.8.7
* @param {string} phone 手机号
* @param {string} captcha 验证码
* @param {string} actionType action_type
* @param {string} [aigis] aegis参数
* @returns {Promise<TGApp.BBS.CaptchaLogin.LoginResp | null>} 返回登录响应或 null
*/
async function tryLoginByCaptcha(
phone: string,
captcha: string,
actionType: string,
aigis?: string,
): Promise<TGApp.BBS.CaptchaLogin.LoginRes | false> {
const loginResp = await passportReq.captcha.login(phone, captcha, actionType, aigis);
if ("retcode" in loginResp) {
if (!loginResp.data || loginResp.data === "") {
showSnackbar.error(`[${loginResp.retcode}] ${loginResp.message}`);
await TGLogger.Error(
`[tc-userBadge][tryLoginByCaptcha] ${loginResp.retcode} ${loginResp.message}`,
);
return false;
}
const aigisResp: TGApp.BBS.CaptchaLogin.CaptchaAigis = JSON.parse(loginResp.data);
const resp = await showGeetest(JSON.parse(aigisResp.data));
const aigisStr = `${aigisResp.session_id};${btoa(JSON.stringify(resp))}`;
return await tryLoginByCaptcha(phone, captcha, actionType, aigisStr);
}
return loginResp;
}
/**
* 通过ck添加账号
* @since Beta v0.8.7
* @returns {Promise<void>}
*/
async function addByCookie(): Promise<void> {
if (showLoginQr.value) showLoginQr.value = false;
isTryLogin.value = true;
const ckInput = await showDialog.input("请输入Cookie", "Cookie:");
if (!ckInput) {
showSnackbar.cancel("已取消Cookie输入");
isTryLogin.value = false;
return;
}
if (ckInput === "") {
showSnackbar.warn("请输入Cookie!");
isTryLogin.value = false;
return;
}
const ckArr = ckInput.split(";");
let ckRes = { stoken: "", stuid: "", mid: "" };
for (const ck of ckArr) {
if (ck.startsWith("mid=")) ckRes.mid = ck.substring(4);
else if (ck.startsWith("stoken=")) ckRes.stoken = ck.substring(7);
else if (ck.startsWith("stuid=")) ckRes.stuid = ck.substring(6);
}
if (ckRes.mid === "" || ckRes.stoken === "" || ckRes.stuid === "") {
showSnackbar.warn("Cookie格式错误");
await TGLogger.Error(`解析Cookie失败${ckInput}`);
isTryLogin.value = false;
return;
}
await showLoading.start("正在添加用户", "正在尝试刷新Cookie");
const ck: TGApp.App.Account.Cookie = {
account_id: ckRes.stuid,
ltuid: ckRes.stuid,
stuid: ckRes.stuid,
mid: ckRes.mid,
cookie_token: "",
stoken: ckRes.stoken,
ltoken: "",
};
await showLoading.update("正在获取 LToken");
const ltokenRes = await passportReq.lToken.get(ck);
if (typeof ltokenRes !== "string") {
await showLoading.end();
showSnackbar.error(`[${ltokenRes.retcode}]${ltokenRes.message}`);
await TGLogger.Error(`获取LToken失败${ltokenRes.retcode}-${ltokenRes.message}`);
isTryLogin.value = false;
return;
}
ck.ltoken = ltokenRes;
await showLoading.update("正在获取 CookieToken");
const cookieTokenRes = await passportReq.cookieToken(ck);
if (typeof cookieTokenRes !== "string") {
await showLoading.end();
showSnackbar.error(`[${cookieTokenRes.retcode}]${cookieTokenRes.message}`);
await TGLogger.Error(
`获取CookieToken失败${cookieTokenRes.retcode}-${cookieTokenRes.message}`,
);
isTryLogin.value = false;
return;
}
ck.cookie_token = cookieTokenRes;
await showLoading.update("正在获取用户信息");
const briefRes = await bbsReq.userInfo(ck);
if ("retcode" in briefRes) {
await showLoading.end();
showSnackbar.error(`[${briefRes.retcode}]${briefRes.message}`);
await TGLogger.Error(`获取用户数据失败:${briefRes.retcode}-${briefRes.message}`);
isTryLogin.value = false;
return;
}
const briefInfo: TGApp.App.Account.BriefInfo = {
nickname: briefRes.nickname,
uid: briefRes.uid,
avatar: briefRes.avatar_url,
desc: briefRes.introduce,
};
await showLoading.update("正在保存用户数据");
await TSUserAccount.account.saveAccount({
uid: briefInfo.uid,
cookie: ck,
brief: briefInfo,
updated: "",
});
await showLoading.update("正在获取游戏账号");
const gameRes = await takumiReq.bind.gameRoles(ck);
if (!Array.isArray(gameRes)) {
await showLoading.end();
showSnackbar.error(`[${gameRes.retcode}]${gameRes.message}`);
isTryLogin.value = false;
return;
}
await showLoading.update("正在保存游戏账号");
await TSUserAccount.game.saveAccounts(briefInfo.uid, gameRes);
const curAccount = await TSUserAccount.game.getCurAccount(briefInfo.uid);
if (!curAccount) {
await showLoading.end();
showSnackbar.warn("未检测到游戏账号,请重新刷新");
isTryLogin.value = false;
return;
}
await showLoading.end();
showSnackbar.success("成功添加用户!");
isTryLogin.value = false;
}
/**
* 尝试启动游戏
*/
async function tryLaunchGame(): Promise<void> {
if (!uid.value || !cookie.value) {
showSnackbar.warn("请先登录!");
return;
}
const gamePath = `${gameDir.value}${path.sep()}YuanShen.exe`;
if (!(await exists(gamePath))) {
showSnackbar.warn("未检测到原神本体应用!");
return;
}
const resp = await passportReq.authTicket(account.value, cookie.value);
if (typeof resp !== "string") {
showSnackbar.error(`[${resp.retcode}] ${resp.message}`);
await TGLogger.Error(
`[sidebar][tryLaunchGame] 尝试获取authTicket失败当前用户${account.value.uid}-${account.value.gameUid}`,
);
await TGLogger.Error(`[sidebar][tryLaunchGame] resp: ${JSON.stringify(resp)}`);
return;
}
showSnackbar.success(`成功获取ticket:${resp},正在启动应用...`);
const cmd = Command.create("exec-sh", [`&"${gamePath}" login_auth_ticket=${resp}`], {
cwd: gameDir.value,
encoding: "utf-8",
});
const result = await cmd.execute();
if (result.stderr) {
await TGLogger.Error(`[sidebar][tryLaunchGame] 启动游戏本体失败!`);
showSnackbar.error(`启动游戏本体失败,代码:${result.code}`);
}
}
</script>
<style lang="css" scoped>
.tsb-box {
@@ -286,6 +719,10 @@ onUnmounted(() => {
background: var(--app-side-bg) !important;
color: var(--app-side-content) !important;
font-family: var(--font-title);
:deep(.v-list-item__spacer) {
width: 0 !important;
}
}
.side-list-menu.sub {

View File

@@ -1,3 +1,4 @@
<!-- 兑换码浮窗组件 -->
<template>
<TOverlay v-model="visible" class="tolc-overlay">
<div class="tolc-box">
@@ -34,6 +35,7 @@
/>
</div>
</div>
<div v-if="props.data.length === 0">暂无兑换码数据</div>
</div>
</TOverlay>
</template>
@@ -48,9 +50,15 @@ import { computed } from "vue";
import TMiImg from "./t-mi-img.vue";
import TOverlay from "./t-overlay.vue";
/**
* 兑换码浮窗组件参数
*/
type ToLiveCodeProps = {
/* 兑换码数据 */
data: Array<TGApp.BBS.Navigator.CodeData>;
/* 前瞻活动ID */
actId: string | undefined;
/* 分区ID */
gid: number;
};
@@ -58,23 +66,32 @@ const { gameList } = storeToRefs(useBBSStore());
const props = defineProps<ToLiveCodeProps>();
const visible = defineModel<boolean>({ default: false });
const gameInfo = computed<TGApp.BBS.Game.Item | undefined>(() => {
return gameList.value.find((i) => i.id === props.gid);
});
const gameInfo = computed<TGApp.BBS.Game.Item | undefined>(() =>
gameList.value.find((i) => i.id === props.gid),
);
function copy(code: string): void {
navigator.clipboard.writeText(code);
/**
* 复制兑换码
* @param {string} code 兑换码
* @returns {Promise<void>}
*/
async function copy(code: string): Promise<void> {
await navigator.clipboard.writeText(code);
showSnackbar.success("已复制到剪贴板");
}
/**
* 生成分享图片
* @returns {Promise<void>}
*/
async function shareImg(): Promise<void> {
const element = document.querySelector<HTMLElement>(".tolc-box");
if (element === null) {
showSnackbar.error("未获取到分享内容");
showSnackbar.warn("未获取到分享内容");
return;
}
const fileName = `LiveCode_${props.actId}_${new Date().getTime()}`;
await generateShareImg(fileName, element, 2);
const fileName = `LiveCode_${props.gid}_${props.actId}_${new Date().getTime()}`;
await generateShareImg(fileName, element, 4);
}
</script>
<style lang="css" scoped>

View File

@@ -1,13 +1,14 @@
<!-- 名片详情浮窗 -->
<template>
<TOverlay v-model="visible" v-if="props.data">
<TOverlay v-if="props.data" v-model="visible">
<div class="ton-container">
<slot name="left"></slot>
<div class="ton-box">
<img
alt="bg"
class="ton-bg"
v-if="props.data"
:src="`/WIKI/nameCard/profile/${props.data.name}.webp`"
alt="bg"
class="ton-bg"
/>
<div class="ton-content">
<span>{{ props.data.name }}</span>
@@ -17,11 +18,11 @@
<TwnTypeTag :type="props.data.type" class="ton-type" />
<div class="ton-sign">ID:{{ props.data.id }} | TeyvatGuide v{{ version }}</div>
<v-btn
class="ton-share"
@click="shareNameCard"
variant="outlined"
:loading="loading"
class="ton-share"
data-html2canvas-ignore
variant="outlined"
@click="shareNameCard"
>
<v-icon>mdi-share-variant</v-icon>
<span>分享</span>
@@ -31,7 +32,7 @@
</div>
</TOverlay>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import showSnackbar from "@comp/func/snackbar.js";
import TwnTypeTag from "@comp/pageWiki/twn-type-tag.vue";
import { getVersion } from "@tauri-apps/api/app";
@@ -83,6 +84,7 @@ function parseNameCard(desc: string): string {
function parseDesc(desc: string, inQuote: boolean = false): string[] {
let res = desc.replace(/。/g, "。\n");
res = res.replace(//g, "\n");
/* 闲云·鹤云 */
if (props?.data?.id !== 210187) {
res = res.replace(//g, "\n");
res = res.replace(//g, "\n");
@@ -93,12 +95,23 @@ function parseDesc(desc: string, inQuote: boolean = false): string[] {
if (!desc.includes("!」")) res = res.replace(//g, "\n");
res = res.replace(/…/g, "…\n");
res = res.replace(/…\n…/g, "……\n");
/* 瓦雷莎·力源 */
if (props?.data?.id === 210236) res = res.replace(/…\n/g, "…");
/* 伊安珊·不懈 */
if (props?.data?.id === 210237) {
res = res.replace(/…\n/g, "…\n");
res = res.replace(/」/g, "」\n");
}
if (props?.data?.id === 210254) res = res.replace(/\n」/g, "」\n");
if (
/* 菲林斯·誓灯 */
props?.data?.id === 210254 ||
/* 杜林·曜心 */
props?.data?.id === 210263 ||
/* 雅珂达·帮手 */
props?.data?.id === 210264
) {
res = res.replace(/\n」/g, "」\n");
}
const match = res.split("\n");
let array: string[] = [];
for (const item of match) {

View File

@@ -0,0 +1,198 @@
<!-- 切换账号浮窗 -->
<template>
<TOverlay v-model="visible">
<div class="to-sac-box">
<div class="tsb-title">账号管理</div>
<v-list variant="text" class="tsb-list" v-model:opened="openList">
<v-list-group v-for="item in ac" :key="item.user.uid" :title.attr="'点击展开/收起'">
<template v-slot:activator="{ props }">
<v-list-item v-bind="props">
<template #title>{{ item.user.brief.nickname }}[{{ item.user.uid }}]</template>
<template #subtitle>{{ item.user.brief.desc }}</template>
<template #prepend>
<img class="tsb-avatar" :src="item.user.brief.avatar" alt="userIcon" />
</template>
<template #append>
<v-checkbox-btn
:model-value="item.user.uid === curUid"
@click="trySelect(item.user)"
readonly
/>
</template>
</v-list-item>
</template>
<v-list-item
density="compact"
v-for="(game, idx) in item.gameAc"
:key="idx"
:value="game.gameUid"
class="tsb-list-game"
@click="trySelect(item.user, game)"
>
<template #title>{{ game.nickname }}</template>
<template #subtitle>{{ game.gameUid }}({{ game.regionName }})</template>
<template #append>
<!-- select -->
<v-checkbox-btn :model-value="game.gameUid === curGameUid" readonly />
</template>
</v-list-item>
</v-list-group>
</v-list>
<v-btn class="tsb-confirm" @click="tryConfirm()">确认</v-btn>
</div>
</TOverlay>
</template>
<script lang="ts" setup>
import TOverlay from "@comp/app/t-overlay.vue";
import showSnackbar from "@comp/func/snackbar.js";
import TSUserAccount from "@Sqlm/userAccount.js";
import useUserStore from "@store/user.js";
import TGLogger from "@utils/TGLogger.js";
import { storeToRefs } from "pinia";
import { shallowRef, ref, watch } from "vue";
type TsaAcItem = { user: TGApp.App.Account.User; gameAc: Array<TGApp.Sqlite.Account.Game> };
const userStore = useUserStore();
const { uid, account, briefInfo, cookie } = storeToRefs(userStore);
const visible = defineModel<boolean>();
const ac = shallowRef<Array<TsaAcItem>>([]);
const openList = ref<Array<string>>([]);
const curUid = ref<string>();
const curGameUid = ref<string>();
watch(
() => visible.value,
async (value) => {
if (value) {
await loadAccounts();
}
},
);
async function loadAccounts(): Promise<void> {
const uidList = await TSUserAccount.account.getAllAccount();
const tmp: Array<TsaAcItem> = [];
for (const item of uidList) {
let accounts = await TSUserAccount.game.getAccount(item.uid);
accounts = accounts.filter((a) => a.gameBiz === "hk4e_cn");
tmp.push({ user: item, gameAc: accounts });
}
ac.value = tmp;
curUid.value = uid.value ?? "";
curGameUid.value = account.value.gameUid;
openList.value = [];
}
function trySelect(user: TGApp.App.Account.User, game?: TGApp.Sqlite.Account.Game): void {
if (game) {
curUid.value = user.uid;
curGameUid.value = game.gameUid;
} else if (curUid.value !== user.uid) {
curUid.value = user.uid;
const firstGame = ac.value.find((u) => u.user.uid === user.uid)?.gameAc?.[0];
curGameUid.value = firstGame ? firstGame.gameUid : "";
}
}
async function tryConfirm(): Promise<void> {
if (!curUid.value) {
showSnackbar.warn("请选择要切换的账号");
return;
}
if (curUid.value === uid.value && curGameUid.value === account.value.gameUid) {
showSnackbar.warn("无需切换当前账号");
return;
}
const acFind = ac.value.find((u) => u.user.uid === curUid.value);
if (!acFind) {
showSnackbar.error("未找到对应用户信息,请重试");
return;
}
const game = acFind.gameAc.find((g) => g.gameUid === curGameUid.value);
uid.value = acFind.user.uid;
briefInfo.value = acFind.user.brief;
cookie.value = acFind.user.cookie;
if (!game) {
showSnackbar.error("未找到对应游戏账号信息,请重试");
return;
} else {
account.value = game;
await userStore.switchGameAccount(game.gameUid);
showSnackbar.success(`成功切换到用户${uid.value}的游戏UID${game.gameUid}`);
await TGLogger.Info(`[ToSwitchAc] 切换到用户${uid.value}的游戏UID${game.gameUid}成功`);
visible.value = false;
}
}
</script>
<style lang="scss" scoped>
.to-sac-box {
display: flex;
width: 400px;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px;
border-radius: 4px;
background: var(--app-page-bg);
row-gap: 12px;
}
.tsb-title {
color: var(--common-text-title);
font-family: var(--font-title);
font-size: 18px;
}
.tsb-list {
position: relative;
display: flex;
width: 100%;
height: fit-content;
max-height: 400px;
flex: 1;
flex-direction: column;
background: var(--box-bg-1);
overflow-y: auto;
}
.tsb-list-item {
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--border-color);
}
.tsb-avatar {
width: 32px;
height: 32px;
margin-right: 4px;
}
.tsb-list-user {
padding: 12px 16px;
cursor: pointer;
&:hover {
background-color: var(--hover-bg);
}
}
.tsb-list-user.active {
background-color: var(--active-bg);
font-weight: bold;
}
.tsb-list-game {
cursor: pointer;
}
.tsb-confirm {
height: 40px;
border-radius: 3px;
margin-left: auto;
background: var(--tgc-btn-1);
color: var(--btn-text);
font-family: var(--font-title);
}
</style>

View File

@@ -1,7 +1,6 @@
/**
* @file component/func/geetest.ts
* @description 封装自定义 geetest 组件,通过函数调用的方式,简化 geetest 的使用
* @since Beta v0.7.1
* 极验验证组件封装
* @since Beta v0.8.7
*/
import type { ComponentInternalInstance, VNode } from "vue";
@@ -12,8 +11,8 @@ import geetest from "./geetest.vue";
const geetestId = "tg-func-geetest";
/**
* @description 自定义 geetest 组件
* @since Beta v0.5.1
* 自定义 geetest 组件
* @since Beta v0.8.7
* @extends ComponentInternalInstance
* @property {Function} exposeProxy.displayBox 弹出 geetest 验证
* @return GeetestInstance
@@ -22,6 +21,7 @@ interface GeetestInstance extends ComponentInternalInstance {
exposeProxy: {
displayBox: (
props: TGApp.BBS.Geetest.CreateRes,
raw?: TGApp.BBS.CaptchaLogin.CaptchaAigis,
) => Promise<TGApp.BBS.Geetest.GeetestVerifyRes | false>;
};
}
@@ -38,21 +38,22 @@ function renderBox(props: TGApp.BBS.Geetest.CreateRes): VNode {
let geetestInstance: VNode;
/**
* @function showGeetest
* @since Beta v0.7.1
* @description 弹出 geetest 验证
* 弹出 geetest 验证
* @since Beta v0.8.7
* @param {TGApp.BBS.Geetest.CreateRes} props geetest 验证的参数
* @param {TGApp.BBS.CaptchaLogin.CaptchaAigis} raw 原始数据,一般用于 Gt4 验证
* @return {Promise<TGApp.BBS.Geetest.GeetestVerifyRes|false>} 验证成功返回验证数据
*/
async function showGeetest(
props: TGApp.BBS.Geetest.CreateRes,
raw?: TGApp.BBS.CaptchaLogin.CaptchaAigis,
): Promise<TGApp.BBS.Geetest.GeetestVerifyRes | false> {
if (geetestInstance !== undefined) {
const boxVue = <GeetestInstance>geetestInstance.component;
return boxVue.exposeProxy.displayBox(props);
return boxVue.exposeProxy.displayBox(props, raw);
} else {
geetestInstance = renderBox(props);
return await showGeetest(props);
return await showGeetest(props, raw);
}
}

View File

@@ -16,6 +16,7 @@
</template>
<script setup lang="ts">
import "https://static.geetest.com/static/js/gt.0.4.9.js";
import "https://static.geetest.com/v4/gt4.js";
import { ref, useTemplateRef, watch } from "vue";
const show = ref<boolean>(false);
@@ -45,19 +46,53 @@ declare function initGeetest(
callback: (captchaObj: TGApp.BBS.Geetest.GeetestCaptcha) => void,
): void;
declare function initGeetest4(
params: TGApp.BBS.Geetest.InitGeetest4Params,
callback: (captchaObj: TGApp.BBS.Geetest.GeetestCaptcha) => void,
): void;
async function displayBox(
props: TGApp.BBS.Geetest.CreateRes,
raw?: TGApp.BBS.CaptchaLogin.CaptchaAigis,
): Promise<TGApp.BBS.Geetest.GeetestVerifyRes | false> {
if ("challenge" in props) {
return await new Promise<TGApp.BBS.Geetest.GeetestVerifyRes | false>((resolve) => {
initGeetest(
{
gt: props.gt,
challenge: props.challenge,
offline: false,
new_captcha: true,
product: "custom",
area: "#verify",
width: "250px",
https: true,
},
(captchaObj: TGApp.BBS.Geetest.GeetestCaptcha) => {
if (geetestEl.value === null) return;
geetestEl.value.innerHTML = "";
captchaObj.appendTo("#geetest");
captchaObj.onReady(() => (show.value = true));
captchaObj.onClose(() => {
const validate = captchaObj.getValidate();
show.value = false;
if (!validate) resolve(false);
resolve(validate);
});
},
);
});
}
return await new Promise<TGApp.BBS.Geetest.GeetestVerifyRes | false>((resolve) => {
initGeetest(
initGeetest4(
{
gt: props.gt,
challenge: props.challenge,
offline: false,
new_captcha: true,
product: "custom",
area: "#verify",
width: "250px",
captchaId: props.gt,
riskType: props.risk_type,
product: "popup",
nextWidth: "250px",
lang: "zho",
userInfo: JSON.stringify({ session_id: raw?.session_id }),
protocol: "https",
},
(captchaObj: TGApp.BBS.Geetest.GeetestCaptcha) => {
if (geetestEl.value === null) return;
@@ -65,6 +100,10 @@ async function displayBox(
captchaObj.appendTo("#geetest");
captchaObj.onReady(() => (show.value = true));
captchaObj.onClose(() => {
show.value = false;
resolve(false);
});
captchaObj.onSuccess(() => {
const validate = captchaObj.getValidate();
show.value = false;
if (!validate) resolve(false);

View File

@@ -38,7 +38,7 @@ async function toRelease(): Promise<void> {
}
async function toGroup(): Promise<void> {
await openUrl("https://h5.qun.qq.com/s/3cgX0hJ4GA");
await openUrl("https://qm.qq.com/q/hUxIfSWluo");
}
async function toGithub(): Promise<void> {

View File

@@ -17,7 +17,7 @@
</template>
<script lang="ts" setup>
import showSnackbar from "@comp/func/snackbar.js";
import PassportApi from "@req/passportReq.js";
import passportReq from "@req/passportReq.js";
import useAppStore from "@store/app.js";
import useUserStore from "@store/user.js";
import { path } from "@tauri-apps/api";
@@ -47,7 +47,7 @@ async function tryPlayGame(): Promise<void> {
showSnackbar.warn("未检测到原神本体应用!");
return;
}
const resp = await PassportApi.authTicket(account.value, cookie.value);
const resp = await passportReq.authTicket(account.value, cookie.value);
if (typeof resp !== "string") {
showSnackbar.error(`[${resp.retcode}] ${resp.message}`);
await TGLogger.Error(

View File

@@ -125,8 +125,7 @@ import showGeetest from "@comp/func/geetest.js";
import showLoading from "@comp/func/loading.js";
import showSnackbar from "@comp/func/snackbar.js";
import ToGameLogin from "@comp/pageConfig/tco-gameLogin.vue";
import BBSApi from "@req/bbsReq.js";
import PassportApi from "@req/passportReq.js";
import bbsReq from "@req/bbsReq.js";
import passportReq from "@req/passportReq.js";
import takumiReq from "@req/takumiReq.js";
import TSUserAccount from "@Sqlm/userAccount.js";
@@ -155,7 +154,7 @@ const userInfo = computed<TGApp.App.Account.BriefInfo>(() => {
async function tryGetTokens(ck: TGApp.App.Account.Cookie): Promise<void> {
await showLoading.update("正在获取 LToken");
const ltokenRes = await PassportApi.lToken.get(ck);
const ltokenRes = await passportReq.lToken.get(ck);
if (typeof ltokenRes !== "string") {
await showLoading.end();
showSnackbar.error(`[${ltokenRes.retcode}]${ltokenRes.message}`);
@@ -165,7 +164,7 @@ async function tryGetTokens(ck: TGApp.App.Account.Cookie): Promise<void> {
showSnackbar.success("获取LToken成功");
ck.ltoken = ltokenRes;
await showLoading.update("正在获取 CookieToken");
const cookieTokenRes = await PassportApi.cookieToken(ck);
const cookieTokenRes = await passportReq.cookieToken(ck);
if (typeof cookieTokenRes !== "string") {
await showLoading.end();
showSnackbar.error(`[${cookieTokenRes.retcode}]${cookieTokenRes.message}`);
@@ -177,7 +176,7 @@ async function tryGetTokens(ck: TGApp.App.Account.Cookie): Promise<void> {
showSnackbar.success("获取CookieToken成功");
ck.cookie_token = cookieTokenRes;
await showLoading.update("正在获取用户信息");
const briefRes = await BBSApi.userInfo(ck);
const briefRes = await bbsReq.userInfo(ck);
if ("retcode" in briefRes) {
await showLoading.end();
showSnackbar.error(`[${briefRes.retcode}]${briefRes.message}`);
@@ -270,7 +269,7 @@ async function refreshUser(uid: string) {
}
let ck = account.cookie;
await showLoading.start("正在刷新用户信息", "正在验证 LToken");
const verifyLTokenRes = await PassportApi.lToken.verify(ck);
const verifyLTokenRes = await passportReq.lToken.verify(ck);
if (typeof verifyLTokenRes === "string") {
await showLoading.update("验证 LToken 成功");
showSnackbar.success("验证 LToken 成功");
@@ -282,7 +281,7 @@ async function refreshUser(uid: string) {
await TGLogger.Warn(
`[tc-userBadge][refreshUser] ${verifyLTokenRes.retcode}: ${verifyLTokenRes.message}`,
);
const ltokenRes = await PassportApi.lToken.get(ck);
const ltokenRes = await passportReq.lToken.get(ck);
if (typeof ltokenRes === "string") {
await showLoading.update("获取 LToken 成功");
ck.ltoken = ltokenRes;
@@ -297,7 +296,7 @@ async function refreshUser(uid: string) {
}
}
await showLoading.update("正在获取 CookieToken");
const cookieTokenRes = await PassportApi.cookieToken(ck);
const cookieTokenRes = await passportReq.cookieToken(ck);
if (typeof cookieTokenRes === "string") {
await showLoading.update("获取 CookieToken 成功");
ck.cookie_token = cookieTokenRes;
@@ -312,7 +311,7 @@ async function refreshUser(uid: string) {
}
account.cookie = ck;
await showLoading.update("正在获取用户信息");
const infoRes = await BBSApi.userInfo(ck);
const infoRes = await bbsReq.userInfo(ck);
if ("retcode" in infoRes) {
await showLoading.update("获取用户信息失败");
showSnackbar.error(`[${infoRes.retcode}]${infoRes.message}`);
@@ -404,10 +403,13 @@ async function tryGetCaptcha(phone: string, aigis?: string): Promise<string | fa
if ("retcode" in captchaResp) {
if (!captchaResp.data || captchaResp.data === "") {
showSnackbar.error(`[${captchaResp.retcode}] ${captchaResp.message}`);
await TGLogger.Error(
`[tc-userBadge][tryGetCaptcha] ${captchaResp.retcode} ${captchaResp.message}`,
);
return false;
}
const aigisResp: TGApp.BBS.CaptchaLogin.CaptchaAigis = JSON.parse(captchaResp.data);
const resp = await showGeetest(JSON.parse(aigisResp.data));
const resp = await showGeetest(JSON.parse(aigisResp.data), aigisResp);
const aigisStr = `${aigisResp.session_id};${btoa(JSON.stringify(resp))}`;
return await tryGetCaptcha(phone, aigisStr);
}
@@ -424,6 +426,9 @@ async function tryLoginByCaptcha(
if ("retcode" in loginResp) {
if (!loginResp.data || loginResp.data === "") {
showSnackbar.error(`[${loginResp.retcode}] ${loginResp.message}`);
await TGLogger.Error(
`[tc-userBadge][tryLoginByCaptcha] ${loginResp.retcode} ${loginResp.message}`,
);
return false;
}
const aigisResp: TGApp.BBS.CaptchaLogin.CaptchaAigis = JSON.parse(loginResp.data);
@@ -484,7 +489,7 @@ async function addByCookie(): Promise<void> {
ltoken: "",
};
await showLoading.update("正在获取 LToken");
const ltokenRes = await PassportApi.lToken.get(ck);
const ltokenRes = await passportReq.lToken.get(ck);
if (typeof ltokenRes !== "string") {
await showLoading.end();
showSnackbar.error(`[${ltokenRes.retcode}]${ltokenRes.message}`);
@@ -493,7 +498,7 @@ async function addByCookie(): Promise<void> {
}
ck.ltoken = ltokenRes;
await showLoading.update("正在获取 CookieToken");
const cookieTokenRes = await PassportApi.cookieToken(ck);
const cookieTokenRes = await passportReq.cookieToken(ck);
if (typeof cookieTokenRes !== "string") {
await showLoading.end();
showSnackbar.error(`[${cookieTokenRes.retcode}]${cookieTokenRes.message}`);
@@ -504,7 +509,7 @@ async function addByCookie(): Promise<void> {
}
ck.cookie_token = cookieTokenRes;
await showLoading.update("正在获取用户信息");
const briefRes = await BBSApi.userInfo(ck);
const briefRes = await bbsReq.userInfo(ck);
if ("retcode" in briefRes) {
await showLoading.end();
showSnackbar.error(`[${briefRes.retcode}]${briefRes.message}`);

View File

@@ -3,7 +3,9 @@
<div class="tucfi-label">
<slot name="label">{{ props.label }}</slot>
</div>
<div v-if="props.data === null"><span class="tucfi-data">暂无数据</span></div>
<div v-if="!props.data">
<span class="tucfi-data">暂无数据</span>
</div>
<div v-else-if="!Array.isArray(props.data)" class="tucfi-data">
<TItemBox :model-value="getBox(props.data)" />
</div>

View File

@@ -0,0 +1,177 @@
<!-- 祈愿日历图表组件 -->
<template>
<v-chart
ref="chartRef"
class="gro-chart-calendar"
:option="chartOptions"
autoresize
:theme="echartsTheme"
:init-options="{ locale: 'ZH' }"
:style="{ height: chartHeight }"
:key="`gro-chart-calendar-${echartsTheme}`"
/>
</template>
<script lang="ts" setup>
import TSUserGacha from "@Sqlm/userGacha.js";
import useAppStore from "@store/app.js";
import { getImageBuffer, saveCanvasImg } from "@utils/TGShare.js";
import type { HeatmapSeriesOption } from "echarts/charts.js";
import { HeatmapChart } from "echarts/charts.js";
import type {
CalendarComponentOption,
ToolboxComponentOption,
TooltipComponentOption,
VisualMapComponentOption,
} from "echarts/components.js";
import {
CalendarComponent,
ToolboxComponent,
TooltipComponent,
VisualMapComponent,
} from "echarts/components.js";
import type { ComposeOption, EChartsType } from "echarts/core.js";
import { use } from "echarts/core.js";
import { LabelLayout } from "echarts/features.js";
import { CanvasRenderer } from "echarts/renderers.js";
import { storeToRefs } from "pinia";
import { computed, onMounted, shallowRef, useTemplateRef, watch } from "vue";
import VChart from "vue-echarts";
use([
LabelLayout,
CanvasRenderer,
HeatmapChart,
CalendarComponent,
ToolboxComponent,
TooltipComponent,
VisualMapComponent,
]);
type GachaChartCalendarProps = { uid: string; gachaType?: string };
type EChartsOption = ComposeOption<
| CalendarComponentOption
| TooltipComponentOption
| VisualMapComponentOption
| ToolboxComponentOption
| HeatmapSeriesOption
>;
const props = defineProps<GachaChartCalendarProps>();
const { theme } = storeToRefs(useAppStore());
const chartOptions = shallowRef<EChartsOption>();
const yearCount = shallowRef<number>(1); // 默认至少1年避免高度为0
const chartEl = useTemplateRef<InstanceType<typeof VChart>>("chartRef");
const echartsTheme = computed<"dark" | "light">(() => (theme.value === "dark" ? "dark" : "light"));
// 根据年份数量动态计算高度每个日历约160px加上顶部空间
const chartHeight = computed<string>(() => {
const baseHeight = 120; // 顶部工具栏和 visualMap 的空间
const perYearHeight = 160; // 每个年份的日历高度
const totalHeight = baseHeight + yearCount.value * perYearHeight;
return `${totalHeight}px`;
});
/**
* @description 获取日历图表配置
* @returns {EChartsOption}
*/
async function getCalendarOptions(): Promise<EChartsOption> {
const records = await TSUserGacha.getGachaRecordsGroupByDate(props.uid, props.gachaType);
// 获取最大长度
const maxLen = Math.max(...Object.values(records).map((v) => v.length));
// 获取年份
const yearsSet = new Set(Object.keys(records).map((v) => v.split("-")[0]));
function getYearData(year: string): [string, number][] {
const res: [string, number][] = [];
for (const key in records) {
if (key.startsWith(year)) res.push([key, records[key].length]);
}
return res;
}
return {
tooltip: { position: "top" },
toolbox: {
show: true,
feature: {
restore: {},
saveAsImage: { show: false },
myDownloadChart: {
show: true,
title: "下载图表",
icon: "M12 4v12m-4-4l4 4 4-4",
onclick: async () => {
if (!chartEl.value) return;
const chart: EChartsType = chartEl.value.chart;
if (!chart) return;
const dataUrl = chart.getDataURL({
pixelRatio: 2,
backgroundColor: theme.value === "dark" ? "#2c343c" : "#ffffff",
excludeComponents: ["toolbox"],
});
const buffer = await getImageBuffer(dataUrl);
await saveCanvasImg(buffer, `gacha-chart-calendar-${props.uid}`);
},
},
},
},
visualMap: {
min: 0,
max: maxLen,
calculable: true,
orient: "horizontal",
left: "center",
top: "top",
},
calendar: Array.from(yearsSet).map((year, index) => ({
range: year,
cellSize: ["auto", 15],
top: 150 * index + 80,
right: 12,
})),
series: Array.from(yearsSet).map((year, index) => ({
type: "heatmap",
coordinateSystem: "calendar",
calendarIndex: index,
data: getYearData(year),
})),
};
}
async function loadChartData(): Promise<void> {
try {
const options = await getCalendarOptions();
chartOptions.value = options;
// 获取年份数量
if (options.calendar && Array.isArray(options.calendar)) {
yearCount.value = options.calendar.length || 1;
}
} catch (error) {
console.error("Failed to load calendar chart:", error);
// 保持默认值,显示基础高度
}
}
onMounted(async () => {
await loadChartData();
});
// 监听 uid 和 gachaType 变化,重新加载数据
watch(
() => [props.uid, props.gachaType],
async () => {
await loadChartData();
},
);
</script>
<style lang="css" scoped>
.gro-chart-calendar {
width: 100%;
min-height: 400px;
}
</style>

View File

@@ -1,20 +1,78 @@
/**
* @file utils/gachaCharts.ts
* @description 祈愿图表配置
* @since Beta v0.8.2
*/
<template>
<v-chart
ref="chartRef"
class="gro-chart-overview"
:option="chartOptions"
autoresize
:theme="echartsTheme"
:init-options="{ locale: 'ZH' }"
:key="`gro-chart-overview-${echartsTheme}`"
/>
</template>
<script lang="ts" setup>
import TSUserGacha from "@Sqlm/userGacha.js";
import type { BarSeriesOption } from "echarts/charts.js";
import type { EChartsOption, XAXisOption } from "echarts/types/dist/shared.js";
import useAppStore from "@store/app.js";
import { getImageBuffer, saveCanvasImg } from "@utils/TGShare.js";
import type { PieSeriesOption } from "echarts/charts.js";
import { BarChart, PieChart } from "echarts/charts.js";
import type {
GridComponentOption,
LegendComponentOption,
TitleComponentOption,
ToolboxComponentOption,
TooltipComponentOption,
} from "echarts/components.js";
import {
GridComponent,
LegendComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent,
} from "echarts/components.js";
import type { ComposeOption, EChartsType } from "echarts/core.js";
import { use } from "echarts/core.js";
import { LabelLayout } from "echarts/features.js";
import { CanvasRenderer } from "echarts/renderers.js";
import { storeToRefs } from "pinia";
import { computed, onMounted, shallowRef, useTemplateRef, watch } from "vue";
import VChart from "vue-echarts";
use([
LabelLayout,
CanvasRenderer,
BarChart,
PieChart,
GridComponent,
LegendComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent,
]);
type GachaChartOverviewProps = { uid: string };
type EChartsOption = ComposeOption<
| TitleComponentOption
| TooltipComponentOption
| LegendComponentOption
| ToolboxComponentOption
| GridComponentOption
| PieSeriesOption
>;
const props = defineProps<GachaChartOverviewProps>();
const { theme } = storeToRefs(useAppStore());
const chartOptions = shallowRef<EChartsOption>({});
const echartsTheme = computed<"dark" | "light">(() => (theme.value === "dark" ? "dark" : "light"));
const chartEl = useTemplateRef<InstanceType<typeof VChart>>("chartRef");
/**
* @description 获取整体祈愿图表配置
* @param {string} uid - 用户UID
* @returns {EChartsOption}
*/
async function getOverviewOptions(uid: string): Promise<EChartsOption> {
const records = await TSUserGacha.getGachaRecords(uid);
async function getOverviewOptions(): Promise<EChartsOption> {
const records = await TSUserGacha.getGachaRecords(props.uid);
const data: EChartsOption = {
title: [
{ text: ">> 祈愿系统大数据分析 <<", left: "center", top: "5%" },
@@ -25,7 +83,30 @@ async function getOverviewOptions(uid: string): Promise<EChartsOption> {
],
tooltip: { trigger: "item" },
legend: { type: "scroll", orient: "vertical", left: 10, top: 20, bottom: 20 },
toolbox: { show: true, feature: { restore: {}, saveAsImage: {} } },
toolbox: {
show: true,
feature: {
restore: {},
saveAsImage: { show: false },
myDownloadChart: {
show: true,
title: "下载图表",
icon: "M12 4v12m-4-4l4 4 4-4",
onclick: async () => {
if (!chartEl.value) return;
const chart: EChartsType = chartEl.value.chart;
if (!chart) return;
const dataUrl = chart.getDataURL({
pixelRatio: 2,
backgroundColor: theme.value === "dark" ? "#2c343c" : "#ffffff",
excludeComponents: ["toolbox"],
});
const buffer = await getImageBuffer(dataUrl);
await saveCanvasImg(buffer, `gacha-overview-${props.uid}`);
},
},
},
},
series: [
{
name: "卡池分布",
@@ -145,105 +226,26 @@ async function getOverviewOptions(uid: string): Promise<EChartsOption> {
return data;
}
/**
* @description 获取日历图表配置
* @param {string} uid - 用户UID
* @param {string} gachaType - 祈愿类型
* @returns {EChartsOption}
*/
async function getCalendarOptions(uid: string, gachaType?: string): Promise<EChartsOption> {
const records = await TSUserGacha.getGachaRecordsGroupByDate(uid, gachaType);
//
const maxLen = Math.max(...Object.values(records).map((v) => v.length));
//
const yearsSet = new Set(Object.keys(records).map((v) => v.split("-")[0]));
function getYearData(year: string): [string, number][] {
const res: [string, number][] = [];
for (const key in records) {
if (key.startsWith(year)) res.push([key, records[key].length]);
}
return res;
}
return {
tooltip: { position: "top" },
toolbox: { show: true, feature: { restore: {}, saveAsImage: {} } },
visualMap: {
min: 0,
max: maxLen,
calculable: true,
orient: "horizontal",
left: "center",
top: "top",
},
calendar: Array.from(yearsSet).map((year, index) => ({
range: year,
cellSize: ["auto", 15],
top: 150 * index + 80,
})),
series: Array.from(yearsSet).map((year, index) => ({
type: "heatmap",
coordinateSystem: "calendar",
calendarIndex: index,
data: getYearData(year),
})),
};
async function loadChartData(): Promise<void> {
chartOptions.value = await getOverviewOptions();
}
/**
* @description 堆叠柱状图
* @param {string} uid - 用户UID
* @param {string} gachaType - 祈愿类型
* @returns {EChartsOption}
*/
async function getStackBarOptions(uid: string, gachaType?: string): Promise<EChartsOption> {
const records = await TSUserGacha.getGachaRecordsGroupByDate(uid, gachaType);
const xAxis: XAXisOption = {
type: "category",
data: Object.keys(records),
axisTick: { alignWithLabel: true },
axisLine: { show: true, lineStyle: { color: "#000" } },
axisLabel: {
rotate: 45,
interval: 4,
fontSize: 12,
fontFamily: "var(--font-title)",
},
axisPointer: { type: "shadow" },
};
const temp5 = [];
const temp4 = [];
const temp3 = [];
for (const key in records) {
const gachaLogs = records[key];
const star5 = gachaLogs.filter((r) => r.rank === "5").length;
const star4 = gachaLogs.filter((r) => r.rank === "4").length;
const star3 = gachaLogs.filter((r) => r.rank === "3").length;
temp5.push(star5);
temp4.push(star4);
temp3.push(star3);
}
const series: BarSeriesOption = [
{ data: temp5, type: "bar", stack: "a", name: "五星数量" },
{ data: temp4, type: "bar", stack: "a", name: "四星数量" },
{ data: temp3, type: "bar", stack: "a", name: "三星数量" },
];
return {
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
toolbox: { show: true, feature: { restore: {}, saveAsImage: {} } },
legend: { data: ["三星数量", "四星数量", "五星数量"] },
xAxis,
yAxis: { type: "value" },
series,
grid: { left: "3%", right: "3%", bottom: "3%", top: "5%" },
};
onMounted(async () => {
await loadChartData();
});
// uid
watch(
() => props.uid,
async () => {
await loadChartData();
},
);
</script>
<style lang="css" scoped>
.gro-chart-overview {
width: 100%;
height: 100%;
min-height: 600px;
}
const TGachaCharts = {
overview: getOverviewOptions,
calendar: getCalendarOptions,
stackBar: getStackBarOptions,
};
export default TGachaCharts;
</style>

View File

@@ -0,0 +1,186 @@
<!-- 祈愿柱状图组件 -->
<template>
<v-chart
ref="chartRef"
class="gro-chart-stackbar"
:option="chartOptions"
autoresize
:theme="echartsTheme"
:init-options="{ locale: 'ZH' }"
:key="`gro-chart-stackbar-${echartsTheme}`"
/>
</template>
<script lang="ts" setup>
import TSUserGacha from "@Sqlm/userGacha.js";
import useAppStore from "@store/app.js";
import { getImageBuffer, saveCanvasImg } from "@utils/TGShare.js";
import type { BarSeriesOption } from "echarts/charts.js";
import { BarChart } from "echarts/charts.js";
import type {
DataZoomComponentOption,
GridComponentOption,
LegendComponentOption,
ToolboxComponentOption,
TooltipComponentOption,
} from "echarts/components.js";
import {
DataZoomComponent,
GridComponent,
LegendComponent,
ToolboxComponent,
TooltipComponent,
} from "echarts/components.js";
import type { ComposeOption, EChartsType } from "echarts/core.js";
import { use } from "echarts/core.js";
import { LabelLayout } from "echarts/features.js";
import { CanvasRenderer } from "echarts/renderers.js";
import { storeToRefs } from "pinia";
import { computed, onMounted, shallowRef, useTemplateRef, watch } from "vue";
import VChart from "vue-echarts";
use([
LabelLayout,
CanvasRenderer,
BarChart,
DataZoomComponent,
GridComponent,
LegendComponent,
ToolboxComponent,
TooltipComponent,
]);
type GachaChartStackBarProps = { uid: string; gachaType?: string };
type EChartsOption = ComposeOption<
| BarSeriesOption
| TooltipComponentOption
| ToolboxComponentOption
| LegendComponentOption
| GridComponentOption
| DataZoomComponentOption
>;
const props = defineProps<GachaChartStackBarProps>();
const { theme } = storeToRefs(useAppStore());
const chartOptions = shallowRef<EChartsOption>({});
const echartsTheme = computed<"dark" | "light">(() => (theme.value === "dark" ? "dark" : "light"));
const chartEl = useTemplateRef<InstanceType<typeof VChart>>("chartRef");
/**
* @description 堆叠柱状图
* @returns {EChartsOption}
*/
async function getStackBarOptions(): Promise<EChartsOption> {
const records = await TSUserGacha.getGachaRecordsGroupByDate(props.uid, props.gachaType);
const dataCount = Object.keys(records).length;
const xAxis = {
type: <const>"category",
data: Object.keys(records),
axisTick: { alignWithLabel: true },
axisLine: { show: true, lineStyle: { color: "#000" } },
axisLabel: {
rotate: 45,
interval: 4,
fontSize: 12,
fontFamily: "var(--font-title)",
},
axisPointer: { type: <const>"shadow" },
};
const temp5 = [];
const temp4 = [];
const temp3 = [];
for (const key in records) {
const gachaLogs = records[key];
const star5 = gachaLogs.filter((r) => r.rank === "5").length;
const star4 = gachaLogs.filter((r) => r.rank === "4").length;
const star3 = gachaLogs.filter((r) => r.rank === "3").length;
temp5.push(star5);
temp4.push(star4);
temp3.push(star3);
}
const series: BarSeriesOption[] = [
{ data: temp5, type: "bar", stack: "a", name: "五星数量" },
{ data: temp4, type: "bar", stack: "a", name: "四星数量" },
{ data: temp3, type: "bar", stack: "a", name: "三星数量" },
];
// 添加 dataZoom 组件以支持数据量大时的缩放和滚动
const dataZoom =
dataCount > 100
? [
{
type: <const>"slider",
show: true,
xAxisIndex: [0],
start: Math.max(0, ((dataCount - 100) / dataCount) * 100),
end: 100,
bottom: "5%",
},
{
type: <const>"inside",
xAxisIndex: [0],
start: Math.max(0, ((dataCount - 100) / dataCount) * 100),
end: 100,
},
]
: undefined;
return {
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
toolbox: {
show: true,
feature: {
restore: {},
saveAsImage: { show: false },
myDownloadChart: {
show: true,
title: "下载图表",
icon: "M12 4v12m-4-4l4 4 4-4",
onclick: async () => {
if (!chartEl.value) return;
const chart: EChartsType = chartEl.value.chart;
if (!chart) return;
const dataUrl = chart.getDataURL({
pixelRatio: 2,
backgroundColor: theme.value === "dark" ? "#2c343c" : "#ffffff",
excludeComponents: ["toolbox"],
});
const buffer = await getImageBuffer(dataUrl);
await saveCanvasImg(buffer, `gacha-overview-${props.uid}`);
},
},
},
},
legend: { data: ["三星数量", "四星数量", "五星数量"] },
xAxis,
yAxis: { type: "value" },
series,
dataZoom,
grid: { left: "3%", right: "3%", bottom: dataZoom ? "15%" : "3%", top: "10%" },
};
}
async function loadChartData(): Promise<void> {
chartOptions.value = await getStackBarOptions();
}
onMounted(async () => {
await loadChartData();
});
// 监听 uid 和 gachaType 变化,重新加载数据
watch(
() => [props.uid, props.gachaType],
async () => {
await loadChartData();
},
);
</script>
<style lang="css" scoped>
.gro-chart-stackbar {
width: 100%;
height: 100%;
min-height: 500px;
}
</style>

View File

@@ -42,14 +42,14 @@
<v-window-item value="5" class="gro-b-window-item">
<v-virtual-scroll :items="star5List" :item-height="48">
<template #default="{ item }">
<GroDataLine :data="item.data" :count="item.count" />
<GroDataLine :key="item.data.id" :data="item.data" :count="item.count" />
</template>
</v-virtual-scroll>
</v-window-item>
<v-window-item value="4" class="gro-b-window-item">
<v-virtual-scroll :items="star4List" :item-height="48">
<template #default="{ item }">
<GroDataLine :data="item.data" :count="item.count" />
<GroDataLine :key="item.data.id" :data="item.data" :count="item.count" />
</template>
</v-virtual-scroll>
</v-window-item>

View File

@@ -1,4 +1,3 @@
<!-- TODO组件拆分 -->
<template>
<div class="gro-chart">
<div class="gro-chart-options">
@@ -15,99 +14,33 @@
width="200px"
/>
</div>
<v-chart
class="gro-chart-box"
:option="chartOptions"
autoresize
:theme="echartsTheme"
:init-options="{ locale: 'ZH' }"
v-if="chartOptions"
/>
<div class="gro-chart-container">
<gro-chart-overview v-if="curChartType === 'overview'" :uid="uid" />
<gro-chart-calendar v-if="curChartType === 'calendar'" :uid="uid" :gacha-type="gachaType" />
<gro-chart-stackbar v-if="curChartType === 'stackBar'" :uid="uid" :gacha-type="gachaType" />
</div>
</div>
</template>
<script lang="ts" setup>
// TODO: 类型声明
// about import err,see:https://github.com/apache/echarts/issues/19992
import showLoading from "@comp/func/loading.js";
import useAppStore from "@store/app.js";
import TGachaCharts from "@utils/gachaCharts.js";
import { BarChart, HeatmapChart, PieChart } from "echarts/charts.js";
import {
CalendarComponent,
DataZoomComponent,
GridComponent,
LegendComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent,
VisualMapComponent,
} from "echarts/components.js";
import { use } from "echarts/core.js";
import { LabelLayout } from "echarts/features.js";
import { CanvasRenderer } from "echarts/renderers.js";
import type { EChartsOption } from "echarts/types/dist/shared.js";
import { storeToRefs } from "pinia";
import { computed, ref, shallowRef, watch } from "vue";
import VChart from "vue-echarts";
// echarts
use([
LabelLayout,
CanvasRenderer,
BarChart,
HeatmapChart,
PieChart,
CalendarComponent,
DataZoomComponent,
GridComponent,
LegendComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent,
VisualMapComponent,
]);
import GroChartCalendar from "@comp/userGacha/gro-chart-calendar.vue";
import GroChartOverview from "@comp/userGacha/gro-chart-overview.vue";
import GroChartStackbar from "@comp/userGacha/gro-chart-stackbar.vue";
import { ref } from "vue";
type GachaOverviewEchartsProps = { uid: string; gachaType?: string };
type ChartsType = "overview" | "calendar" | "stackBar";
type ChartItem = { label: string; value: ChartsType };
type SelectType<T> = { label: string; value: T };
const props = defineProps<GachaOverviewEchartsProps>();
const { theme } = storeToRefs(useAppStore());
const chartTypes: Array<ChartItem> = [
defineProps<GachaOverviewEchartsProps>();
const chartTypes: Array<SelectType<ChartsType>> = [
{ label: "祈愿分析", value: "overview" },
{ label: "祈愿日历", value: "calendar" },
{ label: "祈愿柱状图", value: "stackBar" },
];
// TODO: 分卡池选择图表
const curChartType = ref<ChartsType>("overview");
const chartOptions = shallowRef<EChartsOption>();
const echartsTheme = computed<"dark" | "light">(() => (theme.value === "dark" ? "dark" : "light"));
watch(
() => curChartType.value,
() => {
getOptions();
},
{ immediate: true },
);
async function getOptions(): Promise<void> {
await showLoading.start("加载中...");
switch (curChartType.value) {
case "overview":
chartOptions.value = await TGachaCharts.overview(props.uid);
break;
case "calendar":
chartOptions.value = await TGachaCharts.calendar(props.uid, props.gachaType);
break;
case "stackBar":
chartOptions.value = await TGachaCharts.stackBar(props.uid, props.gachaType);
break;
}
await showLoading.end();
}
</script>
<style lang="css" scoped>
.gro-chart {
@@ -119,12 +52,12 @@ async function getOptions(): Promise<void> {
align-items: flex-start;
justify-content: flex-start;
gap: 10px;
overflow-y: auto;
}
.gro-chart-options {
position: relative;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
margin-right: auto;
@@ -137,9 +70,9 @@ async function getOptions(): Promise<void> {
font-family: var(--font-title);
}
.gro-chart-box {
width: calc(100% - 8px);
height: calc(100% - 64px);
min-height: 300px;
.gro-chart-container {
overflow: hidden auto;
width: 100%;
flex: 1;
}
</style>

View File

@@ -62,9 +62,11 @@ import { computed, onMounted, ref, shallowRef, watch } from "vue";
type UgoUidProps = { mode: "import" | "export" };
type UgoUidItem = { uid: string; length: number; time: string };
const fpEmptyText = "点击选择文件路径";
const props = defineProps<UgoUidProps>();
const visible = defineModel<boolean>();
const fp = ref<string>("未选择");
const fp = ref<string>(fpEmptyText);
const dataRaw = shallowRef<TGApp.Plugins.UIGF.Schema4>();
const data = shallowRef<Array<UgoUidItem>>([]);
const selectedData = shallowRef<Array<UgoUidItem>>([]);
@@ -91,7 +93,7 @@ async function refreshData(): Promise<void> {
data.value = [];
dataRaw.value = undefined;
if (props.mode === "import") {
fp.value = "未选择";
fp.value = fpEmptyText;
await handleImportData();
} else {
fp.value = await getDefaultSavePath();
@@ -118,7 +120,7 @@ async function selectFile(): Promise<void> {
}
async function handleImportData(): Promise<void> {
if (fp.value === "未选择") return;
if (fp.value === fpEmptyText) return;
try {
await showLoading.start("正在导入数据...", "正在验证数据...");
const check = await verifyUigfData(fp.value, true);
@@ -178,7 +180,7 @@ async function handleSelected(): Promise<void> {
async function handleImport(): Promise<void> {
if (!dataRaw.value) {
showSnackbar.error("未获取到数据!");
fp.value = "未选择";
fp.value = fpEmptyText;
return;
}
if (selectedData.value.length === 0) {
@@ -194,10 +196,11 @@ async function handleImport(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
continue;
}
await TSUserGacha.mergeUIGF4(dataFind);
await TSUserGacha.mergeUIGF4(dataFind, true);
}
await showLoading.end();
showSnackbar.success("导入成功!");
showSnackbar.success("导入成功!即将刷新页面...");
window.location.reload();
}
async function handleExport(): Promise<void> {
@@ -234,6 +237,7 @@ async function handleExport(): Promise<void> {
position: relative;
display: flex;
width: 100%;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
column-gap: 10px;
@@ -249,6 +253,7 @@ async function handleExport(): Promise<void> {
color: var(--tgc-od-white);
cursor: pointer;
font-size: 12px;
word-break: break-all;
}
.ugo-header {

View File

@@ -1,3 +1,4 @@
<!-- 自定义表情组件 -->
<template>
<div class="tp-emo-box" title="自定义表情" v-if="localUrl !== undefined">
<img
@@ -18,6 +19,7 @@
v-model="showOverlay"
v-model:link="localUrl"
:ori="true"
:format="fmt"
v-model:bgColor="bgColor"
/>
</template>
@@ -28,34 +30,58 @@ import { computed, onMounted, onUnmounted, ref } from "vue";
import type { TpImage } from "./tp-image.vue";
import VpOverlayImage from "./vp-overlay-image.vue";
/**
* 自定义表情组件数据
*/
type TpCustomEmoticon = {
/* 插入内容 */
insert: {
/* 自定义表情 */
backup_text: "[自定义表情]";
/* 自定义表情信息 */
custom_emoticon: {
/* 表情ID */
id: string;
/* 表情链接 */
url: string;
size: { width: number; height: number; file_size?: number };
/* 表情尺寸 */
size: {
/* 宽度 */
width: number;
/* 高度 */
height: number;
/* 文件大小 */
file_size?: number;
};
/* 是否可用 */
is_available: boolean;
/* 哈希值 */
hash: string;
};
};
};
type TpEmoticonProps = { data: TpCustomEmoticon };
/**
* 自定义表情组件属性
*/
type TpEmoticonProps = {
/* 组件数据 */
data: TpCustomEmoticon;
};
const props = defineProps<TpEmoticonProps>();
const localUrl = ref<string>();
const showOverlay = ref<boolean>(false);
const bgColor = ref<string>("transparent");
const image = computed<TpImage>(() => {
return {
insert: { image: props.data.insert.custom_emoticon.url },
attributes: {
width: props.data.insert.custom_emoticon.size.width,
height: props.data.insert.custom_emoticon.size.height,
size: props.data.insert.custom_emoticon.size.file_size,
},
};
});
const fmt = computed<string>(() => getImageExt());
const image = computed<TpImage>(() => ({
insert: { image: props.data.insert.custom_emoticon.url },
attributes: {
width: props.data.insert.custom_emoticon.size.width,
height: props.data.insert.custom_emoticon.size.height,
size: props.data.insert.custom_emoticon.size.file_size,
},
}));
console.log("tp-emoticon", props.data.insert.custom_emoticon);
@@ -66,6 +92,11 @@ onMounted(async () => {
onUnmounted(() => {
if (localUrl.value) URL.revokeObjectURL(localUrl.value);
});
function getImageExt(): string {
const arr = props.data.insert.custom_emoticon.url.split(".");
return arr[arr.length - 1];
}
</script>
<style lang="scss" scoped>
.tp-emo-box {
@@ -102,6 +133,7 @@ onUnmounted(() => {
cursor: default;
font-family: var(--font-title);
font-size: 12px;
text-shadow: 1px 1px 1px var(--tgc-dark-1);
white-space: nowrap;
}
</style>

View File

@@ -55,10 +55,7 @@ const showOverlay = ref<boolean>(false);
const localUrl = ref<string>();
const bgColor = ref<string>("transparent");
const oriUrl = computed<string>(() => {
if (typeof props.data.insert.image === "string") return props.data.insert.image;
return props.data.insert.image.url;
});
const oriUrl = ref<string>("");
const imgExt = computed<string>(() => getImageExt());
const showOri = ref<boolean>(imgExt.value === "gif" || imageQualityPercent.value === 100);
@@ -71,6 +68,7 @@ const imgWidth = computed<string>(() => {
console.log("tp-image", props.data.insert.image, props.data.attributes);
onMounted(async () => {
oriUrl.value = miniImgUrl();
const link = appStore.getImageUrl(oriUrl.value, imgExt.value);
localUrl.value = await saveImgLocal(link);
});
@@ -93,6 +91,17 @@ onUnmounted(() => {
if (localUrl.value) URL.revokeObjectURL(localUrl.value);
});
function miniImgUrl(): string {
let url: string;
if (typeof props.data.insert.image === "string") {
url = props.data.insert.image;
} else {
url = props.data.insert.image.url;
}
const link = new URL(url);
return `${link.origin}${link.pathname}`;
}
function getImageTitle(): string {
const res: string[] = [];
if (props.data.attributes) {
@@ -120,11 +129,11 @@ function getImageTitle(): string {
function getImageExt(): string {
if (props.data.attributes && props.data.attributes.ext) return props.data.attributes.ext;
if (typeof props.data.insert.image === "string") {
const arr = props.data.insert.image.split(".");
return arr[arr.length - 1];
if (typeof props.data.insert.image !== "string") {
return props.data.insert.image.format;
}
return props.data.insert.image.format;
const arr = oriUrl.value.split(".");
return arr[arr.length - 1];
}
</script>
<style lang="scss" scoped>

View File

@@ -4,7 +4,14 @@
class="tp-video-container"
:src="props.data.insert.video"
:allowfullscreen="true"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allow="
accelerometer;
autoplay;
clipboard-write;
encrypted-media;
gyroscope;
picture-in-picture;
"
sandbox="allow-forms allow-same-origin allow-popups allow-presentation allow-scripts"
:id="`tp-video-${props.data.insert.video}`"
/>

View File

@@ -23,6 +23,7 @@
import showLoading from "@comp/func/loading.js";
import useAppStore from "@store/app.js";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { openUrl } from "@tauri-apps/plugin-opener";
import { getImageBuffer, saveCanvasImg, saveImgLocal } from "@utils/TGShare.js";
import { getVideoDuration } from "@utils/toolFunc.js";
import Artplayer, { type Option } from "artplayer";
@@ -106,7 +107,7 @@ onMounted(async () => {
name: "download-cover",
index: 0,
position: "right",
html: `<i class="mdi mdi-download"></i>`,
html: `<span class="mdi mdi-image-check"></span>`,
tooltip: "下载封面",
click: async () => {
await showLoading.start("正在下载封面", props.data.insert.vod.cover);
@@ -117,6 +118,17 @@ onMounted(async () => {
await showLoading.end();
},
},
{
name: "download-video",
index: 0,
position: "right",
html: `<span class="mdi mdi-video-check"></span>`,
tooltip: "下载视频",
click: async () => {
if (!container.value) return;
await openUrl(container.value.url);
},
},
],
};
container.value = new Artplayer(option);

View File

@@ -1,3 +1,4 @@
<!-- 投票组件 -->
<template>
<div class="tp-vote-box">
<div class="tp-vote-info">
@@ -22,6 +23,8 @@
</template>
<script lang="ts" setup>
import ApiHubReq from "@req/apiHubReq.js";
import useAppStore from "@store/app.js";
import { storeToRefs } from "pinia";
import { onMounted, ref, shallowRef } from "vue";
type TpVote = { insert: { vote: { id: string; uid: string } } };
@@ -33,6 +36,8 @@ const props = defineProps<TpVoteProps>();
const votes = shallowRef<TpVoteInfo>();
const maxCnt = ref<number>(0);
const { postViewWide } = storeToRefs(useAppStore());
onMounted(async () => {
const vote = props.data.insert.vote;
const voteInfo = await ApiHubReq.vote.info(vote.id, vote.uid);
@@ -69,6 +74,7 @@ function getWidth(item: TpVoteData): string {
.tp-vote-info {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
}
@@ -81,12 +87,13 @@ function getWidth(item: TpVoteData): string {
.tp-vote-list {
display: grid;
gap: 12px 16px;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: v-bind("postViewWide ? '1fr 1fr' : '1fr'");
}
.tp-vote-item {
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 4px;
}
@@ -94,6 +101,7 @@ function getWidth(item: TpVoteData): string {
display: flex;
align-items: flex-end;
justify-content: space-between;
column-gap: 4px;
.title {
font-size: 16px;

View File

@@ -1,3 +1,4 @@
<!-- 帖子回复按钮&一级回复列表组件 -->
<template>
<div class="tpr-main-box" title="查看回复">
<v-menu
@@ -7,7 +8,7 @@
:persistent="true"
:no-click-animation="true"
z-index="60"
:offset="[4, 400]"
:offset="[12, 0]"
>
<template #activator="{ props }">
<v-btn
@@ -51,7 +52,7 @@
</div>
</div>
</div>
<v-list class="tpr-reply-list">
<v-list class="tpr-reply-list" @scroll="handleListScroll">
<VpReplyItem
v-for="(item, index) in reply"
:key="index"
@@ -75,21 +76,54 @@
import showSnackbar from "@comp/func/snackbar.js";
import postReq from "@req/postReq.js";
import useAppStore from "@store/app.js";
import { emit } from "@tauri-apps/api/event";
import TGLogger from "@utils/TGLogger.js";
import { storeToRefs } from "pinia";
import { computed, ref, shallowRef, watch } from "vue";
import VpReplyDebug from "./vp-reply-debug.vue";
import VpReplyItem from "./vp-reply-item.vue";
type TprMainProps = { gid: number; postId: string };
type SelectItem = { label: string; value: string };
const props = defineProps<TprMainProps>();
/**
* 帖子一级回复列表组件参数
*/
type TprMainProps = {
/* 版块ID */
gid: number;
/* 帖子ID */
postId: string;
};
/**
* 回复排序类型
*/
enum ReplyOrderType {
/* 热门 */
HOT = 0,
/* 最新 */
LATEST = 2,
/* 最早 */
OLDEST = 1,
}
/**
* 选择项类型
*/
type SelectItem = {
/* 文本 */
label: string;
/* 值 */
value: ReplyOrderType;
};
const { devMode } = storeToRefs(useAppStore());
const props = defineProps<TprMainProps>();
const orderList: Array<SelectItem> = [
{ label: "热门", value: "hot" },
{ label: "最新", value: "latest" },
{ label: "最早", value: "oldest" },
{ label: "热门", value: ReplyOrderType.HOT },
{ label: "最新", value: ReplyOrderType.LATEST },
{ label: "最早", value: ReplyOrderType.OLDEST },
];
const pinId = ref<string>("0");
@@ -99,14 +133,12 @@ const loading = ref<boolean>(false);
const showOverlay = ref<boolean>(false);
const showDebug = ref<boolean>(false);
const onlyLz = ref<boolean>(false);
const orderType = ref<"hot" | "latest" | "oldest">("hot");
const orderType = ref<ReplyOrderType>(ReplyOrderType.HOT);
const reply = shallowRef<Array<TGApp.BBS.Reply.ReplyFull>>([]);
const isHot = computed<boolean>(() => orderType.value === "hot");
const isHot = computed<boolean>(() => orderType.value === ReplyOrderType.HOT);
const replyOrder = computed<1 | 2 | undefined>(() => {
if (orderType.value === "hot") return undefined;
if (orderType.value === "latest") return 2;
if (orderType.value === "oldest") return 1;
return undefined;
if (orderType.value === ReplyOrderType.HOT) return undefined;
return orderType.value;
});
watch(
@@ -117,6 +149,22 @@ watch(
},
);
function handleListScroll(e: Event): void {
const target = <HTMLElement>e.target;
if (!target) return;
// Emit event to close sub-reply menus when parent scrolls
emit("closeReplySub");
// Check if scrolled to bottom for auto-load
const scrollTop = target.scrollTop;
const clientHeight = target.clientHeight;
const scrollHeight = target.scrollHeight;
if (scrollTop + clientHeight >= scrollHeight - 1) {
if (!loading.value && !isLast.value) {
loadReply();
}
}
}
async function showReply(): Promise<void> {
if (reply.value.length > 0) return;
if (isLast.value) return;
@@ -144,8 +192,11 @@ async function loadReply(): Promise<void> {
onlyLz.value,
replyOrder.value,
);
console.debug("[VpBtnReply] Load Reply Response:", resp);
if ("retcode" in resp) {
showSnackbar.error(`[${resp.retcode}] ${resp.message}`);
await TGLogger.Warn(`[VpBtnReply] Load Reply Error: ${resp.retcode} - ${resp.message}`);
loading.value = false;
return;
}
isLast.value = resp.is_last;
@@ -205,7 +256,6 @@ async function handleDebug(): Promise<void> {
position: relative;
display: flex;
width: 300px;
height: 400px;
box-sizing: border-box;
flex-direction: column;
align-items: center;
@@ -217,6 +267,7 @@ async function handleDebug(): Promise<void> {
box-shadow: 2px 2px 8px var(--common-shadow-4);
overflow-y: auto;
row-gap: 8px;
transition: height 0.5s ease-in-out;
}
.tpr-main-filter {
@@ -283,7 +334,7 @@ async function handleDebug(): Promise<void> {
display: flex;
overflow: hidden auto;
width: 100%;
height: 360px;
height: fit-content;
box-sizing: border-box;
flex-direction: column;
align-items: center;

View File

@@ -1,3 +1,4 @@
<!-- 图片浮窗 -->
<template>
<TOverlay v-model="visible" blur-val="10px">
<div class="tpoi-box">
@@ -62,7 +63,13 @@ import { computed, nextTick, ref, shallowRef } from "vue";
import type { TpImage } from "./tp-image.vue";
type TpoImageProps = { image: TpImage };
/**
* 图片浮窗组件参数
*/
type TpoImageProps = {
/* 图片数据 */
image: TpImage;
};
const props = defineProps<TpoImageProps>();
const visible = defineModel<boolean>();
@@ -70,13 +77,11 @@ const localLink = defineModel<string>("link");
const showOri = defineModel<boolean>("ori");
const bgColor = defineModel<string>("bgColor", { default: "transparent" });
const format = defineModel<string>("format", { default: "png" });
const bgMode = ref<number>(0); // 0: transparent, 1: black, 2: white
const isOriSize = ref<boolean>(false);
const buffer = shallowRef<Uint8Array | null>(null);
const oriLink = computed<string>(() => {
const image = props.image.insert.image;
return typeof image === "string" ? image : image.url;
});
const oriLink = computed<string>(() => miniImgUrl());
const showCopy = computed<boolean>(() => {
// 只能显示 png/jpg/jpeg/webp 格式的复制按钮
return ["png", "jpg", "jpeg", "webp"].includes(format.value.toLowerCase());
@@ -124,6 +129,17 @@ async function onDownload(): Promise<void> {
await saveCanvasImg(buffer.value, fileName, format.value);
await showLoading.end();
}
function miniImgUrl(): string {
let url: string;
if (typeof props.image.insert.image === "string") {
url = props.image.insert.image;
} else {
url = props.image.insert.image.url;
}
const link = new URL(url);
return `${link.origin}${link.pathname}`;
}
</script>
<style lang="css" scoped>
.tpoi-box {

View File

@@ -1,9 +1,24 @@
<!-- 搜索悬浮层 -->
<template>
<TOverlay v-model="visible">
<div class="tops-box">
<div class="tops-top">查找{{ search }}</div>
<div class="tops-act">
<span>分区{{ label }}</span>
<div class="tops-game">
<span>分区{{ label }}</span>
</div>
<div class="tops-sort">
<v-select
density="compact"
v-model="sortType"
:items="sortOrderList"
item-title="text"
item-value="value"
variant="outlined"
label="排序"
:disabled="load"
/>
</div>
</div>
<div class="tops-divider" />
<div class="tops-list" ref="listRef">
@@ -28,6 +43,12 @@ import { storeToRefs } from "pinia";
import { computed, onMounted, ref, shallowRef, useTemplateRef, watch } from "vue";
type ToPostSearchProps = { gid: string; keyword?: string };
type SortSelect = { text: string; value: number };
const sortOrderList: Array<SortSelect> = [
{ text: "最新", value: 2 },
{ text: "最热", value: 1 },
];
const { gameList } = storeToRefs(useBBSStore());
@@ -42,6 +63,7 @@ const lastId = ref<string>("");
const gameId = ref<string>("2");
const isLast = ref<boolean>(false);
const load = ref<boolean>(false);
const sortType = ref<number>(1);
const results = shallowRef<Array<TGApp.BBS.Post.FullData>>([]);
const label = computed<string>(() => {
const gameFind = gameList.value.find((v) => v.id.toString() === gameId.value);
@@ -62,6 +84,15 @@ watch(
await searchPosts();
},
);
watch(
() => sortType.value,
async () => {
results.value = [];
lastId.value = "";
isLast.value = false;
await searchPosts();
},
);
watch(
() => visible.value,
async () => {
@@ -119,7 +150,7 @@ async function searchPosts(): Promise<void> {
load.value = false;
return;
}
const res = await postReq.search(gameId.value, search.value, lastId.value);
const res = await postReq.search(gameId.value, search.value, lastId.value, sortType.value);
if (lastId.value === "") results.value = res.posts;
else results.value = results.value.concat(res.posts);
lastId.value = res.last_id;
@@ -166,6 +197,11 @@ async function searchPosts(): Promise<void> {
justify-content: space-between;
}
.tops-sort {
width: 100px;
height: 40px;
}
.tops-divider {
position: relative;
width: 100%;

View File

@@ -55,7 +55,7 @@ async function selectFile(): Promise<void> {
.tpr-debug-box {
position: relative;
display: flex;
width: 400px;
width: 800px;
flex-direction: column;
align-items: center;
justify-content: center;
@@ -70,7 +70,7 @@ async function selectFile(): Promise<void> {
overflow: hidden;
width: 100%;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
padding: 5px;
border-bottom: 1px solid var(--box-bg-2);
font-family: var(--font-title);
@@ -79,7 +79,8 @@ async function selectFile(): Promise<void> {
:nth-child(2) {
overflow: hidden;
width: 300px;
max-width: 600px;
margin-right: auto;
color: var(--common-text-title);
text-overflow: ellipsis;
white-space: nowrap;
@@ -90,7 +91,7 @@ async function selectFile(): Promise<void> {
display: flex;
width: 100%;
min-height: 50px;
max-height: 300px;
max-height: 800px;
flex-direction: column;
align-items: center;
justify-content: flex-start;

View File

@@ -38,7 +38,7 @@
{{ props.modelValue.stat.like_num }}
</span>
<span
v-if="props.modelValue.sub_reply_count > 0"
v-if="props.modelValue.sub_replies.length > 0"
class="tpr-reply"
title="查看子回复"
@click="showReply()"
@@ -52,7 +52,12 @@
:close-on-content-click="false"
v-model="showSub"
>
<v-list class="tpr-reply-sub" width="300px" max-height="400px">
<v-list
class="tpr-reply-sub"
width="300px"
max-height="400px"
@scroll="handleSubScroll"
>
<VpReplyItem
v-for="(reply, index) in subReplies"
:key="index"
@@ -121,6 +126,7 @@ type TprReplyProps =
const props = defineProps<TprReplyProps>();
const replyId = `reply_${props.modelValue.reply.post_id}_${props.modelValue.reply.floor_id}_${props.modelValue.reply.reply_id}`;
let subListener: UnlistenFn | null = null;
let closeSubListener: UnlistenFn | null = null;
console.log("TprReply", toRaw(props.modelValue));
@@ -129,6 +135,7 @@ const lastId = ref<string>();
const isLast = ref<boolean>(false);
const loading = ref<boolean>(false);
const subReplies = shallowRef<Array<TGApp.BBS.Reply.ReplyFull>>([]);
const existingIds = new Set<string>();
const levelColor = computed<string>(() => {
const level = props.modelValue.user.level_exp.level;
if (level < 5) return "var(--tgc-od-green)";
@@ -138,12 +145,21 @@ const levelColor = computed<string>(() => {
return "var(--tgc-od-white)";
});
onMounted(async () => (props.mode === "main" ? (subListener = await listenSub()) : null));
onMounted(async () => {
if (props.mode === "main") {
subListener = await listenSub();
closeSubListener = await listenCloseSub();
}
});
onUnmounted(() => {
if (subListener !== null) {
subListener();
subListener = null;
}
if (closeSubListener !== null) {
closeSubListener();
closeSubListener = null;
}
});
watch(
@@ -159,6 +175,26 @@ async function listenSub(): Promise<UnlistenFn> {
});
}
async function listenCloseSub(): Promise<UnlistenFn> {
return await event.listen<void>("closeReplySub", async () => {
if (showSub.value) showSub.value = false;
});
}
function handleSubScroll(e: globalThis.Event): void {
const target = <HTMLElement>e.target;
if (!target) return;
// Check if scrolled to bottom for auto-load
const scrollTop = target.scrollTop;
const clientHeight = target.clientHeight;
const scrollHeight = target.scrollHeight;
if (scrollTop + clientHeight >= scrollHeight - 1) {
if (!loading.value && !isLast.value) {
loadSub();
}
}
}
async function share(): Promise<void> {
const replyDom = document.querySelector<HTMLElement>(`#${replyId}`);
if (replyDom === null) return;
@@ -166,6 +202,17 @@ async function share(): Promise<void> {
}
async function showReply(): Promise<void> {
if (subReplies.value.length === 0 && props.modelValue.sub_replies?.length > 0) {
subReplies.value = [...props.modelValue.sub_replies];
// Populate existingIds with embedded sub-replies
props.modelValue.sub_replies.forEach((r) => existingIds.add(r.reply.reply_id));
const lastReply = props.modelValue.sub_replies[props.modelValue.sub_replies.length - 1];
if (lastReply?.reply?.reply_id) lastId.value = lastReply.reply.reply_id;
if (props.modelValue.sub_replies.length >= props.modelValue.sub_reply_count) {
isLast.value = true;
}
return;
}
if (subReplies.value.length > 0) return;
if (isLast.value) return;
await loadSub();
@@ -186,7 +233,11 @@ async function loadSub(): Promise<void> {
}
isLast.value = resp.is_last;
lastId.value = resp.last_id;
subReplies.value = subReplies.value.concat(resp.list);
// Filter out duplicates using persistent existingIds Set
const newReplies = resp.list.filter((r) => !existingIds.has(r.reply.reply_id));
// Add new reply IDs to the Set
newReplies.forEach((r) => existingIds.add(r.reply.reply_id));
subReplies.value = subReplies.value.concat(newReplies);
loading.value = false;
if (isLast.value) showSnackbar.warn("没有更多了");
}

View File

@@ -244,6 +244,10 @@
"Title": "关于塔利雅…",
"Context": "旅者,你想要聆听风的声音,寻求风的指引吗?那么…去找这位助祭肯定没错。哈哈,开个玩笑。你随时可以找我一边喝酒一边聊天噢!至于我平时拜托塔利雅传递消息的原因…你已经猜到了吧?他热衷于追逐喧嚣的风,却从来不会失去自己的方向。"
},
{
"Title": "关于杜林…",
"Context": "又有一位魔女小姐的孩子在蒙德落脚了,真热闹呀。这位小朋友看起来很想跟我的一位大朋友一起玩,于是我便做了一些微不足道的引荐工作。让人惊喜的是,大朋友比小朋友还激动,高兴得上蹿下跳,拍着肚皮转圈跳舞呢。哎呀,被大朋友听见了?快快,我们往树后躲一躲。"
},
{ "Title": "想要了解温迪·其一", "Context": "来得正好,旅行者。我想听听,你的愿望是什么?" },
{
"Title": "想要了解温迪·其二",

View File

@@ -248,6 +248,7 @@
"Title": "关于闲云…",
"Context": "再过些时日,我打算约闲云女士去茶室小坐。我恰巧收集了一些机巧玩物,想听听她的高见,也想趁此机会,听她讲讲仙人在璃月港生活的趣事。至于为我递这份邀请函的人选…你可有意?"
},
{ "Title": "关于白马仙人…", "Context": "好感等级达到4后开启" },
{
"Title": "想要了解凝光·其一",
"Context": "既然是你,就不用恪守那些繁文缛节了,可以随意一些。不过,希望你能珍惜我的时间,言简意赅地进行说明。"

View File

@@ -237,6 +237,10 @@
"Title": "关于砂糖…",
"Context": "砂糖姐姐是好人!她问我,想不想要一个会跑会跳的蹦蹦!嘿嘿嘿,当然想要了,我还想要好几百个,让蒙德的街道上,台阶上,广场上,全是蹦蹦!"
},
{
"Title": "关于杜林…",
"Context": "好耶!小杜林哥哥现在住在蒙德,我要天天找他玩!炸鱼的时候,我想让小杜林哥哥带我飞上很高很高的地方,在天上丢蹦蹦炸弹!"
},
{ "Title": "想要了解可莉·其一", "Context": "你好!你是来找可莉玩的吗?" },
{
"Title": "想要了解可莉·其二",

View File

@@ -250,6 +250,10 @@
"Title": "关于赫布里穆…",
"Context": "纳塔将悠久的抗争历史谱写成诗,激励无数战士前仆后继,由此得以跨越深渊与宿命的威胁,创造出新的未来。而率领他们的,是一位将时间与历史化作武器的人类战士,她足以与神明比肩的实力,令人叹服。"
},
{
"Title": "关于白马仙人…",
"Context": "白马,白马…这位仙人,其实也是我许久未见的一位故人。只可叹,春夜短,秋日速,隙过驹,风过烛。"
},
{
"Title": "想要了解钟离·其一",
"Context": "怎么,难得闲暇,不好好休息却反来找我。是想听我讲故事吗?"

View File

@@ -215,6 +215,10 @@
"Title": "关于芭芭拉…",
"Context": "芭芭拉吗?她是一位认真的牧师,说来以前碰巧有机会给她画过速写…你问画哪儿去了?嗯…我拒绝了艾伯特的开价,直接把画送给了代理团长。人际交往…真是耗费精力啊。"
},
{
"Title": "关于杜林…",
"Context": "我的这位兄弟正在探索很多新事物,他很聪明,也热衷于思考。尽管他时常因此陷入某些认知困境,但这并不妨碍他对这个世界保持热情。他的到来,或者说,他作为家人的到来让我对人类关系的理解有了新的心得。现在,有了最亲近的同类,家里变得热闹起来,这无疑是一种好的改变。"
},
{
"Title": "想要了解阿贝多·其一",
"Context": "有问题想问我?请说吧。啊,冒昧地问一句,应该不会花费太长时间吧?手头的研究马上要进入最后一个阶段了。"

View File

@@ -240,6 +240,10 @@
"Title": "关于赛索斯…",
"Context": "你为什么觉得我会认识他?就因为他经常追在我后面吵着要看我的帽子吗?我们没怎么说过话,第一次见面他就问我是不是叫阿帽,这又何尝不是以貌取人?哼,阿帽,这个名字倒是流传开了…"
},
{
"Title": "关于杜林…",
"Context": "那个家伙刚从书里出来时就已经带了一堆问题了,没想到成为人类之后,问题更多。每次都把信写那么长,字还歪七扭八的看都看不清楚,啧。\n你要是闲得慌倒是可以去给他解答解答。免得他走上什么歪路。"
},
{ "Title": "想要了解流浪者·其一", "Context": "真稀奇,竟然想了解我。会招来麻烦哦?" },
{
"Title": "想要了解流浪者·其二",

View File

@@ -237,6 +237,7 @@
"Title": "关于嘉明…",
"Context": "嘉明这小伙为人热情,说话有趣,也从来不会夸夸其谈,本仙很是看重。说起最初和他相遇的时候,看到本仙在池边歇脚,他还主动拿出吃食与本仙分享呢。后来他说在他眼里,比起仙人,本仙更像是一位可敬的长辈。呵呵,那本仙自然也该以长辈的身份照顾他喽。"
},
{ "Title": "关于兹白…", "Context": "好感等级达到4后开启" },
{
"Title": "想要了解闲云·其一",
"Context": "想要知道本仙的过往?都是些陈年旧事,有什么好说的。倒是你四处游历,想必遇到不少趣事。其他国家的神仙是何样貌?可有哪位像本仙一样喜欢聊天?沏壶好茶,跟本仙细细说来。"

View File

@@ -141,7 +141,7 @@
},
{
"Title": "月之轮",
"Context": "菈乌玛非常感谢月之轮的存在,也非常高兴自己能拥有一枚,但说实话,这东西让她着实有些困扰,因为她不记得自己是如何获得它的了。\n莎莱卡奶奶说她的这枚月之轮是从襁褓中掉出来的。虽然父母当时正被新生命的到来冲昏头脑记忆有些模糊但也确认了这个说法。\n如果将她的烦恼说给其他人听他们一定会很困惑出生起就拥有了一枚月之轮这不是一件好事吗有什么值得烦忧的吗\n似乎除了她自己以外没有人觉得一个婴儿身边出现一枚月之轮有什么问题「如果是咏月使大人身上无论发生什么奇迹都很正常呢。」\n菈乌玛有口难辩。想要知道一件事的起源是多么自然、多么符合人类好奇心的事情啊但她查遍了书库的资料都没能找到令人满意的解答。\n她一直没有放下这个问题以至于在后来见到月之少女时忍不住询问了此事。\n库塔尔的回答极其简单「哦」\n…因此最接近的解释还是来自「亥珀波瑞亚」。黄金国历史悠久可追溯至天空中升起三轮月亮的时期。他们学会了利用月亮的潮汐驱动魔法但也因此受月之潮汐的影响有些人的头顶上长出了类似鹿角的角冠。\n然而在那个时候世界上有月之轮这种东西吗\n菈乌玛就这样皱眉思考大概是不小心自言自语了起来引得一个路过的小孩大声问道「菈乌玛大人你是因为有了月之轮头上的角才这么长的吗」\n菈乌玛眉头皱得更紧了「这个…应该…抱歉我不清楚。」\n让小孩失望肯定是菈乌玛不想见到的事情但意外的是小朋友似乎并没有因为问题得不到解答而感到沮丧。\n「哦」他恍然大悟「难道其实是因为头上的角最长菈乌玛姐姐才拿到了月之轮的吗」\n有那么一瞬间菈乌玛从内心深处想要接受这个答案。多么巧妙啊这从前到后、再从后到前的因果轮回似乎可以拿来解释搪塞很多问题。\n然后她马上制止了自己以免陷入谎言的陷阱。\n又或许这就是这枚月之轮的作用是对她的警戒、限制以及责任的象征。\n于是她将那枚令她不解的月之轮握在手心在心中又默念了一遍三条戒律。"
"Context": "菈乌玛非常感谢月之轮的存在,也非常高兴自己能拥有一枚,但说实话,这东西让她着实有些困扰,因为她不记得自己是如何获得它的了。\n莎莱卡奶奶说她的这枚月之轮是从襁褓中掉出来的。虽然父母当时正被新生命的到来冲昏头脑记忆有些模糊但也确认了这个说法。\n如果将她的烦恼说给其他人听他们一定会很困惑出生起就拥有了一枚月之轮这不是一件好事吗有什么值得烦忧的吗\n似乎除了她自己以外没有人觉得一个婴儿身边出现一枚月之轮有什么问题「如果是咏月使大人身上无论发生什么奇迹都很正常呢。」\n菈乌玛有口难辩。想要知道一件事的起源是多么自然、多么符合人类好奇心的事情啊但她查遍了书库的资料都没能找到令人满意的解答。\n她一直没有放下这个问题以至于在后来见到月之少女时忍不住询问了此事。\n库塔尔的回答极其简单「哦」\n…因此最接近的解释还是来自「亥珀波瑞亚」。黄金国历史悠久可追溯至天空中升起三轮月亮的时期。他们学会了利用月亮的潮汐驱动魔法但也因此受月之潮汐的影响有些人的头顶上长出了类似鹿角的角冠。\n然而在那个时候世界上有月之轮这种东西吗\n菈乌玛就这样皱眉思考大概是不小心自言自语了起来引得一个路过的小孩大声问道「菈乌玛大人你是因为有了月之轮头上的角才这么长的吗」\n菈乌玛眉头皱得更紧了「这个…应该…抱歉我不清楚。」\n让小孩失望肯定是菈乌玛不想见到的事情但意外的是小朋友似乎并没有因为问题得不到解答而感到沮丧。\n「哦」他恍然大悟「难道其实是因为头上的角最长菈乌玛姐姐才拿到了月之轮的吗」\n有那么一瞬间菈乌玛从内心深处想要接受这个答案。多么巧妙啊这从前到后、再从后到前的因果轮回似乎可以拿来解释搪塞很多问题。\n然后她马上制止了自己以免陷入谎言的陷阱。\n又或许这就是这枚月之轮的作用是对她的警戒、限制以及责任的象征。\n于是她将那枚令她不解的月之轮握在手心在心中又默念了一遍三条戒律。"
}
],
"talks": [

View File

@@ -23,7 +23,8 @@
"Id": 1223,
"Name": "诳言掩虚实之迹",
"Description": "元素战技<color=#FFD780FF>{LINK#S11222}弈术·千夜一舞{/LINK}</color>的技能等级提高3级。\n至多提升至15级。",
"Icon": "UI_Talent_U_Nefer_01"
"Icon": "UI_Talent_U_Nefer_01",
"ExtraLevel": { "Index": 2, "Level": 3 }
},
{
"Id": 1224,
@@ -35,7 +36,8 @@
"Id": 1225,
"Name": "见机在忽微之间",
"Description": "元素爆发<color=#FFD780FF>{LINK#S11225}圣约·真眸幻戏{/LINK}</color>的技能等级提高3级。\n至多提升至15级。",
"Icon": "UI_Talent_U_Nefer_02"
"Icon": "UI_Talent_U_Nefer_02",
"ExtraLevel": { "Index": 9, "Level": 3 }
},
{
"Id": 1226,
@@ -103,7 +105,7 @@
"GroupId": 12225,
"Id": 1222501,
"Name": "金室的密谋",
"Description": "在挪德卡莱执行时长为20小时的探索派遣任务时获得的奖励增加25%。\n此外作为「秘闻馆」的主人奈芙尔似乎可以通过某种方式获取各方的情报而那夏镇的某些势力似乎对这些情报很有兴趣…",
"Description": "在挪德卡莱执行时长为20小时的探索派遣任务时获得的奖励增加25%。",
"Icon": "UI_Talent_S_Nefer_08"
}
],

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