Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc03edd30b | ||
|
|
d18b6bb898 | ||
|
|
0f107abde6 | ||
|
|
46cf40734f | ||
|
|
676ef8e8ee | ||
|
|
5d8ea639b8 | ||
|
|
bc64e20ebd | ||
|
|
98efd557d6 | ||
|
|
b0b3120b7b | ||
|
|
c2ea3cf026 | ||
|
|
90d71be17e | ||
|
|
9435622a6d | ||
|
|
5ac9c24379 | ||
|
|
9e359b9621 | ||
|
|
cde9149bbd | ||
|
|
b38c3f9fbe | ||
|
|
92ad548061 | ||
|
|
37cea99bbd | ||
|
|
b267599039 | ||
|
|
1d204c8284 | ||
|
|
51ce0217f0 | ||
|
|
fac394be8b | ||
|
|
a12a12e786 | ||
|
|
2d1890645d | ||
|
|
38f3301664 | ||
|
|
14c47369e7 | ||
|
|
93be279cbb | ||
|
|
aca47f822b | ||
|
|
ccb4730c82 | ||
|
|
670e9deba3 | ||
|
|
93cca5f715 | ||
|
|
d787b8dc8b | ||
|
|
6b90dde0ab | ||
|
|
323b951c10 | ||
|
|
4c3648481e | ||
|
|
afcba5ec1a | ||
|
|
725d62b755 | ||
|
|
e72d6b1b9f | ||
|
|
482c7fb1c9 | ||
|
|
d84d68607b | ||
|
|
758f0d519f | ||
|
|
72b7dc5405 | ||
|
|
50a528d25b | ||
|
|
4d937b365b | ||
|
|
1124927c0e | ||
|
|
b3c42428e9 | ||
|
|
db03f211d4 | ||
|
|
5df5868549 | ||
|
|
b5e4b013c9 | ||
|
|
6af43bf957 | ||
|
|
256b529b16 | ||
|
|
c521f3cc26 | ||
|
|
dcc0d7d052 | ||
|
|
3a542ead17 | ||
|
|
dce90b64a6 | ||
|
|
2fdb2e7b51 | ||
|
|
586b506fca | ||
|
|
45ff02b998 | ||
|
|
955a1a3c54 | ||
|
|
5b5589d213 | ||
|
|
759804a99a | ||
|
|
310c1c91cf | ||
|
|
954fb1a1e8 | ||
|
|
17f5a87e31 | ||
|
|
7a9ef78376 | ||
|
|
aa5ef06ffd | ||
|
|
260e9ce4dd | ||
|
|
e45ebff0fc | ||
|
|
04c1bd0446 | ||
|
|
239196149e | ||
|
|
77da679a70 | ||
|
|
50383c2365 | ||
|
|
a099e4e413 | ||
|
|
df8af9eecd | ||
|
|
0ba4690085 | ||
|
|
98c911469a | ||
|
|
999ddc708c | ||
|
|
5298ecdd0a | ||
|
|
dc18cd75a7 | ||
|
|
1f4248bde8 | ||
|
|
ae7b4acb88 | ||
|
|
2ab31d8f5c | ||
|
|
1af990512d | ||
|
|
d96d451156 | ||
|
|
f029306ebb | ||
|
|
d3c5baa0c2 | ||
|
|
ba0802752c | ||
|
|
ff94e12ff5 | ||
|
|
0fbf1f7c2a | ||
|
|
68809a93c6 | ||
|
|
0edcadef63 | ||
|
|
9f9c30914f | ||
|
|
04cf372798 | ||
|
|
6617a26c90 | ||
|
|
d244423800 | ||
|
|
3366efaadd | ||
|
|
d74e7a7a31 | ||
|
|
2d0b409813 | ||
|
|
942068faea | ||
|
|
0f0f7684d2 |
42
.github/ISSUE_TEMPLATE/source_update.yml
vendored
@@ -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: 请填写其他信息
|
||||
10
.github/workflows/build.yml
vendored
@@ -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
@@ -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
|
||||
53
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
21
README.md
@@ -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`
|
||||
|
||||
[](https://deepwiki.com/BTMuli/TeyvatGuide)  
|
||||
[](https://deepwiki.com/BTMuli/TeyvatGuide)
|
||||
|
||||
   
|
||||
[](https://github.com/BTMuli/TeyvatGuide/commits) [](https://github.com/BTMuli/TeyvatGuide/commits)
|
||||
|
||||
[](./docs/standards/UIAF.md) [](./docs/standards/UIGF3.md) [](./docs/standards/UIGF.md)
|
||||
|
||||
[](./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.0,UIGF 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)
|
||||
|
||||
[](https://star-history.com/#BTMuli/TeyvatGuide&Timeline)
|
||||
|
||||
@@ -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):成就数据仓库,成就数据的详细信息来源于此。
|
||||
|
||||
## 字体
|
||||
|
||||
62
package.json
@@ -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
BIN
public/WIKI/character/10000123.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/WIKI/character/10000124.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/WIKI/nameCard/bg/庆典·梦读.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/WIKI/nameCard/bg/杜林·曜心.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/WIKI/nameCard/bg/纪行·炽雪.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/WIKI/nameCard/bg/雅珂达·帮手.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/WIKI/nameCard/icon/庆典·梦读.webp
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
public/WIKI/nameCard/icon/杜林·曜心.webp
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
public/WIKI/nameCard/icon/纪行·炽雪.webp
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
public/WIKI/nameCard/icon/雅珂达·帮手.webp
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
public/WIKI/nameCard/profile/庆典·梦读.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/WIKI/nameCard/profile/杜林·曜心.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/WIKI/nameCard/profile/纪行·炽雪.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/WIKI/nameCard/profile/雅珂达·帮手.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/WIKI/weapon/11518.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/WIKI/weapon/15434.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/WIKI/weapon/15515.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/icon/constellations/UI_Talent_S_Durin_03.webp
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
public/icon/constellations/UI_Talent_S_Durin_04.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/icon/constellations/UI_Talent_S_Durin_05.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/icon/constellations/UI_Talent_S_Durin_06.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/icon/constellations/UI_Talent_S_Jahoda_01.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/icon/constellations/UI_Talent_S_Jahoda_02.webp
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/icon/constellations/UI_Talent_S_Jahoda_03.webp
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
public/icon/constellations/UI_Talent_S_Jahoda_04.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/icon/constellations/UI_Talent_U_Durin_01.webp
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
public/icon/constellations/UI_Talent_U_Durin_02.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/icon/constellations/UI_Talent_U_Jahoda_01.webp
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/icon/constellations/UI_Talent_U_Jahoda_02.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/icon/material/113080.webp
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
public/icon/material/220126.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/icon/talents/Skill_E_Durin_01.webp
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/icon/talents/Skill_E_Jahoda_01.webp
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/icon/talents/Skill_S_Durin_01.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/icon/talents/Skill_S_Jahoda_01.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/icon/talents/UI_Talent_S_Durin_01.webp
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/icon/talents/UI_Talent_S_Durin_02.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/icon/talents/UI_Talent_S_Durin_07.webp
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/icon/talents/UI_Talent_S_Jahoda_05.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/icon/talents/UI_Talent_S_Jahoda_06.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/icon/talents/UI_Talent_S_Jahoda_07.webp
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/icon/talents/UI_Talent_S_Jahoda_08.webp
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
3
src-tauri/.gitignore
vendored
@@ -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
@@ -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"
|
||||
|
||||
BIN
src-tauri/lib/YaeAchievementLib.dll
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
95
src/components/app/t-postWidth.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
198
src/components/app/to-switchAc.vue
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>
|
||||
|
||||
177
src/components/userGacha/gro-chart-calendar.vue
Normal 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>
|
||||
@@ -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>
|
||||
186
src/components/userGacha/gro-chart-stackbar.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`"
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("没有更多了");
|
||||
}
|
||||
|
||||
@@ -244,6 +244,10 @@
|
||||
"Title": "关于塔利雅…",
|
||||
"Context": "旅者,你想要聆听风的声音,寻求风的指引吗?那么…去找这位助祭肯定没错。哈哈,开个玩笑。你随时可以找我一边喝酒一边聊天噢!至于我平时拜托塔利雅传递消息的原因…你已经猜到了吧?他热衷于追逐喧嚣的风,却从来不会失去自己的方向。"
|
||||
},
|
||||
{
|
||||
"Title": "关于杜林…",
|
||||
"Context": "又有一位魔女小姐的孩子在蒙德落脚了,真热闹呀。这位小朋友看起来很想跟我的一位大朋友一起玩,于是我便做了一些微不足道的引荐工作。让人惊喜的是,大朋友比小朋友还激动,高兴得上蹿下跳,拍着肚皮转圈跳舞呢。哎呀,被大朋友听见了?快快,我们往树后躲一躲。"
|
||||
},
|
||||
{ "Title": "想要了解温迪·其一", "Context": "来得正好,旅行者。我想听听,你的愿望是什么?" },
|
||||
{
|
||||
"Title": "想要了解温迪·其二",
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"Title": "关于闲云…",
|
||||
"Context": "再过些时日,我打算约闲云女士去茶室小坐。我恰巧收集了一些机巧玩物,想听听她的高见,也想趁此机会,听她讲讲仙人在璃月港生活的趣事。至于为我递这份邀请函的人选…你可有意?"
|
||||
},
|
||||
{ "Title": "关于白马仙人…", "Context": "好感等级达到4后开启" },
|
||||
{
|
||||
"Title": "想要了解凝光·其一",
|
||||
"Context": "既然是你,就不用恪守那些繁文缛节了,可以随意一些。不过,希望你能珍惜我的时间,言简意赅地进行说明。"
|
||||
|
||||
@@ -237,6 +237,10 @@
|
||||
"Title": "关于砂糖…",
|
||||
"Context": "砂糖姐姐是好人!她问我,想不想要一个会跑会跳的蹦蹦!嘿嘿嘿,当然想要了,我还想要好几百个,让蒙德的街道上,台阶上,广场上,全是蹦蹦!"
|
||||
},
|
||||
{
|
||||
"Title": "关于杜林…",
|
||||
"Context": "好耶!小杜林哥哥现在住在蒙德,我要天天找他玩!炸鱼的时候,我想让小杜林哥哥带我飞上很高很高的地方,在天上丢蹦蹦炸弹!"
|
||||
},
|
||||
{ "Title": "想要了解可莉·其一", "Context": "你好!你是来找可莉玩的吗?" },
|
||||
{
|
||||
"Title": "想要了解可莉·其二",
|
||||
|
||||
@@ -250,6 +250,10 @@
|
||||
"Title": "关于赫布里穆…",
|
||||
"Context": "纳塔将悠久的抗争历史谱写成诗,激励无数战士前仆后继,由此得以跨越深渊与宿命的威胁,创造出新的未来。而率领他们的,是一位将时间与历史化作武器的人类战士,她足以与神明比肩的实力,令人叹服。"
|
||||
},
|
||||
{
|
||||
"Title": "关于白马仙人…",
|
||||
"Context": "白马,白马…这位仙人,其实也是我许久未见的一位故人。只可叹,春夜短,秋日速,隙过驹,风过烛。"
|
||||
},
|
||||
{
|
||||
"Title": "想要了解钟离·其一",
|
||||
"Context": "怎么,难得闲暇,不好好休息却反来找我。是想听我讲故事吗?"
|
||||
|
||||
@@ -215,6 +215,10 @@
|
||||
"Title": "关于芭芭拉…",
|
||||
"Context": "芭芭拉吗?她是一位认真的牧师,说来以前碰巧有机会给她画过速写…你问画哪儿去了?嗯…我拒绝了艾伯特的开价,直接把画送给了代理团长。人际交往…真是耗费精力啊。"
|
||||
},
|
||||
{
|
||||
"Title": "关于杜林…",
|
||||
"Context": "我的这位兄弟正在探索很多新事物,他很聪明,也热衷于思考。尽管他时常因此陷入某些认知困境,但这并不妨碍他对这个世界保持热情。他的到来,或者说,他作为家人的到来让我对人类关系的理解有了新的心得。现在,有了最亲近的同类,家里变得热闹起来,这无疑是一种好的改变。"
|
||||
},
|
||||
{
|
||||
"Title": "想要了解阿贝多·其一",
|
||||
"Context": "有问题想问我?请说吧。啊,冒昧地问一句,应该不会花费太长时间吧?手头的研究马上要进入最后一个阶段了。"
|
||||
|
||||
@@ -240,6 +240,10 @@
|
||||
"Title": "关于赛索斯…",
|
||||
"Context": "你为什么觉得我会认识他?就因为他经常追在我后面吵着要看我的帽子吗?我们没怎么说过话,第一次见面他就问我是不是叫阿帽,这又何尝不是以貌取人?哼,阿帽,这个名字倒是流传开了…"
|
||||
},
|
||||
{
|
||||
"Title": "关于杜林…",
|
||||
"Context": "那个家伙刚从书里出来时就已经带了一堆问题了,没想到成为人类之后,问题更多。每次都把信写那么长,字还歪七扭八的看都看不清楚,啧。\n你要是闲得慌,倒是可以去给他解答解答。免得他走上什么歪路。"
|
||||
},
|
||||
{ "Title": "想要了解流浪者·其一", "Context": "真稀奇,竟然想了解我。会招来麻烦哦?" },
|
||||
{
|
||||
"Title": "想要了解流浪者·其二",
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
"Title": "关于嘉明…",
|
||||
"Context": "嘉明这小伙为人热情,说话有趣,也从来不会夸夸其谈,本仙很是看重。说起最初和他相遇的时候,看到本仙在池边歇脚,他还主动拿出吃食与本仙分享呢。后来他说在他眼里,比起仙人,本仙更像是一位可敬的长辈。呵呵,那本仙自然也该以长辈的身份照顾他喽。"
|
||||
},
|
||||
{ "Title": "关于兹白…", "Context": "好感等级达到4后开启" },
|
||||
{
|
||||
"Title": "想要了解闲云·其一",
|
||||
"Context": "想要知道本仙的过往?都是些陈年旧事,有什么好说的。倒是你四处游历,想必遇到不少趣事。其他国家的神仙是何样貌?可有哪位像本仙一样喜欢聊天?沏壶好茶,跟本仙细细说来。"
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||