commit 4cb96f794d8f30b250d06a40919fec7ba8840521 Author: wehub-resource-sync Date: Mon Jul 13 13:11:28 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..0670c6e --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,71 @@ +## 配置cargo的国内源 (依赖很多所以还是需要慢慢等待,不至于卡死下载不了) +[source.crates-io] +replace-with = 'rsproxy-sparse' +[source.rsproxy] +registry = "https://rsproxy.cn/crates.io-index" +[source.rsproxy-sparse] +registry = "sparse+https://rsproxy.cn/index/" +[registries.rsproxy] +index = "https://rsproxy.cn/crates.io-index" +[net] +git-fetch-with-cli = true + +[env] +CFLAGS_aarch64_apple_darwin = "-fno-modules" +CFLAGS_x86_64_apple_darwin = "-fno-modules" + +# Android NDK 链接配置 +[target.aarch64-linux-android] +linker = "aarch64-linux-android26-clang" +rustflags = [ + "-C", + "link-arg=-lc++_shared", + "-C", + "link-arg=-llog", + "-C", + "link-arg=-landroid", +] + +[target.armv7-linux-androideabi] +linker = "armv7a-linux-androideabi26-clang" +rustflags = [ + "-C", + "link-arg=-lc++_shared", + "-C", + "link-arg=-llog", + "-C", + "link-arg=-landroid", +] + +[target.i686-linux-android] +linker = "i686-linux-android26-clang" +rustflags = [ + "-C", + "link-arg=-lc++_shared", + "-C", + "link-arg=-llog", + "-C", + "link-arg=-landroid", +] + +[target.x86_64-linux-android] +linker = "x86_64-linux-android26-clang" +rustflags = [ + "-C", + "link-arg=-lc++_shared", + "-C", + "link-arg=-llog", + "-C", + "link-arg=-landroid", +] + +# Windows OpenSSL 编译需要原生 Windows Perl (Strawberry Perl) +# winget 安装的默认路径,避免与 MSYS2/Cygwin 的 perl 冲突 +[target.x86_64-pc-windows-msvc.env] +PERL = "C:/Strawberry/perl/bin/perl.exe" + +[target.i686-pc-windows-msvc.env] +PERL = "C:/Strawberry/perl/bin/perl.exe" + +[target.aarch64-pc-windows-msvc.env] +PERL = "C:/Strawberry/perl/bin/perl.exe" diff --git a/.clinerules b/.clinerules new file mode 120000 index 0000000..8a63b64 --- /dev/null +++ b/.clinerules @@ -0,0 +1 @@ +.rules \ No newline at end of file diff --git a/.cursorrules b/.cursorrules new file mode 120000 index 0000000..8a63b64 --- /dev/null +++ b/.cursorrules @@ -0,0 +1 @@ +.rules \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3f98608 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# 编辑器配置, @see: http://editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +# 对于自动生成的文件,保持原有格式 +[src-tauri/gen/schemas/**] +insert_final_newline = false +trim_trailing_whitespace = false + +[tauri-plugin-hula/permissions/schemas/**] +insert_final_newline = false +trim_trailing_whitespace = false \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..f7546df --- /dev/null +++ b/.env @@ -0,0 +1,9 @@ +# 项目名称 +VITE_APP_NAME="HuLa" +# 客户端项目地址 +VITE_PC_URL="https://github.com/HuLaSpark/HuLa" +# 后端项目地址 +VITE_SERVICE_URL="https://github.com/HuLaSpark/HuLa-Server" + +# gitee token +VITE_GITEE_TOKEN="a9029798336825cea39ac9e4413b8579" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..40e5e1e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,97 @@ +# Auto detect text files and perform LF normalization +* text=auto eol=lf + +# 行尾符设置 +"*.rs" eol=lf +"Cargo.toml" eol=lf +"Cargo.lock" eol=lf +"*.vue" eol=lf +"*.js" eol=lf +"*.ts" eol=lf +"*.jsx" eol=lf +"*.tsx" eol=lf +"*.cjs" eol=lf +"*.cts" eol=lf +"*.mjs" eol=lf +"*.mts" eol=lf +"*.json" eol=lf +"*.html" eol=lf +"*.css" eol=lf +"*.less" eol=lf +"*.scss" eol=lf +"*.sass" eol=lf +"*.styl" eol=lf +"*.md" eol=lf + +# 代码统计设置 - 只统计 Vue、TypeScript 和 Rust 文件 +# 排除 JavaScript 文件 +*.js linguist-vendored +*.jsx linguist-vendored +*.cjs linguist-vendored +*.mjs linguist-vendored + +# 排除样式文件 +*.css linguist-vendored +*.scss linguist-vendored +*.sass linguist-vendored +*.less linguist-vendored +*.styl linguist-vendored + +# 排除配置和文档文件 +*.json linguist-vendored +*.html linguist-vendored +*.md linguist-documentation +*.yml linguist-vendored +*.yaml linguist-vendored + +# 排除其他语言文件 +*.kt linguist-vendored +*.java linguist-vendored +*.py linguist-vendored +*.nsh linguist-vendored +*.nsi linguist-vendored + +# 确保构建目录和依赖目录不被统计 +**/target/** linguist-vendored +**/node_modules/** linguist-vendored +scripts/** linguist-vendored +**/build/** linguist-vendored +**/dist/** linguist-vendored + +# 确保主要代码文件被正确识别 +*.vue linguist-detectable +*.ts linguist-detectable +*.tsx linguist-language=TypeScript +*.rs linguist-detectable + +## 语言统计控制:排除 Ruby / Shell / Objective-C++ +# Ruby(例如 CocoaPods 的 Podfile 等) +*.rb linguist-vendored +**/Podfile linguist-vendored +**/Podfile.lock linguist-vendored +*.podspec* linguist-vendored + +# Shell(包括 Android 的 gradlew) +*.sh linguist-vendored +**/gradlew linguist-vendored + +# Objective-C++ / Objective-C(若仓库中存在 .m 可按需启用) +*.mm linguist-vendored +# *.m linguist-vendored + +# 生成产物与平台工程整体排除,避免引入非 Rust 语言统计 +src-tauri/gen/** linguist-vendored +src-tauri/gen/android/** linguist-vendored +src-tauri/gen/apple/** linguist-vendored +tauri-plugin-hula/ios/** linguist-vendored + +# tauri-plugin-hula:仅统计 src 目录,其他子目录全部排除 +tauri-plugin-hula/*/** linguist-vendored +tauri-plugin-hula/src/** -linguist-vendored + +# 如果需要把上面这些都算作 Rust,而不是排除: +# *.rb linguist-language=Rust +# *.sh linguist-language=Rust +# *.mm linguist-language=Rust +# **/Podfile linguist-language=Rust +# **/gradlew linguist-language=Rust diff --git a/.gitee/ISSUE_TEMPLATE/bug.yml b/.gitee/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..d011153 --- /dev/null +++ b/.gitee/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,58 @@ +name: '🐛 反馈缺陷' +description: '反馈一个问题缺陷' +title: '[bug] ' +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + ## 首先 + 1.请先搜索 [现有问题](https://gitee.com/HuLaSpark/HuLa/issues)关于此问题。
+ 2.确保 rustc 和所有相关的 HuLa 包都是最新的。
+ 3.确保这是 HuLa 的问题,而不是你正在使用的其他东西。
+ 4.请记住遵循我们的社区准则并保持友好。
+ + - type: input + attributes: + label: '📌 软件版本' + validations: + required: true + + - type: dropdown + attributes: + label: '💻 系统环境' + multiple: true + options: + - 'Windows' + - 'macOS' + validations: + required: true + + - type: textarea + attributes: + label: '🐛 问题描述' + description: 请提供一个清晰且简洁的问题描述,如果适用,请包括屏幕截图。 + validations: + required: true + + - type: textarea + id: info + attributes: + label: '☄️ 完整的 `pnpm tauri info` 输出' + description: '请运行 “pnpm tauri info” 在控制台等待输出完毕,并将输出内容复制到此处' + render: text + + - type: textarea + attributes: + label: '📷 复现步骤' + description: 请提供一个清晰且简洁的描述,说明如何复现问题。 + + - type: textarea + attributes: + label: '🚦 期望结果' + description: 请提供一个清晰且简洁的描述,说明您期望发生什么。 + + - type: textarea + attributes: + label: '📝 补充信息' + description: 如果您的问题需要进一步说明,或者您遇到的问题无法在一个简单的示例中复现,请在这里添加更多信息。 diff --git a/.gitee/ISSUE_TEMPLATE/feature.yml b/.gitee/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..7eaae61 --- /dev/null +++ b/.gitee/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,35 @@ +name: 💡 功能请求 +title: '[功能建议] ' +description: 提出想法 +labels: ['enhancement'] + +body: + - type: textarea + id: problem + attributes: + label: 描述问题 + description: 明确描述此功能将解决的问题 + placeholder: '我总是感到困扰...' + validations: + required: true + + - type: textarea + id: solution + attributes: + label: '描述您想要的解决方案' + description: 明确说明您希望做出的改变 + placeholder: '我想...' + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: 考虑的替代方案 + description: '您考虑过的任何替代解决方案' + + - type: textarea + id: context + attributes: + label: 其他上下文 + description: 在此处添加有关问题的任何其他上下文。 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..8ff200b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +open_collective: HuLaSpark +custom: https://opencollective.com/hulaspark diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..3d425ea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,62 @@ +name: '🐛 Bug Report' +description: 'Report an bug' +title: '[bug] ' +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + ## First of all + 1. Please search for [existing issues](https://github.com/HuLaSpark/HuLa/issues?q=is%3Aissue) about this problem first. + 2. Make sure `rustc` and all relevant HuLa packages are up to date. + 3. Make sure it's an issue with HuLa and not something else you are using. + 4. Remember to follow our community guidelines and be friendly. + + - type: input + attributes: + label: '📌 Version' + validations: + required: true + + - type: dropdown + attributes: + label: '💻 Operating System' + multiple: true + options: + - 'Windows 10' + - 'Windows 11' + - 'macOS' + - 'Linux' + - 'Android' + - 'ios' + validations: + required: true + + - type: textarea + attributes: + label: '🐛 Bug Description' + description: A clear and concise description of the bug, Include screenshots if applicable. + validations: + required: true + + - type: textarea + id: info + attributes: + label: '☄️ intact `pnpm tauri info` output' + description: 'Please run "pnpm tauri info" in the console and wait for the output to finish, then copy the output here' + render: text + + - type: textarea + attributes: + label: '📷 Recurrence Steps' + description: A clear and concise description of how to recurrence. + + - type: textarea + attributes: + label: '🚦 Expected Behavior' + description: A clear and concise description of what you expected to happen. + + - type: textarea + attributes: + label: '📝 Additional Information' + description: If your problem needs further explanation, or if the issue you're seeing cannot be reproduced in a gist, please add more information here. diff --git a/.github/ISSUE_TEMPLATE/bug_report_cn.yml b/.github/ISSUE_TEMPLATE/bug_report_cn.yml new file mode 100644 index 0000000..671c26b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report_cn.yml @@ -0,0 +1,62 @@ +name: '🐛 反馈缺陷' +description: '反馈一个问题缺陷' +title: '[bug] ' +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + ## 首先 + 1.请先搜索 [现有问题](https://github.com/HuLaSpark/HuLa/issues?q=is%3Aissue)关于此问题。 + 2.确保 rustc 和所有相关的 HuLa 包都是最新的。 + 3.确保这是 HuLa 的问题,而不是你正在使用的其他东西。 + 4.请记住遵循我们的社区准则并保持友好。 + + - type: input + attributes: + label: '📌 软件版本' + validations: + required: true + + - type: dropdown + attributes: + label: '💻 系统环境' + multiple: true + options: + - 'Windows 10' + - 'Windows 11' + - 'macOS' + - 'Linux' + - 'Android' + - 'ios' + validations: + required: true + + - type: textarea + attributes: + label: '🐛 问题描述' + description: 请提供一个清晰且简洁的问题描述,如果适用,请包括屏幕截图。 + validations: + required: true + + - type: textarea + id: info + attributes: + label: '☄️ 完整的 `pnpm tauri info` 输出' + description: '请运行 “pnpm tauri info” 在控制台等待输出完毕,并将输出内容复制到此处' + render: text + + - type: textarea + attributes: + label: '📷 复现步骤' + description: 请提供一个清晰且简洁的描述,说明如何复现问题。 + + - type: textarea + attributes: + label: '🚦 期望结果' + description: 请提供一个清晰且简洁的描述,说明您期望发生什么。 + + - type: textarea + attributes: + label: '📝 补充信息' + description: 如果您的问题需要进一步说明,或者您遇到的问题无法在一个简单的示例中复现,请在这里添加更多信息。 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..503886c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,35 @@ +name: 💡 Feature Request +title: '[feat] ' +description: Suggest an idea +labels: ['enhancement'] + +body: + - type: textarea + id: problem + attributes: + label: Describe the problem + description: A clear description of the problem this feature would solve + placeholder: "I'm always frustrated when..." + validations: + required: true + + - type: textarea + id: solution + attributes: + label: "Describe the solution you'd like" + description: A clear description of what change you would like + placeholder: 'I would like to...' + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: "Any alternative solutions you've considered" + + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request_cn.yml b/.github/ISSUE_TEMPLATE/feature_request_cn.yml new file mode 100644 index 0000000..edfc7d8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request_cn.yml @@ -0,0 +1,35 @@ +name: 💡 功能请求 +title: '[feat] ' +description: 提出想法 +labels: ['enhancement'] + +body: + - type: textarea + id: problem + attributes: + label: 描述问题 + description: 明确描述此功能将解决的问题 + placeholder: '我总是感到困扰...' + validations: + required: true + + - type: textarea + id: solution + attributes: + label: '描述您想要的解决方案' + description: 明确说明您希望做出的改变 + placeholder: '我想...' + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: 考虑的替代方案 + description: '您考虑过的任何替代解决方案' + + - type: textarea + id: context + attributes: + label: 其他上下文 + description: 在此处添加有关问题的任何其他上下文。 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..3c2c90f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ +#### 💻 变更类型 | Change Type + + + +- [ ] ✨ feat | 新增功能 +- [ ] 🐛 fix | 修复缺陷 +- [ ] ♻️ refactor | 代码重构(不包括 bug 修复、功能新增) +- [ ] 💄 style | 代码格式(不影响功能,例如空格、分号等格式修正) +- [ ] 📦️ build | 构建流程、外部依赖变更(如升级 npm 包、修改 vite 配置等) +- [ ] 🚀 perf | 性能优化 +- [ ] 📝 docs | 文档变更 +- [ ] 🧪 test | 添加疏漏测试或已有测试改动 +- [ ] ⚙️ ci | 修改 CI 配置、脚本 +- [ ] ↩️ revert | 回滚 commit +- [ ] 🛠️ chore | 对构建过程或辅助工具和库的更改(不影响源文件、测试用例) + +#### 🔀 变更说明 | Description of Change + + + +#### 📝 补充信息 | Additional Information + + diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..26e6e43 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,41 @@ +# 前端相关变更 +'前端': + - src/**/* + +# 工作流程相关变更 +'工作流程': + - .github/**/* + +# 文档相关变更 +'文档': + - '**/*.md' + +# 依赖相关变更 +'依赖更新': + - package.json + - pnpm-lock.yaml + +# Rust 相关变更 +'Rust': + - '**/*.rs' + - '**/Cargo.toml' + - '**/Cargo.lock' + - 'src-tauri/**/*' + +# 配置文件变更 +'配置': + - '*.config.ts' + - '*.config.js' + - 'tsconfig.json' + - '.env*' + +# 测试相关变更 +'测试': + - '**/*.test.ts' + - 'test/**/*' + +# 样式相关变更 +'样式': + - '**/*.css' + - '**/*.scss' + - 'uno.config.*' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..f461a8d --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,83 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + # push: + # branches: ["master"] + # 暂时只在pr的时候运行 + pull_request: + branches: ["master"] + schedule: + - cron: "0 0 1 * *" # 每月1号运行 + +permissions: + contents: read + +jobs: + analyze: + # 跳过 Renovate PR + if: | + github.actor != 'renovate[bot]' && + github.actor != 'renovate-preview[bot]' + + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ["javascript", "typescript"] + # CodeQL supports [ $supported-codeql-languages ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Harden Runner + uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@df409f7d9260372bd5f19e5b04e83cb3c43714ae # v3.27.9 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@df409f7d9260372bd5f19e5b04e83cb3c43714ae # v3.27.9 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@df409f7d9260372bd5f19e5b04e83cb3c43714ae # v3.27.9 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/debug-build.yml b/.github/workflows/debug-build.yml new file mode 100644 index 0000000..2d6985e --- /dev/null +++ b/.github/workflows/debug-build.yml @@ -0,0 +1,91 @@ +name: Debug Build + +on: + workflow_dispatch: # 手动触发 + inputs: + platform: + type: choice + description: '选择测试平台' + required: true + default: 'ubuntu-22.04' + options: + - ubuntu-22.04 + - macos-latest + - windows-latest + +jobs: + debug-build: + runs-on: ${{ inputs.platform }} + steps: + - uses: actions/checkout@v4 + + - name: Install Ubuntu dependencies + if: inputs.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + librsvg2-dev \ + patchelf \ + libudev-dev \ + libasound2-dev \ + pkg-config \ + libgtk-3-dev \ + libayatana-appindicator3-dev + + # 验证安装的包 + dpkg -l | grep -E 'webkit|appindicator|rsvg|udev|asound|gtk' + + # 添加环境变量配置 + - name: Set up environment variables + run: echo "${{ secrets.ENV_LOCAL_CONTENT }}" > .env.local + + # 安装 pnpm + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + run_install: false + + # 设置 Node.js + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + env: + NODE_OPTIONS: --max-old-space-size=4096 + run: pnpm install + + - name: Generate component typings + env: + NODE_OPTIONS: --max-old-space-size=4096 + run: pnpm vite build --emptyOutDir + + - name: Build Vite + Tauri + run: pnpm build + + # 安装 Rust + - name: install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ inputs.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: './src-tauri -> target' + + # 只构建不发布 + - name: Build Tauri app + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + # 增加 Node.js 内存限制,避免构建时内存溢出 + NODE_OPTIONS: --max-old-space-size=4096 + with: + releaseId: "debug-build" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..21a469b --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,27 @@ +# Dependency Review Action +# +# This Action will scan dependency manifest files that change as part of a Pull Request, +# surfacing known-vulnerable versions of the packages declared or updated in the PR. +# Once installed, if the workflow run is marked as required, +# PRs introducing known-vulnerable packages will be blocked from merging. +# +# Source repository: https://github.com/actions/dependency-review-action +name: 'Dependency Review' +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2 + with: + egress-policy: audit + + - name: 'Checkout Repository' + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: 'Dependency Review' + uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0 diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml new file mode 100644 index 0000000..11e25dc --- /dev/null +++ b/.github/workflows/greetings.yml @@ -0,0 +1,24 @@ +name: Greetings + +on: [pull_request_target, issues] + +# 顶层设置最小权限,推荐 contents: read +permissions: + contents: read + +jobs: + greeting: + # 跳过 Renovate PR + if: | + github.actor != 'renovate[bot]' && + github.actor != 'renovate-preview[bot]' + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/first-interaction@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + issue-message: "👋 Thank you for your first Issue! We will check and reply as soon as possible. Please ensure that sufficient information has been provided to describe the problem." + pr-message: "🎉 Thank you for your first PR! We are very happy to have you join the HuLa project. We will review your contribution as soon as possible." diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml new file mode 100644 index 0000000..4613569 --- /dev/null +++ b/.github/workflows/label.yml @@ -0,0 +1,22 @@ +# This workflow will triage pull requests and apply a label based on the +# paths that are modified in the pull request. +# +# To use this workflow, you will need to set up a .github/labeler.yml +# file with configuration. For more information, see: +# https://github.com/actions/labeler + +name: Labeler +on: [pull_request_target] + +jobs: + label: + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/labeler@v4 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..19801b8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,299 @@ +name: Release CI + +on: + push: + tags: + - 'v*' + +# 确保默认情况下所有 job 都只有只读权限,只有需要写权限的 job(比如发布 release 的 job)才会单独提升权限,其他 job 依然保持最小权限,最大程度保护仓库安全 +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true # 如果有新的发布任务,取消正在进行的任务 + +jobs: + prepare-frontend: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Set up environment variables + run: echo "${{ secrets.ENV_LOCAL_CONTENT }}" > .env.local + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + run_install: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Generate frontend build (typings & assets) + env: + NODE_OPTIONS: --max-old-space-size=4096 + run: pnpm vite build --emptyOutDir + + - name: Upload frontend dist + uses: actions/upload-artifact@v4 + with: + name: frontend-dist + if-no-files-found: error + path: dist + + - name: Upload generated component typings + uses: actions/upload-artifact@v4 + with: + name: components-typings + if-no-files-found: error + path: | + src/typings/components.pc.d.ts + src/typings/components.mobile.d.ts + + publish-tauri: + needs: prepare-frontend + permissions: + contents: write # 授予写入仓库内容的权限 + strategy: + fail-fast: false # 某个平台构建失败不影响其他平台 + matrix: + include: + - platform: 'macos-latest' # for Arm based macs (M1 and above). + args: '--target aarch64-apple-darwin' + - platform: 'macos-latest' # for Intel based macs. + args: '--target x86_64-apple-darwin' + - platform: 'ubuntu-22.04' + args: '' + - platform: 'windows-latest' + args: '' + + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + + - name: Determine build mode + id: build_mode + shell: bash + run: | + TAG="${GITHUB_REF##*/}" + MODE="full" + if [[ "$TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + PATCH="${BASH_REMATCH[3]}" + if (( PATCH > 0 )); then + MODE="incremental" + fi + fi + echo "Detected tag: $TAG" + echo "mode=$MODE" >> "$GITHUB_OUTPUT" + if [[ "$MODE" == "incremental" ]]; then + echo "INCREMENTAL_BUILD=true" >> "$GITHUB_ENV" + else + echo "INCREMENTAL_BUILD=false" >> "$GITHUB_ENV" + fi + echo "Build mode: $MODE" + + - name: Download component typings + uses: actions/download-artifact@v4 + with: + name: components-typings + path: components-typings + + - name: Download frontend dist + uses: actions/download-artifact@v4 + with: + name: frontend-dist + path: frontend-dist + + - name: Prepare downloaded artifacts + shell: bash + run: | + set -euo pipefail + + rm -rf dist + if [[ -d frontend-dist/dist ]]; then + mv frontend-dist/dist dist + rm -rf frontend-dist + elif [[ -d frontend-dist ]]; then + mv frontend-dist dist + elif [[ ! -d dist ]]; then + echo "无法在 artifact 中找到 dist 目录" >&2 + ls -la + exit 1 + fi + + if [[ -d components-typings/src/typings ]]; then + mkdir -p src/typings + cp -R components-typings/src/typings/. src/typings/ + rm -rf components-typings + fi + + - name: install dependencies (ubuntu only) + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + librsvg2-dev \ + patchelf \ + libudev-dev \ + libasound2-dev \ + pkg-config \ + libgtk-3-dev \ + libayatana-appindicator3-dev + + # 添加环境变量配置 + - name: Set up environment variables + run: echo "${{ secrets.ENV_LOCAL_CONTENT }}" > .env.local + + # 首先安装 pnpm + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + run_install: false + + # 然后设置 Node.js + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + env: + NODE_OPTIONS: --max-old-space-size=4096 + run: pnpm install + + - name: Disable frontend rebuild for release build + shell: bash + run: | + cat <<'EOF' > release-tauri-config.json + { + "build": { + "beforeBuildCommand": "" + } + } + EOF + + - name: Prepare production config + shell: bash + env: + YOUDAO_APP_KEY: ${{ secrets.YOUDAO_APP_KEY }} + YOUDAO_APP_SECRET: ${{ secrets.YOUDAO_APP_SECRET }} + TENCENT_API_KEY: ${{ secrets.TENCENT_API_KEY }} + TENCENT_SECRET_ID: ${{ secrets.TENCENT_SECRET_ID }} + TENCENT_MAP_KEY: ${{ secrets.TENCENT_MAP_KEY }} + run: | + mkdir -p src-tauri/configuration + cat > src-tauri/configuration/production.yaml <<'EOF' + youdao: + app_key: "${YOUDAO_APP_KEY}" + app_secret: "${YOUDAO_APP_SECRET}" + tencent: + api_key: "${TENCENT_API_KEY}" + secret_id: "${TENCENT_SECRET_ID}" + map_key: "${TENCENT_MAP_KEY}" + EOF + + # 安装 Rust + - name: install Rust stable + uses: dtolnay/rust-toolchain@stable # Set this to dtolnay/rust-toolchain@nightly + with: + # Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds. + targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + + - name: Cache cargo target + uses: actions/cache@v4 + with: + path: | + ${{ runner.os == 'Windows' && env.USERPROFILE || env.HOME }}/.cargo/registry + ${{ runner.os == 'Windows' && env.USERPROFILE || env.HOME }}/.cargo/git + src-tauri/target + key: cargo-target-${{ matrix.platform }}-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: | + cargo-target-${{ matrix.platform }}- + + - name: Create release + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # 使用之前配置的私钥 + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + # 使用之前配置的私钥密码 + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + # 增加 Node.js 内存限制,避免构建时内存溢出 + NODE_OPTIONS: --max-old-space-size=4096 + with: + tagName: v__VERSION__ #这个动作会自动将\_\_VERSION\_\_替换为app version + releaseName: 'v__VERSION__' + releaseBody: 'See the assets to download and install this version.' + releaseDraft: true + prerelease: false + args: ${{ matrix.args }} --config release-tauri-config.json + + publish-release: + needs: publish-tauri + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Extract changelog for current version + id: changelog + run: | + # 从 CHANGELOG.md 中提取最新版本的内容 + # 匹配从第一个 ## [version] 到下一个 ## [version] 之间的内容 + CHANGELOG_CONTENT=$(awk '/^## \[/{if(found) exit; found=1} found' CHANGELOG.md) + + # 将内容写入文件以保留格式 + echo "$CHANGELOG_CONTENT" > /tmp/release_notes.md + + # 输出到 GITHUB_OUTPUT(使用 EOF 分隔符处理多行) + { + echo 'body<> "$GITHUB_OUTPUT" + + - name: Publish draft release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # 直接按 tag 获取 release,避免同 tag 多个草稿导致多个 ID + RELEASE=$(gh api repos/${{ github.repository }}/releases/tags/${{ github.ref_name }} 2>/dev/null || true) + if [ -n "$RELEASE" ] && [ "$(echo "$RELEASE" | jq -r '.draft')" = "true" ]; then + RELEASE_ID=$(echo "$RELEASE" | jq -r '.id') + echo "Publishing draft release with ID: $RELEASE_ID" + + # 使用 changelog 内容更新 release body 并发布 + gh api -X PATCH repos/${{ github.repository }}/releases/$RELEASE_ID \ + -f draft=false \ + -f body="$(cat /tmp/release_notes.md)" + + echo "Release published successfully with changelog" + else + echo "No draft release found for tag ${{ github.ref_name }}, it may already be published" + fi + + upgradeLink-upload: + needs: publish-release # 依赖于 publish-release 作业完成(确保 release 已正式发布) + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Send a request to UpgradeLink + uses: toolsetlink/upgradelink-action@v5 + with: + source-url: 'https://github.com/HuLaSpark/HuLa/releases/download/${{ github.ref_name }}/latest.json' + access-key: ${{ secrets.UPGRADE_LINK_ACCESS_KEY }} # ACCESS_KEY 密钥key + tauri-key: ${{ secrets.UPGRADE_LINK_TAURI_KEY }} # TAURI_KEY tauri 应用唯一标识 + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/rust-clippy.yml b/.github/workflows/rust-clippy.yml new file mode 100644 index 0000000..8f28b48 --- /dev/null +++ b/.github/workflows/rust-clippy.yml @@ -0,0 +1,58 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# rust-clippy is a tool that runs a bunch of lints to catch common +# mistakes in your Rust code and help improve your Rust code. +# More details at https://github.com/rust-lang/rust-clippy +# and https://rust-lang.github.io/rust-clippy/ + +name: rust-clippy analyze + +on: + pull_request: + # The branches below must be a subset of the branches above + branches: [ "master" ] + paths: + - '**/*.rs' + +jobs: + rust-clippy-analyze: + # 跳过 Renovate PR + if: | + github.actor != 'renovate[bot]' && + github.actor != 'renovate-preview[bot]' + + name: Run rust-clippy analyzing + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af #@v1 + with: + profile: minimal + toolchain: stable + components: clippy + override: true + + - name: Install required cargo + run: cargo install clippy-sarif sarif-fmt + + - name: Run rust-clippy + run: + cargo clippy + --all-features + --message-format=json | clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt + continue-on-error: true + + - name: Upload analysis results to GitHub + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: rust-clippy-results.sarif + wait-for-processing: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8d1509 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.idea + +src-tauri/target + +.history/ + +/coverage + +target +.claude/ +.serena/ +*.sqlite +db.sqlite +.idea +.qoder + +src/typings/components.pc.d.ts +src/typings/components.mobile.d.ts diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..cf2b7ce --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,2 @@ +#!/bin/sh +npx --no-install commitlint --edit "$1" \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..b82632a --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +#!/bin/sh +pnpm run lint:staged \ No newline at end of file diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs new file mode 100644 index 0000000..1d56d44 --- /dev/null +++ b/.lintstagedrc.mjs @@ -0,0 +1,24 @@ +import path from 'node:path' + +function createCommand(prefix) { + return (filenames) => `${prefix} ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')}` +} + +export default { + // JavaScript/TypeScript 文件使用 Biome (排除 .d.ts 文件和tauri-plugin-hula目录) + '*.{js,jsx,ts,tsx,json}': [ + (filenames) => { + const filteredFiles = filenames.filter( + (f) => + !f.includes('src-tauri/') && !f.includes('tauri-plugin-hula/') && !f.includes('public/') && !f.endsWith('.d.ts') && !f.includes('.vscode/') + ) + return filteredFiles.length > 0 + ? `biome check --write --unsafe ${filteredFiles.map((f) => path.relative(process.cwd(), f)).join(' ')}` + : 'echo "No files to check"' + } + ], + // Vue 文件:使用 Biome 检查和修复,然后用 Prettier 格式化 + '*.vue': [createCommand('biome check --write --unsafe'), createCommand('prettier --write')], + // Rust 代码:使用 manifest-path 指定 workspace,避免切换目录 + 'src-tauri/**/*.rs': () => 'cargo fmt --manifest-path src-tauri/Cargo.toml' +} diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..88899a6 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +# 配置npm镜像源 (华为云) +registry=https://repo.huaweicloud.com/repository/npm/ +# 严格检查 package.json 中 "engines" 字段指定的版本要求 +engine-strict=true \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..68c020d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,14 @@ +{ + "singleQuote": true, + "semi": false, + "printWidth": 120, + "useTabs": false, + "tabWidth": 2, + "trailingComma": "none", + "bracketSpacing": true, + "arrowParens": "always", + "bracketSameLine": true, + "endOfLine": "lf", + "vueIndentScriptAndStyle": false, + "htmlWhitespaceSensitivity": "ignore" +} \ No newline at end of file diff --git a/.release-it.js b/.release-it.js new file mode 100644 index 0000000..24384e9 --- /dev/null +++ b/.release-it.js @@ -0,0 +1,44 @@ +module.exports = { + plugins: { + '@release-it/bumper': { + in: ['src-tauri/tauri.*.json', 'src-tauri/Cargo.toml'], + out: ['src-tauri/tauri.*.json', 'src-tauri/Cargo.toml'] + }, + '@release-it/conventional-changelog': { + preset: { + name: 'conventionalcommits', + types: [ + { type: 'feat', section: '✨ Features | 新功能' }, + { type: 'fix', section: '🐛 Bug Fixes | Bug 修复' }, + { type: 'chore', section: '🎫 Chores | 其他更新', hidden: true }, + { type: 'docs', section: '📝 Documentation | 文档', hidden: true }, + { type: 'style', section: '💄 Styles | 风格', hidden: true }, + { type: 'refactor', section: '♻️ Code Refactoring | 代码重构', hidden: true }, + { type: 'perf', section: '⚡️ Performance Improvements | 性能优化' }, + { type: 'test', section: '✅ Tests | 测试', hidden: true }, + { type: 'revert', section: '⏪ Reverts | 回退', hidden: true }, + { type: 'build', section: '👷‍ Build System | 构建', hidden: true }, + { type: 'ci', section: '🔧 Continuous Integration | CI 配置', hidden: true }, + { type: 'config', section: '🔨 CONFIG | 配置', hidden: true } + ] + }, + whatBump() { + return { releaseType: false } + }, + infile: 'CHANGELOG.md', + ignoreRecommendedBump: true, + strictSemVer: true + } + }, + git: { + commitMessage: 'chore: release v${version}' + }, + npm: { + publish: false + }, + github: { + release: true, + draft: true, + releaseName: 'v${version}' + } +} diff --git a/.rules b/.rules new file mode 100644 index 0000000..2095998 --- /dev/null +++ b/.rules @@ -0,0 +1,130 @@ +# HuLa Project Context + +## Overview +HuLa is a modern, cross-platform Instant Messaging (IM) system. It leverages **Tauri v2** for the application container, **Vite 7** for fast frontend tooling, **Vue 3** for the user interface, and **TypeScript** for type safety. The backend logic is implemented in **Rust**. + +The project supports: +- **Desktop:** Windows, macOS, Linux +- **Mobile:** Android, iOS + +## Tech Stack + +### Frontend +- **Framework:** Vue 3 (Composition API) +- **Language:** TypeScript +- **Build Tool:** Vite 7 +- **State Management:** Pinia (with persistence plugins) +- **Routing:** Vue Router +- **Styling:** UnoCSS, Sass +- **UI Libraries:** Naive UI (Desktop), Vant (Mobile) +- **I18n:** vue-i18n + +### Backend (Rust / Tauri) +- **Core:** Tauri v2 +- **Database:** SQLite (managed via SeaORM with SQLCipher support) +- **Async Runtime:** Tokio +- **HTTP Client:** Reqwest +- **WebSocket:** tokio-tungstenite +- **Audio:** Rodio + +## Development Workflow + +### Prerequisites +- Node.js (v20+ recommended) +- pnpm (v10+ recommended) +- Rust (latest stable) +- Android Studio / Xcode (for mobile development) + +### Key Commands + +| Action | Command | Description | +| :--- | :--- | :--- | +| **Install Dependencies** | `pnpm install` | Installs Node.js dependencies. | +| **Start Desktop Dev** | `pnpm tauri:dev` | Starts the Tauri development server for desktop. | +| **Build Desktop** | `pnpm tauri:build` | Builds the production application for desktop (interactive). | +| **Commit Changes** | `pnpm commit` | Interactive git commit using Commitizen. | +| **Lint/Format** | `pnpm check` | Checks code using Biome. | +| **Run Tests** | `pnpm test:run` | Runs unit tests with Vitest. | + +### Directory Structure + +- **`src/`**: Frontend source code. + - **`views/`**: Page components. + - **`stores/`**: Pinia stores. + - **`services/`**: API and service layers (e.g., WebSocket adapter). + - **`components/`**: Reusable Vue components. + - **`layout/`**: App layout structures. +- **`src-tauri/`**: Rust backend source code. + - **`src/`**: Main Rust application logic. + - **`entity/`**: SeaORM entity definitions. + - **`migration/`**: Database migrations. + - **`tauri.conf.json`**: Tauri configuration. +- **`tauri-plugin-hula/`**: Custom local Tauri plugin. + +## Coding Style & Naming Conventions + +- Indent 2 spaces, LF endings, trim whitespace (see `.editorconfig`). +- Format/lint with Biome: `pnpm check` (read-only) / `pnpm check:write` (fixes). Vue templates also use Prettier: `pnpm format:vue` or `pnpm format:all`. +- Prefer import aliases: `@/` → `src/`, `~/` → repo root. +- Naming: components `PascalCase.vue`, composables `useXxx.ts`, Pinia stores in `src/stores/`. +- **Commits:** Use `pnpm commit` to enforce Conventional Commits. +- **Styling:** Use UnoCSS utility classes where possible. +- **State:** Use Pinia for global state; prefer Composition API ` + + + + + + + +
+ + + + diff --git a/locales/en/agreement.json b/locales/en/agreement.json new file mode 100644 index 0000000..6b48ec5 --- /dev/null +++ b/locales/en/agreement.json @@ -0,0 +1,260 @@ +{ + "privacy": { + "header": { + "title": "HuLa Privacy Protection Guidelines", + "updatedAt": "Updated on: September 3, 2025" + }, + "sections": [ + { + "title": "Introduction", + "paragraphs": [ + "HuLa (hereinafter referred to as \"we\") fully understands the importance of personal information to you and will do everything possible to keep it safe and reliable. To earn and maintain your trust, we strictly abide by principles such as responsibility-consistency, explicit purpose, informed consent, minimal necessity, security assurance, subject participation, and openness and transparency. We also pledge to adopt industry-standard security measures to safeguard your personal information.", + "Please read and understand these Privacy Protection Guidelines carefully before using our products or services." + ] + }, + { + "title": "1. How We Collect and Use Your Personal Information", + "paragraphs": [ + "Personal information refers to any kind of information that is recorded electronically or otherwise and can identify a specific natural person, either alone or in combination with other information, or that reflects the activities of a specific natural person." + ], + "subSections": [ + { + "subtitle": "1.1 When We Collect Your Personal Information", + "paragraphs": [ + "(1) Account registration: When you register a HuLa account, we collect your mobile number, email address, and other details to create the account.", + "(2) Identity verification: To ensure account security, we may ask you to provide identity documents for real-name verification.", + "(3) Service usage: While you use HuLa services, we collect your chat history, friend relationships, group information, etc.", + "(4) Device information: We collect data such as device model, operating system version, and device identifiers to provide a better service experience." + ] + }, + { + "subtitle": "1.2 How We Use Your Personal Information", + "paragraphs": [ + "(1) Providing services: Including core features like instant messaging, file transfer, and audio/video calls.", + "(2) Maintaining service security: Detecting and preventing security threats, fraud, and other illegal activities.", + "(3) Improving service quality: Analyzing usage to optimize product features and user experience.", + "(4) Customer service: Handling your inquiries, complaints, and feedback." + ] + } + ] + }, + { + "title": "2. How We Use Cookies and Similar Technologies", + "paragraphs": [ + "To ensure the proper operation of our services, we store small data files called cookies on your computer or mobile device. Cookies typically contain identifiers, site names, and a series of numbers and characters. They help websites save your preferences or shopping cart contents, among other data.", + "We do not use cookies for purposes other than those described in this policy. You can manage or delete cookies based on your preferences. Most browsers allow you to clear cookies or block them, but doing so may require you to reconfigure user settings each time you visit our website." + ] + }, + { + "title": "3. How We Share, Transfer, and Disclose Your Personal Information", + "subSections": [ + { + "subtitle": "3.1 Sharing", + "paragraphs": [ + "We do not share your personal information with companies, organizations, or individuals outside of HuLa except in the following circumstances:", + "(1) With your explicit consent or authorization;", + "(2) When required by applicable laws, legal procedures, or mandatory governmental or judicial requests;", + "(3) Within the scope permitted by law to protect the rights, property, or safety of HuLa, HuLa users, or the public;", + "(4) With our affiliates: we may share your personal information with affiliated companies." + ] + }, + { + "subtitle": "3.2 Transfer", + "paragraphs": [ + "We will not transfer your personal information to any company, organization, or individual except in the following situations:", + "(1) With your explicit consent or authorization;", + "(2) During mergers, acquisitions, or bankruptcy proceedings, we will require the new entity holding your personal information to remain bound by this privacy policy." + ] + }, + { + "subtitle": "3.3 Public Disclosure", + "paragraphs": [ + "We will only disclose your personal information publicly in the following circumstances:", + "(1) After obtaining your explicit consent;", + "(2) For disclosures based on law: when required by laws, legal procedures, litigation, or governmental authorities." + ] + } + ] + }, + { + "title": "4. How We Protect Your Personal Information", + "paragraphs": [ + "(1) We use industry-standard security safeguards to protect the personal information you provide, preventing unauthorized access, disclosure, use, modification, damage, or loss.", + "(2) We take all reasonable measures to ensure that no irrelevant personal information is collected.", + "(3) We take all reasonable measures to ensure that personal information is not retained for longer than necessary to achieve the stated purposes.", + "(4) The internet is not an absolutely secure environment, and communications such as emails, instant messages, or social software may not be fully encrypted." + ] + }, + { + "title": "5. Your Rights", + "paragraphs": [ + "In accordance with relevant laws, regulations, and common practices in China and other regions, we ensure that you can exercise the following rights regarding your personal information:" + ], + "subSections": [ + { + "subtitle": "5.1 Access Your Personal Information", + "paragraphs": [ + "You have the right to access your personal information, except as otherwise provided by laws and regulations." + ] + }, + { + "subtitle": "5.2 Correct Your Personal Information", + "paragraphs": [ + "When you discover errors in the personal information we process about you, you have the right to request corrections." + ] + }, + { + "subtitle": "5.3 Delete Your Personal Information", + "paragraphs": [ + "You may request deletion of personal information in the following situations:", + "(1) If our handling of personal information violates laws or regulations;", + "(2) If we collect or use your personal information without your consent;", + "(3) If our handling of personal information breaches our agreement with you." + ] + }, + { + "subtitle": "5.4 Change the Scope of Your Authorization", + "paragraphs": [ + "Each business function requires certain essential personal information. For any additional data collection or usage, you may grant or withdraw consent at any time." + ] + } + ] + }, + { + "title": "6. How We Handle Children's Personal Information", + "paragraphs": [ + "Our products, websites, and services are primarily intended for adults. Children may not create their own user accounts without the consent of their parents or guardians.", + "For personal information collected from children with parental consent, we will use or disclose it only when permitted by law, with explicit parental or guardian approval, or when necessary to protect the child." + ] + }, + { + "title": "7. Global Transfer of Your Personal Information", + "paragraphs": [ + "In principle, personal information collected and generated in the People's Republic of China will be stored within China.", + "Because we provide products or services through resources and servers around the world, your personal information may be transferred to, or accessed from, jurisdictions outside the country/region where you use the services, after obtaining your authorization." + ] + }, + { + "title": "8. How This Policy Is Updated", + "paragraphs": [ + "Our privacy policy may change.", + "Without your explicit consent, we will not diminish your rights under this privacy policy. We will post any changes on this page.", + "For major changes, we will also provide more prominent notices (including email notifications for certain services describing the specific changes)." + ] + }, + { + "title": "9. Open-Source Project Disclaimer", + "paragraphs": [ + "This project is provided as an open-source project. To the fullest extent permitted by law, the developers make no explicit or implied warranties regarding functionality, security, or suitability.", + "You expressly understand and agree that your use of this software is entirely at your own risk. The software is provided \"as is\" and \"as available,\" without any express or implied warranties, including but not limited to merchantability, fitness for a particular purpose, or non-infringement.", + "In no event shall the developers or their suppliers be liable for any direct, indirect, incidental, special, punitive, or consequential damages, including but not limited to lost profits, business interruption, personal information leaks, or other commercial losses arising from the use of this software.", + "Any users who perform secondary development on this project must commit to using the software for lawful purposes and are solely responsible for complying with local laws and regulations.", + "The developers reserve the right to modify the software's features or any part of this disclaimer at any time, and such changes may be delivered through software updates." + ] + }, + { + "title": "10. Final Interpretation", + "paragraphs": [ + "The final right of interpretation of these Privacy Protection Guidelines rests with the author." + ] + } + ] + }, + "server": { + "header": { + "title": "HuLa Service Agreement", + "updatedAt": "Updated on: September 3, 2025" + }, + "sections": [ + { + "title": "1. Confirmation and Acceptance of Service Terms", + "paragraphs": [ + "Welcome to HuLa! All electronic services of HuLa are owned and operated by HuLa. Users must fully agree to all terms of service and complete the registration process to become official HuLa users." + ] + }, + { + "title": "2. Service Overview", + "paragraphs": [ + "HuLa is a free instant messaging application that allows users to chat via text, voice, and video, as well as share images and files. HuLa is committed to providing secure, stable, and convenient communication services." + ] + }, + { + "title": "3. User Registration", + "paragraphs": [ + "3.1 Users must provide authentic, accurate, and complete personal information when registering.", + "3.2 Users are responsible for the security of their passwords and accounts. Any losses or damages resulting from activities conducted through the account are borne solely by the user.", + "3.3 Users agree to receive information from HuLa, including but not limited to product updates, service announcements, and special offers." + ] + }, + { + "title": "4. User Conduct", + "paragraphs": [ + "4.1 Users must comply with applicable laws and regulations of the People's Republic of China while using HuLa services.", + "4.2 Users are prohibited from using HuLa services for unlawful activities.", + "4.3 Users may not publish or disseminate unlawful, harmful, threatening, abusive, harassing, infringing, defamatory, vulgar, obscene, or otherwise objectionable content.", + "4.4 Users may not publish or disseminate content that infringes the intellectual property or other rights of third parties.", + "4.5 Users may not maliciously spread viruses, trojans, or other malicious programs." + ] + }, + { + "title": "5. Privacy Protection", + "paragraphs": [ + "5.1 HuLa attaches great importance to user privacy. Unless otherwise required by law, HuLa will not disclose or transfer user information to third parties without consent.", + "5.2 HuLa adopts industry-standard security measures to protect user information from unauthorized access, use, or disclosure.", + "5.3 Users understand and agree that HuLa may collect and use relevant information to provide better services." + ] + }, + { + "title": "6. Intellectual Property", + "paragraphs": [ + "6.1 HuLa and its related technologies, trademarks, and copyrights are owned by HuLa.", + "6.2 Without written permission from HuLa, users may not perform reverse engineering, disassembly, or decompilation on the software.", + "6.3 Users retain intellectual property rights over the content they publish via HuLa, but grant HuLa a worldwide, royalty-free, non-exclusive license to use such content." + ] + }, + { + "title": "7. Disclaimer", + "paragraphs": [ + "7.1 Users agree that they use HuLa services entirely at their own risk.", + "7.2 HuLa does not guarantee that the service will meet users' requirements, nor does it guarantee uninterrupted service.", + "7.3 HuLa assumes no responsibility for any network service interruptions or defects caused by force majeure or reasons beyond HuLa's control." + ] + }, + { + "title": "8. Service Changes, Interruptions, or Termination", + "paragraphs": [ + "8.1 HuLa reserves the right to change, suspend, or terminate part or all of its services based on business needs.", + "8.2 If services must be paused for maintenance or upgrades, HuLa will provide prior notice whenever possible.", + "8.3 If a user violates any term of this agreement, HuLa may terminate service to that user." + ] + }, + { + "title": "9. Modifications to the Agreement", + "paragraphs": [ + "HuLa may modify any terms of this agreement at any time. When changes occur, HuLa will notify users appropriately. If you do not agree with the changes, you may stop using the services. Continued use constitutes acceptance of the modifications." + ] + }, + { + "title": "10. Governing Law and Dispute Resolution", + "paragraphs": [ + "10.1 The formation, execution, interpretation, and dispute resolution related to this agreement are governed by the laws of the People's Republic of China.", + "10.2 In the event of disputes, both parties should attempt to resolve them through friendly negotiation. If negotiations fail, either party may file a lawsuit with the People's Court located in HuLa's jurisdiction." + ] + }, + { + "title": "11. Open-Source Project Disclaimer", + "paragraphs": [ + "This project is provided as an open-source project. To the fullest extent permitted by law, the developers make no explicit or implied warranties regarding functionality, security, or suitability.", + "You expressly understand and agree that your use of this software is entirely at your own risk. The software is provided \"as is\" and \"as available,\" without any express or implied warranties, including but not limited to merchantability, fitness for a particular purpose, or non-infringement.", + "In no event shall the developers or their suppliers be liable for any direct, indirect, incidental, special, punitive, or consequential damages, including but not limited to lost profits, business interruption, personal information leaks, or other commercial losses arising from the use of this software.", + "Any users who perform secondary development on this project must commit to using the software for lawful purposes and are solely responsible for complying with local laws and regulations.", + "The developers reserve the right to modify the software's features or any part of this disclaimer at any time, and such changes may be delivered through software updates." + ] + }, + { + "title": "12. Final Interpretation", + "paragraphs": ["The final right of interpretation of this agreement rests with the author."] + } + ] + } +} diff --git a/locales/en/announcement.json b/locales/en/announcement.json new file mode 100644 index 0000000..8869686 --- /dev/null +++ b/locales/en/announcement.json @@ -0,0 +1,36 @@ +{ + "title": { + "create": "Create group announcement", + "view": "Group announcements", + "edit": "Edit group announcement" + }, + "form": { + "placeholder": "Enter announcement (1-600 characters)", + "pinned": "Pin to top", + "actions": { + "cancel": "Cancel", + "publish": "Publish", + "new": "Post new announcement" + } + }, + "list": { + "empty": "No announcements yet", + "loadMore": "Load more", + "noMore": "No more announcements", + "deleteConfirm": "Are you sure to delete all messages?", + "delete": { + "confirm": "Delete", + "cancel": "Cancel" + }, + "expand": "Expand", + "collapse": "Collapse" + }, + "toast": { + "contentRequired": "Announcement content cannot be empty", + "contentTooLong": "Announcement cannot exceed 600 characters", + "createSuccess": "Announcement published successfully", + "createFail": "Failed to publish announcement", + "editSuccess": "Announcement updated successfully", + "editFail": "Failed to update announcement" + } +} diff --git a/locales/en/auth.json b/locales/en/auth.json new file mode 100644 index 0000000..d084fbb --- /dev/null +++ b/locales/en/auth.json @@ -0,0 +1,161 @@ +{ + "onlineStatus": { + "reset_title": "Clear Status", + "messages": { + "success": "Status updated successfully", + "error": "Failed to update status" + }, + "states": { + "离开": "Away", + "忙碌": "Busy", + "请勿打扰": "Do not disturb", + "隐身": "Invisible", + "今日天气": "Today's weather", + "一言难尽": "It's complicated", + "我太难了": "Having a hard day", + "难得糊涂": "Go with the flow", + "元气满满": "Full of energy", + "嗨到飞起": "On cloud nine", + "水逆退散": "Bad luck go away", + "好运锦鲤": "Lucky koi", + "恋爱中": "In love", + "我crush了": "I have a crush", + "被掏空": "Drained", + "听歌中": "Listening to music", + "我没事": "I'm okay", + "学习中": "Studying", + "睡觉中": "Sleeping", + "搬砖中": "Grinding at work", + "想静静": "Need some quiet", + "运动中": "Working out", + "我想开了": "I figured it out", + "信号弱": "Weak signal", + "追剧中": "Binging shows", + "美滋滋": "Feeling great", + "摸鱼中": "Slacking off", + "无聊中": "Bored", + "悠哉哉": "Chilling", + "去旅行": "Traveling", + "游戏中": "Gaming" + } + }, + "register": { + "title": "Welcome", + "labels": { + "nickname": "Nickname", + "password": "Password", + "confirm": "Confirm", + "email": "Email" + }, + "placeholders": { + "nickname": "Enter HuLa nickname", + "email": "Enter email", + "password": "Enter HuLa password", + "confirm": "Enter password again", + "confirm_placeholder": "Confirm password" + }, + "password_hints": { + "min_length": "At least 6 characters", + "alpha_numeric": "Use letters and numbers", + "special_char": "Must contain a special character" + }, + "validation": { + "confirm_match": "Passwords match" + }, + "form": { + "rules": { + "nickname_required": "Please enter a nickname", + "email_required": "Please enter an email", + "email_format": "Please enter a valid email", + "password_required": "Please enter a password", + "confirm_mismatch": "Passwords do not match" + } + }, + "tips": { + "reopen_code": "Closed the verification window? Click the button to reopen it." + }, + "actions": { + "send_code": "Send Code", + "retry_in": "Retry in {seconds}s", + "sending": "Sending...", + "submit": "Register" + }, + "modal": { + "title": "Star us on GitHub", + "desc": "If you love HuLa and want to support us, could you star our GitHub repo? Your support keeps us moving forward!", + "cta": "Give a star" + }, + "email_modal": { + "title": "Enter Email Verification Code", + "desc": "We’ve sent a code to {email}. Please check your inbox to finish registration." + }, + "messages": { + "code_sent": "Verification code sent", + "register_success": "Registered successfully", + "register_fail": "Registration failed. Please retry." + } + }, + "forget": { + "title": "Forgot Password", + "steps": { + "verify": { + "title": "Verify Email", + "desc": "Verify your account email" + }, + "reset": { + "title": "Set New Password", + "desc": "Create your new password" + }, + "done": { + "title": "Finished", + "desc": "PAW update" + } + }, + "form": { + "email_label": "Email", + "email_placeholder": "Enter your email", + "code_label": "Verification Code", + "code_placeholder": "Enter the code", + "password_label": "New Password", + "password_placeholder": "Enter a 6-16 character password", + "confirm_label": "Confirm Password", + "confirm_placeholder": "Re-enter the password" + }, + "password_hints": { + "length": "Password length must be 6-16 characters", + "alpha_numeric": "Use letters and numbers", + "special_char": "Must contain a special character", + "confirm_match": "Passwords match" + }, + "buttons": { + "next": "Next", + "prev": "Previous", + "submit": "Submit" + }, + "actions": { + "send_code": "Send Code", + "retry_in": "Retry in {seconds}s", + "resend": "Resend" + }, + "messages": { + "captcha_cooldown": "Too many requests. Try again in {seconds}s", + "enter_email": "Please enter your email", + "email_format": "Please enter a valid email", + "code_sent": "Code sent to your email" + }, + "rules": { + "email_required": "Please enter email address", + "email_format": "Please enter a valid email address", + "code_required": "Please enter verification code", + "code_length": "Code must be 6 digits", + "password_required": "Please enter new password", + "password_length": "Password length must be 6-16 characters", + "confirm_required": "Please confirm password", + "confirm_mismatch": "Passwords do not match" + }, + "success": { + "title": "Password reset successfully", + "desc": "You can now sign in with your new password." + } + } +} diff --git a/locales/en/chatHistory.json b/locales/en/chatHistory.json new file mode 100644 index 0000000..2ebb7c5 --- /dev/null +++ b/locales/en/chatHistory.json @@ -0,0 +1,18 @@ +{ + "search": { + "placeholder": "Search chats" + }, + "tabs": { + "all": "All", + "imageVideo": "Images/Videos", + "file": "Files" + }, + "datePicker": { + "placeholder": "Select date range", + "start": "Start date", + "end": "End date" + }, + "empty": { + "noData": "No chat history yet" + } +} diff --git a/locales/en/components.json b/locales/en/components.json new file mode 100644 index 0000000..6d1b543 --- /dev/null +++ b/locales/en/components.json @@ -0,0 +1,31 @@ +{ + "common": { + "confirm": "Confirm", + "cancel": "Cancel" + }, + "actionBar": { + "always_on_top": { + "enabled": "Disable always-on-top", + "disabled": "Pin to top" + }, + "close_prompt": { + "title": "Minimize to tray or exit the app?", + "hide_to_tray": "Minimize to system tray", + "exit_app": "Exit application", + "no_prompt": "Don't show this again" + } + }, + "avatarCropper": { + "title": "Crop Avatar", + "preview": { + "round": "Round preview", + "square": "Rounded-square preview" + }, + "uploading": "Uploading..." + }, + "announcementCard": { + "title": "Announcement", + "viewDetail": "View details", + "windowTitle": "View group announcement" + } +} diff --git a/locales/en/dynamic.json b/locales/en/dynamic.json new file mode 100644 index 0000000..1bb7c6b --- /dev/null +++ b/locales/en/dynamic.json @@ -0,0 +1,109 @@ +{ + "common": { + "loading_title": "Loading...", + "loading_desc": "Fetching feed details", + "image_alt": "Image", + "video_alt": "Video", + "unknown_user": "Unknown user" + }, + "page": { + "detail": { + "title": "Feed detail" + }, + "mobile_title": "Community" + }, + "list": { + "empty": "No feeds yet, share your first moment!", + "actions": { + "publish": "Post", + "refresh": "Refresh", + "comment_notice": "notifications", + "like": "Like", + "liked": "Liked", + "comment": "Comment", + "comment_with_count": "Comments ({count})" + }, + "permission": { + "open": "Public", + "part_visible": "Visible to selected", + "not_anyone": "Hidden from selected" + }, + "modal": { + "add_title": "Post a feed", + "content_placeholder": "Share something new...", + "visibility_label": "Who can view", + "visibility_placeholder": "Choose visibility", + "select_visible": "Choose who can view", + "select_hidden": "Choose who cannot view", + "select_users": "Select users", + "selected_count": "Select users ({count} selected)", + "user_modal_title": "Select users", + "user_search_placeholder": "Search users...", + "comment_notice_title": "Comment notifications", + "comment_notice_empty_title": "No notifications", + "comment_notice_empty_desc": "No comments yet" + }, + "buttons": { + "cancel": "Cancel", + "publish": "Post", + "select_users": "Select users", + "confirm": "Confirm", + "confirm_with_count": "Confirm ({count})" + }, + "comments": { + "more": "{count} more comments" + }, + "loading": "loading...", + "load_more": "Load more", + "loaded_all": "Loaded all feeds" + }, + "detail": { + "content": { + "view_image": "View full image", + "video_tag": "Video", + "video_cta": "Tap to play" + }, + "stats": { + "liked_by": "Liked by:", + "like": "Like", + "liked": "Liked", + "comments": "Comments ({count})" + }, + "actions": { + "comment": "Comment", + "reply": "Reply", + "delete": "Delete" + }, + "dropdown": { + "report": "Report", + "delete": "Delete feed" + }, + "modal": { + "title": "Post a comment", + "send": "Send", + "cancel": "Cancel", + "placeholder": "Write your comment...", + "replying": "Replying to {name}" + }, + "empty": "Feed not found or has been removed" + }, + "messages": { + "refresh_success": "Refreshed", + "refresh_fail": "Refresh failed, please retry", + "publish_empty": "Please enter feed content", + "publish_select_visible": "Select who can view", + "publish_select_hidden": "Select who cannot view", + "publish_success": "Posted successfully!", + "publish_fail": "Post failed, try again later", + "delete_success": "Deleted", + "delete_fail": "Delete failed, please retry", + "copy_success": "Link copied", + "report_todo": "Report feature in development", + "like_fail": "Action failed, please retry", + "comment_empty": "Please enter your comment", + "comment_fail": "Comment failed, please retry", + "comment_delete_fail": "Delete failed, please retry", + "detail_missing": "Feed not found or removed", + "detail_fetch_fail": "Failed to fetch feed detail" + } +} diff --git a/locales/en/editor.json b/locales/en/editor.json new file mode 100644 index 0000000..dc74ba5 --- /dev/null +++ b/locales/en/editor.json @@ -0,0 +1,22 @@ +{ + "menu": { + "select_all": "Select All", + "save_as": "Save As", + "paste": "Paste", + "copy": "Copy", + "cut": "Cut" + }, + "send": "Send", + "placeholder": "A kind word warms the heart; a harsh word wounds it.", + "send_or_newline": "{send} Send/{newline} Enter", + "file": "File", + "image": "Image", + "voice": "Voice", + "location": "Location", + "chat_history": "Chat History", + "screenshot": "Screenshot", + "screenshot_hide_curr_window": "Hide window during screenshot", + "relation": { + "not_friends": "You are no longer friends." + } +} diff --git a/locales/en/emoticon.json b/locales/en/emoticon.json new file mode 100644 index 0000000..f39b37e --- /dev/null +++ b/locales/en/emoticon.json @@ -0,0 +1,21 @@ +{ + "recent": { + "title": "Recently used" + }, + "tabs": { + "emoji": "Emoji", + "favorites": "My favorites" + }, + "favorites": { + "title": "My stickers", + "empty": "No stickers yet", + "delete": "Delete", + "deleteSuccess": "Sticker removed", + "deleteFail": "Failed to delete sticker" + }, + "categories": { + "expression": "Yellow face emojis", + "animal": "Animal emojis", + "gesture": "Gesture emojis" + } +} diff --git a/locales/en/fileManager.json b/locales/en/fileManager.json new file mode 100644 index 0000000..aa3505f --- /dev/null +++ b/locales/en/fileManager.json @@ -0,0 +1,89 @@ +{ + "navigation": { + "title": "File categories", + "items": { + "myFiles": "My files", + "senders": "By sender", + "sessions": "By conversation", + "groups": "By group", + "default": "Files" + } + }, + "search": { + "placeholder": { + "default": "Search files", + "myFiles": "Search my files", + "senders": "Search sender files", + "sessions": "Search conversation files", + "groups": "Search group files" + }, + "clear": "Clear search", + "showAllUsers": "Show all users" + }, + "header": { + "titles": { + "myFiles": "My files", + "senders": "Grouped by sender", + "sessions": "Grouped by conversation", + "groups": "Grouped by group", + "default": "File list" + }, + "subtitle": { + "userFiles": "{name}'s files · {total} items", + "search": "{total} files found", + "total": "{total} files in total" + } + }, + "list": { + "fileCount": "{count} files", + "meta": { + "from": "From:" + } + }, + "empty": { + "search": "No related files", + "userHasNoFiles": "{name} has no files", + "default": "No files", + "senders": "No sender files", + "sessions": "No conversation files", + "groups": "No group files" + }, + "notifications": { + "saveFileSuccess": "File saved successfully", + "saveFileFail": "Failed to save file", + "saveVideoSuccess": "Video saved successfully", + "saveVideoFail": "Failed to save video" + }, + "common": { + "unknownUser": "Unknown user", + "unknown": "Unknown", + "loading": "Loading" + }, + "userList": { + "searchPlaceholder": { + "default": "Search", + "senders": "Search senders", + "sessions": "Search conversations", + "groups": "Search groups" + }, + "sectionTitle": { + "default": "List ({count})", + "senders": "Senders ({count})", + "sessions": "Conversations ({count})", + "groups": "Groups ({count})" + }, + "allOptions": { + "default": "All", + "senders": "All senders", + "sessions": "All conversations", + "groups": "All groups" + }, + "empty": { + "default": "No matching items", + "senders": "No matching senders", + "sessions": "No matching conversations", + "groups": "No matching groups" + }, + "memberCount": "{count} members" + } +} diff --git a/locales/en/home.json b/locales/en/home.json new file mode 100644 index 0000000..0416082 --- /dev/null +++ b/locales/en/home.json @@ -0,0 +1,489 @@ +{ + "search_suggestions": "Suggestions", + "search_guide": "Search in the search box", + "no_search_results": "No results found", + "search_result": "Results", + "search_history": "History", + "search_input_placeholder": "Search", + "clear_search_history": "Clear", + "loading": { + "app": "Loading app...", + "left_panel": "Loading left panel...", + "data": "Loading data...", + "right_panel": "Loading right panel..." + }, + "action": { + "message": "Messages", + "message_short_title": "Msg", + "contact": "Friend", + "contact_short_title": "Friend", + "file_manager": "File Manager", + "file_manager_short_title": "Files", + "favorite": "Favorites", + "favorite_short_title": "Favorites", + "plugin": "Plugins", + "plugin_manage": "Plugin Management", + "more": "More", + "opened": "opened", + "start_group_chat": "Start group chat", + "add_friend_or_group": "Add friend/group" + }, + "plugins": { + "dynamic": "Moments", + "dynamic_short_title": "Moments", + "chatbot": "ChatBot", + "chatbot_short_title": "ChatBot", + "status": { + "builtin": "Built-in", + "uninstalling": "Uninstalling" + }, + "actions": { + "install": "Install", + "pin": "Pin to sidebar", + "unpin": "Unpin", + "uninstall": "Uninstall" + } + }, + "search_window": { + "title": "Global Search", + "tabs": { + "recommend": "Recommend", + "user": "Find Friends", + "group": "Find Groups" + }, + "placeholder": { + "recommend": "Enter keywords to explore", + "user": "Search nickname to add friends", + "group": "Enter group ID to find groups" + }, + "labels": { + "account": "Account: {account}" + }, + "tooltip": { + "copy_account": "Copy account", + "bound_gitee": "Gitee linked", + "bound_github": "GitHub linked", + "bound_gitcode": "Gitcode linked" + }, + "empty": { + "no_result": "No matching result", + "prompt": "Enter keywords to search" + }, + "notification": { + "copy_success": "Copied {account}", + "search_fail": "Search failed" + }, + "buttons": { + "add": "Add", + "message": "Message", + "edit_profile": "Edit profile" + }, + "modal": { + "add_friend": "Add friend request", + "add_group": "Join group request" + } + }, + "chat_details": { + "single": { + "empty_signature": "This user hasn't left any bio yet", + "region": "Region: {place}", + "unknown": "Unknown", + "badge_label": "Badges:", + "call_only_single": "Audio/video calls are available only in direct chats", + "friend_info_missing": "Unable to load friend info", + "footer": { + "audio_call": "Voice call", + "video_call": "Video call" + } + }, + "group": { + "official_badge": "Official group", + "id": "Group ID {account}", + "copy": "Copy", + "copy_success": "Copied {account}", + "info_missing": "Unable to load group info", + "remark": { + "label": "Group remark", + "placeholder": "Enter a remark", + "empty": "Set group remark", + "success": "Group remark updated", + "fail": "Failed to update group remark" + }, + "nickname": { + "label": "My nickname", + "placeholder": "Enter nickname", + "empty": "Not set", + "success": "Nickname updated", + "fail": "Failed to update nickname" + }, + "announcement": { + "label": "Announcements", + "empty": "Not set", + "window_title": "View announcements" + }, + "members": { + "count": "Members ({count})", + "online": "Online {count}" + } + }, + "actions": { + "message": "Message" + }, + "window": { + "image_viewer": "Image viewer" + } + }, + "chat_reaction": { + "like": "Like", + "unsatisfied": "Unsatisfied", + "heart": "Heart", + "angry": "Angry", + "party": "Celebrate", + "rocket": "Rocket", + "lol": "Haha", + "clap": "Clap", + "flower": "Flower", + "bomb": "Bomb", + "question": "Confused", + "victory": "Victory", + "light": "Light", + "red_envelope": "Lucky money" + }, + "chat_header": { + "bot_tag": "Assistant", + "status_abnormal": "Friend status unavailable", + "toolbar": { + "audio_call": "Voice call", + "video_call": "Video call", + "screen_share": "Screen share", + "remote_assist": "Remote assist", + "invite_to_group": "Invite to group", + "start_group_chat": "Start group chat" + }, + "sidebar": { + "single": { + "pin": "Pin to top", + "mute": "Mute notifications", + "shield": "Block user", + "delete_history": "Delete chat history", + "delete_friend": "Remove friend", + "report": "Harassed? Report this user" + }, + "group": { + "name_placeholder": "Enter group name (max 12 chars)", + "official_badge": "Official group", + "copy": "Copy", + "qr": "QR", + "members": "Group members", + "channel_members": "Channel members", + "my_name": "My nickname", + "remark": "Group remark", + "remark_desc": "(Only visible to you)", + "settings": { + "title": "Group settings", + "pin": "Pin to top", + "mute": "Mute notifications", + "scan": "Allow QR join" + }, + "message_settings": { + "title": "Message settings" + }, + "manage_members": "Manage members", + "delete_history": "Delete chat history", + "dissolve": "Disband group", + "exit": "Leave group", + "report": "Harassed? Report this group" + } + }, + "message_setting": { + "receive_no_alert": "Receive without alerts", + "shield": "Block messages" + }, + "modal": { + "confirm": "Confirm", + "cancel": "Cancel", + "invite_friends": "Invite friends", + "tips": { + "delete_friend": "Delete this friend?", + "dissolve_group": "Disband this group?", + "exit_group": "Leave this group?", + "rename_group": "Rename group to {name} ?", + "update_info": "Save changes to group info?", + "delete_history": "Delete local chat history?" + } + }, + "qr": { + "tip": "Scan the QR code to join", + "group_id_label": "Group ID: ", + "actions": { + "forward": "Forward", + "copy_group_id": "Copy group ID", + "save_image": "Save image" + }, + "toast": { + "group_id_copied": "Group ID copied", + "save_success": "Image saved", + "save_failed": "Save failed" + } + }, + "toast": { + "copy_success": "Copied {account}", + "group_info_updated": "Group info updated", + "group_info_update_failed": "Failed to update group info", + "todo": "Coming soon", + "pin_on": "Pinned", + "pin_off": "Unpinned", + "pin_failed": "Pin failed", + "mute_on": "Muted without alerts", + "mute_off": "Notifications restored", + "shield_on": "Messages blocked", + "shield_off": "Messages unblocked", + "action_failed": "Operation failed", + "group_name_empty": "Group name cannot be empty", + "group_name_too_long": "Group name cannot exceed 12 characters", + "delete_history_success": "Chat history deleted", + "delete_history_failed": "Failed to delete chat history", + "delete_friend_success": "Friend removed", + "dissolve_not_allowed": "Cannot disband channel", + "dissolve_success": "Group disbanded", + "exit_not_allowed": "Cannot leave channel", + "exit_success": "Left group", + "group_name_update_failed": "Failed to rename group" + } + }, + "manage_group_member": { + "title": "Manage group members", + "search_placeholder_mobile": "Search members~", + "search_placeholder_pc": "Search group members", + "selected_count": "Selected {count} members", + "remove_button": "Remove from group", + "dialog_negative": "Cancel", + "dialog_positive": "Confirm", + "remove_confirm": "Remove {count} members?", + "channel_not_allowed": "Channels cannot remove members", + "select_member_warning": "Select members to remove", + "remove_success": "Removed {count} members", + "remove_failed": "Failed to remove members, please retry", + "confirm_remove_title": "Confirm removal", + "channel_manage_unsupported": "Channels do not support member management" + }, + "profile_card": { + "online_status": "Online status", + "status": { + "online": "Online", + "offline": "Offline" + }, + "labels": { + "account": "Account", + "location": "Location", + "badges": "Badges", + "activities": "Activities" + }, + "location_unknown": "Unknown", + "tooltip": { + "copy_account": "Copy account", + "bound_gitee": "Gitee linked", + "bound_github": "GitHub linked", + "bound_gitcode": "Gitcode linked" + }, + "buttons": { + "edit": "Edit profile", + "message": "Message", + "add_friend": "Add friend" + }, + "notification": { + "copy_success": "Copied {account}" + }, + "modal": { + "add_friend": "Add friend request" + }, + "developer_badge": "HuLa Developer" + }, + "profile_edit": { + "title": "Edit profile", + "avatar": { + "change": "Change avatar", + "tips": "Recommended size 180x180px. Supports JPG, PNG, WEBP formats and files under 500kb" + }, + "badge": { + "current": "Current badge:", + "wear": "Wear" + }, + "form": { + "nickname": { + "label": "Nickname", + "placeholder": "Enter your nickname", + "remaining": "Rename attempts left: {count}" + } + }, + "actions": { + "save": "Save" + }, + "toast": { + "avatar_update_success": "Avatar updated" + } + }, + "chat_sidebar": { + "announcement": { + "title": "Group announcement", + "load_failed": "Failed to load announcement, please retry", + "default": "Please avoid sharing sensitive information. Follow internet regulations or the message will be removed.", + "window": { + "add": "Add announcement", + "view": "View announcement" + } + }, + "actions": { + "retry": "Retry" + }, + "online_members": "Online members {count}", + "search": { + "placeholder": "Search" + }, + "roles": { + "owner": "Owner", + "admin": "Admin" + } + }, + "about": { + "version": "Version: {version} ({arch})", + "device": "Device: {type}{version}", + "copyright": "Copyright © {start}-{end} HuLaSpark", + "rights": "All Rights Reserved." + }, + "apply_list": { + "friend_notice": "Friend Requests", + "group_notice": "Group Notifications", + "you": "You", + "unknown_user": "Unknown user", + "message_label": "Message: ", + "handler_label": "Handled by: {name}", + "accept": "Accept", + "status": { + "accepted": "Approved", + "rejected": "Rejected", + "ignored": "Ignored", + "rejected_by_other": "Rejected by recipient", + "pending": "Pending verification" + }, + "friend": { + "accepted_you": "Request approved", + "pending": "Waiting for verification", + "request": "Wants to add you as a friend" + }, + "group": { + "loading": "Loading...", + "apply": "Request to join [{group}]", + "invite": "Invite {name} to join [{group}]", + "invite_confirmed": "Joined [{group}]", + "invite_you": "Invites you to join [{group}]", + "kicked": "Removed by {operator} from [{group}]", + "set_admin": "Set as admin of [{group}]", + "remove_admin": "Admin role removed from [{group}]" + }, + "dropdown": { + "reject": "Reject", + "ignore": "Ignore" + }, + "empty_friend": "No friend requests", + "empty_group": "No group notifications" + }, + "chat_main": { + "network_offline": "Network unavailable, please check your settings", + "network_connecting": "Connecting to server...", + "network_ws_offline": "Server connection lost, retrying", + "copy": { + "empty": "Nothing to copy", + "image_processing": "Converting {format} image to PNG...", + "image_success": "Image copied to clipboard", + "image_convert_success": "Image converted to PNG and copied", + "selected_success": "Selected text copied", + "message_success": "Message copied", + "unknown_error": "Failed to copy content, please try again later" + }, + "feature": { + "coming_soon": "Feature under development" + }, + "translate": { + "empty": "Nothing to translate" + }, + "file": { + "missing_local": "File not found locally, please download first", + "show_failed": "Unable to reveal file in folder", + "download_prompt": "File not downloaded yet, please download first", + "download_success": "File downloaded successfully", + "download_failed": "File download failed, please retry", + "save_success": "File saved locally", + "save_failed": "Failed to save file, please retry" + }, + "image": { + "fetch_failed": "Failed to get image URL", + "locate_failed": "Unable to locate this image for now", + "download_prompt": "Image not downloaded, saving locally...", + "save_success": "Image saved locally", + "save_failed": "Failed to save image", + "download_failed": "Image download failed, please retry" + }, + "delete": { + "invalid_session": "Unable to determine the session of this message", + "confirm": "Deleted messages will disappear from your history. Continue?", + "success": "Message deleted" + }, + "announcement": { + "view_all": "View all" + }, + "no_more": "No more messages", + "new_messages": "{count} new messages", + "confirm": "Confirm", + "cancel": "Cancel", + "group_nickname": { + "title": "Edit group nickname", + "placeholder": "Enter a group nickname", + "error": { + "empty": "Group nickname cannot be empty", + "invalid_room": "Group information is invalid" + } + }, + "toast": { + "searching": "Searching for messages...", + "not_found": "Failed to load history, please try again", + "multi_select_mobile": "Message multi-select is not available on mobile", + "disbanded_group": "This group has been disbanded" + } + }, + "friends_list": { + "notice": { + "friend": "Friend Notifications", + "group": "Group Notifications" + }, + "tabs": { + "friend": "Friends", + "group": "Groups" + }, + "collapse": { + "friend": "My Friends", + "group": "My Groups" + }, + "bot_tag": "Assistant", + "status": { + "online": "Online", + "offline": "Offline" + }, + "menu": { + "add_group": "Add Category", + "rename_group": "Rename Category", + "delete_group": "Delete Category" + } + }, + "create_group": { + "title": "Create Group Chat", + "action": "Create", + "success": "Group chat created", + "fail": "Failed to create group chat", + "required_tag": "Required" + }, + "file_drop": { + "title": "Release to send files", + "desc": "Drag and drop images or files, the current session will receive them" + } +} diff --git a/locales/en/login.json b/locales/en/login.json new file mode 100644 index 0000000..c71e60c --- /dev/null +++ b/locales/en/login.json @@ -0,0 +1,193 @@ +{ + "button": { + "login": { + "default": "Login", + "network_error": "Network Error" + }, + "qr_code": "QR Code", + "cancel_login": "Cancel", + "remove_account": "Remove" + }, + "input": { + "account": { + "placeholder": "Email / HuLa Account" + }, + "pass": { + "placeholder": "Enter HuLa Password" + } + }, + "option": { + "more": "More Options", + "items": { + "forget": "Forgot Password", + "network_setting": "Network Settings" + } + }, + "term": { + "checkout": { + "text1": "I accept the", + "text2": " Terms ", + "text3": " & ", + "text4": "Privacy Policy" + } + }, + "auth_way": { + "acc_pss": "Account & Password Login" + }, + "register": "Register Account", + "third_party": { + "title": "OAuth Login / Sign Up", + "gitee": "Login with Gitee", + "github": "Login with GitHub", + "gitcode": "Login with GitCode" + }, + "qr_code_expired": "QR code expired", + "qr_code_expired_hint": "Please try again later", + "qr_code_scan_hint": "Please use HuLa App to scan the QR code", + "qr_code_gen_fail_hint": "Failed to generate QR code", + "qr_code_scan_success_hint": "Scan successful, waiting for authorization", + "wait_auth_hint": "Waiting for authorization...", + "login_success": "Login successful", + "fetch_user_fail": "Failed to fetch user information", + "refreshing": "Refreshing...", + "loading": "Loading...", + "scan_qr_code_fail": "QR code scan failed", + "status": { + "logging_in": "Logging in...", + "success_redirect": "Redirecting...", + "service_disconnected": "Service disconnected" + }, + "qr": { + "overlay": { + "success": "Login successful", + "error": "QR scan failed", + "auth": "Scanned, waiting for authorization", + "expired": "QR code expired", + "fetch_failed": "Login succeeded, but failed to fetch user info", + "generate_fail": "Failed to generate QR code", + "general_error": "Something went wrong", + "refreshing": "Refreshing..." + }, + "load_text": { + "loading": "Loading...", + "refreshing": "Refreshing...", + "scan_hint": "Scan with the HuLa App", + "login": "Signing in...", + "retry": "Please try again later", + "auth_pending": "Waiting for authorization..." + }, + "actions": { + "account_login": "Pwd Login", + "register": "Register", + "register_title": "Register" + } + }, + "guide": { + "welcome": { + "title": "🎉 Welcome to HuLa", + "desc": "HuLa is built with Tauri and supports Windows, macOS, Linux, iOS, Android" + }, + "privacy": { + "title": "🤔 Privacy & Service Terms", + "desc": "Please review HuLa's privacy policy and terms first" + }, + "network": { + "title": "⚙️ Network Settings", + "desc": "HuLa allows custom service endpoints to replace the official one" + }, + "register": { + "title": "🤓 How to sign in", + "desc": "Register an account and complete your profile before using HuLa" + }, + "actions": { + "next": "Next", + "prev": "Back", + "done": "Done", + "progress": "{current}/{total}" + } + }, + "network": { + "title": "Network Settings", + "tabs": { + "api": "API", + "ws": "WebSocket" + }, + "fields": { + "type": "Type", + "host": "IP or Domain", + "port": "Port", + "suffix": "Suffix" + }, + "placeholder": { + "host": "127.0.0.1 or hulaspark.com", + "port": "443", + "api_suffix": "api", + "ws_suffix": "websocket" + }, + "api": { + "options": { + "none": "Disabled (use official endpoint)", + "http": "HTTP", + "https": "HTTPS" + } + }, + "ws": { + "options": { + "none": "Disabled (use official endpoint)", + "ws": "WS", + "wss": "WSS" + } + }, + "actions": { + "save": "Save", + "back": "Back" + }, + "messages": { + "incomplete": "Please complete all required network fields", + "save_success": "Network settings saved", + "save_failed": "Failed to save network settings: {error}" + } + }, + "mobile": { + "welcome_title": "HI, Welcome", + "tabs": { + "login": "Login", + "register": "Register" + }, + "forget_code": "Forget", + "btn": { + "login": "Login" + }, + "input": { + "account_placeholder": "Enter HuLa account", + "code_placeholder": "Enter password" + }, + "register": { + "input": { + "nickname": "Nickname", + "password": "Password", + "confirm_password": "Confirm Password", + "email": "Email", + "email_verification_code": "Enter email verification code" + }, + "btn": { + "register": "Register", + "next": "Next", + "send_email_code": "Send code", + "resend_in": "Resend in {seconds}s" + }, + "pass_validate_info": { + "minlength": "At least {len} characters", + "valid_characters": "Composed of English and numbers", + "must_special_char": "There must be a special character" + } + }, + "email_invalid": "Invalid email.", + "code_sent_email": "Verification code sent. Please check your email.", + "code_send_failed_with_reason": "Failed to send verification code: {reason}", + "code_send_failed_retry": "Failed to send verification code. Please try again later.", + "complete_info_before_register": "Please complete your information before registering.", + "register_success": "Registration successful.", + "register_fail": "Registration failed." + } +} diff --git a/locales/en/menu.json b/locales/en/menu.json new file mode 100644 index 0000000..0e8a486 --- /dev/null +++ b/locales/en/menu.json @@ -0,0 +1,55 @@ +{ + "check_update": "Check Updates", + "lock_screen": "Lock Screen", + "settings": "Settings", + "about": "About", + "sign_out": "Sign Out", + "select": "Select", + "add_sticker": "Add Sticker", + "forward": "Forward", + "reply": "Reply", + "recall": "Recall", + "copy": "Copy", + "save_as": "Save as", + "show_in_finder": "Show in Finder", + "show_in_folder": "Show in Folder", + "translate": "Translate", + "del": "Delete", + "preview": "Preview", + "send_message": "Send Msg", + "get_user_info": "Peek", + "modify_group_nickname": "Modify nickname", + "add_friend": "Add Friend", + "report": "Report", + "ctx_menu_more": "More", + "set_admin": "Make admin", + "set_admin_success": "Admin added", + "set_admin_fail": "Grant admin failed", + "revoke_admin": "Remove admin", + "revoke_admin_success": "Admin revoked", + "revoke_admin_fail": "Revoke admin failed", + "remove_from_group": "kick out group", + "remove_from_group_success": "Removed from group", + "remove_from_group_fail": "Remove member failed", + "pin": "Pin", + "unpin": "Unpin", + "copy_account": "Copy Account", + "mark_unread": "Mark as Unread", + "group_message_setting": "Group notifications", + "set_do_not_disturb": "Mute alerts", + "unset_do_not_disturb": "Unmute alerts", + "allow_notifications": "Notify me", + "receive_silently": "Receive silently", + "block_group_messages": "Mute group", + "unblock_group_messages": "Unmute group", + "block_user_messages": "Block chat", + "unblock_user_messages": "Unblock chat", + "remove_from_list": "Remove chat", + "delete_friend": "Remove friend", + "dissolve_group": "Disband group", + "leave_group": "Leave Group", + "today": "Today", + "yesterday": "Yesterday", + "start_group_chat": "New Chat", + "add_contact": "Add Contact" +} diff --git a/locales/en/message.json b/locales/en/message.json new file mode 100644 index 0000000..fb9dab5 --- /dev/null +++ b/locales/en/message.json @@ -0,0 +1,277 @@ +{ + "file": { + "unknown_file": "Unknown file", + "unknown_error": "Unknown error", + "status": { + "downloaded": "Downloaded", + "not_downloaded": "Not downloaded" + }, + "toast": { + "missing_local": "Local file not found. Please download it first~", + "reveal_fail": "Unable to reveal the file in folder", + "open_fail_hint": "Unable to open or reveal the file. Please locate it manually in your file manager.", + "download_open_fail": "File downloaded but cannot be opened or revealed. Please find it manually in your file manager.", + "download_failed": "Failed to download file: {reason}" + } + }, + "video": { + "unknown_video": "Video file", + "uploading": "Uploading video... {progress}%", + "opening": "Opening video..." + }, + "file_upload": { + "title": "Send files", + "cancel": "Cancel", + "send": "Send ({count})" + }, + "file_upload_progress": { + "uploading_with_name": "Uploading: {name}", + "uploading": "Uploading files", + "preparing": "Preparing upload", + "completed_failed": "Upload finished ({count} failed)", + "completed": "Upload finished" + }, + "voice_recorder": { + "tap_prefix": "Tap", + "tap_suffix": "to speak", + "recording": "Recording", + "processing": "Processing audio", + "error": "Recording failed" + }, + "call_window": { + "incoming": "Incoming call", + "video_call": "Video call", + "voice_call": "Voice call", + "unknown_user": "Unknown user", + "status": { + "calling": "Calling", + "ongoing": "In call", + "ended": "Call ended", + "preparing": "Preparing" + }, + "mic_on": "Mic on", + "mic_off": "Mic off", + "speaker_on": "Speaker on", + "speaker_off": "Speaker off", + "camera_disable": "Turn camera off", + "camera_enable": "Turn camera on", + "hangup": "Hang up" + }, + "permission": { + "mic": { + "title": "Microphone permission", + "denied": "Microphone permission denied. Please enable it in system settings and retry", + "unavailable": "No microphone device detected" + }, + "camera": { + "title": "Camera permission", + "denied": "Camera permission denied. Please enable it in system settings and retry", + "unavailable": "No camera device detected" + }, + "open_settings": "Open system settings", + "retry": "Retry", + "hangup": "Hang up", + "requesting": "Requesting device permission..." + }, + "friend_verify": { + "title": "Add friend request", + "account": "Account: {account}", + "placeholder": "Say something to them...", + "send_btn": "Add friend", + "toast_success": "Friend request sent", + "default_msg": "I'm {name}" + }, + "group_verify": { + "title": "Join group request", + "account": "Group ID: {account}", + "placeholder": "Enter your verification message", + "send_btn": "Apply to join", + "toast_success": "Group request sent", + "default_msg": "I'm {name}" + }, + "message_list": { + "sync_loading": "Syncing messages", + "official_popover": "Official group", + "bot_popover": "HuLa Assistant", + "mention_tag": "[Mentioned me]", + "shield_group": "Group muted", + "shield_user": "User muted", + "empty_description": "Start chatting with friends!", + "empty_action": "Find friends", + "default_last_msg": "Welcome to HuLa" + }, + "message_menu": { + "pin_success": "Pinned", + "unpin_success": "Unpinned", + "pin_fail": "Failed to pin", + "unpin_fail": "Failed to unpin", + "copy_success": "Copied {account}", + "shield_success": "Messages blocked", + "unshield_success": "Block removed", + "delete_friend_success": "Friend removed", + "cannot_dissolve_channel": "Cannot dissolve channel", + "cannot_quit_channel": "Cannot quit channel", + "dissolve_group_success": "Group dissolved", + "quit_group_success": "Left group", + "notification_allowed": "Notifications enabled", + "notification_silent": "Receiving silently" + }, + "image_viewer": { + "zoom_out": "Zoom out", + "zoom_in": "Zoom in", + "rotate_left": "Rotate left", + "rotate_right": "Rotate right", + "reset": "Reset", + "save_as": "Save as", + "first_image": "This is the first image", + "last_image": "This is already the last image", + "filter_name": "Image" + }, + "video_viewer": { + "previous_video": "Previous video", + "next_video": "Next video", + "play": "Play", + "pause": "Pause", + "mute": "Mute", + "unmute": "Unmute", + "tip_playing_index": "Playing video #{index}", + "tip_playing_previous": "Now playing previous video", + "tip_first": "This is the first video", + "tip_playing_next": "Now playing next video", + "tip_last": "This is the last video" + }, + "screenshot": { + "border_radius": "Radius", + "redo": "Redo", + "undo": "Undo", + "confirm": "Confirm", + "cancel": "Cancel", + "tool_rect": "Rectangle", + "tool_circle": "Circle", + "tool_arrow": "Arrow", + "tool_mosaic": "Mosaic", + "tooltip_drag": "Drag to adjust selection", + "tooltip_resize": "Drag handles to resize", + "save_success": "Screenshot copied to clipboard", + "save_failed": "Screenshot failed, please retry" + }, + "lock_screen": { + "title": "Lock screen", + "password_label": "Lock password", + "password_placeholder": "Enter lock password", + "confirm_button": "Confirm", + "validation_required": "Please enter the lock password", + "tip_description": "An open-source IM app built with cutting-edge tech like Tauri, Vue 3, Vite 5, UnoCSS, and TypeScript.", + "enter_system_tooltip": "Enter", + "unlocking": "Unlocking", + "wrong_password": "Incorrect password. Please try again", + "toast_empty_password": "Please enter the password", + "return_action": "Back", + "logout_action": "Log out", + "forgot_password": "Forgot password", + "enter_system_action": "Enter" + }, + "check_update": { + "current_version": "Current version", + "new_tag": "new", + "new_release_date": "New release date:", + "release_date": "Release date:", + "log_title": "Release notes", + "collapse": "Collapse", + "expand": "Expand", + "ignore": "Ignore", + "update_now": "Update now", + "check_now": "Check update", + "downloading": "Downloading...", + "update_success": "Updated", + "confirm_update": "Proceed with update?", + "confirm_ignore": "Ignoring will stop this reminder but you can still update later. Confirm ignore?", + "update_error": "Update check failed, please try again later", + "fetch_log_failed": "Failed to load release notes, please set token and retry", + "download_success_toast": "Installer downloaded. It will install and restart soon", + "restart_failed": "Restart failed, please restart manually" + }, + "notify": { + "new_messages": "New messages", + "ignore_all": "Ignore all" + }, + "tray": { + "online_status_window_title": "Online status", + "more_status": "More statuses", + "mute_all": "Mute all sounds", + "unmute_all": "Enable all sounds", + "open_main_panel": "Open main panel", + "exit": "Exit" + }, + "location": { + "modal": { + "title": { + "map_error": "Map error", + "location_error": "Location failed", + "default": "Pick a location" + }, + "result": { + "map_error_title": "Map failed to load", + "location_error_title": "Failed to get location" + }, + "buttons": { + "cancel": "Cancel", + "retry": "Retry", + "send": "Send location" + }, + "loading": { + "locating": "Fetching location...", + "map": "Loading map..." + }, + "info": { + "current": "Current location", + "fetching_address": "Getting address...", + "coordinate": "Coord: {lat}, {lng}", + "unknown_address": "Unknown address" + }, + "errors": { + "missing_api_key": "Tencent Map API key not configured", + "geocode_failed": "Failed to fetch address" + } + }, + "map": { + "marker_current": "Current location" + }, + "hook": { + "permission_check_failed": "Failed to check geolocation permission", + "unsupported": "Geolocation is not supported in this browser", + "error_generic": "Failed to obtain location", + "permission_denied": "Location permission denied", + "position_unavailable": "Location unavailable", + "timeout": "Location request timed out" + } + }, + "update_window": { + "updating": "Updating", + "fetch_commit_failed": "Failed to load release notes for v{version}", + "fetch_release_error": "Failed to fetch release info", + "fetch_release_error_with_reason": "Failed to fetch release info: {reason}" + }, + "multi_choose": { + "search_placeholder": "Search sessions", + "send_to_separately": "Send separately to", + "leave_message_placeholder": "Leave a note", + "single_forward": "Forward individually", + "merge_forward": "Forward as summary", + "save_to_pc": "Save to PC", + "delete_action": "Delete", + "exit_multi_select": "Exit selection", + "cancel_button": "Cancel", + "send_button": "Send", + "not_implemented": "Not implemented yet", + "select_delete_prompt": "Please select messages to delete", + "delete_confirm": "Deleted messages will disappear from your history. Delete these {count} messages?", + "room_missing": "Current session unknown, delete failed", + "delete_failed_short": "Failed to delete message", + "delete_failed_retry": "Failed to delete, please try again later", + "delete_success": "Selected messages deleted", + "forward_success": "Messages forwarded", + "forward_failed": "Failed to forward", + "non_text_message": "[Non-text message]" + } +} diff --git a/locales/en/mobile_chat_setting.json b/locales/en/mobile_chat_setting.json new file mode 100644 index 0000000..30b5124 --- /dev/null +++ b/locales/en/mobile_chat_setting.json @@ -0,0 +1,61 @@ +{ + "title": "{t} Settings", + "type": { + "group": "Group", + "single_chat": "Chat" + }, + "group_members_title": "Group Members", + "member_count": "{count} members", + "group_invite_member": "Invite", + "manage_group_members": "Manage Group Members", + "search_history": "Search Chat History", + "id_card": { + "type": { + "group": "Group", + "single_chat": "Chat" + }, + "qr_code_label": "{t} / QR Code" + }, + "group_notice": { + "title": "Announcement" + }, + "group_name": "Group Name", + "input": { + "group_name": "Enter the group nickname", + "group_alias": "Enter the group alias", + "remark": "The name is only visible to you." + }, + "group_alias": "My Alias in Group", + "remark": "Remark", + "remar_kprivate_visible": "(Only you)", + "setting_type": "{t} Settings", + "silent": "Mute Notifications", + "pintop": "Sticky", + "delete_chat_history": "Delete Chat History", + "disband_group": "Disband Group", + "leave_group": "Leave Group", + "delete_friend": "Delete Friend", + "copy_id": "Copied {id} successfully", + "confirm_disband_group": "Are you sure you want to disband this group?", + "confirm_leave_group": "Are you sure you want to leave this group?", + "confirm_delete_friend": "Are you sure you want to delete this friend?", + "session_not_exist": "The current conversation does not exist", + "disband_channel_failed": "Failed to disband the channel", + "group_disbanded": "Group has been disbanded", + "leave_channel_failed": "Failed to leave the channel", + "group_left": "You have left the group", + "get_friend_info_failed": "Failed to get friend information", + "delete_friend_success": "Friend deleted successfully", + "session_not_ready": "The current conversation information is not ready", + "unpinned_success": "Unpinned successfully", + "pinned_success": "Pinned successfully", + "pin_failed": "Failed to pin", + "group_note_updated": "Group note updated successfully", + "remark_updated": "{n} remark update successful", + "group_name_updated": "Group name updated successfully", + "setting_failed": "Setting failed", + "messages_muted": "Messages muted", + "messages_unmuted": "Messages unmuted", + "notifications_silent": "Messages allowed, notifications off", + "notifications_enabled": "Notifications enabled" +} diff --git a/locales/en/mobile_contact.json b/locales/en/mobile_contact.json new file mode 100644 index 0000000..f4a376a --- /dev/null +++ b/locales/en/mobile_contact.json @@ -0,0 +1,17 @@ +{ + "title": "Contacts", + "input": { + "search": "Search" + }, + "my_chat": "My Chat", + "tab": { + "contacts": "Friends", + "group": "Group" + }, + "friend": { + "title": "My Friends" + }, + "group": { + "title": "My Group" + } +} diff --git a/locales/en/mobile_edit_bio.json b/locales/en/mobile_edit_bio.json new file mode 100644 index 0000000..cf2716e --- /dev/null +++ b/locales/en/mobile_edit_bio.json @@ -0,0 +1,5 @@ +{ + "title": "Edit BIO", + "placeholder": "Introduce yourself ~", + "save_btn": "SAVE" +} diff --git a/locales/en/mobile_edit_brithday.json b/locales/en/mobile_edit_brithday.json new file mode 100644 index 0000000..59bbfc6 --- /dev/null +++ b/locales/en/mobile_edit_brithday.json @@ -0,0 +1,9 @@ +{ + "title": "Edit Brithday", + "options": { + "display_birthday_tag": "Display Birthday Tag", + "displsy_age": "Display Age", + "display_constellation": "Display Constellations" + }, + "save_btn": "SAVE" +} diff --git a/locales/en/mobile_edit_profile.json b/locales/en/mobile_edit_profile.json new file mode 100644 index 0000000..c61f371 --- /dev/null +++ b/locales/en/mobile_edit_profile.json @@ -0,0 +1,23 @@ +{ + "title": "Profile", + "nickname": "Nickname", + "gender": "Gender", + "brithday": "Brithday", + "region": "Region", + "phone": "Phone", + "bio": "Bio", + "placeholder": { + "nickname": "Enter a nickname", + "gender": "Click to select gender", + "brithday": "Please select a birthday", + "region": "Click to select region", + "phone": "Enter a phone number", + "bio": "Please enter a personal introduction" + }, + "save_btn": "SAVE", + "change_avatar": "Change", + "genders": { + "male": "Male", + "female": "Female" + } +} diff --git a/locales/en/mobile_forget_code.json b/locales/en/mobile_forget_code.json new file mode 100644 index 0000000..53595ed --- /dev/null +++ b/locales/en/mobile_forget_code.json @@ -0,0 +1,49 @@ +{ + "title": "Forget password", + "steps": { + "verify_email": "Verify Email", + "set_new_password": "Set Password", + "done": "Done" + }, + "input": { + "label": { + "email": "Email", + "email_verification_code": "Verification code", + "new_pass": "New password", + "confirm_password": "Confirm Password" + }, + "email": "Email", + "email_code": "Code", + "new_pass": "Please enter a new password ({len} characters)", + "confirm_password": "Please enter the password again" + }, + "validation": { + "minlength": "At least {len} characters", + "valid_characters": "Composed of English and numbers", + "must_special_char": "There must be a special character", + "passwords_match": "Passwords match" + }, + "button": { + "send_email_code": "Send code", + "next": "Next", + "go_back_setp": "Back", + "submit": "Submit" + }, + "password_reset_success": "Password reset successfully", + "password_reset_success_desc": "You can now log in with your new password", + "rules": { + "email_require": "Please enter your email address", + "email_invalid": "Please enter a valid email address", + "email_code_require": "Please enter the verification code sent to your email", + "code_length": "Verification code must be {len} digits", + + "new_pass_require": "Please enter a new password", + "new_pass_length": "Password must be {len} characters", + + "confirm_pass_require": "Please confirm your password", + "pass_not_match": "Passwords do not match" + }, + "too_many_requests": "Too many requests. Please try again in {s} seconds", + "code_sent_email": "Verification code has been sent to your email", + "email_resend_in": "Resend in {seconds}s" +} diff --git a/locales/en/mobile_home.json b/locales/en/mobile_home.json new file mode 100644 index 0000000..33f12ee --- /dev/null +++ b/locales/en/mobile_home.json @@ -0,0 +1,25 @@ +{ + "menu": { + "pintop": "Pin Top", + "unpin": "Unpin", + "read": "Read", + "unread": "Unread", + "delete": "Delete" + }, + "input": { + "search": "Search" + }, + "noname": "No Name", + "china": "China", + "chat": { + "unpin": "Unpin", + "pintop": "Pin Top", + "mark_as_read": "Read", + "mark_as_unread": "Unread", + "delete": "Delete" + }, + "marked_as_read": "Marked as Read", + "marked_as_unread": "Marked as Unread", + "mark_as_read_failed": "Failed to mark as read", + "mark_as_unread_failed": "Failed to mark as unread" +} diff --git a/locales/en/mobile_my.json b/locales/en/mobile_my.json new file mode 100644 index 0000000..f21ea7b --- /dev/null +++ b/locales/en/mobile_my.json @@ -0,0 +1,10 @@ +{ + "photos": "Photos", + "favorites": "Favorites", + "files": "Files", + "appearance": "Appearance", + "intelligent": "AI", + "default_bio": "Here to share, connect, and maybe overthink a little.", + + "online": "Online" +} diff --git a/locales/en/mobile_mymessage.json b/locales/en/mobile_mymessage.json new file mode 100644 index 0000000..e795b2f --- /dev/null +++ b/locales/en/mobile_mymessage.json @@ -0,0 +1,41 @@ +{ + "title": "Messages", + "accept": "Accept", + "approved": "Approved", + "refused": "Refused", + "agreed": "Agreed", + "declined": "Declined", + "pending": "Pending", + "ignored": "Ignored", + "notification": { + "friend": "Friend Notifications", + "group": "Group Notifications" + }, + "friend_request_message": "Message: ", + "unknown_user": "Unknown", + "empty_require": "No friend requests available", + "empty_group_require": "No group application notifications available", + "menu": { + "decline": "Decline", + "ignore": "Ignore" + }, + "friend_request_status": { + "accepted": "Your request has been accepted", + "verifying": "Verifying your invitation", + "sent": "Sent a friend request" + }, + "loading": "loading{tail}", + "group": { + "apply_to_join": "Apply to join [{name}]", + "invited_to_join": "Invited {inviter} to join [{group}]", + "joined_group": "Joined [{group}] successfully", + "invited_curr_to_join": "Invited you to join [{group}]", + "kicked_out": "Kicked out of [{group}] by {operator}", + "set_as_admin": "Set as an admin of [{group}] by the group owner", + "removed_as_admin": "Removed as admin of [{group}] by the group owner" + }, + "tab": { + "friend_messages": "Friend Messages", + "group_messages": "Group Messages" + } +} diff --git a/locales/en/mobile_personal_info.json b/locales/en/mobile_personal_info.json new file mode 100644 index 0000000..17468f1 --- /dev/null +++ b/locales/en/mobile_personal_info.json @@ -0,0 +1,21 @@ +{ + "account": "ID", + "unlocked_count_park": { + "text1": "Unlocked", + "text2": "badges" + }, + "no_medal": "No medals yet~", + "follower": "Followers", + "follow": "Following", + "like": "Like", + "edit_profile": "Edit profile", + "remove_user": "Remove", + "add_friend": "Add friend", + "open_bot": "Open Bot", + "chat": "Chat", + "delete_user": { + "success": "Friend deleted successfully", + "failed": "Failed to delete friend" + }, + "not_found": "No friends found" +} diff --git a/locales/en/mobile_personal_info_qr.json b/locales/en/mobile_personal_info_qr.json new file mode 100644 index 0000000..0102bd3 --- /dev/null +++ b/locales/en/mobile_personal_info_qr.json @@ -0,0 +1,5 @@ +{ + "account": "ID", + "scan_to_add": "Scan me to add as a friend~", + "title": "Personal QR code" +} diff --git a/locales/en/mobile_photo.json b/locales/en/mobile_photo.json new file mode 100644 index 0000000..5a5fdd0 --- /dev/null +++ b/locales/en/mobile_photo.json @@ -0,0 +1,6 @@ +{ + "title": "Photos", + "empty": "No Items", + "image_alt": "Album photos", + "image_load_failed": "Failed to load image" +} diff --git a/locales/en/mobile_post.json b/locales/en/mobile_post.json new file mode 100644 index 0000000..a4c7c4d --- /dev/null +++ b/locales/en/mobile_post.json @@ -0,0 +1,47 @@ +{ + "title": "Publish Community", + "content": { + "label": "Post Content", + "placeholder": "Share your life 😎" + }, + "media_type": { + "label": "Media Type", + "option_image": "Image (Temporarily Unavailable)", + "option_video": "Video (Temporarily Unavailable)" + }, + "visibility": { + "label": "Visibility", + "public": "Public", + "selected": "Selected people", + "exclude": "Hide from specific people", + "emptySelectionHint": { + "visible": "Please select users who can see this", + "hidden": "Please select users to hide from" + } + }, + "visibility_selected_btn_label": "Select viewers", + "visibility_exclude_btn_label": "Hide from...", + "visibility_select_btn": "Select users ({count} selected)", + "btn": { + "cancel": "Cancel", + "publish": "Publish" + }, + "select_users": { + "title": "Select Users", + "search_placeholder": "Search users", + "btn": { + "done": "Done" + } + }, + "empty": "No contacts yet", + "error": { + "publish_failed": "Failed to publish post. Please try again", + "required": "Post content cannot be empty", + "select_visible_users": "Please select users who can see this", + "select_exclude_users": "Please select users to hide from" + }, + "success": { + "publish_success": "Post published successfully" + }, + "unknown_user": "Unknown User" +} diff --git a/locales/en/mobile_setting.json b/locales/en/mobile_setting.json new file mode 100644 index 0000000..c02131f --- /dev/null +++ b/locales/en/mobile_setting.json @@ -0,0 +1,14 @@ +{ + "title": "Settings", + "silent_label": "Notification", + "nickname": "Nickname", + "theme": "Theme", + "language": "Language", + "button": { + "logout": "Log Out" + }, + "themes": { + "dark": "Dark", + "light": "Light" + } +} diff --git a/locales/en/mobile_tabbar.json b/locales/en/mobile_tabbar.json new file mode 100644 index 0000000..2f8c2f0 --- /dev/null +++ b/locales/en/mobile_tabbar.json @@ -0,0 +1,8 @@ +{ + "items": { + "messages": "Chats", + "contacts": "Contacts", + "community": "Community", + "me": "me" + } +} diff --git a/locales/en/setting.json b/locales/en/setting.json new file mode 100644 index 0000000..0b91bb1 --- /dev/null +++ b/locales/en/setting.json @@ -0,0 +1,162 @@ +{ + "common": { + "provider_label": "Provider", + "provider_name": "HuLa", + "tag_new": "New" + }, + "footer": { + "like_product": "Like this product? On", + "star_cta": "GitHub give us a star", + "share_joiner": "and", + "share_cta": "share your valuable ideas", + "star_popover_title": "Star us on GitHub", + "star_popover_desc": "If you enjoy HuLa and want to support us, could you star the repo on GitHub? This tiny action means a lot and keeps us shipping great features.", + "later": "Later", + "star_button": "Give a Star", + "issue_popover_title": "Share feedback on GitHub", + "issue_popover_desc": "Every idea matters. We’d love to hear from you—feel free to tell us what feature or experience you’d like to improve!", + "issue_button": "Submit feedback" + }, + "general": { + "title": "General", + "appearance": { + "title": "Appearance", + "theme": { + "auto": "Follow system", + "light": "Light", + "dark": "Dark" + } + }, + "chat": { + "title": "Chat", + "translate_service": "Translation Service", + "translate_options": { + "tencent": "Tencent Cloud Translate", + "youdao": "Youdao Translate" + } + }, + "ui": { + "title": "UI", + "language": "Language", + "blur": "Gaussian blur", + "shadow": "Enable Shadow", + "font": "Font Family", + "menu_name": "Menu Name", + "font_options": { + "PingFang": "PingFang", + "AliFangYuan": "Alibaba Rounded" + } + }, + "system": { + "title": "System", + "close_panel": "Close Panel", + "close_options": { + "minimize_to_tray": "Minimize to Tray", + "exit_program": "Exit Directly" + }, + "close_prompt": "Close Prompt", + "esc_close_window": "ESC Close Window" + } + }, + "login": { + "title": "Log-in", + "auto_login_startup": "Auto-login on startup", + "launch_startup": "Launch at startup" + }, + "notice": { + "title": "Notifications", + "sound": "Sound", + "message_sound": "New Message Alert Sound", + "message_sound_descript": "After turning on, a prompt sound will play when a new message is received.", + "message_volume": "Alert Volume", + "message_volume_descript": "Drag to adjust the prompt sound volume (0-100).", + "group_setting": "Group settings", + "select_all": "Select All", + "group_notic_type": { + "allow": "Allow", + "silent": "Silent", + "block": "Block" + }, + "input": { + "search_group_placholder": "Serch Group" + }, + "batch_set": "Batch Set", + "message_select_group_first": "Please select a group chat to configure first.", + "unknow_group": "Unknow Group", + "group_chat_not_found": "Group chat information not found.", + "setup_fail": "Setup failed", + "message_group_batch_setup_success": "Batch settings completed! {count} group chat has been set to {type}.", + "message_group_batch_update_result": "Batch update completed: {success_count} successful, {fail_count} failed.", + "message_reminder_allowed": "Message reminders have been allowed", + "message_reminder_silent": "Messages will be received but not notified", + "message_ring": "Ring", + "message_unblocked": "Unblocked" + }, + "shortcut": { + "title": "Shortcuts", + "global_shortcut_title": "Global", + "enable_global_shortcuts": "Enable Global Shortcuts", + "enable_global_shortcuts_hint": "After closing, all shortcuts below will be disabled.", + "function_shortcut_title": "Function", + "message_title": "Message", + "screenshot": "Screenshot", + "screenshot_hint": "Press the shortcut key to capture", + "panel_switch": "Main panel switch", + "panel_switch_hint": "Press the shortcut key to switch the main panel", + "message_shortcut": "Message", + "send_message_shortcut": "Send Message", + "send_message_shortcut_hint": "Press the shortcut key to send a message", + "send_message_shortcut_option": "Press {key} to send", + "unbound": "Unbound", + "bound": "Bound", + "reset": "Reset", + "shortcut_update_result": "{name} has been updated", + "shortcut_setting_failed": "{name} setting failed", + "global_enable": "Global shortcuts enabled", + "global_disable": "Global shortcuts disabled", + "global_toggle_failed": "Failed to set the global shortcut toggle", + "send_message_updated": "Send message shortcut updated", + "send_message_failed": "Send message shortcut failed" + }, + "storage": { + "title": "Storage", + "file_scan_progress": "Scanning Progress", + "usage": "File Scan Progress", + "used_space": "Used Space", + "app_used_space": "Hula Data", + "free_space": "Free Space", + "directory": "Directory", + "curr_dir": "Current directory", + "current_file": "Current file", + "scanning_progress": "Scanning progress", + "processed_files": "Processed files", + "processed_files_unit": "{count} files", + "total_size": "Total size", + "mount_point": "Mount point", + "disk_total": "Disk total", + "disk_used": "Disk used", + "disk_usage_percent": "Disk usage %", + "elapsed": "Elapsed", + "elapsed_unit": "{seconds}s", + "path_type": "Scan directory type", + "path_type_default": "Default", + "path_type_custom": "Custom", + "fetching_directory": "Fetching directory...", + "select_directory": "Choose directory", + "start_scan": "Start scan", + "scanning": "Scanning...", + "select_directory_title": "Select a directory to scan", + "select_directory_failed": "Failed to choose directory", + "select_directory_error": "Select directory failed" + }, + "theme": { + "title": "Theme", + "restore_default": "Restore", + "versatile": { + "title": "Vivid palette", + "description": "Feel the charm of colors and craft your own palette", + "simple": "Minimal grace" + } + }, + "unknow": "Unknow" +} diff --git a/locales/ko/agreement.json b/locales/ko/agreement.json new file mode 100644 index 0000000..19bb5e3 --- /dev/null +++ b/locales/ko/agreement.json @@ -0,0 +1,258 @@ +{ + "privacy": { + "header": { + "title": "HuLa 개인정보 보호 지침", + "updatedAt": "업데이트: 2025년 9월 3일" + }, + "sections": [ + { + "title": "머리말", + "paragraphs": [ + "HuLa(이하 \"저희\")는 개인정보가 여러분께 얼마나 중요한지 깊이 이해하고 있으며, 이를 안전하고 신뢰할 수 있게 보호하기 위해 최선을 다하고 있습니다. 여러분의 신뢰를 얻고 유지하기 위해 책임 일치, 목적 명확화, 고지 및 동의, 최소 필요, 안전 보장, 주체 참여, 공개 투명성 등의 원칙을 엄격히 준수합니다. 또한 업계 표준에 부합하는 보안 조치를 도입하여 여러분의 개인정보를 보호할 것을 약속드립니다.", + "저희 제품이나 서비스를 이용하시기 전에 본 개인정보 보호 지침을 주의 깊게 읽고 이해해 주시기 바랍니다." + ] + }, + { + "title": "1. 개인정보를 수집하고 이용하는 방법", + "paragraphs": [ + "개인정보란 전자적 방식이나 그 밖의 방식으로 기록된 정보로서, 단독으로 또는 다른 정보와 결합하여 특정 개인을 식별할 수 있거나 특정 개인의 활동을 반영하는 모든 정보를 말합니다." + ], + "subSections": [ + { + "subtitle": "1.1 개인정보를 수집하는 경우", + "paragraphs": [ + "(1) 계정 등록: HuLa 계정을 등록하실 때, 계정 생성을 위해 휴대폰 번호, 이메일 주소 등의 정보를 수집합니다.", + "(2) 본인 확인: 계정 보안을 위해 실명 인증을 위한 신분 증명 서류 제출을 요청할 수 있습니다.", + "(3) 서비스 이용: HuLa 서비스 이용 중 채팅 기록, 친구 관계, 그룹 정보 등을 수집합니다.", + "(4) 기기 정보: 더 나은 서비스 경험을 제공하기 위해 기기 모델, 운영체제 버전, 기기 식별자 등의 정보를 수집합니다." + ] + }, + { + "subtitle": "1.2 개인정보를 이용하는 방법", + "paragraphs": [ + "(1) 서비스 제공: 인스턴트 메시징, 파일 전송, 음성/영상 통화 등 핵심 기능을 포함합니다.", + "(2) 서비스 보안 유지: 보안 위협, 사기 및 기타 불법 행위를 탐지하고 예방합니다.", + "(3) 서비스 품질 개선: 이용 현황을 분석하여 제품 기능과 사용자 경험을 최적화합니다.", + "(4) 고객 서비스: 문의, 불만 및 피드백을 처리합니다." + ] + } + ] + }, + { + "title": "2. 쿠키 및 유사 기술을 이용하는 방법", + "paragraphs": [ + "서비스의 원활한 운영을 위해 저희는 쿠키라는 작은 데이터 파일을 여러분의 컴퓨터나 모바일 기기에 저장합니다. 쿠키에는 일반적으로 식별자, 사이트 이름, 일련의 숫자와 문자가 포함됩니다. 쿠키는 웹사이트가 여러분의 환경설정이나 장바구니 내용 등을 저장하는 데 도움을 줍니다.", + "저희는 본 정책에서 설명한 목적 외에는 쿠키를 사용하지 않습니다. 여러분은 원하는 대로 쿠키를 관리하거나 삭제할 수 있습니다. 대부분의 브라우저는 쿠키를 지우거나 차단하는 기능을 제공하지만, 이 경우 저희 웹사이트를 방문할 때마다 사용자 설정을 다시 구성해야 할 수 있습니다." + ] + }, + { + "title": "3. 개인정보를 공유, 이전 및 공개하는 방법", + "subSections": [ + { + "subtitle": "3.1 공유", + "paragraphs": [ + "저희는 다음의 경우를 제외하고는 HuLa 외부의 회사, 조직 또는 개인과 여러분의 개인정보를 공유하지 않습니다.", + "(1) 여러분의 명시적인 동의 또는 승인을 받은 경우;", + "(2) 관련 법률, 법적 절차, 또는 정부·사법 기관의 강제적 요청에 따른 경우;", + "(3) HuLa, HuLa 이용자 또는 공중의 권리, 재산 또는 안전을 보호하기 위해 법률이 허용하는 범위 내인 경우;", + "(4) 계열사와의 공유: 계열사와 여러분의 개인정보를 공유할 수 있습니다." + ] + }, + { + "subtitle": "3.2 이전", + "paragraphs": [ + "저희는 다음의 경우를 제외하고는 여러분의 개인정보를 어떠한 회사, 조직 또는 개인에게도 이전하지 않습니다.", + "(1) 여러분의 명시적인 동의 또는 승인을 받은 경우;", + "(2) 합병, 인수 또는 파산 절차 중에는, 여러분의 개인정보를 보유하게 되는 새로운 주체가 본 개인정보 보호 지침을 계속 준수하도록 요구할 것입니다." + ] + }, + { + "subtitle": "3.3 공개", + "paragraphs": [ + "저희는 다음의 경우에만 여러분의 개인정보를 공개적으로 공개합니다.", + "(1) 여러분의 명시적인 동의를 얻은 후;", + "(2) 법률에 따른 공개: 법률, 법적 절차, 소송 또는 정부 기관의 요구가 있는 경우." + ] + } + ] + }, + { + "title": "4. 개인정보를 보호하는 방법", + "paragraphs": [ + "(1) 무단 접근, 공개, 이용, 변조, 훼손 또는 손실을 방지하기 위해 업계 표준에 부합하는 보안 조치를 사용하여 여러분이 제공한 개인정보를 보호합니다.", + "(2) 관련 없는 개인정보가 수집되지 않도록 합리적으로 가능한 모든 조치를 취합니다.", + "(3) 개인정보가 명시된 목적을 달성하는 데 필요한 기간을 초과하여 보관되지 않도록 합리적으로 가능한 모든 조치를 취합니다.", + "(4) 인터넷은 절대적으로 안전한 환경이 아니며, 이메일, 인스턴트 메시지, 소셜 소프트웨어 등의 통신 수단은 완전히 암호화되지 않을 수 있습니다." + ] + }, + { + "title": "5. 여러분의 권리", + "paragraphs": [ + "중국 및 기타 지역의 관련 법률, 규정 및 일반적인 관행에 따라, 저희는 여러분이 자신의 개인정보에 대해 다음과 같은 권리를 행사할 수 있도록 보장합니다." + ], + "subSections": [ + { + "subtitle": "5.1 개인정보 열람", + "paragraphs": [ + "법률 및 규정에서 달리 정한 경우를 제외하고, 여러분은 자신의 개인정보를 열람할 권리가 있습니다." + ] + }, + { + "subtitle": "5.2 개인정보 정정", + "paragraphs": [ + "저희가 처리하는 여러분에 관한 개인정보에서 오류를 발견하신 경우, 정정을 요청할 권리가 있습니다." + ] + }, + { + "subtitle": "5.3 개인정보 삭제", + "paragraphs": [ + "다음의 경우 개인정보 삭제를 요청하실 수 있습니다.", + "(1) 저희의 개인정보 처리가 법률 또는 규정을 위반하는 경우;", + "(2) 여러분의 동의 없이 개인정보를 수집하거나 이용한 경우;", + "(3) 저희의 개인정보 처리가 여러분과의 약정을 위반하는 경우." + ] + }, + { + "subtitle": "5.4 동의 범위 변경", + "paragraphs": [ + "각 업무 기능에는 일정한 필수 개인정보가 필요합니다. 추가적인 정보 수집이나 이용에 대해서는 언제든지 동의를 부여하거나 철회하실 수 있습니다." + ] + } + ] + }, + { + "title": "6. 아동의 개인정보를 처리하는 방법", + "paragraphs": [ + "저희 제품, 웹사이트 및 서비스는 주로 성인을 대상으로 합니다. 아동은 부모나 보호자의 동의 없이 자신의 사용자 계정을 만들 수 없습니다.", + "부모의 동의를 받아 아동으로부터 수집한 개인정보에 대해서는, 법률이 허용하거나 부모 또는 보호자의 명시적인 승인이 있거나 아동을 보호하기 위해 필요한 경우에만 이를 이용하거나 공개합니다." + ] + }, + { + "title": "7. 개인정보의 국외 이전", + "paragraphs": [ + "원칙적으로 중화인민공화국 내에서 수집·생성된 개인정보는 중국 내에 저장됩니다.", + "저희는 전 세계에 분산된 자원과 서버를 통해 제품 및 서비스를 제공하므로, 여러분의 동의를 받은 후 개인정보가 여러분이 서비스를 이용하는 국가/지역 외의 관할권으로 이전되거나 해당 관할권에서 접근될 수 있습니다." + ] + }, + { + "title": "8. 본 정책의 업데이트 방법", + "paragraphs": [ + "저희의 개인정보 보호정책은 변경될 수 있습니다.", + "여러분의 명시적인 동의 없이는 본 개인정보 보호정책에 따른 여러분의 권리를 축소하지 않습니다. 변경 사항은 이 페이지에 게시됩니다.", + "중요한 변경 사항의 경우, 더 눈에 띄는 방식으로 안내해 드립니다(일부 서비스의 경우 구체적인 변경 내용을 설명하는 이메일 통지 포함)." + ] + }, + { + "title": "9. 오픈소스 프로젝트 면책조항", + "paragraphs": [ + "본 프로젝트는 오픈소스 프로젝트로 제공됩니다. 법률이 허용하는 최대한의 범위 내에서 개발자는 소프트웨어의 기능성, 보안성 또는 적합성에 대해 명시적이든 묵시적이든 어떠한 보증도 하지 않습니다.", + "여러분은 본 소프트웨어의 이용이 전적으로 본인의 책임임을 명확히 이해하고 동의합니다. 본 소프트웨어는 \"있는 그대로\" 및 \"이용 가능한 상태 그대로\" 제공되며, 상품성, 특정 목적에의 적합성, 비침해성을 포함하되 이에 국한되지 않는 어떠한 명시적 또는 묵시적 보증도 제공되지 않습니다.", + "어떠한 경우에도 개발자 또는 그 공급자는 본 소프트웨어의 이용으로 인해 발생하는 이익 손실, 사업 중단, 개인정보 유출 또는 기타 상업적 손해를 포함하되 이에 국한되지 않는 직접적, 간접적, 부수적, 특별, 징벌적 또는 결과적 손해에 대해 책임을 지지 않습니다.", + "본 프로젝트를 기반으로 2차 개발을 진행하는 모든 사용자는 소프트웨어를 합법적인 목적으로만 사용할 것을 약속해야 하며, 현지 법률 및 규정 준수에 대한 책임은 전적으로 본인에게 있습니다.", + "개발자는 언제든지 소프트웨어의 기능이나 본 면책조항의 일부를 수정할 권리를 보유하며, 이러한 변경 사항은 소프트웨어 업데이트를 통해 제공될 수 있습니다." + ] + }, + { + "title": "10. 최종 해석권", + "paragraphs": ["본 개인정보 보호 지침에 대한 최종 해석권은 저작자에게 있습니다."] + } + ] + }, + "server": { + "header": { + "title": "HuLa 서비스 이용약관", + "updatedAt": "업데이트: 2025년 9월 3일" + }, + "sections": [ + { + "title": "1. 서비스 약관의 확인 및 수락", + "paragraphs": [ + "HuLa에 오신 것을 환영합니다! HuLa의 모든 전자 서비스는 HuLa가 소유하고 운영합니다. 이용자는 모든 서비스 약관에 완전히 동의하고 등록 절차를 완료해야 정식 HuLa 이용자가 될 수 있습니다." + ] + }, + { + "title": "2. 서비스 개요", + "paragraphs": [ + "HuLa는 텍스트, 음성, 영상으로 채팅하고 이미지와 파일을 공유할 수 있는 무료 인스턴트 메시징 애플리케이션입니다. HuLa는 안전하고 안정적이며 편리한 커뮤니케이션 서비스를 제공하기 위해 최선을 다하고 있습니다." + ] + }, + { + "title": "3. 이용자 등록", + "paragraphs": [ + "3.1 이용자는 등록 시 진실하고 정확하며 완전한 개인정보를 제공해야 합니다.", + "3.2 이용자는 자신의 비밀번호와 계정의 보안에 책임을 지며, 계정을 통해 이루어진 활동으로 인한 모든 손실이나 손해는 전적으로 이용자 본인이 부담합니다.", + "3.3 이용자는 제품 업데이트, 서비스 공지, 특별 혜택 등을 포함하되 이에 국한되지 않는 HuLa의 정보 수신에 동의합니다." + ] + }, + { + "title": "4. 이용자 행동 규범", + "paragraphs": [ + "4.1 이용자는 HuLa 서비스를 이용하는 동안 중화인민공화국의 관련 법률 및 규정을 준수해야 합니다.", + "4.2 이용자는 HuLa 서비스를 불법적인 활동에 이용해서는 안 됩니다.", + "4.3 이용자는 불법적이거나 유해하거나 위협적이거나 모욕적이거나 괴롭힘, 침해, 명예훼손, 저속하거나 외설적이거나 그 밖에 부적절한 콘텐츠를 게시하거나 유포해서는 안 됩니다.", + "4.4 이용자는 제3자의 지식재산권 또는 기타 권리를 침해하는 콘텐츠를 게시하거나 유포해서는 안 됩니다.", + "4.5 이용자는 바이러스, 트로이 목마 또는 기타 악성 프로그램을 악의적으로 유포해서는 안 됩니다." + ] + }, + { + "title": "5. 개인정보 보호", + "paragraphs": [ + "5.1 HuLa는 이용자의 개인정보 보호를 매우 중요하게 생각합니다. 법률에서 달리 요구하지 않는 한, HuLa는 동의 없이 이용자 정보를 제3자에게 공개하거나 이전하지 않습니다.", + "5.2 HuLa는 무단 접근, 이용 또는 공개로부터 이용자 정보를 보호하기 위해 업계 표준에 부합하는 보안 조치를 채택합니다.", + "5.3 이용자는 더 나은 서비스 제공을 위해 HuLa가 관련 정보를 수집하고 이용할 수 있음을 이해하고 동의합니다." + ] + }, + { + "title": "6. 지식재산권", + "paragraphs": [ + "6.1 HuLa 및 이와 관련된 기술, 상표, 저작권은 HuLa가 소유합니다.", + "6.2 HuLa의 서면 허가 없이 이용자는 소프트웨어에 대한 리버스 엔지니어링, 디스어셈블 또는 디컴파일을 수행할 수 없습니다.", + "6.3 이용자는 HuLa를 통해 게시한 콘텐츠에 대한 지식재산권을 보유하지만, HuLa에 전 세계적으로 무상의 비독점적 이용 허락을 부여합니다." + ] + }, + { + "title": "7. 면책조항", + "paragraphs": [ + "7.1 이용자는 HuLa 서비스 이용이 전적으로 본인의 책임임에 동의합니다.", + "7.2 HuLa는 서비스가 이용자의 요구를 충족한다는 것을 보장하지 않으며, 서비스가 중단 없이 제공된다는 것도 보장하지 않습니다.", + "7.3 HuLa는 불가항력 또는 HuLa가 통제할 수 없는 사유로 인한 네트워크 서비스 중단이나 결함에 대해 어떠한 책임도 지지 않습니다." + ] + }, + { + "title": "8. 서비스 변경, 중단 또는 종료", + "paragraphs": [ + "8.1 HuLa는 사업상의 필요에 따라 서비스의 일부 또는 전부를 변경, 중단 또는 종료할 권리를 보유합니다.", + "8.2 유지보수나 업그레이드를 위해 서비스를 일시 중단해야 하는 경우, HuLa는 가능한 한 사전에 공지합니다.", + "8.3 이용자가 본 약관의 조항을 위반하는 경우, HuLa는 해당 이용자에 대한 서비스를 종료할 수 있습니다." + ] + }, + { + "title": "9. 약관의 수정", + "paragraphs": [ + "HuLa는 언제든지 본 약관의 조항을 수정할 수 있습니다. 변경 사항이 발생하면 HuLa는 적절한 방법으로 이용자에게 알립니다. 변경 사항에 동의하지 않으시면 서비스 이용을 중단하실 수 있습니다. 서비스를 계속 이용하시면 변경 사항에 동의한 것으로 간주됩니다." + ] + }, + { + "title": "10. 준거법 및 분쟁 해결", + "paragraphs": [ + "10.1 본 약관의 체결, 이행, 해석 및 관련 분쟁 해결에는 중화인민공화국 법률이 적용됩니다.", + "10.2 분쟁이 발생할 경우, 양 당사자는 우호적인 협의를 통해 해결하도록 노력해야 합니다. 협의가 이루어지지 않을 경우, 어느 당사자든 HuLa 소재지의 인민법원에 소를 제기할 수 있습니다." + ] + }, + { + "title": "11. 오픈소스 프로젝트 면책조항", + "paragraphs": [ + "본 프로젝트는 오픈소스 프로젝트로 제공됩니다. 법률이 허용하는 최대한의 범위 내에서 개발자는 소프트웨어의 기능성, 보안성 또는 적합성에 대해 명시적이든 묵시적이든 어떠한 보증도 하지 않습니다.", + "여러분은 본 소프트웨어의 이용이 전적으로 본인의 책임임을 명확히 이해하고 동의합니다. 본 소프트웨어는 \"있는 그대로\" 및 \"이용 가능한 상태 그대로\" 제공되며, 상품성, 특정 목적에의 적합성, 비침해성을 포함하되 이에 국한되지 않는 어떠한 명시적 또는 묵시적 보증도 제공되지 않습니다.", + "어떠한 경우에도 개발자 또는 그 공급자는 본 소프트웨어의 이용으로 인해 발생하는 이익 손실, 사업 중단, 개인정보 유출 또는 기타 상업적 손해를 포함하되 이에 국한되지 않는 직접적, 간접적, 부수적, 특별, 징벌적 또는 결과적 손해에 대해 책임을 지지 않습니다.", + "본 프로젝트를 기반으로 2차 개발을 진행하는 모든 사용자는 소프트웨어를 합법적인 목적으로만 사용할 것을 약속해야 하며, 현지 법률 및 규정 준수에 대한 책임은 전적으로 본인에게 있습니다.", + "개발자는 언제든지 소프트웨어의 기능이나 본 면책조항의 일부를 수정할 권리를 보유하며, 이러한 변경 사항은 소프트웨어 업데이트를 통해 제공될 수 있습니다." + ] + }, + { + "title": "12. 최종 해석권", + "paragraphs": ["본 약관의 최종 해석권은 저작자에게 있습니다."] + } + ] + } +} diff --git a/locales/ko/announcement.json b/locales/ko/announcement.json new file mode 100644 index 0000000..7cb15fe --- /dev/null +++ b/locales/ko/announcement.json @@ -0,0 +1,36 @@ +{ + "title": { + "create": "그룹 공지 작성", + "view": "그룹 공지", + "edit": "그룹 공지 수정" + }, + "form": { + "placeholder": "공지 내용을 입력하세요 (1~600자)", + "pinned": "상단 고정", + "actions": { + "cancel": "취소", + "publish": "게시", + "new": "새 공지 작성" + } + }, + "list": { + "empty": "아직 공지가 없습니다", + "loadMore": "더 보기", + "noMore": "더 이상 공지가 없습니다", + "deleteConfirm": "정말로 모든 메시지를 삭제하시겠습니까?", + "delete": { + "confirm": "삭제", + "cancel": "취소" + }, + "expand": "펼치기", + "collapse": "접기" + }, + "toast": { + "contentRequired": "공지 내용을 입력해 주세요", + "contentTooLong": "공지는 600자를 초과할 수 없습니다", + "createSuccess": "공지가 게시되었습니다", + "createFail": "공지 게시에 실패했습니다", + "editSuccess": "공지가 수정되었습니다", + "editFail": "공지 수정에 실패했습니다" + } +} diff --git a/locales/ko/auth.json b/locales/ko/auth.json new file mode 100644 index 0000000..299dd77 --- /dev/null +++ b/locales/ko/auth.json @@ -0,0 +1,161 @@ +{ + "onlineStatus": { + "reset_title": "상태 초기화", + "messages": { + "success": "상태가 업데이트되었습니다", + "error": "상태 업데이트에 실패했습니다" + }, + "states": { + "离开": "자리비움", + "忙碌": "바쁨", + "请勿打扰": "방해 금지", + "隐身": "숨김", + "今日天气": "오늘 날씨", + "一言难尽": "복잡 미묘", + "我太难了": "힘든 하루", + "难得糊涂": "대충 살자", + "元气满满": "에너지 충전", + "嗨到飞起": "완전 신남", + "水逆退散": "액운아 물러가라", + "好运锦鲤": "행운의 잉어", + "恋爱中": "연애중", + "我crush了": "심쿵함", + "被掏空": "방전됨", + "听歌中": "음악 감상중", + "我没事": "저 괜찮아요", + "学习中": "공부중", + "睡觉中": "수면중", + "搬砖中": "열일중", + "想静静": "혼자 있고 싶어요", + "运动中": "운동중", + "我想开了": "생각 정리함", + "信号弱": "신호 약함", + "追剧中": "정주행중", + "美滋滋": "기분 최고", + "摸鱼中": "농땡이중", + "无聊中": "심심함", + "悠哉哉": "여유롭게", + "去旅行": "여행중", + "游戏中": "게임중" + } + }, + "register": { + "title": "환영합니다", + "labels": { + "nickname": "닉네임", + "password": "비밀번호", + "confirm": "확인", + "email": "이메일" + }, + "placeholders": { + "nickname": "HuLa 닉네임을 입력하세요", + "email": "이메일을 입력하세요", + "password": "HuLa 비밀번호를 입력하세요", + "confirm": "비밀번호를 다시 입력하세요", + "confirm_placeholder": "비밀번호 확인" + }, + "password_hints": { + "min_length": "최소 6자 이상", + "alpha_numeric": "영문과 숫자를 사용하세요", + "special_char": "특수문자를 포함해야 합니다" + }, + "validation": { + "confirm_match": "비밀번호가 일치합니다" + }, + "form": { + "rules": { + "nickname_required": "닉네임을 입력해 주세요", + "email_required": "이메일을 입력해 주세요", + "email_format": "올바른 이메일 형식을 입력해 주세요", + "password_required": "비밀번호를 입력해 주세요", + "confirm_mismatch": "비밀번호가 일치하지 않습니다" + } + }, + "tips": { + "reopen_code": "인증 코드 창을 닫으셨나요? 버튼을 클릭하면 다시 열 수 있습니다." + }, + "actions": { + "send_code": "인증 코드 전송", + "retry_in": "{seconds}초 후 재시도", + "sending": "전송 중...", + "submit": "회원가입" + }, + "modal": { + "title": "GitHub에서 저희에게 별을 눌러주세요", + "desc": "HuLa를 좋아하시고 저희를 응원하고 싶으시다면, GitHub 저장소에 별을 눌러주시겠어요? 여러분의 응원이 저희가 계속 나아갈 수 있는 힘이 됩니다!", + "cta": "별 누르기" + }, + "email_modal": { + "title": "이메일 인증 코드를 입력하세요", + "desc": "{email}(으)로 인증 코드를 보내드렸습니다. 받은 편지함을 확인해 가입을 완료해 주세요." + }, + "messages": { + "code_sent": "인증 코드가 전송되었습니다", + "register_success": "회원가입이 완료되었습니다", + "register_fail": "회원가입에 실패했습니다. 다시 시도해 주세요." + } + }, + "forget": { + "title": "비밀번호 찾기", + "steps": { + "verify": { + "title": "이메일 인증", + "desc": "계정 이메일을 인증해 주세요" + }, + "reset": { + "title": "새 비밀번호 설정", + "desc": "새 비밀번호를 만들어 주세요" + }, + "done": { + "title": "완료", + "desc": "비밀번호가 변경되었습니다" + } + }, + "form": { + "email_label": "이메일", + "email_placeholder": "이메일을 입력하세요", + "code_label": "인증 코드", + "code_placeholder": "인증 코드를 입력하세요", + "password_label": "새 비밀번호", + "password_placeholder": "6~16자의 비밀번호를 입력하세요", + "confirm_label": "비밀번호 확인", + "confirm_placeholder": "비밀번호를 다시 입력하세요" + }, + "password_hints": { + "length": "비밀번호는 6~16자여야 합니다", + "alpha_numeric": "영문과 숫자를 사용하세요", + "special_char": "특수문자를 포함해야 합니다", + "confirm_match": "비밀번호가 일치합니다" + }, + "buttons": { + "next": "다음", + "prev": "이전", + "submit": "제출" + }, + "actions": { + "send_code": "인증 코드 전송", + "retry_in": "{seconds}초 후 재시도", + "resend": "재전송" + }, + "messages": { + "captcha_cooldown": "요청이 너무 많습니다. {seconds}초 후 다시 시도해 주세요", + "enter_email": "이메일을 입력해 주세요", + "email_format": "올바른 이메일 형식을 입력해 주세요", + "code_sent": "이메일로 인증 코드가 전송되었습니다" + }, + "rules": { + "email_required": "이메일 주소를 입력해 주세요", + "email_format": "올바른 이메일 주소를 입력해 주세요", + "code_required": "인증 코드를 입력해 주세요", + "code_length": "인증 코드는 6자리여야 합니다", + "password_required": "새 비밀번호를 입력해 주세요", + "password_length": "비밀번호는 6~16자여야 합니다", + "confirm_required": "비밀번호를 확인해 주세요", + "confirm_mismatch": "비밀번호가 일치하지 않습니다" + }, + "success": { + "title": "비밀번호가 성공적으로 변경되었습니다", + "desc": "새 비밀번호로 로그인할 수 있습니다." + } + } +} diff --git a/locales/ko/chatHistory.json b/locales/ko/chatHistory.json new file mode 100644 index 0000000..23198df --- /dev/null +++ b/locales/ko/chatHistory.json @@ -0,0 +1,18 @@ +{ + "search": { + "placeholder": "채팅 검색" + }, + "tabs": { + "all": "전체", + "imageVideo": "이미지/동영상", + "file": "파일" + }, + "datePicker": { + "placeholder": "날짜 범위 선택", + "start": "시작일", + "end": "종료일" + }, + "empty": { + "noData": "채팅 기록이 없습니다" + } +} diff --git a/locales/ko/components.json b/locales/ko/components.json new file mode 100644 index 0000000..94c3d99 --- /dev/null +++ b/locales/ko/components.json @@ -0,0 +1,31 @@ +{ + "common": { + "confirm": "확인", + "cancel": "취소" + }, + "actionBar": { + "always_on_top": { + "enabled": "항상 위 고정 해제", + "disabled": "항상 위에 고정" + }, + "close_prompt": { + "title": "트레이로 최소화할까요, 앱을 종료할까요?", + "hide_to_tray": "시스템 트레이로 최소화", + "exit_app": "앱 종료", + "no_prompt": "다시 표시하지 않기" + } + }, + "avatarCropper": { + "title": "아바타 자르기", + "preview": { + "round": "원형 미리보기", + "square": "둥근 사각형 미리보기" + }, + "uploading": "업로드 중..." + }, + "announcementCard": { + "title": "공지사항", + "viewDetail": "자세히 보기", + "windowTitle": "그룹 공지 보기" + } +} diff --git a/locales/ko/dynamic.json b/locales/ko/dynamic.json new file mode 100644 index 0000000..c3941ee --- /dev/null +++ b/locales/ko/dynamic.json @@ -0,0 +1,109 @@ +{ + "common": { + "loading_title": "로딩 중...", + "loading_desc": "피드 상세 정보를 불러오는 중", + "image_alt": "이미지", + "video_alt": "동영상", + "unknown_user": "알 수 없는 사용자" + }, + "page": { + "detail": { + "title": "피드 상세" + }, + "mobile_title": "커뮤니티" + }, + "list": { + "empty": "아직 피드가 없어요, 첫 순간을 공유해보세요!", + "actions": { + "publish": "게시", + "refresh": "새로고침", + "comment_notice": "알림", + "like": "좋아요", + "liked": "좋아요함", + "comment": "댓글", + "comment_with_count": "댓글 ({count})" + }, + "permission": { + "open": "전체 공개", + "part_visible": "선택한 사람에게만 공개", + "not_anyone": "선택한 사람에게 비공개" + }, + "modal": { + "add_title": "피드 작성", + "content_placeholder": "새로운 소식을 공유해보세요...", + "visibility_label": "볼 수 있는 사람", + "visibility_placeholder": "공개 범위 선택", + "select_visible": "볼 수 있는 사람 선택", + "select_hidden": "볼 수 없는 사람 선택", + "select_users": "사용자 선택", + "selected_count": "사용자 선택 ({count}명 선택됨)", + "user_modal_title": "사용자 선택", + "user_search_placeholder": "사용자 검색...", + "comment_notice_title": "댓글 알림", + "comment_notice_empty_title": "알림 없음", + "comment_notice_empty_desc": "아직 댓글이 없습니다" + }, + "buttons": { + "cancel": "취소", + "publish": "게시", + "select_users": "사용자 선택", + "confirm": "확인", + "confirm_with_count": "확인 ({count})" + }, + "comments": { + "more": "댓글 {count}개 더보기" + }, + "loading": "로딩 중...", + "load_more": "더 보기", + "loaded_all": "모든 피드를 불러왔습니다" + }, + "detail": { + "content": { + "view_image": "원본 이미지 보기", + "video_tag": "동영상", + "video_cta": "탭하여 재생" + }, + "stats": { + "liked_by": "좋아요를 누른 사람:", + "like": "좋아요", + "liked": "좋아요함", + "comments": "댓글 ({count})" + }, + "actions": { + "comment": "댓글", + "reply": "답글", + "delete": "삭제" + }, + "dropdown": { + "report": "신고", + "delete": "피드 삭제" + }, + "modal": { + "title": "댓글 작성", + "send": "보내기", + "cancel": "취소", + "placeholder": "댓글을 입력하세요...", + "replying": "{name}님에게 답글 작성 중" + }, + "empty": "피드를 찾을 수 없거나 삭제되었습니다" + }, + "messages": { + "refresh_success": "새로고침되었습니다", + "refresh_fail": "새로고침에 실패했습니다, 다시 시도해주세요", + "publish_empty": "피드 내용을 입력해주세요", + "publish_select_visible": "볼 수 있는 사람을 선택해주세요", + "publish_select_hidden": "볼 수 없는 사람을 선택해주세요", + "publish_success": "게시되었습니다!", + "publish_fail": "게시에 실패했습니다, 잠시 후 다시 시도해주세요", + "delete_success": "삭제되었습니다", + "delete_fail": "삭제에 실패했습니다, 다시 시도해주세요", + "copy_success": "링크가 복사되었습니다", + "report_todo": "신고 기능은 준비 중입니다", + "like_fail": "작업에 실패했습니다, 다시 시도해주세요", + "comment_empty": "댓글 내용을 입력해주세요", + "comment_fail": "댓글 등록에 실패했습니다, 다시 시도해주세요", + "comment_delete_fail": "삭제에 실패했습니다, 다시 시도해주세요", + "detail_missing": "피드를 찾을 수 없거나 삭제되었습니다", + "detail_fetch_fail": "피드 상세 정보를 불러오지 못했습니다" + } +} diff --git a/locales/ko/editor.json b/locales/ko/editor.json new file mode 100644 index 0000000..c8456ba --- /dev/null +++ b/locales/ko/editor.json @@ -0,0 +1,22 @@ +{ + "menu": { + "select_all": "전체 선택", + "save_as": "다른 이름으로 저장", + "paste": "붙여넣기", + "copy": "복사", + "cut": "잘라내기" + }, + "send": "전송", + "placeholder": "따뜻한 말 한마디가 마음을 녹이고, 모진 말 한마디가 마음에 상처를 남깁니다.", + "send_or_newline": "{send} 전송/{newline} 줄바꿈", + "file": "파일", + "image": "이미지", + "voice": "음성", + "location": "위치", + "chat_history": "채팅 기록", + "screenshot": "스크린샷", + "screenshot_hide_curr_window": "스크린샷 시 현재 창 숨기기", + "relation": { + "not_friends": "더 이상 친구 관계가 아닙니다." + } +} diff --git a/locales/ko/emoticon.json b/locales/ko/emoticon.json new file mode 100644 index 0000000..7b4565b --- /dev/null +++ b/locales/ko/emoticon.json @@ -0,0 +1,21 @@ +{ + "recent": { + "title": "최근 사용" + }, + "tabs": { + "emoji": "이모지", + "favorites": "즐겨찾기" + }, + "favorites": { + "title": "내 이모티콘", + "empty": "이모티콘이 없습니다", + "delete": "삭제", + "deleteSuccess": "이모티콘이 삭제되었습니다", + "deleteFail": "이모티콘 삭제에 실패했습니다" + }, + "categories": { + "expression": "표정 이모지", + "animal": "동물 이모지", + "gesture": "제스처 이모지" + } +} diff --git a/locales/ko/fileManager.json b/locales/ko/fileManager.json new file mode 100644 index 0000000..9066228 --- /dev/null +++ b/locales/ko/fileManager.json @@ -0,0 +1,89 @@ +{ + "navigation": { + "title": "파일 분류", + "items": { + "myFiles": "내 파일", + "senders": "보낸 사람별", + "sessions": "대화별", + "groups": "그룹별", + "default": "파일" + } + }, + "search": { + "placeholder": { + "default": "파일 검색", + "myFiles": "내 파일 검색", + "senders": "보낸 사람 파일 검색", + "sessions": "대화 파일 검색", + "groups": "그룹 파일 검색" + }, + "clear": "검색 지우기", + "showAllUsers": "모든 사용자 보기" + }, + "header": { + "titles": { + "myFiles": "내 파일", + "senders": "보낸 사람별 분류", + "sessions": "대화별 분류", + "groups": "그룹별 분류", + "default": "파일 목록" + }, + "subtitle": { + "userFiles": "{name}님의 파일 · {total}개", + "search": "{total}개의 파일을 찾았습니다", + "total": "전체 {total}개 파일" + } + }, + "list": { + "fileCount": "파일 {count}개", + "meta": { + "from": "보낸 사람:" + } + }, + "empty": { + "search": "관련 파일이 없습니다", + "userHasNoFiles": "{name}님은 파일이 없습니다", + "default": "파일이 없습니다", + "senders": "보낸 사람 파일이 없습니다", + "sessions": "대화 파일이 없습니다", + "groups": "그룹 파일이 없습니다" + }, + "notifications": { + "saveFileSuccess": "파일이 저장되었습니다", + "saveFileFail": "파일 저장에 실패했습니다", + "saveVideoSuccess": "동영상이 저장되었습니다", + "saveVideoFail": "동영상 저장에 실패했습니다" + }, + "common": { + "unknownUser": "알 수 없는 사용자", + "unknown": "알 수 없음", + "loading": "로딩 중" + }, + "userList": { + "searchPlaceholder": { + "default": "검색", + "senders": "보낸 사람 검색", + "sessions": "대화 검색", + "groups": "그룹 검색" + }, + "sectionTitle": { + "default": "목록 ({count})", + "senders": "보낸 사람 ({count})", + "sessions": "대화 ({count})", + "groups": "그룹 ({count})" + }, + "allOptions": { + "default": "전체", + "senders": "전체 보낸 사람", + "sessions": "전체 대화", + "groups": "전체 그룹" + }, + "empty": { + "default": "일치하는 항목이 없습니다", + "senders": "일치하는 보낸 사람이 없습니다", + "sessions": "일치하는 대화가 없습니다", + "groups": "일치하는 그룹이 없습니다" + }, + "memberCount": "{count}명" + } +} diff --git a/locales/ko/home.json b/locales/ko/home.json new file mode 100644 index 0000000..ad08145 --- /dev/null +++ b/locales/ko/home.json @@ -0,0 +1,489 @@ +{ + "search_suggestions": "추천 검색어", + "search_guide": "검색창에서 검색하세요", + "no_search_results": "검색 결과가 없습니다", + "search_result": "검색 결과", + "search_history": "검색 기록", + "search_input_placeholder": "검색", + "clear_search_history": "전체 삭제", + "loading": { + "app": "앱을 불러오는 중...", + "left_panel": "왼쪽 패널을 불러오는 중...", + "data": "데이터를 불러오는 중...", + "right_panel": "오른쪽 패널을 불러오는 중..." + }, + "action": { + "message": "메시지", + "message_short_title": "메시지", + "contact": "친구", + "contact_short_title": "친구", + "file_manager": "파일 관리자", + "file_manager_short_title": "파일", + "favorite": "즐겨찾기", + "favorite_short_title": "즐겨찾기", + "plugin": "플러그인", + "plugin_manage": "플러그인 관리", + "more": "더보기", + "opened": "열림", + "start_group_chat": "그룹 채팅 시작", + "add_friend_or_group": "친구/그룹 추가" + }, + "plugins": { + "dynamic": "모멘트", + "dynamic_short_title": "모멘트", + "chatbot": "챗봇", + "chatbot_short_title": "챗봇", + "status": { + "builtin": "기본 제공", + "uninstalling": "제거 중" + }, + "actions": { + "install": "설치", + "pin": "사이드바에 고정", + "unpin": "고정 해제", + "uninstall": "제거" + } + }, + "search_window": { + "title": "전체 검색", + "tabs": { + "recommend": "추천", + "user": "친구 찾기", + "group": "그룹 찾기" + }, + "placeholder": { + "recommend": "키워드를 입력해 둘러보세요", + "user": "닉네임을 검색해 친구를 추가하세요", + "group": "그룹 ID를 입력해 그룹을 찾으세요" + }, + "labels": { + "account": "계정: {account}" + }, + "tooltip": { + "copy_account": "계정 복사", + "bound_gitee": "Gitee 연동됨", + "bound_github": "GitHub 연동됨", + "bound_gitcode": "Gitcode 연동됨" + }, + "empty": { + "no_result": "일치하는 결과가 없습니다", + "prompt": "키워드를 입력해 검색하세요" + }, + "notification": { + "copy_success": "{account} 복사 완료", + "search_fail": "검색 실패" + }, + "buttons": { + "add": "추가", + "message": "메시지 보내기", + "edit_profile": "프로필 편집" + }, + "modal": { + "add_friend": "친구 추가 요청", + "add_group": "그룹 가입 요청" + } + }, + "chat_details": { + "single": { + "empty_signature": "아직 등록된 소개글이 없습니다", + "region": "지역: {place}", + "unknown": "알 수 없음", + "badge_label": "배지:", + "call_only_single": "음성/영상 통화는 1:1 채팅에서만 가능합니다", + "friend_info_missing": "친구 정보를 불러올 수 없습니다", + "footer": { + "audio_call": "음성 통화", + "video_call": "영상 통화" + } + }, + "group": { + "official_badge": "공식 그룹", + "id": "그룹 ID {account}", + "copy": "복사", + "copy_success": "{account} 복사 완료", + "info_missing": "그룹 정보를 불러올 수 없습니다", + "remark": { + "label": "그룹 메모", + "placeholder": "메모를 입력하세요", + "empty": "그룹 메모 설정", + "success": "그룹 메모가 변경되었습니다", + "fail": "그룹 메모 변경에 실패했습니다" + }, + "nickname": { + "label": "내 닉네임", + "placeholder": "닉네임을 입력하세요", + "empty": "설정 안 됨", + "success": "닉네임이 변경되었습니다", + "fail": "닉네임 변경에 실패했습니다" + }, + "announcement": { + "label": "공지사항", + "empty": "설정 안 됨", + "window_title": "공지사항 보기" + }, + "members": { + "count": "멤버 ({count}명)", + "online": "온라인 {count}명" + } + }, + "actions": { + "message": "메시지 보내기" + }, + "window": { + "image_viewer": "이미지 뷰어" + } + }, + "chat_reaction": { + "like": "좋아요", + "unsatisfied": "별로예요", + "heart": "하트", + "angry": "화나요", + "party": "축하해요", + "rocket": "로켓", + "lol": "웃겨요", + "clap": "박수", + "flower": "꽃", + "bomb": "폭탄", + "question": "궁금해요", + "victory": "승리", + "light": "반짝", + "red_envelope": "세뱃돈" + }, + "chat_header": { + "bot_tag": "봇", + "status_abnormal": "친구 상태를 확인할 수 없습니다", + "toolbar": { + "audio_call": "음성 통화", + "video_call": "영상 통화", + "screen_share": "화면 공유", + "remote_assist": "원격 지원", + "invite_to_group": "그룹에 초대", + "start_group_chat": "그룹 채팅 시작" + }, + "sidebar": { + "single": { + "pin": "상단 고정", + "mute": "알림 끄기", + "shield": "이 사용자 차단", + "delete_history": "대화 내용 삭제", + "delete_friend": "친구 삭제", + "report": "괴롭힘을 당하셨나요? 이 사용자 신고하기" + }, + "group": { + "name_placeholder": "그룹 이름을 입력하세요 (최대 12자)", + "official_badge": "공식 그룹", + "copy": "복사", + "qr": "QR코드", + "members": "그룹 멤버", + "channel_members": "채널 멤버", + "my_name": "이 그룹에서의 내 닉네임", + "remark": "그룹 메모", + "remark_desc": "(나에게만 보임)", + "settings": { + "title": "그룹 설정", + "pin": "상단 고정", + "mute": "알림 끄기", + "scan": "QR코드로 가입 허용" + }, + "message_settings": { + "title": "메시지 설정" + }, + "manage_members": "멤버 관리", + "delete_history": "대화 내용 삭제", + "dissolve": "그룹 해산", + "exit": "그룹 나가기", + "report": "괴롭힘을 당하셨나요? 이 그룹 신고하기" + } + }, + "message_setting": { + "receive_no_alert": "알림 없이 받기", + "shield": "메시지 차단" + }, + "modal": { + "confirm": "확인", + "cancel": "취소", + "invite_friends": "친구 초대", + "tips": { + "delete_friend": "이 친구를 삭제하시겠습니까?", + "dissolve_group": "이 그룹을 해산하시겠습니까?", + "exit_group": "이 그룹을 나가시겠습니까?", + "rename_group": "그룹 이름을 {name}(으)로 변경하시겠습니까?", + "update_info": "변경한 그룹 정보를 저장하시겠습니까?", + "delete_history": "로컬 대화 내용을 삭제하시겠습니까?" + } + }, + "qr": { + "tip": "QR코드를 스캔해 가입하세요", + "group_id_label": "그룹 ID: ", + "actions": { + "forward": "전달", + "copy_group_id": "그룹 ID 복사", + "save_image": "이미지 저장" + }, + "toast": { + "group_id_copied": "그룹 ID가 복사되었습니다", + "save_success": "이미지가 저장되었습니다", + "save_failed": "저장 실패" + } + }, + "toast": { + "copy_success": "{account} 복사 완료", + "group_info_updated": "그룹 정보가 업데이트되었습니다", + "group_info_update_failed": "그룹 정보 업데이트에 실패했습니다", + "todo": "준비 중입니다", + "pin_on": "고정됨", + "pin_off": "고정 해제됨", + "pin_failed": "고정 실패", + "mute_on": "알림 없이 받도록 설정했습니다", + "mute_off": "알림을 받도록 설정했습니다", + "shield_on": "메시지가 차단되었습니다", + "shield_off": "메시지 차단이 해제되었습니다", + "action_failed": "작업에 실패했습니다", + "group_name_empty": "그룹 이름을 입력해주세요", + "group_name_too_long": "그룹 이름은 12자를 초과할 수 없습니다", + "delete_history_success": "대화 내용이 삭제되었습니다", + "delete_history_failed": "대화 내용 삭제에 실패했습니다", + "delete_friend_success": "친구가 삭제되었습니다", + "dissolve_not_allowed": "채널은 해산할 수 없습니다", + "dissolve_success": "그룹이 해산되었습니다", + "exit_not_allowed": "채널은 나갈 수 없습니다", + "exit_success": "그룹에서 나갔습니다", + "group_name_update_failed": "그룹 이름 변경에 실패했습니다" + } + }, + "manage_group_member": { + "title": "그룹 멤버 관리", + "search_placeholder_mobile": "멤버 검색~", + "search_placeholder_pc": "그룹 멤버 검색", + "selected_count": "{count}명 선택됨", + "remove_button": "그룹에서 내보내기", + "dialog_negative": "취소", + "dialog_positive": "확인", + "remove_confirm": "{count}명의 멤버를 내보내시겠습니까?", + "channel_not_allowed": "채널에서는 멤버를 내보낼 수 없습니다", + "select_member_warning": "내보낼 멤버를 선택하세요", + "remove_success": "{count}명의 멤버를 내보냈습니다", + "remove_failed": "멤버 내보내기에 실패했습니다. 다시 시도해주세요", + "confirm_remove_title": "내보내기 확인", + "channel_manage_unsupported": "채널은 멤버 관리를 지원하지 않습니다" + }, + "profile_card": { + "online_status": "온라인 상태", + "status": { + "online": "온라인", + "offline": "오프라인" + }, + "labels": { + "account": "계정", + "location": "지역", + "badges": "배지", + "activities": "활동" + }, + "location_unknown": "알 수 없음", + "tooltip": { + "copy_account": "계정 복사", + "bound_gitee": "Gitee 연동됨", + "bound_github": "GitHub 연동됨", + "bound_gitcode": "Gitcode 연동됨" + }, + "buttons": { + "edit": "프로필 편집", + "message": "메시지 보내기", + "add_friend": "친구 추가" + }, + "notification": { + "copy_success": "{account} 복사 완료" + }, + "modal": { + "add_friend": "친구 추가 요청" + }, + "developer_badge": "HuLa 개발자" + }, + "profile_edit": { + "title": "프로필 편집", + "avatar": { + "change": "프로필 사진 변경", + "tips": "권장 크기 180x180px, JPG·PNG·WEBP 형식 지원, 500kb 이하 파일만 가능합니다" + }, + "badge": { + "current": "현재 착용 중인 배지:", + "wear": "착용" + }, + "form": { + "nickname": { + "label": "닉네임", + "placeholder": "닉네임을 입력하세요", + "remaining": "닉네임 변경 가능 횟수: {count}회" + } + }, + "actions": { + "save": "저장" + }, + "toast": { + "avatar_update_success": "프로필 사진이 변경되었습니다" + } + }, + "chat_sidebar": { + "announcement": { + "title": "그룹 공지사항", + "load_failed": "공지사항을 불러오지 못했습니다. 다시 시도해주세요", + "default": "민감한 정보는 공유하지 마세요. 관련 법규를 준수하지 않으면 메시지가 삭제될 수 있습니다.", + "window": { + "add": "공지사항 추가", + "view": "공지사항 보기" + } + }, + "actions": { + "retry": "다시 시도" + }, + "online_members": "온라인 멤버 {count}명", + "search": { + "placeholder": "검색" + }, + "roles": { + "owner": "방장", + "admin": "관리자" + } + }, + "about": { + "version": "버전: {version} ({arch})", + "device": "기기: {type}{version}", + "copyright": "Copyright © {start}-{end} HuLaSpark", + "rights": "All Rights Reserved." + }, + "apply_list": { + "friend_notice": "친구 요청", + "group_notice": "그룹 알림", + "you": "나", + "unknown_user": "알 수 없는 사용자", + "message_label": "메시지: ", + "handler_label": "처리자: {name}", + "accept": "수락", + "status": { + "accepted": "승인됨", + "rejected": "거절됨", + "ignored": "무시됨", + "rejected_by_other": "상대방이 거절함", + "pending": "인증 대기 중" + }, + "friend": { + "accepted_you": "요청이 승인되었습니다", + "pending": "인증 대기 중", + "request": "친구 추가를 요청했습니다" + }, + "group": { + "loading": "불러오는 중...", + "apply": "[{group}] 가입 신청", + "invite": "{name}님을 [{group}]에 초대", + "invite_confirmed": "[{group}]에 가입했습니다", + "invite_you": "[{group}]에 초대했습니다", + "kicked": "{operator}님이 [{group}]에서 내보냈습니다", + "set_admin": "[{group}]의 관리자로 설정되었습니다", + "remove_admin": "[{group}]의 관리자 권한이 해제되었습니다" + }, + "dropdown": { + "reject": "거절", + "ignore": "무시" + }, + "empty_friend": "친구 요청이 없습니다", + "empty_group": "그룹 알림이 없습니다" + }, + "chat_main": { + "network_offline": "네트워크를 사용할 수 없습니다. 설정을 확인해주세요", + "network_connecting": "서버에 연결하는 중...", + "network_ws_offline": "서버 연결이 끊어졌습니다. 재연결 중입니다", + "copy": { + "empty": "복사할 내용이 없습니다", + "image_processing": "{format} 이미지를 PNG로 변환하는 중...", + "image_success": "이미지가 클립보드에 복사되었습니다", + "image_convert_success": "이미지를 PNG로 변환해 복사했습니다", + "selected_success": "선택한 텍스트가 복사되었습니다", + "message_success": "메시지가 복사되었습니다", + "unknown_error": "복사에 실패했습니다. 나중에 다시 시도해주세요" + }, + "feature": { + "coming_soon": "준비 중인 기능입니다" + }, + "translate": { + "empty": "번역할 내용이 없습니다" + }, + "file": { + "missing_local": "로컬에서 파일을 찾을 수 없습니다. 먼저 다운로드해주세요", + "show_failed": "폴더에서 파일을 열 수 없습니다", + "download_prompt": "아직 다운로드되지 않은 파일입니다. 먼저 다운로드해주세요", + "download_success": "파일이 다운로드되었습니다", + "download_failed": "파일 다운로드에 실패했습니다. 다시 시도해주세요", + "save_success": "파일이 로컬에 저장되었습니다", + "save_failed": "파일 저장에 실패했습니다. 다시 시도해주세요" + }, + "image": { + "fetch_failed": "이미지 주소를 가져오지 못했습니다", + "locate_failed": "지금은 이 이미지를 찾을 수 없습니다", + "download_prompt": "이미지가 다운로드되지 않아 로컬에 저장하는 중...", + "save_success": "이미지가 로컬에 저장되었습니다", + "save_failed": "이미지 저장에 실패했습니다", + "download_failed": "이미지 다운로드에 실패했습니다. 다시 시도해주세요" + }, + "delete": { + "invalid_session": "이 메시지의 대화를 확인할 수 없습니다", + "confirm": "삭제한 메시지는 대화 내용에서 사라집니다. 계속하시겠습니까?", + "success": "메시지가 삭제되었습니다" + }, + "announcement": { + "view_all": "전체 보기" + }, + "no_more": "더 이상 메시지가 없습니다", + "new_messages": "새 메시지 {count}개", + "confirm": "확인", + "cancel": "취소", + "group_nickname": { + "title": "그룹 닉네임 수정", + "placeholder": "그룹 닉네임을 입력하세요", + "error": { + "empty": "그룹 닉네임을 입력해주세요", + "invalid_room": "그룹 정보가 올바르지 않습니다" + } + }, + "toast": { + "searching": "메시지를 검색하는 중...", + "not_found": "이전 메시지를 불러오지 못했습니다. 다시 시도해주세요", + "multi_select_mobile": "모바일에서는 메시지 다중 선택을 사용할 수 없습니다", + "disbanded_group": "이 그룹은 이미 해산되었습니다" + } + }, + "friends_list": { + "notice": { + "friend": "친구 알림", + "group": "그룹 알림" + }, + "tabs": { + "friend": "친구", + "group": "그룹" + }, + "collapse": { + "friend": "내 친구", + "group": "내 그룹" + }, + "bot_tag": "봇", + "status": { + "online": "온라인", + "offline": "오프라인" + }, + "menu": { + "add_group": "카테고리 추가", + "rename_group": "카테고리 이름 변경", + "delete_group": "카테고리 삭제" + } + }, + "create_group": { + "title": "그룹 채팅 만들기", + "action": "만들기", + "success": "그룹 채팅이 생성되었습니다", + "fail": "그룹 채팅 생성에 실패했습니다", + "required_tag": "필수" + }, + "file_drop": { + "title": "놓으면 파일을 전송합니다", + "desc": "이미지나 파일을 끌어다 놓으면 현재 대화방으로 전송됩니다" + } +} diff --git a/locales/ko/login.json b/locales/ko/login.json new file mode 100644 index 0000000..f922dd9 --- /dev/null +++ b/locales/ko/login.json @@ -0,0 +1,193 @@ +{ + "button": { + "login": { + "default": "로그인", + "network_error": "네트워크 오류" + }, + "qr_code": "QR 코드", + "cancel_login": "취소", + "remove_account": "삭제" + }, + "input": { + "account": { + "placeholder": "이메일 / HuLa 계정" + }, + "pass": { + "placeholder": "HuLa 비밀번호 입력" + } + }, + "option": { + "more": "더보기", + "items": { + "forget": "비밀번호 찾기", + "network_setting": "네트워크 설정" + } + }, + "term": { + "checkout": { + "text1": "다음에 동의합니다: ", + "text2": "이용약관", + "text3": " 및 ", + "text4": "개인정보 처리방침" + } + }, + "auth_way": { + "acc_pss": "계정/비밀번호 로그인" + }, + "register": "회원가입", + "third_party": { + "title": "OAuth 로그인 / 회원가입", + "gitee": "Gitee로 로그인", + "github": "GitHub로 로그인", + "gitcode": "GitCode로 로그인" + }, + "qr_code_expired": "QR 코드가 만료되었습니다", + "qr_code_expired_hint": "잠시 후 다시 시도해 주세요", + "qr_code_scan_hint": "HuLa 앱으로 QR 코드를 스캔해 주세요", + "qr_code_gen_fail_hint": "QR 코드 생성에 실패했습니다", + "qr_code_scan_success_hint": "스캔 성공, 승인을 기다리는 중", + "wait_auth_hint": "승인을 기다리는 중...", + "login_success": "로그인 성공", + "fetch_user_fail": "사용자 정보를 가져오지 못했습니다", + "refreshing": "새로고침 중...", + "loading": "불러오는 중...", + "scan_qr_code_fail": "QR 코드 스캔에 실패했습니다", + "status": { + "logging_in": "로그인 중...", + "success_redirect": "이동 중...", + "service_disconnected": "서비스 연결이 끊어졌습니다" + }, + "qr": { + "overlay": { + "success": "로그인 성공", + "error": "QR 스캔 실패", + "auth": "스캔 완료, 승인을 기다리는 중", + "expired": "QR 코드가 만료되었습니다", + "fetch_failed": "로그인은 성공했지만 사용자 정보를 가져오지 못했습니다", + "generate_fail": "QR 코드 생성에 실패했습니다", + "general_error": "문제가 발생했습니다", + "refreshing": "새로고침 중..." + }, + "load_text": { + "loading": "불러오는 중...", + "refreshing": "새로고침 중...", + "scan_hint": "HuLa 앱으로 스캔해 주세요", + "login": "로그인 중...", + "retry": "잠시 후 다시 시도해 주세요", + "auth_pending": "승인을 기다리는 중..." + }, + "actions": { + "account_login": "계정 로그인", + "register": "회원가입", + "register_title": "회원가입" + } + }, + "guide": { + "welcome": { + "title": "🎉 HuLa에 오신 것을 환영합니다", + "desc": "HuLa는 Tauri로 제작되었으며 Windows, macOS, Linux, iOS, Android를 지원합니다" + }, + "privacy": { + "title": "🤔 개인정보 및 서비스 약관", + "desc": "먼저 HuLa의 개인정보 처리방침과 이용약관을 확인해 주세요" + }, + "network": { + "title": "⚙️ 네트워크 설정", + "desc": "HuLa는 사용자 지정 서버 주소로 공식 서버를 대체할 수 있습니다" + }, + "register": { + "title": "🤓 로그인 방법", + "desc": "HuLa를 사용하기 전에 계정을 등록하고 프로필을 완성해 주세요" + }, + "actions": { + "next": "다음", + "prev": "이전", + "done": "완료", + "progress": "{current}/{total}" + } + }, + "network": { + "title": "네트워크 설정", + "tabs": { + "api": "API", + "ws": "WebSocket" + }, + "fields": { + "type": "유형", + "host": "IP 또는 도메인", + "port": "포트", + "suffix": "접미사" + }, + "placeholder": { + "host": "127.0.0.1 또는 hulaspark.com", + "port": "443", + "api_suffix": "api", + "ws_suffix": "websocket" + }, + "api": { + "options": { + "none": "사용 안 함(공식 서버 사용)", + "http": "HTTP", + "https": "HTTPS" + } + }, + "ws": { + "options": { + "none": "사용 안 함(공식 서버 사용)", + "ws": "WS", + "wss": "WSS" + } + }, + "actions": { + "save": "저장", + "back": "뒤로" + }, + "messages": { + "incomplete": "네트워크 설정 항목을 모두 입력해 주세요", + "save_success": "네트워크 설정이 저장되었습니다", + "save_failed": "네트워크 설정 저장에 실패했습니다: {error}" + } + }, + "mobile": { + "welcome_title": "안녕하세요, 환영합니다", + "tabs": { + "login": "로그인", + "register": "회원가입" + }, + "forget_code": "비밀번호 찾기", + "btn": { + "login": "로그인" + }, + "input": { + "account_placeholder": "HuLa 계정 입력", + "code_placeholder": "비밀번호 입력" + }, + "register": { + "input": { + "nickname": "닉네임", + "password": "비밀번호", + "confirm_password": "비밀번호 확인", + "email": "이메일", + "email_verification_code": "이메일 인증 코드 입력" + }, + "btn": { + "register": "회원가입", + "next": "다음", + "send_email_code": "인증 코드 전송", + "resend_in": "{seconds}초 후 재전송" + }, + "pass_validate_info": { + "minlength": "최소 {len}자 이상", + "valid_characters": "영문과 숫자로 구성", + "must_special_char": "특수문자를 포함해야 합니다" + } + }, + "email_invalid": "유효하지 않은 이메일입니다.", + "code_sent_email": "인증 코드가 전송되었습니다. 이메일을 확인해 주세요.", + "code_send_failed_with_reason": "인증 코드 전송에 실패했습니다: {reason}", + "code_send_failed_retry": "인증 코드 전송에 실패했습니다. 잠시 후 다시 시도해 주세요.", + "complete_info_before_register": "회원가입 전에 정보를 모두 입력해 주세요.", + "register_success": "회원가입이 완료되었습니다.", + "register_fail": "회원가입에 실패했습니다." + } +} diff --git a/locales/ko/menu.json b/locales/ko/menu.json new file mode 100644 index 0000000..85882ab --- /dev/null +++ b/locales/ko/menu.json @@ -0,0 +1,55 @@ +{ + "check_update": "업데이트 확인", + "lock_screen": "화면 잠금", + "settings": "설정", + "about": "정보", + "sign_out": "로그아웃", + "select": "선택", + "add_sticker": "이모티콘 추가", + "forward": "전달", + "reply": "답장", + "recall": "회수", + "copy": "복사", + "save_as": "다른 이름으로 저장", + "show_in_finder": "Finder에서 보기", + "show_in_folder": "폴더에서 보기", + "translate": "번역", + "del": "삭제", + "preview": "미리보기", + "send_message": "메시지 보내기", + "get_user_info": "정보 보기", + "modify_group_nickname": "닉네임 수정", + "add_friend": "친구 추가", + "report": "신고", + "ctx_menu_more": "더보기", + "set_admin": "관리자로 지정", + "set_admin_success": "관리자로 지정되었습니다", + "set_admin_fail": "관리자 지정에 실패했습니다", + "revoke_admin": "관리자 해제", + "revoke_admin_success": "관리자가 해제되었습니다", + "revoke_admin_fail": "관리자 해제에 실패했습니다", + "remove_from_group": "강퇴", + "remove_from_group_success": "강퇴되었습니다", + "remove_from_group_fail": "강퇴에 실패했습니다", + "pin": "고정", + "unpin": "고정 해제", + "copy_account": "계정 복사", + "mark_unread": "읽지 않음으로 표시", + "group_message_setting": "그룹 알림 설정", + "set_do_not_disturb": "알림 끄기", + "unset_do_not_disturb": "알림 켜기", + "allow_notifications": "알림 받기", + "receive_silently": "알림 없이 받기", + "block_group_messages": "그룹 음소거", + "unblock_group_messages": "그룹 음소거 해제", + "block_user_messages": "채팅 차단", + "unblock_user_messages": "채팅 차단 해제", + "remove_from_list": "대화 삭제", + "delete_friend": "친구 삭제", + "dissolve_group": "그룹 해산", + "leave_group": "그룹 나가기", + "today": "오늘", + "yesterday": "어제", + "start_group_chat": "새 채팅", + "add_contact": "연락처 추가" +} diff --git a/locales/ko/message.json b/locales/ko/message.json new file mode 100644 index 0000000..565d9ee --- /dev/null +++ b/locales/ko/message.json @@ -0,0 +1,277 @@ +{ + "file": { + "unknown_file": "알 수 없는 파일", + "unknown_error": "알 수 없는 오류", + "status": { + "downloaded": "다운로드됨", + "not_downloaded": "다운로드되지 않음" + }, + "toast": { + "missing_local": "로컬 파일을 찾을 수 없습니다. 먼저 다운로드해주세요~", + "reveal_fail": "폴더에서 파일을 표시할 수 없습니다", + "open_fail_hint": "파일을 열거나 표시할 수 없습니다. 파일 관리자에서 직접 찾아주세요.", + "download_open_fail": "파일이 다운로드되었지만 열거나 표시할 수 없습니다. 파일 관리자에서 직접 찾아주세요.", + "download_failed": "파일 다운로드 실패: {reason}" + } + }, + "video": { + "unknown_video": "동영상 파일", + "uploading": "동영상 업로드 중... {progress}%", + "opening": "동영상 여는 중..." + }, + "file_upload": { + "title": "파일 보내기", + "cancel": "취소", + "send": "보내기 ({count})" + }, + "file_upload_progress": { + "uploading_with_name": "업로드 중: {name}", + "uploading": "파일 업로드 중", + "preparing": "업로드 준비 중", + "completed_failed": "업로드 완료 ({count}개 실패)", + "completed": "업로드 완료" + }, + "voice_recorder": { + "tap_prefix": "누르고", + "tap_suffix": "말하기", + "recording": "녹음 중", + "processing": "오디오 처리 중", + "error": "녹음 실패" + }, + "call_window": { + "incoming": "수신 전화", + "video_call": "영상 통화", + "voice_call": "음성 통화", + "unknown_user": "알 수 없는 사용자", + "status": { + "calling": "전화 거는 중", + "ongoing": "통화 중", + "ended": "통화 종료", + "preparing": "준비 중" + }, + "mic_on": "마이크 켜짐", + "mic_off": "마이크 꺼짐", + "speaker_on": "스피커 켜짐", + "speaker_off": "스피커 꺼짐", + "camera_disable": "카메라 끄기", + "camera_enable": "카메라 켜기", + "hangup": "전화 끊기" + }, + "permission": { + "mic": { + "title": "마이크 권한", + "denied": "마이크 권한이 거부되었습니다. 시스템 설정에서 권한을 허용한 후 다시 시도해주세요", + "unavailable": "마이크 장치를 찾을 수 없습니다" + }, + "camera": { + "title": "카메라 권한", + "denied": "카메라 권한이 거부되었습니다. 시스템 설정에서 권한을 허용한 후 다시 시도해주세요", + "unavailable": "카메라 장치를 찾을 수 없습니다" + }, + "open_settings": "시스템 설정 열기", + "retry": "다시 시도", + "hangup": "전화 끊기", + "requesting": "장치 권한 요청 중..." + }, + "friend_verify": { + "title": "친구 추가 요청", + "account": "계정: {account}", + "placeholder": "하고 싶은 말을 남겨보세요...", + "send_btn": "친구 추가", + "toast_success": "친구 요청을 보냈습니다", + "default_msg": "저는 {name}입니다" + }, + "group_verify": { + "title": "그룹 가입 요청", + "account": "그룹 ID: {account}", + "placeholder": "인증 메시지를 입력하세요", + "send_btn": "가입 신청", + "toast_success": "그룹 가입 요청을 보냈습니다", + "default_msg": "저는 {name}입니다" + }, + "message_list": { + "sync_loading": "메시지 동기화 중", + "official_popover": "공식 그룹", + "bot_popover": "HuLa 어시스턴트", + "mention_tag": "[나를 언급함]", + "shield_group": "그룹 알림 차단됨", + "shield_user": "사용자 알림 차단됨", + "empty_description": "친구들과 대화를 시작해보세요!", + "empty_action": "친구 찾기", + "default_last_msg": "HuLa에 오신 것을 환영합니다" + }, + "message_menu": { + "pin_success": "고정되었습니다", + "unpin_success": "고정이 해제되었습니다", + "pin_fail": "고정에 실패했습니다", + "unpin_fail": "고정 해제에 실패했습니다", + "copy_success": "{account} 복사됨", + "shield_success": "메시지가 차단되었습니다", + "unshield_success": "차단이 해제되었습니다", + "delete_friend_success": "친구가 삭제되었습니다", + "cannot_dissolve_channel": "채널을 해체할 수 없습니다", + "cannot_quit_channel": "채널을 나갈 수 없습니다", + "dissolve_group_success": "그룹이 해체되었습니다", + "quit_group_success": "그룹에서 나갔습니다", + "notification_allowed": "알림이 켜졌습니다", + "notification_silent": "무음으로 수신 중입니다" + }, + "image_viewer": { + "zoom_out": "축소", + "zoom_in": "확대", + "rotate_left": "왼쪽으로 회전", + "rotate_right": "오른쪽으로 회전", + "reset": "초기화", + "save_as": "다른 이름으로 저장", + "first_image": "첫 번째 이미지입니다", + "last_image": "이미 마지막 이미지입니다", + "filter_name": "이미지" + }, + "video_viewer": { + "previous_video": "이전 동영상", + "next_video": "다음 동영상", + "play": "재생", + "pause": "일시정지", + "mute": "음소거", + "unmute": "음소거 해제", + "tip_playing_index": "{index}번째 동영상 재생 중", + "tip_playing_previous": "이전 동영상을 재생합니다", + "tip_first": "첫 번째 동영상입니다", + "tip_playing_next": "다음 동영상을 재생합니다", + "tip_last": "마지막 동영상입니다" + }, + "screenshot": { + "border_radius": "모서리 반경", + "redo": "다시 실행", + "undo": "실행 취소", + "confirm": "확인", + "cancel": "취소", + "tool_rect": "사각형", + "tool_circle": "원형", + "tool_arrow": "화살표", + "tool_mosaic": "모자이크", + "tooltip_drag": "드래그하여 선택 영역 조정", + "tooltip_resize": "핸들을 드래그하여 크기 조정", + "save_success": "스크린샷이 클립보드에 복사되었습니다", + "save_failed": "스크린샷에 실패했습니다. 다시 시도해주세요" + }, + "lock_screen": { + "title": "화면 잠금", + "password_label": "잠금 비밀번호", + "password_placeholder": "잠금 비밀번호를 입력하세요", + "confirm_button": "확인", + "validation_required": "잠금 비밀번호를 입력해주세요", + "tip_description": "Tauri, Vue 3, Vite 5, UnoCSS, TypeScript 등 최신 기술로 만든 오픈소스 메신저 앱입니다.", + "enter_system_tooltip": "입장", + "unlocking": "잠금 해제 중", + "wrong_password": "비밀번호가 올바르지 않습니다. 다시 시도해주세요", + "toast_empty_password": "비밀번호를 입력해주세요", + "return_action": "뒤로", + "logout_action": "로그아웃", + "forgot_password": "비밀번호를 잊으셨나요", + "enter_system_action": "입장" + }, + "check_update": { + "current_version": "현재 버전", + "new_tag": "NEW", + "new_release_date": "새 버전 출시일:", + "release_date": "출시일:", + "log_title": "릴리스 노트", + "collapse": "접기", + "expand": "펼치기", + "ignore": "무시", + "update_now": "지금 업데이트", + "check_now": "업데이트 확인", + "downloading": "다운로드 중...", + "update_success": "업데이트 완료", + "confirm_update": "업데이트를 진행하시겠습니까?", + "confirm_ignore": "무시하면 이번 알림은 더 이상 표시되지 않지만 나중에 직접 업데이트할 수 있습니다. 무시하시겠습니까?", + "update_error": "업데이트 확인에 실패했습니다. 나중에 다시 시도해주세요", + "fetch_log_failed": "릴리스 노트를 불러오지 못했습니다. 토큰을 설정한 후 다시 시도해주세요", + "download_success_toast": "설치 파일 다운로드가 완료되었습니다. 곧 설치 후 재시작됩니다", + "restart_failed": "재시작에 실패했습니다. 수동으로 재시작해주세요" + }, + "notify": { + "new_messages": "새 메시지", + "ignore_all": "모두 무시" + }, + "tray": { + "online_status_window_title": "온라인 상태", + "more_status": "더 많은 상태", + "mute_all": "모든 소리 끄기", + "unmute_all": "모든 소리 켜기", + "open_main_panel": "메인 패널 열기", + "exit": "종료" + }, + "location": { + "modal": { + "title": { + "map_error": "지도 오류", + "location_error": "위치 확인 실패", + "default": "위치 선택" + }, + "result": { + "map_error_title": "지도를 불러오지 못했습니다", + "location_error_title": "위치를 가져오지 못했습니다" + }, + "buttons": { + "cancel": "취소", + "retry": "다시 시도", + "send": "위치 보내기" + }, + "loading": { + "locating": "위치 확인 중...", + "map": "지도 불러오는 중..." + }, + "info": { + "current": "현재 위치", + "fetching_address": "주소 가져오는 중...", + "coordinate": "좌표: {lat}, {lng}", + "unknown_address": "알 수 없는 주소" + }, + "errors": { + "missing_api_key": "텐센트 지도 API 키가 설정되지 않았습니다", + "geocode_failed": "주소를 가져오지 못했습니다" + } + }, + "map": { + "marker_current": "현재 위치" + }, + "hook": { + "permission_check_failed": "위치 권한 확인에 실패했습니다", + "unsupported": "이 브라우저는 위치 정보를 지원하지 않습니다", + "error_generic": "위치를 가져오지 못했습니다", + "permission_denied": "위치 권한이 거부되었습니다", + "position_unavailable": "위치 정보를 사용할 수 없습니다", + "timeout": "위치 요청이 시간 초과되었습니다" + } + }, + "update_window": { + "updating": "업데이트 중", + "fetch_commit_failed": "v{version} 릴리스 노트를 불러오지 못했습니다", + "fetch_release_error": "릴리스 정보를 가져오지 못했습니다", + "fetch_release_error_with_reason": "릴리스 정보를 가져오지 못했습니다: {reason}" + }, + "multi_choose": { + "search_placeholder": "채팅 검색", + "send_to_separately": "개별적으로 보내기", + "leave_message_placeholder": "메모 남기기", + "single_forward": "개별 전달", + "merge_forward": "묶어서 전달", + "save_to_pc": "PC에 저장", + "delete_action": "삭제", + "exit_multi_select": "선택 모드 종료", + "cancel_button": "취소", + "send_button": "보내기", + "not_implemented": "아직 구현되지 않았습니다", + "select_delete_prompt": "삭제할 메시지를 선택해주세요", + "delete_confirm": "삭제한 메시지는 대화 목록에서 사라집니다. 이 {count}개의 메시지를 삭제하시겠습니까?", + "room_missing": "현재 채팅방을 확인할 수 없어 삭제에 실패했습니다", + "delete_failed_short": "메시지 삭제에 실패했습니다", + "delete_failed_retry": "삭제에 실패했습니다. 나중에 다시 시도해주세요", + "delete_success": "선택한 메시지가 삭제되었습니다", + "forward_success": "메시지를 전달했습니다", + "forward_failed": "전달에 실패했습니다", + "non_text_message": "[텍스트가 아닌 메시지]" + } +} diff --git a/locales/ko/mobile_chat_setting.json b/locales/ko/mobile_chat_setting.json new file mode 100644 index 0000000..870fd54 --- /dev/null +++ b/locales/ko/mobile_chat_setting.json @@ -0,0 +1,61 @@ +{ + "title": "{t} 설정", + "type": { + "group": "그룹", + "single_chat": "채팅" + }, + "group_members_title": "그룹 멤버", + "member_count": "멤버 {count}명", + "group_invite_member": "초대", + "manage_group_members": "그룹 멤버 관리", + "search_history": "채팅 기록 검색", + "id_card": { + "type": { + "group": "그룹", + "single_chat": "채팅" + }, + "qr_code_label": "{t} / QR 코드" + }, + "group_notice": { + "title": "공지사항" + }, + "group_name": "그룹 이름", + "input": { + "group_name": "그룹 별명을 입력하세요", + "group_alias": "그룹 내 별명을 입력하세요", + "remark": "이 이름은 나만 볼 수 있습니다." + }, + "group_alias": "그룹 내 내 별명", + "remark": "메모", + "remar_kprivate_visible": "(나만 보기)", + "setting_type": "{t} 설정", + "silent": "메시지 알림 끄기", + "pintop": "상단 고정", + "delete_chat_history": "채팅 기록 삭제", + "disband_group": "그룹 해체", + "leave_group": "그룹 나가기", + "delete_friend": "친구 삭제", + "copy_id": "{id} 복사 완료", + "confirm_disband_group": "정말로 이 그룹을 해체하시겠습니까?", + "confirm_leave_group": "정말로 이 그룹을 나가시겠습니까?", + "confirm_delete_friend": "정말로 이 친구를 삭제하시겠습니까?", + "session_not_exist": "현재 대화가 존재하지 않습니다", + "disband_channel_failed": "채널 해체에 실패했습니다", + "group_disbanded": "그룹이 해체되었습니다", + "leave_channel_failed": "채널 나가기에 실패했습니다", + "group_left": "그룹에서 나갔습니다", + "get_friend_info_failed": "친구 정보를 가져오지 못했습니다", + "delete_friend_success": "친구가 삭제되었습니다", + "session_not_ready": "현재 대화 정보가 준비되지 않았습니다", + "unpinned_success": "고정이 해제되었습니다", + "pinned_success": "상단에 고정되었습니다", + "pin_failed": "고정에 실패했습니다", + "group_note_updated": "그룹 메모가 업데이트되었습니다", + "remark_updated": "{n} 메모가 업데이트되었습니다", + "group_name_updated": "그룹 이름이 업데이트되었습니다", + "setting_failed": "설정에 실패했습니다", + "messages_muted": "메시지 알림이 꺼졌습니다", + "messages_unmuted": "메시지 알림이 켜졌습니다", + "notifications_silent": "메시지는 수신되지만 알림은 꺼져 있습니다", + "notifications_enabled": "알림이 켜졌습니다" +} diff --git a/locales/ko/mobile_contact.json b/locales/ko/mobile_contact.json new file mode 100644 index 0000000..57c7a68 --- /dev/null +++ b/locales/ko/mobile_contact.json @@ -0,0 +1,17 @@ +{ + "title": "연락처", + "input": { + "search": "검색" + }, + "my_chat": "내 채팅", + "tab": { + "contacts": "친구", + "group": "그룹" + }, + "friend": { + "title": "내 친구" + }, + "group": { + "title": "내 그룹" + } +} diff --git a/locales/ko/mobile_edit_bio.json b/locales/ko/mobile_edit_bio.json new file mode 100644 index 0000000..06916c3 --- /dev/null +++ b/locales/ko/mobile_edit_bio.json @@ -0,0 +1,5 @@ +{ + "title": "소개 편집", + "placeholder": "자기소개를 입력해주세요~", + "save_btn": "저장" +} diff --git a/locales/ko/mobile_edit_brithday.json b/locales/ko/mobile_edit_brithday.json new file mode 100644 index 0000000..c5ed9d1 --- /dev/null +++ b/locales/ko/mobile_edit_brithday.json @@ -0,0 +1,9 @@ +{ + "title": "생일 편집", + "options": { + "display_birthday_tag": "생일 태그 표시", + "displsy_age": "나이 표시", + "display_constellation": "별자리 표시" + }, + "save_btn": "저장" +} diff --git a/locales/ko/mobile_edit_profile.json b/locales/ko/mobile_edit_profile.json new file mode 100644 index 0000000..2f70603 --- /dev/null +++ b/locales/ko/mobile_edit_profile.json @@ -0,0 +1,23 @@ +{ + "title": "프로필", + "nickname": "닉네임", + "gender": "성별", + "brithday": "생일", + "region": "지역", + "phone": "전화번호", + "bio": "소개", + "placeholder": { + "nickname": "닉네임을 입력해주세요", + "gender": "성별을 선택해주세요", + "brithday": "생일을 선택해주세요", + "region": "지역을 선택해주세요", + "phone": "전화번호를 입력해주세요", + "bio": "자기소개를 입력해주세요" + }, + "save_btn": "저장", + "change_avatar": "변경", + "genders": { + "male": "남성", + "female": "여성" + } +} diff --git a/locales/ko/mobile_forget_code.json b/locales/ko/mobile_forget_code.json new file mode 100644 index 0000000..8dcdc9a --- /dev/null +++ b/locales/ko/mobile_forget_code.json @@ -0,0 +1,49 @@ +{ + "title": "비밀번호 찾기", + "steps": { + "verify_email": "이메일 인증", + "set_new_password": "비밀번호 설정", + "done": "완료" + }, + "input": { + "label": { + "email": "이메일", + "email_verification_code": "인증코드", + "new_pass": "새 비밀번호", + "confirm_password": "비밀번호 확인" + }, + "email": "이메일", + "email_code": "인증코드", + "new_pass": "새 비밀번호를 입력해주세요 ({len}자)", + "confirm_password": "비밀번호를 다시 입력해주세요" + }, + "validation": { + "minlength": "최소 {len}자 이상", + "valid_characters": "영문과 숫자로 구성", + "must_special_char": "특수문자를 포함해야 합니다", + "passwords_match": "비밀번호가 일치합니다" + }, + "button": { + "send_email_code": "인증코드 전송", + "next": "다음", + "go_back_setp": "이전", + "submit": "제출" + }, + "password_reset_success": "비밀번호가 재설정되었습니다", + "password_reset_success_desc": "이제 새 비밀번호로 로그인할 수 있습니다", + "rules": { + "email_require": "이메일 주소를 입력해주세요", + "email_invalid": "올바른 이메일 주소를 입력해주세요", + "email_code_require": "이메일로 전송된 인증코드를 입력해주세요", + "code_length": "인증코드는 {len}자리여야 합니다", + + "new_pass_require": "새 비밀번호를 입력해주세요", + "new_pass_length": "비밀번호는 {len}자여야 합니다", + + "confirm_pass_require": "비밀번호를 확인해주세요", + "pass_not_match": "비밀번호가 일치하지 않습니다" + }, + "too_many_requests": "요청이 너무 많습니다. {s}초 후 다시 시도해주세요", + "code_sent_email": "인증코드가 이메일로 전송되었습니다", + "email_resend_in": "{seconds}초 후 재전송" +} diff --git a/locales/ko/mobile_home.json b/locales/ko/mobile_home.json new file mode 100644 index 0000000..3317424 --- /dev/null +++ b/locales/ko/mobile_home.json @@ -0,0 +1,25 @@ +{ + "menu": { + "pintop": "상단 고정", + "unpin": "고정 해제", + "read": "읽음", + "unread": "읽지 않음", + "delete": "삭제" + }, + "input": { + "search": "검색" + }, + "noname": "이름 없음", + "china": "중국", + "chat": { + "unpin": "고정 해제", + "pintop": "상단 고정", + "mark_as_read": "읽음", + "mark_as_unread": "읽지 않음", + "delete": "삭제" + }, + "marked_as_read": "읽음으로 표시했습니다", + "marked_as_unread": "읽지 않음으로 표시했습니다", + "mark_as_read_failed": "읽음 표시에 실패했습니다", + "mark_as_unread_failed": "읽지 않음 표시에 실패했습니다" +} diff --git a/locales/ko/mobile_my.json b/locales/ko/mobile_my.json new file mode 100644 index 0000000..fbcab2d --- /dev/null +++ b/locales/ko/mobile_my.json @@ -0,0 +1,10 @@ +{ + "photos": "사진", + "favorites": "즐겨찾기", + "files": "파일", + "appearance": "테마", + "intelligent": "AI", + "default_bio": "공유하고 소통하며, 가끔은 생각이 많아지는 곳.", + + "online": "온라인" +} diff --git a/locales/ko/mobile_mymessage.json b/locales/ko/mobile_mymessage.json new file mode 100644 index 0000000..d889aaf --- /dev/null +++ b/locales/ko/mobile_mymessage.json @@ -0,0 +1,41 @@ +{ + "title": "메시지", + "accept": "수락", + "approved": "수락됨", + "refused": "거절됨", + "agreed": "수락됨", + "declined": "거절됨", + "pending": "대기중", + "ignored": "무시됨", + "notification": { + "friend": "친구 알림", + "group": "그룹 알림" + }, + "friend_request_message": "메시지: ", + "unknown_user": "알 수 없음", + "empty_require": "친구 요청이 없습니다", + "empty_group_require": "그룹 가입 신청 알림이 없습니다", + "menu": { + "decline": "거절", + "ignore": "무시" + }, + "friend_request_status": { + "accepted": "요청이 수락되었습니다", + "verifying": "초대를 확인하는 중입니다", + "sent": "친구 요청을 보냈습니다" + }, + "loading": "로딩 중{tail}", + "group": { + "apply_to_join": "[{name}] 가입 신청", + "invited_to_join": "{inviter}님을 [{group}]에 초대했습니다", + "joined_group": "[{group}]에 가입했습니다", + "invited_curr_to_join": "[{group}]에 초대되었습니다", + "kicked_out": "{operator}님에 의해 [{group}]에서 강퇴되었습니다", + "set_as_admin": "그룹장에 의해 [{group}]의 관리자로 설정되었습니다", + "removed_as_admin": "그룹장에 의해 [{group}]의 관리자 권한이 해제되었습니다" + }, + "tab": { + "friend_messages": "친구 메시지", + "group_messages": "그룹 메시지" + } +} diff --git a/locales/ko/mobile_personal_info.json b/locales/ko/mobile_personal_info.json new file mode 100644 index 0000000..cbbf01e --- /dev/null +++ b/locales/ko/mobile_personal_info.json @@ -0,0 +1,21 @@ +{ + "account": "ID", + "unlocked_count_park": { + "text1": "획득한", + "text2": "개의 배지" + }, + "no_medal": "아직 획득한 배지가 없어요~", + "follower": "팔로워", + "follow": "팔로잉", + "like": "좋아요", + "edit_profile": "프로필 편집", + "remove_user": "삭제", + "add_friend": "친구 추가", + "open_bot": "봇 열기", + "chat": "채팅", + "delete_user": { + "success": "친구 삭제에 성공했습니다", + "failed": "친구 삭제에 실패했습니다" + }, + "not_found": "친구를 찾을 수 없습니다" +} diff --git a/locales/ko/mobile_personal_info_qr.json b/locales/ko/mobile_personal_info_qr.json new file mode 100644 index 0000000..d431d9b --- /dev/null +++ b/locales/ko/mobile_personal_info_qr.json @@ -0,0 +1,5 @@ +{ + "account": "ID", + "scan_to_add": "스캔하여 친구 추가하기~", + "title": "개인 QR코드" +} diff --git a/locales/ko/mobile_photo.json b/locales/ko/mobile_photo.json new file mode 100644 index 0000000..25840fe --- /dev/null +++ b/locales/ko/mobile_photo.json @@ -0,0 +1,6 @@ +{ + "title": "사진", + "empty": "항목이 없습니다", + "image_alt": "앨범 사진", + "image_load_failed": "이미지를 불러오지 못했습니다" +} diff --git a/locales/ko/mobile_post.json b/locales/ko/mobile_post.json new file mode 100644 index 0000000..715c446 --- /dev/null +++ b/locales/ko/mobile_post.json @@ -0,0 +1,47 @@ +{ + "title": "커뮤니티 게시", + "content": { + "label": "게시글 내용", + "placeholder": "당신의 일상을 공유해보세요 😎" + }, + "media_type": { + "label": "미디어 유형", + "option_image": "이미지 (일시적으로 사용 불가)", + "option_video": "동영상 (일시적으로 사용 불가)" + }, + "visibility": { + "label": "공개 범위", + "public": "전체 공개", + "selected": "선택한 사람만", + "exclude": "특정 사람에게 숨기기", + "emptySelectionHint": { + "visible": "볼 수 있는 사용자를 선택해주세요", + "hidden": "숨길 사용자를 선택해주세요" + } + }, + "visibility_selected_btn_label": "공개 대상 선택", + "visibility_exclude_btn_label": "숨길 대상...", + "visibility_select_btn": "사용자 선택 ({count}명 선택됨)", + "btn": { + "cancel": "취소", + "publish": "게시" + }, + "select_users": { + "title": "사용자 선택", + "search_placeholder": "사용자 검색", + "btn": { + "done": "완료" + } + }, + "empty": "아직 연락처가 없습니다", + "error": { + "publish_failed": "게시물 게시에 실패했습니다. 다시 시도해주세요", + "required": "게시글 내용을 입력해주세요", + "select_visible_users": "볼 수 있는 사용자를 선택해주세요", + "select_exclude_users": "숨길 사용자를 선택해주세요" + }, + "success": { + "publish_success": "게시물이 게시되었습니다" + }, + "unknown_user": "알 수 없는 사용자" +} diff --git a/locales/ko/mobile_setting.json b/locales/ko/mobile_setting.json new file mode 100644 index 0000000..3f7bfdb --- /dev/null +++ b/locales/ko/mobile_setting.json @@ -0,0 +1,14 @@ +{ + "title": "설정", + "silent_label": "알림", + "nickname": "닉네임", + "theme": "테마", + "language": "언어", + "button": { + "logout": "로그아웃" + }, + "themes": { + "dark": "다크", + "light": "라이트" + } +} diff --git a/locales/ko/mobile_tabbar.json b/locales/ko/mobile_tabbar.json new file mode 100644 index 0000000..a83e887 --- /dev/null +++ b/locales/ko/mobile_tabbar.json @@ -0,0 +1,8 @@ +{ + "items": { + "messages": "채팅", + "contacts": "연락처", + "community": "커뮤니티", + "me": "나" + } +} diff --git a/locales/ko/setting.json b/locales/ko/setting.json new file mode 100644 index 0000000..a9059da --- /dev/null +++ b/locales/ko/setting.json @@ -0,0 +1,162 @@ +{ + "common": { + "provider_label": "제공자", + "provider_name": "HuLa", + "tag_new": "신규" + }, + "footer": { + "like_product": "이 제품이 마음에 드시나요? ", + "star_cta": "GitHub에서 별을 눌러", + "share_joiner": "그리고", + "share_cta": "소중한 의견을 공유해 주세요", + "star_popover_title": "GitHub에서 스타 눌러주기", + "star_popover_desc": "HuLa가 마음에 드셨고 저희를 응원해 주고 싶으시다면, GitHub 저장소에 별을 눌러주실 수 있나요? 작은 행동이지만 저희에게는 큰 힘이 되며, 더 좋은 기능을 계속 만들어갈 수 있는 원동력이 됩니다.", + "later": "나중에", + "star_button": "스타 누르기", + "issue_popover_title": "GitHub에 피드백 남기기", + "issue_popover_desc": "모든 의견은 저희에게 소중합니다. 개선하고 싶은 기능이나 사용 경험이 있다면 언제든 편하게 알려주세요!", + "issue_button": "피드백 남기기" + }, + "general": { + "title": "일반", + "appearance": { + "title": "화면 표시", + "theme": { + "auto": "시스템 설정 따르기", + "light": "라이트", + "dark": "다크" + } + }, + "chat": { + "title": "채팅", + "translate_service": "번역 서비스", + "translate_options": { + "tencent": "텐센트 클라우드 번역", + "youdao": "유다오 번역" + } + }, + "ui": { + "title": "UI", + "language": "언어", + "blur": "가우시안 블러", + "shadow": "그림자 효과 사용", + "font": "글꼴", + "menu_name": "메뉴 이름", + "font_options": { + "PingFang": "PingFang", + "AliFangYuan": "알리바바 방원체" + } + }, + "system": { + "title": "시스템", + "close_panel": "닫기 패널", + "close_options": { + "minimize_to_tray": "트레이로 최소화", + "exit_program": "바로 종료" + }, + "close_prompt": "닫기 안내", + "esc_close_window": "ESC로 창 닫기" + } + }, + "login": { + "title": "로그인", + "auto_login_startup": "시작 시 자동 로그인", + "launch_startup": "시스템 시작 시 자동 실행" + }, + "notice": { + "title": "알림", + "sound": "소리", + "message_sound": "새 메시지 알림음", + "message_sound_descript": "켜면 새 메시지를 받을 때 알림음이 재생됩니다.", + "message_volume": "알림음 볼륨", + "message_volume_descript": "드래그하여 알림음 볼륨을 조절하세요 (0-100).", + "group_setting": "그룹 설정", + "select_all": "전체 선택", + "group_notic_type": { + "allow": "허용", + "silent": "방해 금지", + "block": "차단" + }, + "input": { + "search_group_placholder": "그룹 검색" + }, + "batch_set": "일괄 설정", + "message_select_group_first": "먼저 설정할 그룹을 선택해 주세요.", + "unknow_group": "알 수 없는 그룹", + "group_chat_not_found": "그룹 채팅 정보를 찾을 수 없습니다.", + "setup_fail": "설정 실패", + "message_group_batch_setup_success": "일괄 설정이 완료되었습니다! 그룹 채팅 {count}개가 {type}(으)로 설정되었습니다.", + "message_group_batch_update_result": "일괄 업데이트 완료: 성공 {success_count}개, 실패 {fail_count}개.", + "message_reminder_allowed": "메시지 알림이 허용되었습니다", + "message_reminder_silent": "메시지는 수신되지만 알림은 표시되지 않습니다", + "message_ring": "벨소리", + "message_unblocked": "차단 해제됨" + }, + "shortcut": { + "title": "단축키", + "global_shortcut_title": "전역", + "enable_global_shortcuts": "전역 단축키 사용", + "enable_global_shortcuts_hint": "끄면 아래의 모든 단축키가 비활성화됩니다.", + "function_shortcut_title": "기능", + "message_title": "메시지", + "screenshot": "스크린샷", + "screenshot_hint": "단축키를 눌러 캡처하세요", + "panel_switch": "메인 패널 전환", + "panel_switch_hint": "단축키를 눌러 메인 패널을 전환하세요", + "message_shortcut": "메시지", + "send_message_shortcut": "메시지 보내기", + "send_message_shortcut_hint": "단축키를 눌러 메시지를 보내세요", + "send_message_shortcut_option": "{key}를 눌러 보내기", + "unbound": "지정되지 않음", + "bound": "지정됨", + "reset": "초기화", + "shortcut_update_result": "{name}이(가) 업데이트되었습니다", + "shortcut_setting_failed": "{name} 설정에 실패했습니다", + "global_enable": "전역 단축키가 켜졌습니다", + "global_disable": "전역 단축키가 꺼졌습니다", + "global_toggle_failed": "전역 단축키 전환 설정에 실패했습니다", + "send_message_updated": "메시지 보내기 단축키가 업데이트되었습니다", + "send_message_failed": "메시지 보내기 단축키 설정에 실패했습니다" + }, + "storage": { + "title": "저장소", + "file_scan_progress": "파일 스캔 중", + "usage": "파일 스캔 비율", + "used_space": "사용 중인 공간", + "app_used_space": "HuLa 데이터", + "free_space": "여유 공간", + "directory": "디렉터리", + "curr_dir": "현재 디렉터리", + "current_file": "현재 파일", + "scanning_progress": "스캔 진행률", + "processed_files": "처리된 파일", + "processed_files_unit": "{count}개", + "total_size": "총 용량", + "mount_point": "마운트 지점", + "disk_total": "디스크 총 용량", + "disk_used": "디스크 사용 용량", + "disk_usage_percent": "디스크 사용률", + "elapsed": "소요 시간", + "elapsed_unit": "{seconds}초", + "path_type": "스캔 디렉터리 유형", + "path_type_default": "기본값", + "path_type_custom": "사용자 지정", + "fetching_directory": "디렉터리 경로를 가져오는 중...", + "select_directory": "디렉터리 선택", + "start_scan": "스캔 시작", + "scanning": "스캔 중...", + "select_directory_title": "스캔할 디렉터리를 선택하세요", + "select_directory_failed": "디렉터리 선택에 실패했습니다", + "select_directory_error": "디렉터리 선택 실패" + }, + "theme": { + "title": "테마", + "restore_default": "기본값으로 복원", + "versatile": { + "title": "생동감 있는 팔레트", + "description": "색상의 매력을 느끼고 나만의 팔레트를 만들어보세요", + "simple": "심플한 우아함" + } + }, + "unknow": "알 수 없음" +} diff --git a/locales/zh-CN/agreement.json b/locales/zh-CN/agreement.json new file mode 100644 index 0000000..8c162de --- /dev/null +++ b/locales/zh-CN/agreement.json @@ -0,0 +1,254 @@ +{ + "privacy": { + "header": { + "title": "HuLa隐私保护指引", + "updatedAt": "更新时间:2025年9月3日" + }, + "sections": [ + { + "title": "引言", + "paragraphs": [ + "HuLa(以下简称“我们”)深知个人信息对您的重要性,并会尽全力保护您的个人信息安全可靠。我们致力于维持您对我们的信任,恪守以下原则,保护您的个人信息:权责一致原则、目的明确原则、选择同意原则、最少够用原则、确保安全原则、主体参与原则、公开透明原则等。同时,我们承诺,我们将按业界成熟的安全标准,采取相应的安全保护措施来保护您的个人信息。", + "请在使用我们的产品(或服务)前,仔细阅读并了解本《隐私保护指引》。" + ] + }, + { + "title": "一、我们如何收集和使用您的个人信息", + "paragraphs": [ + "个人信息是指以电子或者其他方式记录的能够单独或者与其他信息结合识别特定自然人身份或者反映特定自然人活动情况的各种信息。" + ], + "subSections": [ + { + "subtitle": "1.1 我们收集您个人信息的情形", + "paragraphs": [ + "(1)账号注册:当您注册HuLa账号时,我们需要收集您的手机号码、邮箱地址等信息,以便为您创建账号。", + "(2)身份验证:为了确保账号安全,我们可能需要您提供身份证明文件进行实名认证。", + "(3)服务使用:在您使用HuLa服务过程中,我们会收集您的聊天记录、好友关系、群组信息等。", + "(4)设备信息:我们会收集您的设备型号、操作系统版本、设备标识符等信息,以便为您提供更好的服务体验。" + ] + }, + { + "subtitle": "1.2 我们如何使用您的个人信息", + "paragraphs": [ + "(1)为您提供服务:包括即时通讯、文件传输、音视频通话等核心功能。", + "(2)维护服务安全:检测和防范安全威胁、欺诈等违法犯罪活动。", + "(3)改进服务质量:分析服务使用情况,优化产品功能和用户体验。", + "(4)客户服务:处理您的咨询、投诉和建议。" + ] + } + ] + }, + { + "title": "二、我们如何使用Cookie和同类技术", + "paragraphs": [ + "为确保服务正常运转,我们会在您的计算机或移动设备上存储名为Cookie的小数据文件。Cookie通常包含标识符、站点名称以及一些号码和字符。借助于Cookie,网站能够存储您的偏好或购物篮内的商品等数据。", + "我们不会将Cookie用于本政策所述目的之外的任何用途。您可根据自己的偏好管理或删除Cookie。您可以清除计算机上保存的所有Cookie,大部分网络浏览器都设有阻止Cookie的功能。但如果您这么做,则需要在每一次访问我们的网站时亲自更改用户设置。" + ] + }, + { + "title": "三、我们如何共享、转让、公开披露您的个人信息", + "subSections": [ + { + "subtitle": "3.1 共享", + "paragraphs": [ + "我们不会与HuLa以外的公司、组织和个人共享您的个人信息,但以下情况除外:", + "(1)事先获得您明确的同意或授权;", + "(2)根据适用的法律法规、法律程序的要求、强制性的政府要求或司法裁定;", + "(3)在法律要求或允许的范围内,为了保护HuLa、HuLa用户或公众的权利、财产或安全免遭损害;", + "(4)与我们的关联公司共享:我们可能会与我们的关联公司共享您的个人信息。" + ] + }, + { + "subtitle": "3.2 转让", + "paragraphs": [ + "我们不会将您的个人信息转让给任何公司、组织和个人,但以下情况除外:", + "(1)事先获得您明确的同意或授权;", + "(2)在涉及合并、收购或破产清算时,如涉及到个人信息转让,我们会要求新的持有您个人信息的公司、组织继续受此隐私政策的约束。" + ] + }, + { + "subtitle": "3.3 公开披露", + "paragraphs": [ + "我们仅会在以下情况下,公开披露您的个人信息:", + "(1)获得您明确同意后;", + "(2)基于法律的披露:在法律、法律程序、诉讼或政府主管部门强制性要求的情况下。" + ] + } + ] + }, + { + "title": "四、我们如何保护您的个人信息", + "paragraphs": [ + "(1)我们已使用符合业界标准的安全防护措施保护您提供的个人信息,防止数据遭到未经授权访问、公开披露、使用、修改、损坏或丢失。", + "(2)我们会采取一切合理可行的措施,确保未收集无关的个人信息。", + "(3)我们会采取一切合理可行的措施,确保个人信息不被保留超过实现目的所必需的期限。", + "(4)互联网并非绝对安全的环境,而且电子邮件、即时通讯、社交软件等与其他用户的交流方式无法确定是否完全加密。" + ] + }, + { + "title": "五、您的权利", + "paragraphs": [ + "按照中国相关的法律、法规、标准,以及其他国家、地区的通行做法,我们保障您对自己的个人信息行使以下权利:" + ], + "subSections": [ + { + "subtitle": "5.1 访问您的个人信息", + "paragraphs": ["您有权访问您的个人信息,法律法规规定的例外情况除外。"] + }, + { + "subtitle": "5.2 更正您的个人信息", + "paragraphs": ["当您发现我们处理的关于您的个人信息有错误时,您有权要求我们做出更正。"] + }, + { + "subtitle": "5.3 删除您的个人信息", + "paragraphs": [ + "在以下情形中,您可以向我们提出删除个人信息的请求:", + "(1)如果我们处理个人信息的行为违反法律法规;", + "(2)如果我们收集、使用您的个人信息,却未征得您的同意;", + "(3)如果我们处理个人信息的行为违反了与您的约定。" + ] + }, + { + "subtitle": "5.4 改变您授权同意的范围", + "paragraphs": [ + "每个业务功能需要一些基本的个人信息才能得以完成。对于额外收集的个人信息的收集和使用,您可以随时给予或收回您的授权同意。" + ] + } + ] + }, + { + "title": "六、我们如何处理儿童的个人信息", + "paragraphs": [ + "我们的产品、网站和服务主要面向成人。如果没有父母或监护人的同意,儿童不得创建自己的用户账户。", + "对于经父母同意而收集儿童个人信息的情况,我们只会在受到法律允许、父母或监护人明确同意或者保护儿童所必要的情况下使用或公开披露此信息。" + ] + }, + { + "title": "七、您的个人信息如何在全球范围转移", + "paragraphs": [ + "原则上,我们在中华人民共和国境内收集和产生的个人信息,将存储在中华人民共和国境内。", + "由于我们通过遍布全球的资源和服务器提供产品或服务,这意味着,在获得您的授权同意后,您的个人信息可能会被转移到您使用产品或服务所在国家/地区的境外管辖区,或者受到来自这些管辖区的访问。" + ] + }, + { + "title": "八、本政策如何更新", + "paragraphs": [ + "我们的隐私政策可能变更。", + "未经您明确同意,我们不会削减您按照本隐私政策所应享有的权利。我们会在本页面上发布对本政策所做的任何变更。", + "对于重大变更,我们还会提供更为显著的通知(包括对于某些服务,我们会通过电子邮件发送通知,说明隐私政策的具体变更内容)。" + ] + }, + { + "title": "九、开源项目免责声明", + "paragraphs": [ + "本项目是作为一款开源项目提供的,开发者在法律允许的范围内不对软件的功能性、安全性或适用性提供任何形式的明示或暗示的保证。", + "用户明确理解并同意,使用本软件的风险完全由用户自己承担,软件以“现状”和“现有”基础提供。开发者不提供任何形式的担保,无论是明示还是暗示的,包括但不限于适销性、特定用途的适用性和非侵权的担保。", + "在任何情况下,开发者或其供应商都不对任何直接的、间接的、偶然的、特殊的、惩罚性的或后果性的损害承担责任,包括但不限于使用本软件产生的利润损失、业务中断、个人信息泄露或其他商业损害或损失。", + "所有在本项目上进行二次开发的用户,都需承诺将本软件用于合法目的,并自行负责遵守当地的法律和法规。", + "开发者有权在任何时间修改软件的功能或特性,以及本免责声明的任何部分,并且这些修改可能会以软件更新的形式体现。" + ] + }, + { + "title": "十、最终解释权", + "paragraphs": ["本隐私保护指引的最终解释权归作者所有。"] + } + ] + }, + "server": { + "header": { + "title": "HuLa服务协议", + "updatedAt": "更新时间:2025年9月3日" + }, + "sections": [ + { + "title": "1. 服务条款的确认和接纳", + "paragraphs": [ + "欢迎使用HuLa即时通讯软件!HuLa的各项电子服务的所有权和运作权归HuLa所有。用户必须完全同意所有服务条款并完成注册程序,才能成为HuLa的正式用户。" + ] + }, + { + "title": "2. 服务简介", + "paragraphs": [ + "HuLa是一款免费的即时通讯软件,用户可以通过HuLa与好友进行文字、语音、视频聊天,分享图片、文件等。HuLa致力于为用户提供安全、稳定、便捷的通讯服务。" + ] + }, + { + "title": "3. 用户注册", + "paragraphs": [ + "3.1 用户注册时必须提供真实、准确、完整的个人资料。", + "3.2 用户有义务保证密码和账号的安全,用户利用该密码和账号所进行的一切活动引起的任何损失或损害,由用户自行承担全部责任。", + "3.3 用户同意接收来自HuLa的信息,包括但不限于产品信息、服务信息、特别优惠信息等。" + ] + }, + { + "title": "4. 用户行为规范", + "paragraphs": [ + "4.1 用户在使用HuLa服务时,必须遵守中华人民共和国相关法律法规。", + "4.2 禁止利用HuLa服务进行任何违法犯罪活动。", + "4.3 禁止发布、传播任何违法、有害、威胁、辱骂、骚扰、侵权、中伤、粗俗、猥亵或其他道德上令人反感的内容。", + "4.4 禁止发布、传播任何侵犯他人知识产权或其他合法权益的内容。", + "4.5 禁止恶意传播病毒、木马等恶意程序。" + ] + }, + { + "title": "5. 隐私保护", + "paragraphs": [ + "5.1 HuLa非常重视用户隐私的保护。除法律法规另有规定外,未经用户同意,HuLa不会向第三方披露、转让用户个人信息。", + "5.2 HuLa采用行业标准的安全措施保护用户个人信息,防止数据的未经授权访问、使用或披露。", + "5.3 用户理解并同意,为了向用户提供更好的服务,HuLa可能会收集、使用用户的相关信息。" + ] + }, + { + "title": "6. 知识产权", + "paragraphs": [ + "6.1 HuLa软件及其相关技术、商标、版权等知识产权均归HuLa所有。", + "6.2 未经HuLa书面许可,用户不得对HuLa软件进行反向工程、反汇编、反编译等。", + "6.3 用户通过HuLa发布的内容,用户保留其知识产权,但同时授予HuLa在全球范围内免费的、非独占的使用许可。" + ] + }, + { + "title": "7. 免责声明", + "paragraphs": [ + "7.1 用户明确同意其使用HuLa服务所存在的风险将完全由其自己承担。", + "7.2 HuLa不保证服务一定能满足用户的要求,也不保证服务不会中断。", + "7.3 对于因不可抗力或HuLa不能控制的原因造成的网络服务中断或其它缺陷,HuLa不承担任何责任。" + ] + }, + { + "title": "8. 服务变更、中断或终止", + "paragraphs": [ + "8.1 HuLa有权根据业务发展需要,变更、中断或终止部分或全部服务。", + "8.2 如因系统维护或升级的需要而需暂停网络服务,HuLa将尽可能事先进行通告。", + "8.3 如用户违反本协议的任何条款,HuLa有权终止向该用户提供服务。" + ] + }, + { + "title": "9. 协议修改", + "paragraphs": [ + "HuLa有权随时修改本协议的任何条款,一旦本协议的内容发生变动,HuLa将会通过适当方式向用户提示修改内容。如果不同意HuLa对本协议相关条款所做的修改,用户有权停止使用网络服务。如果用户继续使用网络服务,则视为用户接受HuLa对本协议相关条款所做的修改。" + ] + }, + { + "title": "10. 法律适用及争议解决", + "paragraphs": [ + "10.1 本协议的订立、执行和解释及争议的解决均应适用中华人民共和国法律。", + "10.2 如双方就本协议内容或其执行发生任何争议,双方应尽量友好协商解决;协商不成时,任何一方均可向HuLa所在地的人民法院起诉。" + ] + }, + { + "title": "11. 开源项目免责声明", + "paragraphs": [ + "本项目是作为一款开源项目提供的,开发者在法律允许的范围内不对软件的功能性、安全性或适用性提供任何形式的明示或暗示的保证。", + "用户明确理解并同意,使用本软件的风险完全由用户自己承担,软件以“现状”和“现有”基础提供。开发者不提供任何形式的担保,无论是明示还是暗示的,包括但不限于适销性、特定用途的适用性和非侵权的担保。", + "在任何情况下,开发者或其供应商都不对任何直接的、间接的、偶然的、特殊的、惩罚性的或后果性的损害承担责任,包括但不限于使用本软件产生的利润损失、业务中断、个人信息泄露或其他商业损害或损失。", + "所有在本项目上进行二次开发的用户,都需承诺将本软件用于合法目的,并自行负责遵守当地的法律和法规。", + "开发者有权在任何时间修改软件的功能或特性,以及本免责声明的任何部分,并且这些修改可能会以软件更新的形式体现。" + ] + }, + { + "title": "12. 最终解释权", + "paragraphs": ["本协议的最终解释权归作者所有。"] + } + ] + } +} diff --git a/locales/zh-CN/announcement.json b/locales/zh-CN/announcement.json new file mode 100644 index 0000000..b4e8a4f --- /dev/null +++ b/locales/zh-CN/announcement.json @@ -0,0 +1,36 @@ +{ + "title": { + "create": "新增群公告", + "view": "查看群公告", + "edit": "编辑群公告" + }, + "form": { + "placeholder": "填写公告,1~600字", + "pinned": "置顶", + "actions": { + "cancel": "取消", + "publish": "发布", + "new": "发布新公告" + } + }, + "list": { + "empty": "暂无公告", + "loadMore": "加载更多", + "noMore": "没有更多公告了", + "deleteConfirm": "你确定要删除全部会话吗?", + "delete": { + "confirm": "删除", + "cancel": "取消" + }, + "expand": "展开", + "collapse": "收起" + }, + "toast": { + "contentRequired": "公告内容不能为空", + "contentTooLong": "公告内容不能超过600字", + "createSuccess": "公告发布成功", + "createFail": "公告发布失败", + "editSuccess": "公告修改成功", + "editFail": "公告修改失败" + } +} diff --git a/locales/zh-CN/auth.json b/locales/zh-CN/auth.json new file mode 100644 index 0000000..aa20428 --- /dev/null +++ b/locales/zh-CN/auth.json @@ -0,0 +1,161 @@ +{ + "onlineStatus": { + "reset_title": "清空状态", + "messages": { + "success": "状态更新成功", + "error": "状态更新失败" + }, + "states": { + "离开": "离开", + "忙碌": "忙碌", + "请勿打扰": "请勿打扰", + "隐身": "隐身", + "今日天气": "今日天气", + "一言难尽": "一言难尽", + "我太难了": "我太难了", + "难得糊涂": "难得糊涂", + "元气满满": "元气满满", + "嗨到飞起": "嗨到飞起", + "水逆退散": "水逆退散", + "好运锦鲤": "好运锦鲤", + "恋爱中": "恋爱中", + "我crush了": "我crush了", + "被掏空": "被掏空", + "听歌中": "听歌中", + "我没事": "我没事", + "学习中": "学习中", + "睡觉中": "睡觉中", + "搬砖中": "搬砖中", + "想静静": "想静静", + "运动中": "运动中", + "我想开了": "我想开了", + "信号弱": "信号弱", + "追剧中": "追剧中", + "美滋滋": "美滋滋", + "摸鱼中": "摸鱼中", + "无聊中": "无聊中", + "悠哉哉": "悠哉哉", + "去旅行": "去旅行", + "游戏中": "游戏中" + } + }, + "register": { + "title": "欢迎注册", + "labels": { + "nickname": "昵称", + "password": "密码", + "confirm": "验证", + "email": "邮箱" + }, + "placeholders": { + "nickname": "输入HuLa昵称", + "email": "输入邮箱", + "password": "输入HuLa密码", + "confirm": "输入二次密码", + "confirm_placeholder": "二次确认密码" + }, + "password_hints": { + "min_length": "最少6位", + "alpha_numeric": "由英文和数字构成", + "special_char": "必须有一个特殊字符" + }, + "validation": { + "confirm_match": "两次密码输入一致" + }, + "form": { + "rules": { + "nickname_required": "请输入昵称", + "email_required": "请输入邮箱", + "email_format": "请输入正确的邮箱格式", + "password_required": "请输入密码", + "confirm_mismatch": "两次密码不一致" + } + }, + "tips": { + "reopen_code": "验证码窗口关闭?点击按钮可再次打开验证码输入框" + }, + "actions": { + "send_code": "发送验证码", + "retry_in": "{seconds}秒后重试", + "sending": "发送中...", + "submit": "注册" + }, + "modal": { + "title": "在 GitHub 为我们点亮星标", + "desc": "如果您喜爱我们的产品,并希望支持我们,可以去 GitHub 给我们点一颗星吗?这个小小的动作对我们来说意义重大,能激励我们为您持续提供特性体验。", + "cta": "点亮星标" + }, + "email_modal": { + "title": "请输入邮箱验证码", + "desc": "验证码已发送至 {email},请查收并输入验证码完成注册" + }, + "messages": { + "code_sent": "验证码已发送", + "register_success": "注册成功", + "register_fail": "注册失败,请重试" + } + }, + "forget": { + "title": "找回密码", + "steps": { + "verify": { + "title": "验证邮箱", + "desc": "验证您的账号邮箱" + }, + "reset": { + "title": "设置新密码", + "desc": "设置您的新密码" + }, + "done": { + "title": "完成", + "desc": "密码修改成功" + } + }, + "form": { + "email_label": "邮箱账号", + "email_placeholder": "请输入您的邮箱", + "code_label": "邮箱验证码", + "code_placeholder": "请输入邮箱验证码", + "password_label": "新密码", + "password_placeholder": "请输入6-16位新密码", + "confirm_label": "确认密码", + "confirm_placeholder": "请再次输入密码" + }, + "password_hints": { + "length": "密码长度为6-16位", + "alpha_numeric": "由英文和数字构成", + "special_char": "必须有一个特殊字符", + "confirm_match": "两次密码输入一致" + }, + "buttons": { + "next": "下一步", + "prev": "上一步", + "submit": "提交" + }, + "actions": { + "send_code": "发送验证码", + "retry_in": "{seconds}秒后重新获取", + "resend": "重新获取" + }, + "messages": { + "captcha_cooldown": "请求过于频繁,{seconds}秒后再试", + "enter_email": "请先输入邮箱", + "email_format": "请输入正确的邮箱格式", + "code_sent": "验证码已发送至您的邮箱" + }, + "rules": { + "email_required": "请输入邮箱地址", + "email_format": "请输入正确的邮箱格式", + "code_required": "请输入邮箱验证码", + "code_length": "验证码长度为6位", + "password_required": "请输入新密码", + "password_length": "密码长度为6-16位", + "confirm_required": "请确认密码", + "confirm_mismatch": "两次输入的密码不一致" + }, + "success": { + "title": "密码修改成功", + "desc": "您已成功重置密码,可以使用新密码登录" + } + } +} diff --git a/locales/zh-CN/chatHistory.json b/locales/zh-CN/chatHistory.json new file mode 100644 index 0000000..e740de1 --- /dev/null +++ b/locales/zh-CN/chatHistory.json @@ -0,0 +1,18 @@ +{ + "search": { + "placeholder": "搜索聊天记录" + }, + "tabs": { + "all": "全部", + "imageVideo": "图片/视频", + "file": "文件" + }, + "datePicker": { + "placeholder": "选择日期范围", + "start": "开始日期", + "end": "结束日期" + }, + "empty": { + "noData": "暂无消息记录" + } +} diff --git a/locales/zh-CN/components.json b/locales/zh-CN/components.json new file mode 100644 index 0000000..e488f5e --- /dev/null +++ b/locales/zh-CN/components.json @@ -0,0 +1,31 @@ +{ + "common": { + "confirm": "确定", + "cancel": "取消" + }, + "actionBar": { + "always_on_top": { + "enabled": "取消置顶", + "disabled": "置顶" + }, + "close_prompt": { + "title": "最小化还是直接退出程序?", + "hide_to_tray": "最小化到系统托盘", + "exit_app": "直接退出程序", + "no_prompt": "下次不出现此提示" + } + }, + "avatarCropper": { + "title": "裁剪头像", + "preview": { + "round": "圆形预览", + "square": "矩形预览" + }, + "uploading": "上传中..." + }, + "announcementCard": { + "title": "公告", + "viewDetail": "查看详情", + "windowTitle": "查看群公告" + } +} diff --git a/locales/zh-CN/dynamic.json b/locales/zh-CN/dynamic.json new file mode 100644 index 0000000..ef9883b --- /dev/null +++ b/locales/zh-CN/dynamic.json @@ -0,0 +1,109 @@ +{ + "common": { + "loading_title": "加载中...", + "loading_desc": "正在获取动态详情", + "image_alt": "图片", + "video_alt": "视频", + "unknown_user": "未知用户" + }, + "page": { + "detail": { + "title": "动态详情" + }, + "mobile_title": "社区" + }, + "list": { + "empty": "暂无动态,快去发布第一条吧!", + "actions": { + "publish": "发布动态", + "refresh": "刷新", + "comment_notice": "评论通知", + "like": "赞", + "liked": "已赞", + "comment": "评论", + "comment_with_count": "评论 {count}" + }, + "permission": { + "open": "公开", + "part_visible": "部分可见", + "not_anyone": "不给谁看" + }, + "modal": { + "add_title": "发布动态", + "content_placeholder": "分享新鲜事...", + "visibility_label": "谁可以看", + "visibility_placeholder": "选择可见范围", + "select_visible": "选择可见的人", + "select_hidden": "选择不可见的人", + "select_users": "选择用户", + "selected_count": "选择用户 (已选 {count} 人)", + "user_modal_title": "选择用户", + "user_search_placeholder": "搜索用户...", + "comment_notice_title": "评论通知", + "comment_notice_empty_title": "暂无评论通知", + "comment_notice_empty_desc": "暂无评论记录呢" + }, + "buttons": { + "cancel": "取消", + "publish": "发布", + "select_users": "选择用户", + "confirm": "确定", + "confirm_with_count": "确定 ({count})" + }, + "comments": { + "more": "还有 {count} 条评论,点击查看全部" + }, + "loading": "加载中...", + "load_more": "加载更多", + "loaded_all": "已加载全部动态" + }, + "detail": { + "content": { + "view_image": "查看大图", + "video_tag": "视频", + "video_cta": "点击播放" + }, + "stats": { + "liked_by": "赞过的人:", + "like": "赞", + "liked": "已赞", + "comments": "评论 ({count})" + }, + "actions": { + "comment": "评论", + "reply": "回复", + "delete": "删除" + }, + "dropdown": { + "report": "举报", + "delete": "删除动态" + }, + "modal": { + "title": "发表评论", + "send": "发送", + "cancel": "取消", + "placeholder": "写下你的评论...", + "replying": "回复 {name}" + }, + "empty": "动态不存在或已被删除" + }, + "messages": { + "refresh_success": "刷新成功", + "refresh_fail": "刷新失败,请重试", + "publish_empty": "请输入动态内容", + "publish_select_visible": "请选择可见的用户", + "publish_select_hidden": "请选择不可见的用户", + "publish_success": "发布成功!", + "publish_fail": "发布失败,请稍后重试", + "delete_success": "删除成功", + "delete_fail": "删除失败,请重试", + "copy_success": "链接已复制", + "report_todo": "举报功能开发中", + "like_fail": "操作失败,请重试", + "comment_empty": "请输入评论内容", + "comment_fail": "评论失败,请重试", + "comment_delete_fail": "删除失败,请重试", + "detail_missing": "动态不存在或已被删除", + "detail_fetch_fail": "获取动态详情失败" + } +} diff --git a/locales/zh-CN/editor.json b/locales/zh-CN/editor.json new file mode 100644 index 0000000..f5d3431 --- /dev/null +++ b/locales/zh-CN/editor.json @@ -0,0 +1,22 @@ +{ + "menu": { + "select_all": "全选", + "save_as": "另存为", + "paste": "粘贴", + "copy": "复制", + "cut": "剪切" + }, + "send": "发送", + "placeholder": "善言一句暖人心,恶语一句伤人心", + "send_or_newline": "{send} 发送/{newline} 换行", + "file": "文件", + "image": "图片", + "voice": "语音", + "location": "位置", + "chat_history": "聊天记录", + "screenshot": "截图", + "screenshot_hide_curr_window": "截图时隐藏当前窗口", + "relation": { + "not_friends": "你们当前已经不是好友关系" + } +} diff --git a/locales/zh-CN/emoticon.json b/locales/zh-CN/emoticon.json new file mode 100644 index 0000000..fba630c --- /dev/null +++ b/locales/zh-CN/emoticon.json @@ -0,0 +1,21 @@ +{ + "recent": { + "title": "最近使用" + }, + "tabs": { + "emoji": "表情", + "favorites": "我喜欢的" + }, + "favorites": { + "title": "我的表情包", + "empty": "暂无表情包", + "delete": "删除", + "deleteSuccess": "删除表情成功", + "deleteFail": "删除表情失败" + }, + "categories": { + "expression": "小黄脸表情", + "animal": "动物表情", + "gesture": "手势表情" + } +} diff --git a/locales/zh-CN/fileManager.json b/locales/zh-CN/fileManager.json new file mode 100644 index 0000000..2882d7a --- /dev/null +++ b/locales/zh-CN/fileManager.json @@ -0,0 +1,89 @@ +{ + "navigation": { + "title": "文件分类", + "items": { + "myFiles": "我的文件", + "senders": "按发送人", + "sessions": "按会话", + "groups": "按群组", + "default": "文件列表" + } + }, + "search": { + "placeholder": { + "default": "搜索文件", + "myFiles": "搜索我的文件", + "senders": "搜索发送人文件", + "sessions": "搜索会话文件", + "groups": "搜索群组文件" + }, + "clear": "清除搜索", + "showAllUsers": "显示全部用户" + }, + "header": { + "titles": { + "myFiles": "我的文件", + "senders": "按发送人分类", + "sessions": "按会话分类", + "groups": "按群组分类", + "default": "文件列表" + }, + "subtitle": { + "userFiles": "{name} 的文件 · 共 {total} 个文件", + "search": "找到 {total} 个文件", + "total": "共 {total} 个文件" + } + }, + "list": { + "fileCount": "{count} 个文件", + "meta": { + "from": "来自:" + } + }, + "empty": { + "search": "未找到相关文件", + "userHasNoFiles": "{name} 暂无文件", + "default": "暂无文件", + "senders": "暂无发送人文件", + "sessions": "暂无会话文件", + "groups": "暂无群组文件" + }, + "notifications": { + "saveFileSuccess": "文件保存成功", + "saveFileFail": "保存文件失败", + "saveVideoSuccess": "视频保存成功", + "saveVideoFail": "保存视频失败" + }, + "common": { + "unknownUser": "未知用户", + "unknown": "未知", + "loading": "加载中" + }, + "userList": { + "searchPlaceholder": { + "default": "搜索", + "senders": "搜索发送人", + "sessions": "搜索会话", + "groups": "搜索群聊" + }, + "sectionTitle": { + "default": "列表 ({count})", + "senders": "发送人 ({count})", + "sessions": "会话 ({count})", + "groups": "群聊 ({count})" + }, + "allOptions": { + "default": "全部", + "senders": "全部发送人", + "sessions": "全部会话", + "groups": "全部群聊" + }, + "empty": { + "default": "未找到匹配项", + "senders": "未找到匹配的发送人", + "sessions": "未找到匹配的会话", + "groups": "未找到匹配的群聊" + }, + "memberCount": "{count} 人" + } +} diff --git a/locales/zh-CN/home.json b/locales/zh-CN/home.json new file mode 100644 index 0000000..4417dbf --- /dev/null +++ b/locales/zh-CN/home.json @@ -0,0 +1,485 @@ +{ + "search_suggestions": "建议", + "search_guide": "在搜索框内搜索", + "no_search_results": "未找到相关结果", + "search_result": "搜索结果", + "search_history": "历史记录", + "search_input_placeholder": "搜索", + "clear_search_history": "清空", + "loading": { + "app": "加载应用中...", + "left_panel": "加载左侧面板...", + "data": "加载数据中...", + "right_panel": "加载右侧面板..." + }, + "action": { + "message": "消息列表", + "message_short_title": "消息", + "contact": "好友列表", + "contact_short_title": "好友", + "file_manager": "文件管理器", + "file_manager_short_title": "文件", + "favorite": "收藏列表", + "favorite_short_title": "收藏", + "plugin": "插件", + "plugin_manage": "插件管理", + "more": "更多", + "opened": "已打开", + "start_group_chat": "发起群聊", + "add_friend_or_group": "加好友/群" + }, + "plugins": { + "dynamic": "动态列表", + "dynamic_short_title": "动态", + "chatbot": "ChatBot", + "chatbot_short_title": "ChatBot", + "status": { + "builtin": "已内置", + "uninstalling": "卸载中" + }, + "actions": { + "install": "安装", + "pin": "固定侧边栏", + "unpin": "取消固定", + "uninstall": "卸载" + } + }, + "search_window": { + "title": "全网搜索", + "tabs": { + "recommend": "推荐", + "user": "找好友", + "group": "找群聊" + }, + "placeholder": { + "recommend": "输入推荐关键词", + "user": "输入昵称搜索好友", + "group": "输入群号搜索群聊" + }, + "labels": { + "account": "账号:{account}" + }, + "tooltip": { + "copy_account": "复制账号" + }, + "empty": { + "no_result": "未找到相关结果", + "prompt": "输入关键词搜索" + }, + "notification": { + "copy_success": "复制成功 {account}", + "search_fail": "搜索失败" + }, + "buttons": { + "add": "添加", + "message": "发消息", + "edit_profile": "编辑资料" + }, + "modal": { + "add_friend": "申请加好友", + "add_group": "申请加群" + } + }, + "chat_reaction": { + "like": "好赞", + "unsatisfied": "不满", + "heart": "爱心", + "angry": "愤怒", + "party": "礼炮", + "rocket": "火箭", + "lol": "笑哭", + "clap": "鼓掌", + "flower": "鲜花", + "bomb": "炸弹", + "question": "疑问", + "victory": "胜利", + "light": "灯光", + "red_envelope": "红包" + }, + "chat_details": { + "single": { + "empty_signature": "这个人很高冷,暂时没有留下什么", + "region": "地区:{place}", + "unknown": "未知", + "badge_label": "徽章:", + "call_only_single": "仅单聊支持发起音视频通话", + "friend_info_missing": "无法获取好友信息", + "footer": { + "audio_call": "打电话", + "video_call": "打视频" + } + }, + "group": { + "official_badge": "官方群聊认证", + "id": "群号 {account}", + "copy": "复制", + "copy_success": "复制成功 {account}", + "info_missing": "无法获取群聊信息", + "remark": { + "label": "群备注", + "placeholder": "请输入群聊备注", + "empty": "设置群聊备注", + "success": "群备注更新成功", + "fail": "群备注更新失败" + }, + "nickname": { + "label": "我的本群昵称", + "placeholder": "请输入本群昵称", + "empty": "未设置", + "success": "本群昵称更新成功", + "fail": "本群昵称更新失败" + }, + "announcement": { + "label": "群公告", + "empty": "未设置", + "window_title": "查看群公告" + }, + "members": { + "count": "群成员({count}人)", + "online": "在线 {count} 人" + } + }, + "actions": { + "message": "发信息" + }, + "window": { + "image_viewer": "图片查看" + } + }, + "chat_header": { + "bot_tag": "助手", + "status_abnormal": "好友状态异常", + "toolbar": { + "audio_call": "语音通话", + "video_call": "视频通话", + "screen_share": "屏幕共享", + "remote_assist": "远程协助", + "invite_to_group": "邀请进群", + "start_group_chat": "发起群聊" + }, + "sidebar": { + "single": { + "pin": "设为置顶", + "mute": "消息免打扰", + "shield": "屏蔽此人", + "delete_history": "删除聊天记录", + "delete_friend": "删除好友", + "report": "被骚扰了?举报该用户" + }, + "group": { + "name_placeholder": "请输入群名称(最多12字)", + "official_badge": "官方群聊认证", + "copy": "复制", + "qr": "二维码", + "members": "群成员", + "channel_members": "频道成员", + "my_name": "我本群的昵称", + "remark": "群备注", + "remark_desc": "(群备注仅自己可见)", + "settings": { + "title": "群设置", + "pin": "设为置顶", + "mute": "消息免打扰", + "scan": "允许扫码进群" + }, + "message_settings": { + "title": "群消息设置" + }, + "manage_members": "管理群成员", + "delete_history": "删除聊天记录", + "dissolve": "解散群聊", + "exit": "退出群聊", + "report": "被骚扰了?举报该群" + } + }, + "message_setting": { + "receive_no_alert": "接收消息但不提醒", + "shield": "屏蔽消息" + }, + "modal": { + "confirm": "确定", + "cancel": "取消", + "invite_friends": "邀请好友进群", + "tips": { + "delete_friend": "确定删除该好友吗?", + "dissolve_group": "确定解散该群聊?", + "exit_group": "确定退出该群聊?", + "rename_group": "确定将群名称修改为 {name} 吗?", + "update_info": "确定保存群信息修改吗?", + "delete_history": "确定后将删除本地聊天记录" + } + }, + "qr": { + "tip": "扫描二维码加入群聊", + "group_id_label": "群号:", + "actions": { + "forward": "转发", + "copy_group_id": "复制群号", + "save_image": "保存图片" + }, + "toast": { + "group_id_copied": "群号已复制", + "save_success": "图片已保存", + "save_failed": "保存失败" + } + }, + "toast": { + "copy_success": "复制成功 {account}", + "group_info_updated": "群聊信息已更新", + "group_info_update_failed": "群聊信息更新失败", + "todo": "暂未实现", + "pin_on": "已置顶", + "pin_off": "已取消置顶", + "pin_failed": "置顶失败", + "mute_on": "已设置接收消息但不提醒", + "mute_off": "已允许消息提醒", + "shield_on": "已屏蔽消息", + "shield_off": "已取消屏蔽", + "action_failed": "设置失败", + "group_name_empty": "群名称不能为空", + "group_name_too_long": "群名称不能超过12个字符", + "delete_history_success": "聊天记录已删除", + "delete_friend_success": "已删除好友", + "dissolve_not_allowed": "无法解散频道", + "dissolve_success": "已解散群聊", + "exit_not_allowed": "无法退出频道", + "exit_success": "已退出群聊", + "group_name_update_failed": "群名称更新失败" + } + }, + "manage_group_member": { + "title": "管理群成员", + "search_placeholder_mobile": "搜索成员~", + "search_placeholder_pc": "搜索群成员", + "selected_count": "已选择 {count} 人", + "remove_button": "踢出群聊", + "dialog_negative": "取消", + "dialog_positive": "确定", + "remove_confirm": "确定要踢出 {count} 位成员吗?", + "channel_not_allowed": "频道不允许踢出成员", + "select_member_warning": "请选择要踢出的成员", + "remove_success": "成功踢出 {count} 位成员", + "remove_failed": "踢出失败,请重试", + "confirm_remove_title": "确认踢出", + "channel_manage_unsupported": "频道不支持管理成员" + }, + "profile_card": { + "online_status": "在线状态", + "status": { + "online": "在线", + "offline": "离线" + }, + "labels": { + "account": "账号", + "location": "所在地", + "badges": "获得的徽章", + "activities": "动态" + }, + "location_unknown": "未知", + "tooltip": { + "copy_account": "复制账号", + "bound_gitee": "已绑定 Gitee", + "bound_github": "已绑定 GitHub", + "bound_gitcode": "已绑定 Gitcode" + }, + "buttons": { + "edit": "编辑资料", + "message": "发信息", + "add_friend": "加好友" + }, + "notification": { + "copy_success": "复制成功 {account}" + }, + "modal": { + "add_friend": "申请加好友" + }, + "developer_badge": "HuLa开发工程师" + }, + "profile_edit": { + "title": "编辑资料", + "avatar": { + "change": "更换头像", + "tips": "建议大小180 * 180像素,支持JPG、PNG、WEBP等格式,图片需小于500kb" + }, + "badge": { + "current": "当前佩戴的徽章:", + "wear": "佩戴" + }, + "form": { + "nickname": { + "label": "昵称", + "placeholder": "请输入你的昵称", + "remaining": "剩余改名次数: {count}" + } + }, + "actions": { + "save": "保存" + }, + "toast": { + "avatar_update_success": "头像更新成功" + } + }, + "chat_sidebar": { + "announcement": { + "title": "群公告须知", + "load_failed": "公告加载失败,请重试", + "default": "请不要把重要信息发到该群,网络不是法外之地,请遵守网络规范,否则直接删除。", + "window": { + "add": "新增群公告", + "view": "查看群公告" + } + }, + "actions": { + "retry": "重试" + }, + "online_members": "在线成员 {count}", + "search": { + "placeholder": "搜索" + }, + "roles": { + "owner": "群主", + "admin": "管理员" + } + }, + "about": { + "version": "版本:{version} ({arch})", + "device": "当前设备:{type}{version}", + "copyright": "Copyright © {start}-{end} HuLaSpark", + "rights": "All Rights Reserved." + }, + "apply_list": { + "friend_notice": "好友通知", + "group_notice": "群通知", + "you": "你", + "unknown_user": "未知用户", + "message_label": "留言:", + "handler_label": "处理人:{name}", + "accept": "接受", + "status": { + "accepted": "已同意", + "rejected": "已拒绝", + "ignored": "已忽略", + "rejected_by_other": "对方已拒绝", + "pending": "等待验证" + }, + "friend": { + "accepted_you": "已同意你的请求", + "pending": "正在验证你的邀请", + "request": "请求加为好友" + }, + "group": { + "loading": "加载中...", + "apply": "申请加入 [{group}]", + "invite": "邀请{name}加入 [{group}]", + "invite_confirmed": "已同意加入 [{group}]", + "invite_you": "邀请你加入 [{group}]", + "kicked": "已被{operator}踢出 [{group}]", + "set_admin": "已被群主设置为 [{group}] 的管理员", + "remove_admin": "已被群主取消 [{group}] 的管理员权限" + }, + "dropdown": { + "reject": "拒绝", + "ignore": "忽略" + }, + "empty_friend": "暂无好友申请", + "empty_group": "暂无群通知" + }, + "chat_main": { + "network_offline": "当前网络不可用,请检查你的网络设置", + "network_connecting": "正在连接服务器...", + "network_ws_offline": "服务器连接已断开,正在重试", + "copy": { + "empty": "没有可复制的内容", + "image_processing": "正在将 {format} 格式图片转换为 PNG 并复制...", + "image_success": "图片已复制到剪贴板", + "image_convert_success": "图片已转换为 PNG 并复制到剪贴板", + "selected_success": "选中文本已复制", + "message_success": "消息内容已复制", + "unknown_error": "复制内容失败,请稍后再试" + }, + "feature": { + "coming_soon": "功能暂开发" + }, + "translate": { + "empty": "没有可翻译的内容" + }, + "file": { + "missing_local": "暂时找不到本地文件,请先下载后再试~", + "show_failed": "无法在文件夹中显示该文件", + "download_prompt": "文件还未下载,请先下载后再试~", + "download_success": "文件下载完成,请查看~", + "download_failed": "文件下载失败,请重试", + "save_success": "文件已保存到本地", + "save_failed": "文件保存失败,请重试" + }, + "image": { + "fetch_failed": "获取图片地址失败", + "locate_failed": "暂时无法定位该图片~", + "download_prompt": "图片未下载,正在保存到本地...", + "save_success": "图片已保存到本地", + "save_failed": "保存图片失败", + "download_failed": "图片下载失败,请重试~" + }, + "delete": { + "invalid_session": "无法确定消息所属的会话", + "confirm": "删除后将不会出现在你的消息记录中,确定删除吗?", + "success": "消息已删除" + }, + "announcement": { + "view_all": "查看全部" + }, + "no_more": "没有更多消息", + "new_messages": "{count} 条新消息", + "confirm": "确定", + "cancel": "取消", + "group_nickname": { + "title": "修改群昵称", + "placeholder": "请输入群昵称", + "error": { + "empty": "群昵称不能为空", + "invalid_room": "当前群聊信息异常" + } + }, + "toast": { + "searching": "正在查找消息...", + "not_found": "加载历史消息失败,请稍后重试", + "multi_select_mobile": "移动端暂不支持消息多选", + "disbanded_group": "该群聊已解散,无法执行此操作" + } + }, + "friends_list": { + "notice": { + "friend": "好友通知", + "group": "群通知" + }, + "tabs": { + "friend": "好友", + "group": "群聊" + }, + "collapse": { + "friend": "我的好友", + "group": "我的群聊" + }, + "bot_tag": "助手", + "status": { + "online": "在线", + "offline": "离线" + }, + "menu": { + "add_group": "添加分组", + "rename_group": "重命名该组", + "delete_group": "删除分组" + } + }, + "create_group": { + "title": "创建群聊", + "action": "创建", + "success": "创建群聊成功", + "fail": "创建群聊失败", + "required_tag": "必选" + }, + "file_drop": { + "title": "松开鼠标即可发送文件", + "desc": "支持拖拽多张图片或文件,当前会话将收到这些文件" + } +} diff --git a/locales/zh-CN/login.json b/locales/zh-CN/login.json new file mode 100644 index 0000000..66cb8d9 --- /dev/null +++ b/locales/zh-CN/login.json @@ -0,0 +1,193 @@ +{ + "button": { + "login": { + "default": "登录", + "network_error": "网络异常" + }, + "qr_code": "扫码登录", + "cancel_login": "取消登录", + "remove_account": "移除账号" + }, + "input": { + "account": { + "placeholder": "邮箱/HuLa账号" + }, + "pass": { + "placeholder": "输入 HuLa 密码" + } + }, + "option": { + "more": "更多选项", + "items": { + "forget": "忘记密码", + "network_setting": "网络设置" + } + }, + "term": { + "checkout": { + "text1": "已阅读并同意", + "text2": "服务协议", + "text3": "和", + "text4": "HuLa隐私保护指引" + } + }, + "auth_way": { + "acc_pss": "账密登录" + }, + "register": "注册账号", + "third_party": { + "title": "第三方登录/注册", + "gitee": "使用 Gitee 登录", + "github": "使用 GitHub 登录", + "gitcode": "使用 GitCode 登录" + }, + "qr_code_expired": "二维码已过期", + "qr_code_expired_hint": "请稍后再试", + "qr_code_scan_hint": "请使用 HuLa App 扫码登录", + "qr_code_gen_fail_hint": "生成二维码失败", + "qr_code_scan_success_hint": "扫码成功,等待授权", + "wait_auth_hint": "等待授权...", + "login_success": "登录成功", + "fetch_user_fail": "获取用户信息失败", + "refreshing": "刷新中...", + "loading": "加载中...", + "scan_qr_code_fail": "扫码失败", + "status": { + "logging_in": "登录中...", + "success_redirect": "登录成功,正在跳转...", + "service_disconnected": "服务异常断开" + }, + "qr": { + "overlay": { + "success": "登录成功", + "error": "扫码失败", + "auth": "扫码成功,等待授权", + "expired": "二维码已过期", + "fetch_failed": "登录成功,但获取用户信息失败", + "generate_fail": "生成二维码失败", + "general_error": "发生错误", + "refreshing": "刷新中..." + }, + "load_text": { + "loading": "加载中...", + "refreshing": "刷新中...", + "scan_hint": "请使用 HuLa App 扫码登录", + "login": "登录中...", + "retry": "请稍后再试", + "auth_pending": "等待授权..." + }, + "actions": { + "account_login": "账密登录", + "register": "注册账号", + "register_title": "注册" + } + }, + "guide": { + "welcome": { + "title": "🎉 欢迎使用 HuLa", + "desc": "HuLa 基于 Tauri 构建,支持 Windows、macOS、Linux、iOS、Android" + }, + "privacy": { + "title": "🤔 隐私条款与服务协议", + "desc": "请先阅读 HuLa 的隐私条款与服务协议" + }, + "network": { + "title": "⚙️ 网络设置", + "desc": "HuLa 支持自定义服务地址,可替换官方入口" + }, + "register": { + "title": "🤓 如何登录 HuLa", + "desc": "使用前请先注册并完善账号信息" + }, + "actions": { + "next": "下一步", + "prev": "上一步", + "done": "完成", + "progress": "{current}/{total}" + } + }, + "network": { + "title": "网络设置", + "tabs": { + "api": "API", + "ws": "WebSocket" + }, + "fields": { + "type": "类型", + "host": "IP或域名", + "port": "端口", + "suffix": "后缀" + }, + "placeholder": { + "host": "127.0.0.1或hulaspark.com", + "port": "443", + "api_suffix": "api", + "ws_suffix": "websocket" + }, + "api": { + "options": { + "none": "不使用(默认为官方地址)", + "http": "HTTP", + "https": "HTTPS" + } + }, + "ws": { + "options": { + "none": "不使用(默认为官方地址)", + "ws": "WS", + "wss": "WSS" + } + }, + "actions": { + "save": "保存", + "back": "返回" + }, + "messages": { + "incomplete": "请填写完整的网络设置信息", + "save_success": "网络设置保存成功", + "save_failed": "网络设置失败:{error}" + } + }, + "mobile": { + "welcome_title": "HI, 欢迎", + "tabs": { + "login": "登录", + "register": "注册" + }, + "forget_code": "忘记密码", + "btn": { + "login": "登录" + }, + "input": { + "account_placeholder": "账号", + "code_placeholder": "密码" + }, + "register": { + "input": { + "nickname": "昵称", + "password": "密码", + "confirm_password": "确认密码", + "email": "邮箱", + "email_verification_code": "邮箱验证吗" + }, + "btn": { + "register": "注册", + "next": "下一步", + "send_email_code": "发送验证码", + "resend_in": "{seconds}秒后重试" + }, + "pass_validate_info": { + "minlength": "至少 {len} 个字符", + "valid_characters": "由英语和数字组成", + "must_special_char": "必须有一个特殊字符" + } + } + }, + "email_invalid": "无效的邮箱地址。", + "code_sent_email": "验证码已发送,请查收邮箱", + "code_send_failed_with_reason": "验证码发送失败: {reason}", + "code_send_failed_retry": "验证码发送失败,请稍后再试", + "complete_info_before_register": "请完善信息后再注册", + "register_success": "注册成功", + "register_fail": "注册失败" +} diff --git a/locales/zh-CN/menu.json b/locales/zh-CN/menu.json new file mode 100644 index 0000000..8d4d21e --- /dev/null +++ b/locales/zh-CN/menu.json @@ -0,0 +1,55 @@ +{ + "check_update": "检查更新", + "lock_screen": "锁定屏幕", + "settings": "设置", + "about": "关于", + "sign_out": "退出登录", + "select": "选择", + "add_sticker": "添加表情", + "forward": "转发", + "reply": "回复", + "recall": "撤回", + "copy": "复制", + "save_as": "另存为", + "show_in_finder": "在 Finder 中显示", + "show_in_folder": "在文件夹中显示", + "translate": "翻译", + "del": "删除", + "preview": "预览", + "send_message": "发送消息", + "get_user_info": "查看信息", + "modify_group_nickname": "修改群昵称", + "add_friend": "添加好友", + "report": "举报", + "ctx_menu_more": "更多", + "set_admin": "设为管理员", + "set_admin_success": "设置管理员成功", + "set_admin_fail": "设置管理员失败", + "revoke_admin": "撤销管理员", + "revoke_admin_success": "撤销管理员成功", + "revoke_admin_fail": "撤销管理员失败", + "remove_from_group": "移出群聊", + "remove_from_group_success": "移出群聊成功", + "remove_from_group_fail": "移出群聊失败", + "pin": "置顶", + "unpin": "取消置顶", + "copy_account": "复制账号", + "mark_unread": "标记未读", + "group_message_setting": "群消息设置", + "set_do_not_disturb": "设置免打扰", + "unset_do_not_disturb": "取消免打扰", + "allow_notifications": "允许消息提醒", + "receive_silently": "接收消息但不提醒", + "block_group_messages": "屏蔽群消息", + "unblock_group_messages": "取消屏蔽群消息", + "block_user_messages": "屏蔽此人消息", + "unblock_user_messages": "取消屏蔽消息", + "remove_from_list": "删除会话", + "delete_friend": "删除好友", + "dissolve_group": "解散群聊", + "leave_group": "退出群聊", + "today": "今天", + "yesterday": "昨天", + "start_group_chat": "发起群聊", + "add_contact": "添加联系人" +} diff --git a/locales/zh-CN/message.json b/locales/zh-CN/message.json new file mode 100644 index 0000000..888dba1 --- /dev/null +++ b/locales/zh-CN/message.json @@ -0,0 +1,277 @@ +{ + "file": { + "unknown_file": "未知文件", + "unknown_error": "未知错误", + "status": { + "downloaded": "已下载", + "not_downloaded": "未下载" + }, + "toast": { + "missing_local": "暂时找不到本地文件,请先下载后再试", + "reveal_fail": "无法在文件夹中显示该文件", + "open_fail_hint": "无法打开或显示文件。请手动在文件管理器中找到并打开文件。", + "download_open_fail": "文件下载成功,但无法打开或显示文件。请手动在文件管理器中查找下载的文件。", + "download_failed": "下载文件失败: {reason}" + } + }, + "video": { + "unknown_video": "视频文件", + "uploading": "正在上传视频... {progress}%", + "opening": "正在打开视频..." + }, + "file_upload": { + "title": "发送文件", + "cancel": "取消", + "send": "发送 ({count})" + }, + "file_upload_progress": { + "uploading_with_name": "正在上传: {name}", + "uploading": "正在上传文件", + "preparing": "准备上传", + "completed_failed": "上传完成 ({count} 个失败)", + "completed": "上传完成" + }, + "voice_recorder": { + "tap_prefix": "点击", + "tap_suffix": "说话", + "recording": "正在录音", + "processing": "正在处理音频", + "error": "录音失败" + }, + "call_window": { + "incoming": "来电", + "video_call": "视频通话", + "voice_call": "语音通话", + "unknown_user": "未知用户", + "status": { + "calling": "正在呼叫", + "ongoing": "通话中", + "ended": "通话已结束", + "preparing": "准备中" + }, + "mic_on": "麦克风已开", + "mic_off": "麦克风已关", + "speaker_on": "扬声器已开", + "speaker_off": "扬声器已关", + "camera_disable": "关闭摄像头", + "camera_enable": "开启摄像头", + "hangup": "挂断" + }, + "permission": { + "mic": { + "title": "麦克风权限", + "denied": "麦克风权限被拒绝,请在系统设置中开启后重试", + "unavailable": "未检测到麦克风设备" + }, + "camera": { + "title": "摄像头权限", + "denied": "摄像头权限被拒绝,请在系统设置中开启后重试", + "unavailable": "未检测到摄像头设备" + }, + "open_settings": "打开系统设置", + "retry": "重试", + "hangup": "挂断", + "requesting": "正在请求设备权限..." + }, + "friend_verify": { + "title": "申请加好友", + "account": "账号: {account}", + "placeholder": "输入几句话,对TA说些什么吧", + "send_btn": "添加好友", + "toast_success": "已发送好友申请", + "default_msg": "我是{name}" + }, + "group_verify": { + "title": "申请加群", + "account": "群号: {account}", + "placeholder": "输入验证消息", + "send_btn": "申请加入", + "toast_success": "已发送群聊申请", + "default_msg": "我是{name}" + }, + "message_list": { + "sync_loading": "正在同步消息", + "official_popover": "官方群聊认证", + "bot_popover": "HuLa助手", + "mention_tag": "[有人@我]", + "shield_group": "您已屏蔽群聊", + "shield_user": "您已屏蔽此人", + "empty_description": "快和朋友聊天吧!", + "empty_action": "寻找好友", + "default_last_msg": "欢迎使用HuLa" + }, + "message_menu": { + "pin_success": "已置顶", + "unpin_success": "已取消置顶", + "pin_fail": "置顶失败", + "unpin_fail": "取消置顶失败", + "copy_success": "复制成功 {account}", + "shield_success": "已屏蔽消息", + "unshield_success": "已取消屏蔽", + "delete_friend_success": "已删除好友", + "cannot_dissolve_channel": "无法解散频道", + "cannot_quit_channel": "无法退出频道", + "dissolve_group_success": "已解散群聊", + "quit_group_success": "已退出群聊", + "notification_allowed": "已允许消息提醒", + "notification_silent": "已设置接收消息但不提醒" + }, + "image_viewer": { + "zoom_out": "缩小", + "zoom_in": "放大", + "rotate_left": "向左旋转", + "rotate_right": "向右旋转", + "reset": "重置", + "save_as": "另存为", + "first_image": "这是第一张图片", + "last_image": "已经最后一张图片", + "filter_name": "图片" + }, + "video_viewer": { + "previous_video": "上一个视频", + "next_video": "下一个视频", + "play": "播放", + "pause": "暂停", + "mute": "静音", + "unmute": "取消静音", + "tip_playing_index": "正在播放第 {index} 个视频", + "tip_playing_previous": "正在播放上一个视频", + "tip_first": "已经是第一个视频了", + "tip_playing_next": "正在播放下一个视频", + "tip_last": "已经是最后一个视频了" + }, + "screenshot": { + "border_radius": "圆角", + "redo": "重做", + "undo": "撤回", + "confirm": "确定", + "cancel": "取消", + "tool_rect": "矩形", + "tool_circle": "圆形", + "tool_arrow": "箭头", + "tool_mosaic": "马赛克", + "tooltip_drag": "拖动调整选区", + "tooltip_resize": "拖拽控制点调整大小", + "save_success": "截图已复制到剪贴板", + "save_failed": "截图失败,请稍后重试" + }, + "lock_screen": { + "title": "锁定屏幕", + "password_label": "锁屏密码", + "password_placeholder": "请输入锁屏密码", + "confirm_button": "确定", + "validation_required": "请输入锁屏密码", + "tip_description": "这是一个开源的即时通讯(IM)应用,它采用了 Tauri、Vue3、Vite5、UnoCSS 和 TypeScript 等前沿技术。", + "enter_system_tooltip": "进入系统", + "unlocking": "解锁中", + "wrong_password": "密码不正确, 请再试一次", + "toast_empty_password": "请输入密码", + "return_action": "返回", + "logout_action": "退出登录", + "forgot_password": "忘记密码", + "enter_system_action": "进入系统" + }, + "check_update": { + "current_version": "当前版本", + "new_tag": "new", + "new_release_date": "新版本发布日期:", + "release_date": "版本发布日期:", + "log_title": "版本更新日志", + "collapse": "收起", + "expand": "展开", + "ignore": "忽略更新", + "update_now": "立即更新", + "check_now": "检查更新", + "downloading": "正在下载...", + "update_success": "更新成功", + "confirm_update": "确定更新吗?", + "confirm_ignore": "忽略更新不影响登录后手动更新,后续将不再弹出此次更新提醒,确定忽略更新吗?", + "update_error": "检查更新错误,请稍后再试", + "fetch_log_failed": "获取更新日志失败,请配置token后再试", + "download_success_toast": "安装包下载成功,稍后将自动安装并重启", + "restart_failed": "重启失败,请手动重启" + }, + "notify": { + "new_messages": "新消息", + "ignore_all": "忽略全部" + }, + "tray": { + "online_status_window_title": "在线状态", + "more_status": "更多状态", + "mute_all": "关闭所有声音", + "unmute_all": "打开所有声音", + "open_main_panel": "打开主面板", + "exit": "退出" + }, + "location": { + "modal": { + "title": { + "map_error": "地图错误", + "location_error": "位置获取失败", + "default": "选择位置" + }, + "result": { + "map_error_title": "地图加载失败", + "location_error_title": "位置获取失败" + }, + "buttons": { + "cancel": "取消", + "retry": "重试", + "send": "发送位置" + }, + "loading": { + "locating": "正在获取位置...", + "map": "地图加载中..." + }, + "info": { + "current": "当前位置", + "fetching_address": "获取地址中...", + "coordinate": "坐标: {lat}, {lng}", + "unknown_address": "未知地址" + }, + "errors": { + "missing_api_key": "腾讯地图API密钥未配置", + "geocode_failed": "获取地址失败" + } + }, + "map": { + "marker_current": "当前位置" + }, + "hook": { + "permission_check_failed": "检查地理位置权限失败", + "unsupported": "浏览器不支持地理位置功能", + "error_generic": "获取位置失败", + "permission_denied": "位置权限被拒绝", + "position_unavailable": "位置信息不可用", + "timeout": "获取位置超时" + } + }, + "update_window": { + "updating": "更新中", + "fetch_commit_failed": "v{version} 版本更新内容获取失败", + "fetch_release_error": "获取版本更新信息失败", + "fetch_release_error_with_reason": "获取版本更新信息失败:{reason}" + }, + "multi_choose": { + "search_placeholder": "搜索会话", + "send_to_separately": "分别发送给", + "leave_message_placeholder": "给朋友留言", + "single_forward": "逐条转发", + "merge_forward": "合并转发", + "save_to_pc": "保存至电脑", + "delete_action": "删除", + "exit_multi_select": "退出多选", + "cancel_button": "取消", + "send_button": "发送", + "not_implemented": "暂未实现", + "select_delete_prompt": "请选择需要删除的消息", + "delete_confirm": "删除后将不会出现在你的消息记录中,确定删除这{count}条消息吗?", + "room_missing": "无法确定当前会话,删除失败", + "delete_failed_short": "删除消息失败", + "delete_failed_retry": "删除消息失败,请稍后再试", + "delete_success": "已删除选中消息", + "forward_success": "消息转发成功", + "forward_failed": "消息转发失败", + "non_text_message": "[非文本消息]" + } +} diff --git a/locales/zh-CN/mobile_chat_setting.json b/locales/zh-CN/mobile_chat_setting.json new file mode 100644 index 0000000..55d3722 --- /dev/null +++ b/locales/zh-CN/mobile_chat_setting.json @@ -0,0 +1,61 @@ +{ + "title": "{t} 设置", + "type": { + "group": "群聊", + "single_chat": "聊天" + }, + "group_members_title": "群成员", + "member_count": "{count} 位成员", + "group_invite_member": "邀请成员", + "manage_group_members": "管理群成员", + "search_history": "查找聊天记录", + "id_card": { + "type": { + "group": "群", + "single_chat": "聊天" + }, + "qr_code_label": "{t}号 / 二维码" + }, + "group_notice": { + "title": "群公告" + }, + "group_name": "群名称", + "input": { + "group_name": "请输入群名称", + "group_alias": "请输入群内昵称", + "remark": "该名称仅自己可见" + }, + "group_alias": "我在群里的昵称", + "remark": "备注", + "remar_kprivate_visible": "(仅自己可见)", + "setting_type": "{t} 设置", + "silent": "消息免打扰", + "pintop": "置顶", + "delete_chat_history": "删除聊天记录", + "disband_group": "解散群聊", + "leave_group": "退出群聊", + "delete_friend": "删除好友", + "copy_id": "复制 {id} 成功", + "confirm_disband_group": "确定要解散群聊吗?", + "confirm_leave_group": "确定要退出群聊吗?", + "confirm_delete_friend": "确定要删除好友吗?", + "session_not_exist": "当前会话不存在", + "disband_channel_failed": "无法解散频道", + "group_disbanded": "群聊已解散", + "leave_channel_failed": "无法退出频道", + "group_left": "已退出群聊", + "get_friend_info_failed": "无法获取好友信息", + "delete_friend_success": "删除好友成功", + "session_not_ready": "当前会话信息未就绪", + "unpinned_success": "已取消置顶", + "pinned_success": "已置顶", + "pin_failed": "置顶失败", + "group_note_updated": "群备注更新成功", + "remark_updated": "{n} 备注更新成功", + "group_name_updated": "群名称更新成功", + "setting_failed": "设置失败", + "messages_muted": "已屏蔽消息", + "messages_unmuted": "已取消屏蔽", + "notifications_silent": "已设置接收消息但不提醒", + "notifications_enabled": "已允许消息提醒" +} diff --git a/locales/zh-CN/mobile_contact.json b/locales/zh-CN/mobile_contact.json new file mode 100644 index 0000000..af027fa --- /dev/null +++ b/locales/zh-CN/mobile_contact.json @@ -0,0 +1,17 @@ +{ + "title": "联系人", + "input": { + "search": "搜索" + }, + "my_chat": "我的消息", + "tab": { + "contacts": "好友", + "group": "群聊" + }, + "friend": { + "title": "我的好友" + }, + "group": { + "title": "我的群聊" + } +} diff --git a/locales/zh-CN/mobile_edit_bio.json b/locales/zh-CN/mobile_edit_bio.json new file mode 100644 index 0000000..b67f5e4 --- /dev/null +++ b/locales/zh-CN/mobile_edit_bio.json @@ -0,0 +1,5 @@ +{ + "title": "编辑签名", + "placeholder": "介绍一下你自己~", + "save_btn": "保存" +} diff --git a/locales/zh-CN/mobile_edit_brithday.json b/locales/zh-CN/mobile_edit_brithday.json new file mode 100644 index 0000000..73b34a1 --- /dev/null +++ b/locales/zh-CN/mobile_edit_brithday.json @@ -0,0 +1,9 @@ +{ + "title": "编辑生日", + "options": { + "display_birthday_tag": "显示生日标签", + "displsy_age": "显示年龄", + "display_constellation": "显示星座" + }, + "save_btn": "保存" +} diff --git a/locales/zh-CN/mobile_edit_profile.json b/locales/zh-CN/mobile_edit_profile.json new file mode 100644 index 0000000..19b52cf --- /dev/null +++ b/locales/zh-CN/mobile_edit_profile.json @@ -0,0 +1,23 @@ +{ + "title": "个人资料", + "nickname": "昵称", + "gender": "性别", + "brithday": "生日", + "region": "地区", + "phone": "手机号", + "bio": "个人简介", + "placeholder": { + "nickname": "请输入昵称", + "gender": "点击选择性别", + "brithday": "请选择生日", + "region": "点击选择地区", + "phone": "请输入手机号", + "bio": "请输入个人简介" + }, + "save_btn": "保存", + "change_avatar": "更换头像", + "genders": { + "male": "男", + "female": "女" + } +} diff --git a/locales/zh-CN/mobile_forget_code.json b/locales/zh-CN/mobile_forget_code.json new file mode 100644 index 0000000..4f28435 --- /dev/null +++ b/locales/zh-CN/mobile_forget_code.json @@ -0,0 +1,49 @@ +{ + "title": "忘记密码", + "steps": { + "verify_email": "验证邮箱", + "set_new_password": "设置密码", + "done": "完成" + }, + "input": { + "label": { + "email": "邮箱", + "email_verification_code": "验证码", + "new_pass": "新密码", + "confirm_password": "确认密码" + }, + "email": "邮箱", + "email_code": "验证码", + "new_pass": "请输入新密码({len} 个字符)", + "confirm_password": "请再次输入密码" + }, + "validation": { + "minlength": "至少 {len} 个字符", + "valid_characters": "由英文字母和数字组成", + "must_special_char": "必须包含特殊字符", + "passwords_match": "密码一致" + }, + "button": { + "send_email_code": "发送验证码", + "next": "下一步", + "go_back_setp": "返回", + "submit": "提交" + }, + "password_reset_success": "密码重置成功", + "password_reset_success_desc": "您现在可以使用新密码登录", + "rules": { + "email_require": "请输入邮箱地址", + "email_invalid": "请输入有效的邮箱地址", + "email_code_require": "请输入发送到邮箱的验证码", + "code_length": "验证码必须为 {len} 位", + + "new_pass_require": "请输入新密码", + "new_pass_length": "密码必须为 {len} 个字符", + + "confirm_pass_require": "请确认密码", + "pass_not_match": "两次输入的密码不一致" + }, + "too_many_requests": "请求过于频繁,请在 {s} 秒后再试", + "code_sent_email": "验证码已发送至您的邮箱", + "email_resend_in": "{seconds}秒后重新获取" +} diff --git a/locales/zh-CN/mobile_home.json b/locales/zh-CN/mobile_home.json new file mode 100644 index 0000000..dc9e5a7 --- /dev/null +++ b/locales/zh-CN/mobile_home.json @@ -0,0 +1,27 @@ +{ + "menu": { + "start_group_chat": "发起群聊", + "add_contact": "添加联系人", + "pintop": "置顶", + "unpin": "取消置顶", + "read": "已读", + "unread": "未读", + "delete": "删除" + }, + "input": { + "search": "搜索" + }, + "noname": "无名", + "china": "中国", + "chat": { + "unpin": "取消置顶", + "pintop": "置顶", + "mark_as_read": "已读", + "mark_as_unread": "未读", + "delete": "删除" + }, + "marked_as_read": "已标记为已读", + "marked_as_unread": "已标记为未读", + "mark_as_read_failed": "标记已读失败", + "mark_as_unread_failed": "标记未读失败" +} diff --git a/locales/zh-CN/mobile_my.json b/locales/zh-CN/mobile_my.json new file mode 100644 index 0000000..97afd7f --- /dev/null +++ b/locales/zh-CN/mobile_my.json @@ -0,0 +1,10 @@ +{ + "photos": "相册", + "favorites": "收藏", + "files": "文件", + "appearance": "个性装扮", + "intelligent": "AI助手", + "default_bio": "用户很懒没写简介~", + + "online": "在线" +} diff --git a/locales/zh-CN/mobile_mymessage.json b/locales/zh-CN/mobile_mymessage.json new file mode 100644 index 0000000..57d433c --- /dev/null +++ b/locales/zh-CN/mobile_mymessage.json @@ -0,0 +1,41 @@ +{ + "title": "消息", + "accept": "同意", + "approved": "已同意", + "refused": "已拒绝", + "agreed": "已同意", + "declined": "已拒绝", + "pending": "等待验证", + "ignored": "已忽略", + "notification": { + "friend": "好友通知", + "group": "群通知" + }, + "friend_request_message": "留言:", + "unknown_user": "未知用户", + "empty_require": "暂无好友请求", + "empty_group_require": "暂无群申请通知", + "menu": { + "decline": "拒绝", + "ignore": "忽略" + }, + "friend_request_status": { + "accepted": "你的请求已被同意", + "verifying": "正在验证你的邀请", + "sent": "已发送好友请求" + }, + "loading": "加载中{tail}", + "group": { + "apply_to_join": "申请加入 [{name}]", + "invited_to_join": "{inviter} 邀请你加入 [{group}]", + "joined_group": "已加入 [{group}]", + "invited_curr_to_join": "邀请你加入 [{group}]", + "kicked_out": "已被 {operator} 踢出 [{group}]", + "set_as_admin": "已被群主设置为 [{group}] 的管理员", + "removed_as_admin": "已被群主取消 [{group}] 的管理员权限" + }, + "tab": { + "friend_messages": "好友消息", + "group_messages": "群聊消息" + } +} diff --git a/locales/zh-CN/mobile_personal_info.json b/locales/zh-CN/mobile_personal_info.json new file mode 100644 index 0000000..fe75032 --- /dev/null +++ b/locales/zh-CN/mobile_personal_info.json @@ -0,0 +1,21 @@ +{ + "account": "ID", + "unlocked_count_park": { + "text1": "已点亮", + "text2": "枚勋章" + }, + "no_medal": "还没有勋章~", + "follower": "粉丝", + "follow": "关注", + "like": "点赞", + "edit_profile": "编辑资料", + "remove_user": "移除", + "add_friend": "添加好友", + "open_bot": "打开助手", + "chat": "聊天", + "delete_user": { + "success": "删除好友成功", + "failed": "删除好友失败" + }, + "not_found": "没有找到好友哦" +} diff --git a/locales/zh-CN/mobile_personal_info_qr.json b/locales/zh-CN/mobile_personal_info_qr.json new file mode 100644 index 0000000..7a2e262 --- /dev/null +++ b/locales/zh-CN/mobile_personal_info_qr.json @@ -0,0 +1,5 @@ +{ + "account": "ID", + "scan_to_add": "扫描我添加为好友哦~", + "title": "个人二维码" +} diff --git a/locales/zh-CN/mobile_photo.json b/locales/zh-CN/mobile_photo.json new file mode 100644 index 0000000..916b43a --- /dev/null +++ b/locales/zh-CN/mobile_photo.json @@ -0,0 +1,6 @@ +{ + "title": "相册", + "empty": "还没有图片哦", + "image_alt": "相册图片", + "image_load_failed": "图片加载失败" +} diff --git a/locales/zh-CN/mobile_post.json b/locales/zh-CN/mobile_post.json new file mode 100644 index 0000000..4e1f3a4 --- /dev/null +++ b/locales/zh-CN/mobile_post.json @@ -0,0 +1,47 @@ +{ + "title": "发布动态", + "content": { + "label": "动态内容", + "placeholder": "尽情分享生活吧~😎" + }, + "media_type": { + "label": "媒体类型", + "option_image": "图片(暂不可用)", + "option_video": "视频(暂不可用)" + }, + "visibility": { + "label": "可见范围", + "public": "公开", + "selected": "指定可见", + "exclude": "不给谁看", + "emptySelectionHint": { + "visible": "请选择可见的用户", + "hidden": "请选择不可见的用户" + } + }, + "visibility_selected_btn_label": "选择可见的人", + "visibility_exclude_btn_label": "不给谁看", + "visibility_select_btn": "选择用户(已选择 {count} 人)", + "btn": { + "cancel": "取消", + "publish": "发布" + }, + "select_users": { + "title": "选择用户", + "search_placeholder": "搜索用户", + "btn": { + "done": "完成" + } + }, + "empty": "暂无联系人", + "error": { + "publish_failed": "发布失败,请重试", + "required": "请输入动态内容", + "select_visible_users": "请选择可见的用户", + "select_exclude_users": "请选择不可见的用户" + }, + "success": { + "publish_success": "发布成功" + }, + "unknown_user": "未知用户" +} diff --git a/locales/zh-CN/mobile_setting.json b/locales/zh-CN/mobile_setting.json new file mode 100644 index 0000000..e8bc69c --- /dev/null +++ b/locales/zh-CN/mobile_setting.json @@ -0,0 +1,14 @@ +{ + "title": "设置", + "silent_label": "消息通知", + "nickname": "昵称", + "theme": "主题", + "language": "语言", + "button": { + "logout": "退出登录" + }, + "themes": { + "dark": "深色", + "light": "亮色" + } +} diff --git a/locales/zh-CN/mobile_tabbar.json b/locales/zh-CN/mobile_tabbar.json new file mode 100644 index 0000000..9ee1a6b --- /dev/null +++ b/locales/zh-CN/mobile_tabbar.json @@ -0,0 +1,8 @@ +{ + "items": { + "messages": "消息", + "contacts": "联系人", + "community": "社区", + "me": "我的" + } +} diff --git a/locales/zh-CN/setting.json b/locales/zh-CN/setting.json new file mode 100644 index 0000000..73688f6 --- /dev/null +++ b/locales/zh-CN/setting.json @@ -0,0 +1,162 @@ +{ + "common": { + "provider_label": "提供者", + "provider_name": "HuLa", + "tag_new": "新" + }, + "footer": { + "like_product": "喜欢这款产品? 在", + "star_cta": "GitHub 给我们加星", + "share_joiner": "并", + "share_cta": "分享您宝贵的建议", + "star_popover_title": "在 GitHub 为我们点亮星标", + "star_popover_desc": "如果您喜爱我们的产品,并希望支持我们,可以去 GitHub 给我们点一颗星吗?这个小小的动作对我们来说意义重大,能激励我们为您持续提供特性体验。", + "later": "稍后", + "star_button": "点亮星标", + "issue_popover_title": "在 GitHub 分享您宝贵的反馈", + "issue_popover_desc": "您的每一个想法和建议对我们来说都弥足珍贵,我们迫不及待地想知道您的看法!欢迎联系我们提供产品功能和使用体验反馈。", + "issue_button": "提出建议" + }, + "general": { + "title": "通用", + "appearance": { + "title": "外观", + "theme": { + "auto": "跟随系统", + "light": "浅色", + "dark": "深色" + } + }, + "chat": { + "title": "聊天", + "translate_service": "翻译服务", + "translate_options": { + "tencent": "腾讯云翻译", + "youdao": "有道云翻译" + } + }, + "ui": { + "title": "界面", + "language": "语言", + "blur": "高斯模糊", + "shadow": "启用阴影", + "font": "字体", + "menu_name": "菜单名称", + "font_options": { + "PingFang": "苹方", + "AliFangYuan": "阿里妈妈方圆体" + } + }, + "system": { + "title": "系统", + "close_panel": "关闭面板", + "close_options": { + "minimize_to_tray": "最小化到托盘", + "exit_program": "直接退出" + }, + "close_prompt": "关闭提示", + "esc_close_window": "ESC关闭窗口" + } + }, + "login": { + "title": "登录", + "auto_login_startup": "启动时自动登录", + "launch_startup": "开机自启动" + }, + "notice": { + "title": "通知", + "sound": "声音", + "message_sound": "新消息提醒声音", + "message_sound_descript": "开启后,当收到新消息会播放提示音。", + "message_volume": "提示音音量", + "message_volume_descript": "拖动滑块可以调节提示音音量,范围 0-100。", + "group_setting": "群设置", + "select_all": "全选", + "group_notic_type": { + "allow": "允许", + "silent": "免打扰", + "block": "屏蔽" + }, + "input": { + "search_group_placholder": "搜索群聊" + }, + "batch_set": "批量设置", + "message_select_group_first": "请先选择要设置的群聊。", + "unknow_group": "未知群聊", + "group_chat_not_found": "未找到群聊信息。", + "setup_fail": "设置失败", + "message_group_batch_setup_success": "批量设置完成:{count} 个群聊已设置为 {type}", + "message_group_batch_update_result": "批量更新完成:{success_count} 个成功,{fail_count} 个失败", + "message_reminder_allowed": "消息提醒已允许", + "message_reminder_silent": "已设置接收消息但不提醒", + "message_ring": "响铃", + "message_unblocked": "已取消屏蔽" + }, + "shortcut": { + "title": "快捷键", + "global_shortcut_title": "全局", + "enable_global_shortcuts": "启用全局快捷键", + "enable_global_shortcuts_hint": "关闭后,下方所有快捷键将失效。", + "function_shortcut_title": "功能", + "message_title": "Message", + "screenshot": "截图", + "screenshot_hint": "按快捷键进行截图", + "panel_switch": "主面板切换", + "panel_switch_hint": "按快捷键切换主面板", + "message_shortcut": "消息", + "send_message_shortcut": "发送消息", + "send_message_shortcut_hint": "按快捷键发送消息", + "send_message_shortcut_option": "按 {key} 键发送消息", + "unbound": "未绑定", + "bound": "已绑定", + "reset": "重置", + "shortcut_update_result": "{name} 已更新", + "shortcut_setting_failed": "{name} 设置失败", + "global_enable": "全局快捷键已开启", + "global_disable": "全局快捷键已关闭", + "global_toggle_failed": "全局快捷键开关设置失败", + "send_message_updated": "发送消息快捷键已更新", + "send_message_failed": "发送消息快捷键设置失败" + }, + "storage": { + "title": "存储", + "file_scan_progress": "扫描文件中", + "usage": "扫描文件占比", + "used_space": "已用空间", + "app_used_space": "HuLa 数据", + "free_space": "可用空间", + "directory": "目录", + "curr_dir": "当前目录", + "current_file": "当前文件", + "scanning_progress": "扫描进度", + "processed_files": "已处理文件", + "processed_files_unit": "{count} 个", + "total_size": "累计大小", + "mount_point": "挂载点", + "disk_total": "磁盘分区总容量", + "disk_used": "磁盘分区已用空间", + "disk_usage_percent": "磁盘已用空间占比", + "elapsed": "耗时", + "elapsed_unit": "{seconds} 秒", + "path_type": "扫描目录类型", + "path_type_default": "默认目录", + "path_type_custom": "自定义目录", + "fetching_directory": "正在获取目录路径...", + "select_directory": "选择目录", + "start_scan": "开始扫描", + "scanning": "扫描中...", + "select_directory_title": "选择要扫描的目录", + "select_directory_failed": "选择目录失败", + "select_directory_error": "选择目录失败" + }, + "theme": { + "title": "主题", + "restore_default": "恢复默认", + "versatile": { + "title": "变出活力色彩", + "description": "感受色彩的魅力,创造属于你的色彩世界", + "simple": "极简素雅" + } + }, + "unknow": "未知" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..11d5dd2 --- /dev/null +++ b/package.json @@ -0,0 +1,197 @@ +{ + "$schema": "https://json.schemastore.org/package", + "name": "hula", + "type": "module", + "version": "3.0.9", + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || >=22.12.0", + "pnpm": ">=10" + }, + "repository": { + "url": "https://github.com/HuLaSpark/HuLa.git" + }, + "author": { + "name": "HuLaSpark团队", + "email": "2439646234@qq.com", + "url": "https://github.com/HuLaSpark" + }, + "scripts": { + "========= 启动vue(tauri项目会连带执行不需要单独执行) =========": "", + "dev": "vite", + "========= 打包vue(tauri项目会连带执行不需要单独执行) =========": "", + "build": "vue-tsc --noEmit && vite build", + "========= 启动HuLa桌面应用程序 =========": "", + "tauri:dev": "tauri dev", + "========= 启动HuLa桌面应用程序(简化命令) =========": "", + "td": "tauri dev", + "========= windows中启动安卓纯前端(简化命令) =========": "", + "adev:win": "set TAURI_ENV_PLATFORM=android&& vite", + "========= mac中启动ios纯前端(简化命令) =========": "", + "idev:mac": "TAURI_ENV_PLATFORM=ios vite", + "========= 启动安卓程序(简化命令) =========": "", + "adev": "tauri android dev", + "========= 启动ios程序(简化命令) =========": "", + "idev": "tauri ios dev", + "========= 打包桌面应用程序(简化命令) =========": "", + "tb": "node scripts/interactive-build-inquirer.js", + "========= 打包桌面应用程序 =========": "", + "tauri:build": "node scripts/interactive-build-inquirer.js", + "========= 启动HuLa ios 程序 =========": "", + "tauri:ios:dev": "tauri ios dev", + "========= 启动HuLa android 程序 =========": "", + "tauri:android:dev": "tauri android dev", + "========= 生成icon =========": "", + "tauri:icon": "tauri icon public/light.png", + "========= 安装依赖前执行 =========": "", + "preinstall": "pnpm dlx only-allow pnpm && node scripts/check-all.js", + "========= 使用commit来进行代码提交 =========": "", + "commit": "git add . && git-cz", + "========= 只检查代码问题和格式(不修复) =========": "", + "check": "biome check", + "========= 修复代码问题和格式并且检查 =========": "", + "check:write": "biome check --write --unsafe", + "========= 格式化Vue文件模板 =========": "", + "format:vue": "pnpm dlx prettier --write \"src/**/*.vue\"", + "========= 完整格式化(JS+Vue) =========": "", + "format:all": "pnpm check:write && pnpm format:vue", + "========= Git提交前的代码检查(简化命令) =========": "", + "lint:s": "lint-staged && vue-tsc --noEmit", + "========= Git提交前的代码检查 =========": "", + "lint:staged": "lint-staged && vue-tsc --noEmit", + "========= 安装husky =========": "", + "prepare": "husky", + "========= 发版 =========": "", + "release": "release-it", + "========= commit后再次添加修改到上一次的commit =========": "", + "addition-commit": "git add . && git commit --amend --no-edit", + "========= 单元测试 =========": "", + "test:run": "vitest run", + "========= 使用 vitest UI =========": "", + "test:ui": "vitest --ui --coverage.enabled=true", + "========= 测试覆盖率 =========": "", + "coverage": "vitest run --coverage", + "========= 链接HuLa skill =========": "", + "skill": "node scripts/link-skills.js", + "========= 单独提交 =========": "", + "gitcz": "git-cz" + }, + "dependencies": { + "@actions/github": "^9.1.1", + "@breezystack/lamejs": "^1.2.7", + "@fingerprintjs/fingerprintjs": "^5.2.0", + "@tauri-apps/api": "2.11.0", + "@tauri-apps/plugin-autostart": "2.5.1", + "@tauri-apps/plugin-barcode-scanner": "2.4.2", + "@tauri-apps/plugin-clipboard-manager": "2.3.2", + "@tauri-apps/plugin-dialog": "^2.7.0", + "@tauri-apps/plugin-fs": "^2.5.0", + "@tauri-apps/plugin-global-shortcut": "^2.3.1", + "@tauri-apps/plugin-http": "2.5.4", + "@tauri-apps/plugin-log": "^2.8.0", + "@tauri-apps/plugin-notification": "^2.3.3", + "@tauri-apps/plugin-opener": "^2.5.2", + "@tauri-apps/plugin-os": "2.3.2", + "@tauri-apps/plugin-process": "2.3.1", + "@tauri-apps/plugin-shell": "^2.3.3", + "@tauri-apps/plugin-sql": "^2.4.0", + "@tauri-apps/plugin-updater": "2.10.1", + "@vant/area-data": "^2.1.0", + "@vue-office/docx": "^1.6.3", + "@vue-office/excel": "^1.7.14", + "@vue-office/pdf": "^2.0.10", + "@vue-office/pptx": "^1.0.1", + "@vueuse/components": "^14.3.0", + "colorthief": "^3.3.1", + "crypto-js": "^4.2.0", + "dayjs": "^1.11.20", + "digest-wasm": "^0.1.4", + "dompurify": "^3.4.2", + "driver.js": "^1.4.0", + "es-toolkit": "^1.46.1", + "file-type": "^22.0.1", + "grapheme-splitter": "^1.0.4", + "hula-emojis": "^1.3.1", + "internal-ip": "^9.0.0", + "markstream-vue": "1.0.1-beta.1", + "mermaid": "^11.14.0", + "mitt": "^3.0.1", + "naive-ui": "^2.44.1", + "p-limit": "^7.3.0", + "pinia": "^3.0.4", + "pinia-plugin-persistedstate": "^4.7.1", + "pinia-shared-state": "^2.0.1", + "seemly": "^0.3.10", + "stream-markdown": "^0.0.15", + "stream-monaco": "^0.0.41", + "tauri-plugin-mic-recorder-api": "^2.0.0", + "tauri-plugin-safe-area-insets": "^0.1.0", + "three": "^0.184.0", + "tlbs-map-vue": "^1.3.2", + "vant": "^4.9.24", + "vue": "^3.5.33", + "vue-cropper": "1.1.4", + "vue-demi": "0.14.10", + "vue-i18n": "^11.4.0", + "vue-router": "^5.0.6", + "vue-virtual-scroller": "3.0.2" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.14", + "@commitlint/cli": "^20.5.3", + "@commitlint/config-conventional": "^20.5.3", + "@iconify/vue": "^5.0.0", + "@inquirer/prompts": "^8.4.2", + "@release-it/bumper": "^7.0.5", + "@release-it/conventional-changelog": "10.0.6", + "@tauri-apps/cli": "2.9.5", + "@types/crypto-js": "^4.2.2", + "@types/node": "^25.6.0", + "@types/three": "^0.184.0", + "@unocss/preset-wind3": "^66.6.8", + "@unocss/reset": "^66.6.8", + "@unocss/transformer-directives": "^66.6.8", + "@unocss/transformer-variant-group": "^66.6.8", + "@unocss/vite": "^66.6.8", + "@vitejs/plugin-vue": "^6.0.6", + "@vitejs/plugin-vue-jsx": "^5.1.5", + "@vitest/coverage-v8": "^4.1.5", + "@vitest/ui": "^4.1.5", + "@vue/test-utils": "^2.4.10", + "@vueuse/core": "^14.3.0", + "chalk": "^5.6.2", + "commitizen": "^4.3.1", + "cz-git": "^1.13.0", + "eruda": "^3.4.3", + "happy-dom": "^20.9.0", + "husky": "^9.1.7", + "lint-staged": "^16.4.0", + "postcss-pxtorem": "^6.1.0", + "prettier": "^3.8.3", + "release-it": "^20.0.1", + "sass": "1.99.0", + "typescript": "^6.0.3", + "unplugin-auto-import": "^21.0.0", + "unplugin-vue-components": "^32.0.0", + "vite": "8.0.10", + "vite-plugin-vue-setup-extend": "^0.4.0", + "vitest": "^4.1.5", + "vue-tsc": "^3.2.8", + "web-vitals": "^5.2.0" + }, + "config": { + "commitizen": { + "path": "node_modules/cz-git" + } + }, + "pnpm": { + "overrides": { + "conventional-changelog-conventionalcommits": "8.0.0", + "lodash": "4.18.1", + "lodash-es": "4.18.1", + "nopt": "9.0.0", + "abbrev": "4.0.0" + }, + "ignoredBuiltDependencies": ["@parcel/watcher", "esbuild", "sharp", "tlbs-map-vue", "vue-demi"] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..990d29e --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,9048 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + conventional-changelog-conventionalcommits: 8.0.0 + lodash: 4.18.1 + lodash-es: 4.18.1 + nopt: 9.0.0 + abbrev: 4.0.0 + +importers: + + .: + dependencies: + '@actions/github': + specifier: ^9.1.1 + version: 9.1.1 + '@breezystack/lamejs': + specifier: ^1.2.7 + version: 1.2.7 + '@fingerprintjs/fingerprintjs': + specifier: ^5.2.0 + version: 5.2.0 + '@tauri-apps/api': + specifier: 2.11.0 + version: 2.11.0 + '@tauri-apps/plugin-autostart': + specifier: 2.5.1 + version: 2.5.1 + '@tauri-apps/plugin-barcode-scanner': + specifier: 2.4.2 + version: 2.4.2 + '@tauri-apps/plugin-clipboard-manager': + specifier: 2.3.2 + version: 2.3.2 + '@tauri-apps/plugin-dialog': + specifier: ^2.7.0 + version: 2.7.1 + '@tauri-apps/plugin-fs': + specifier: ^2.5.0 + version: 2.5.1 + '@tauri-apps/plugin-global-shortcut': + specifier: ^2.3.1 + version: 2.3.1 + '@tauri-apps/plugin-http': + specifier: 2.5.4 + version: 2.5.4 + '@tauri-apps/plugin-log': + specifier: ^2.8.0 + version: 2.8.0 + '@tauri-apps/plugin-notification': + specifier: ^2.3.3 + version: 2.3.3 + '@tauri-apps/plugin-opener': + specifier: ^2.5.2 + version: 2.5.4 + '@tauri-apps/plugin-os': + specifier: 2.3.2 + version: 2.3.2 + '@tauri-apps/plugin-process': + specifier: 2.3.1 + version: 2.3.1 + '@tauri-apps/plugin-shell': + specifier: ^2.3.3 + version: 2.3.5 + '@tauri-apps/plugin-sql': + specifier: ^2.4.0 + version: 2.4.0 + '@tauri-apps/plugin-updater': + specifier: 2.10.1 + version: 2.10.1 + '@vant/area-data': + specifier: ^2.1.0 + version: 2.1.0 + '@vue-office/docx': + specifier: ^1.6.3 + version: 1.6.3(vue-demi@0.14.10(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)) + '@vue-office/excel': + specifier: ^1.7.14 + version: 1.7.14(vue-demi@0.14.10(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)) + '@vue-office/pdf': + specifier: ^2.0.10 + version: 2.0.10(vue-demi@0.14.10(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)) + '@vue-office/pptx': + specifier: ^1.0.1 + version: 1.0.1(vue-demi@0.14.10(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)) + '@vueuse/components': + specifier: ^14.3.0 + version: 14.3.0(vue@3.5.33(typescript@6.0.3)) + colorthief: + specifier: ^3.3.1 + version: 3.3.1(sharp@0.33.5) + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 + dayjs: + specifier: ^1.11.20 + version: 1.11.20 + digest-wasm: + specifier: ^0.1.4 + version: 0.1.4 + dompurify: + specifier: ^3.4.2 + version: 3.4.2 + driver.js: + specifier: ^1.4.0 + version: 1.4.0 + es-toolkit: + specifier: ^1.46.1 + version: 1.46.1 + file-type: + specifier: ^22.0.1 + version: 22.0.1 + grapheme-splitter: + specifier: ^1.0.4 + version: 1.0.4 + hula-emojis: + specifier: ^1.3.1 + version: 1.3.12 + internal-ip: + specifier: ^9.0.0 + version: 9.0.0 + markstream-vue: + specifier: 1.0.1-beta.1 + version: 1.0.1-beta.1(katex@0.16.45)(mermaid@11.14.0)(stream-markdown@0.0.15(shiki@3.23.0)(vue@3.5.33(typescript@6.0.3)))(stream-monaco@0.0.41(monaco-editor@0.55.1))(vue-i18n@11.4.0(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)) + mermaid: + specifier: ^11.14.0 + version: 11.14.0 + mitt: + specifier: ^3.0.1 + version: 3.0.1 + naive-ui: + specifier: ^2.44.1 + version: 2.44.1(vue@3.5.33(typescript@6.0.3)) + p-limit: + specifier: ^7.3.0 + version: 7.3.0 + pinia: + specifier: ^3.0.4 + version: 3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)) + pinia-plugin-persistedstate: + specifier: ^4.7.1 + version: 4.7.1(pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3))) + pinia-shared-state: + specifier: ^2.0.1 + version: 2.0.1(pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3))) + seemly: + specifier: ^0.3.10 + version: 0.3.10 + stream-markdown: + specifier: ^0.0.15 + version: 0.0.15(shiki@3.23.0)(vue@3.5.33(typescript@6.0.3)) + stream-monaco: + specifier: ^0.0.41 + version: 0.0.41(monaco-editor@0.55.1) + tauri-plugin-mic-recorder-api: + specifier: ^2.0.0 + version: 2.0.0 + tauri-plugin-safe-area-insets: + specifier: ^0.1.0 + version: 0.1.0 + three: + specifier: ^0.184.0 + version: 0.184.0 + tlbs-map-vue: + specifier: ^1.3.2 + version: 1.3.2(vue@3.5.33(typescript@6.0.3)) + vant: + specifier: ^4.9.24 + version: 4.9.24(vue@3.5.33(typescript@6.0.3)) + vue: + specifier: ^3.5.33 + version: 3.5.33(typescript@6.0.3) + vue-cropper: + specifier: 1.1.4 + version: 1.1.4 + vue-demi: + specifier: 0.14.10 + version: 0.14.10(vue@3.5.33(typescript@6.0.3)) + vue-i18n: + specifier: ^11.4.0 + version: 11.4.0(vue@3.5.33(typescript@6.0.3)) + vue-router: + specifier: ^5.0.6 + version: 5.0.6(@vue/compiler-sfc@3.5.33)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)) + vue-virtual-scroller: + specifier: 3.0.2 + version: 3.0.2(vue@3.5.33(typescript@6.0.3)) + devDependencies: + '@biomejs/biome': + specifier: ^2.4.14 + version: 2.4.14 + '@commitlint/cli': + specifier: ^20.5.3 + version: 20.5.3(@types/node@25.6.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@6.0.3) + '@commitlint/config-conventional': + specifier: ^20.5.3 + version: 20.5.3 + '@iconify/vue': + specifier: ^5.0.0 + version: 5.0.0(vue@3.5.33(typescript@6.0.3)) + '@inquirer/prompts': + specifier: ^8.4.2 + version: 8.4.2(@types/node@25.6.0) + '@release-it/bumper': + specifier: ^7.0.5 + version: 7.0.5(release-it@20.0.1(@types/node@25.6.0)(magicast@0.5.2)) + '@release-it/conventional-changelog': + specifier: 10.0.6 + version: 10.0.6(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.0.1(@types/node@25.6.0)(magicast@0.5.2)) + '@tauri-apps/cli': + specifier: 2.9.5 + version: 2.9.5 + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + '@types/three': + specifier: ^0.184.0 + version: 0.184.0 + '@unocss/preset-wind3': + specifier: ^66.6.8 + version: 66.6.8 + '@unocss/reset': + specifier: ^66.6.8 + version: 66.6.8 + '@unocss/transformer-directives': + specifier: ^66.6.8 + version: 66.6.8 + '@unocss/transformer-variant-group': + specifier: ^66.6.8 + version: 66.6.8 + '@unocss/vite': + specifier: ^66.6.8 + version: 66.6.8(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4)) + '@vitejs/plugin-vue': + specifier: ^6.0.6 + version: 6.0.6(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4))(vue@3.5.33(typescript@6.0.3)) + '@vitejs/plugin-vue-jsx': + specifier: ^5.1.5 + version: 5.1.5(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4))(vue@3.5.33(typescript@6.0.3)) + '@vitest/coverage-v8': + specifier: ^4.1.5 + version: 4.1.5(vitest@4.1.5) + '@vitest/ui': + specifier: ^4.1.5 + version: 4.1.5(vitest@4.1.5) + '@vue/test-utils': + specifier: ^2.4.10 + version: 2.4.10(@vue/compiler-dom@3.5.33)(@vue/server-renderer@3.5.33(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)) + '@vueuse/core': + specifier: ^14.3.0 + version: 14.3.0(vue@3.5.33(typescript@6.0.3)) + chalk: + specifier: ^5.6.2 + version: 5.6.2 + commitizen: + specifier: ^4.3.1 + version: 4.3.1(@types/node@25.6.0)(typescript@6.0.3) + cz-git: + specifier: ^1.13.0 + version: 1.13.0 + eruda: + specifier: ^3.4.3 + version: 3.4.3 + happy-dom: + specifier: ^20.9.0 + version: 20.9.0 + husky: + specifier: ^9.1.7 + version: 9.1.7 + lint-staged: + specifier: ^16.4.0 + version: 16.4.0 + postcss-pxtorem: + specifier: ^6.1.0 + version: 6.1.0(postcss@8.5.14) + prettier: + specifier: ^3.8.3 + version: 3.8.3 + release-it: + specifier: ^20.0.1 + version: 20.0.1(@types/node@25.6.0)(magicast@0.5.2) + sass: + specifier: 1.99.0 + version: 1.99.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + unplugin-auto-import: + specifier: ^21.0.0 + version: 21.0.0(@vueuse/core@14.3.0(vue@3.5.33(typescript@6.0.3))) + unplugin-vue-components: + specifier: ^32.0.0 + version: 32.0.0(vue@3.5.33(typescript@6.0.3)) + vite: + specifier: 8.0.10 + version: 8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4) + vite-plugin-vue-setup-extend: + specifier: ^0.4.0 + version: 0.4.0(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4)) + vitest: + specifier: ^4.1.5 + version: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(happy-dom@20.9.0)(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4)) + vue-tsc: + specifier: ^3.2.8 + version: 3.2.8(typescript@6.0.3) + web-vitals: + specifier: ^5.2.0 + version: 5.2.0 + +packages: + + '@actions/github@9.1.1': + resolution: {integrity: sha512-tL5JbYOBZHc0ngEnCsaDcryUizIUIlQyIMwy1Wkx93H5HzbBJ7TbiPx2PnFjBwZW0Vh05JmfFZhecE6gglYegA==} + + '@actions/http-client@3.0.2': + resolution: {integrity: sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA==} + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.3': + resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@biomejs/biome@2.4.14': + resolution: {integrity: sha512-TmAvxOEgrpLypzVGJ8FulIZnlyA9TxrO1hyqYrCz9r+bwma9xXxuLA5IuYnj55XQneFx460KjRbx6SWGLkg3bQ==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.14': + resolution: {integrity: sha512-XvgoE9XOawUOQPdmvs4J7wPhi/DLwSCGks3AlPJDmh34O0awRTqCED1HRcRDdpf1Zrp4us4MGOOdIxNpbqNF5Q==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.14': + resolution: {integrity: sha512-jE7hKBCFhOx3uUh+ZkWBfOHxAcILPfhFplNkuID/eZeSTLHzfZzoZxW8fbqY9xXRnPi7jGNAf1iPVR+0yWsM/Q==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.14': + resolution: {integrity: sha512-/z+6gqAqqUQTHazwStxSXKHg9b8UvqBmDFRp+c4wYbq2KXhELQDon9EoC9RpmQ8JWkqQx/lIUy/cs+MhzDZp6A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.4.14': + resolution: {integrity: sha512-2TELhZnW5RSLL063l9rc5xLpA0ZIw0Ccwy/0q384rvNAgFw3yI76bd59547yxowdQr5MNPET/xDLrLuvgSeeWQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.4.14': + resolution: {integrity: sha512-R6BWgJdQOwW9ulJatuTVrQkjnODjqHZkKNOqb1sz++3Noe5LYd0i3PchnOBUCYAPHoPWHhjJqbdZlHEu0hpjdA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.4.14': + resolution: {integrity: sha512-zHrlQZDBDUz4OLAraYpWKcnLS6HOewBFWYOzY91d1ZjdqZwibOyb6BEu6WuWLugyo0P3riCmsbV9UqV1cSXwQg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.4.14': + resolution: {integrity: sha512-M3EH5hqOI/F/FUA2u4xcLoUgmxd218mvuj/6JL7Hv2toQvr2/AdOvKSpGkoRuWFCtQPVa+ZqkEV3Q5xBA9+XSA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.14': + resolution: {integrity: sha512-WL0EG5qE+EAKomGXbf2g6VnSKJhTL3tXC0QRzWRwA5VpjxNYa6H4P7ZWfymbGE4IhZZQi1KXQ2R0YjwInmz2fA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@breezystack/lamejs@1.2.7': + resolution: {integrity: sha512-6wc7ck65ctA75Hq7FYHTtTvGnYs6msgdxiSUICQ+A01nVOWg6rqouZB8IdyteRlfpYYiFovkf67dIeOgWIUzTA==} + + '@chenglou/pretext@0.0.5': + resolution: {integrity: sha512-A8GZN10REdFGsyuiUgLV8jjPDDFMg5GmgxGWV0I3igxBOnzj+jgz2VMmVD7g+SFyoctfeqHFxbNatKSzVRWtRg==} + + '@chevrotain/cst-dts-gen@12.0.0': + resolution: {integrity: sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==} + + '@chevrotain/gast@12.0.0': + resolution: {integrity: sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==} + + '@chevrotain/regexp-to-ast@12.0.0': + resolution: {integrity: sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==} + + '@chevrotain/types@12.0.0': + resolution: {integrity: sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==} + + '@chevrotain/utils@12.0.0': + resolution: {integrity: sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==} + + '@commitlint/cli@20.5.3': + resolution: {integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@20.5.3': + resolution: {integrity: sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@20.5.0': + resolution: {integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==} + engines: {node: '>=v18'} + + '@commitlint/ensure@20.5.3': + resolution: {integrity: sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@20.0.0': + resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==} + engines: {node: '>=v18'} + + '@commitlint/format@20.5.0': + resolution: {integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@20.5.0': + resolution: {integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==} + engines: {node: '>=v18'} + + '@commitlint/lint@20.5.3': + resolution: {integrity: sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw==} + engines: {node: '>=v18'} + + '@commitlint/load@20.5.3': + resolution: {integrity: sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ==} + engines: {node: '>=v18'} + + '@commitlint/message@20.4.3': + resolution: {integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==} + engines: {node: '>=v18'} + + '@commitlint/parse@20.5.0': + resolution: {integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==} + engines: {node: '>=v18'} + + '@commitlint/read@20.5.0': + resolution: {integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@20.5.3': + resolution: {integrity: sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew==} + engines: {node: '>=v18'} + + '@commitlint/rules@20.5.3': + resolution: {integrity: sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ==} + engines: {node: '>=v18'} + + '@commitlint/to-lines@20.0.0': + resolution: {integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==} + engines: {node: '>=v18'} + + '@commitlint/top-level@20.4.3': + resolution: {integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==} + engines: {node: '>=v18'} + + '@commitlint/types@20.5.0': + resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} + engines: {node: '>=v18'} + + '@conventional-changelog/git-client@2.7.0': + resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==} + engines: {node: '>=18'} + peerDependencies: + conventional-commits-filter: ^5.0.0 + conventional-commits-parser: ^6.4.0 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + + '@css-render/plugin-bem@0.15.14': + resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==} + peerDependencies: + css-render: ~0.15.14 + + '@css-render/vue3-ssr@0.15.14': + resolution: {integrity: sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==} + peerDependencies: + vue: ^3.0.11 + + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@emotion/hash@0.8.0': + resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} + + '@fingerprintjs/fingerprintjs@5.2.0': + resolution: {integrity: sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@iarna/toml@3.0.0': + resolution: {integrity: sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.1': + resolution: {integrity: sha512-MwzoDtw9rO1x+qfgLTV/IVXsHDBqeYZoMIQC8SfxfYSlaSUG+oWiAcoiB1yajAda6mqblm4/1/w2E8tRu7a7Tw==} + + '@iconify/vue@5.0.0': + resolution: {integrity: sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==} + peerDependencies: + vue: '>=3' + + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.0.4': + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.33.5': + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.33.5': + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.33.5': + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@inquirer/ansi@2.0.5': + resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/checkbox@5.1.4': + resolution: {integrity: sha512-w6KF8ZYRvqHhROkOTHXYC3qIV/KYEu5o12oLqQySvch61vrYtRxNSHTONSdJqWiFJPlCUQAHT5OgOIyuTr+MHQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@6.0.12': + resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.1.9': + resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.1.1': + resolution: {integrity: sha512-6y11LgmNpmn5D2aB5FgnCfBUBK8ZstwLCalyJmORcJZ/WrhOjm16mu6eSqIx8DnErxDqSLr+Jkp+GP8/Nwd5tA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.0.13': + resolution: {integrity: sha512-dF2zvrFo9LshkcB23/O1il13kBkBltWIXzut1evfbuBLXMiGIuC45c+ZQ0uukjCDsvI8OWqun4FRYMnzFCQa3g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@3.0.0': + resolution: {integrity: sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.5': + resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/input@5.0.12': + resolution: {integrity: sha512-uiMFBl4LqFzJClh80Q3f9hbOFJ6kgkDWI4LjAeBuyO6EanVVMF69AgOvpi1qdqjDSjDN6578B6nky9ceEpI+1Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.0.12': + resolution: {integrity: sha512-/vrwhEf7Xsuh+YlHF4IjSy3g1cyrQuPaSiHIxCEbLu8qnfvrcvJyCkoktOOF+xV9gSb77/G0n3h04RbMDW2sIg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.0.12': + resolution: {integrity: sha512-CBh7YHju623lxJRcAOo498ZUwIuMy63bqW/vVq0tQAZVv+lkWlHkP9ealYE1utWSisEShY5VMdzIXRmyEODzcQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.4.2': + resolution: {integrity: sha512-XJmn/wY4AX56l1BRU+ZjDrFtg9+2uBEi4JvJQj82kwJDQKiPgSn4CEsbfGGygS4Gw6rkL4W18oATjfVfaqub2Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.2.8': + resolution: {integrity: sha512-Su7FQvp5buZmCymN3PPoYv31ZQQX4ve2j02k7piGgKAWgE+AQRB5YoYVveGXcl3TZ9ldgRMSxj56YfDFmmaqLg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.1.8': + resolution: {integrity: sha512-fGiHKGD6DyPIYUWxoXnQTeXeyYqSOUrasDMABBmMHUalH/LxkuzY0xVRtimXAt1sUeeyYkVuKQx1bebMuN11Kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.1.4': + resolution: {integrity: sha512-2kWcGKPMLAXAWRp1AH1SLsQmX+j0QjeljyXMUji9WMZC8nRDO0b7qquIGr6143E7KMLt3VAIGNXzwa/6PXQs4Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.5': + resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@intlify/core-base@11.4.0': + resolution: {integrity: sha512-nlxFOnmjJgVkL1PsuSMagyh3qIHTwc2KlO2R3qQQV1ydrcwh1XpM7opWUGqvGaLlktttopDzbLBpr/k5tvbNmA==} + engines: {node: '>= 16'} + + '@intlify/devtools-types@11.4.0': + resolution: {integrity: sha512-LtQ04kG8/2Nv6AbuINpkjODuhKHdd+MGLlXKW3I0GTCeDsDIBZUot82nnyK7D6+qersF08FqSvoN/eGPcL3c7Q==} + engines: {node: '>= 16'} + + '@intlify/message-compiler@11.4.0': + resolution: {integrity: sha512-v455gVZqMb0er63Wd/akX8DXTnwSubgrgQaRigLB60V3xpnq3B99oPvGXW+N4G/5QFt8Ls84FJ8qHJUVnRCs1A==} + engines: {node: '>= 16'} + + '@intlify/shared@11.4.0': + resolution: {integrity: sha512-r9qUeLeO0TMZmUZ+mXS6IGQ6xwzZJaVMK6j4CdoA3eQP8xp3JtCfwkZ30gB4+knlN40pmBdDXgx85SWhMCzHng==} + engines: {node: '>= 16'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@juggle/resize-observer@3.4.0': + resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + + '@mermaid-js/parser@1.1.0': + resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + + '@octokit/core@7.0.6': + resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.3': + resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} + engines: {node: '>= 20'} + + '@octokit/graphql@9.0.3': + resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} + engines: {node: '>= 20'} + + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + + '@octokit/plugin-paginate-rest@14.0.0': + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-request-log@6.0.0': + resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-rest-endpoint-methods@17.0.0': + resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/request-error@7.1.0': + resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.8': + resolution: {integrity: sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==} + engines: {node: '>= 20'} + + '@octokit/rest@22.0.1': + resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==} + engines: {node: '>= 20'} + + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + + '@phun-ky/typeof@2.0.3': + resolution: {integrity: sha512-oeQJs1aa8Ghke8JIK9yuq/+KjMiaYeDZ38jx7MhkXncXlUKjqQ3wEm2X3qCKyjo+ZZofZj+WsEEiqkTtRuE2xQ==} + engines: {node: ^20.9.0 || >=22.0.0, npm: '>=10.8.2'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@release-it/bumper@7.0.5': + resolution: {integrity: sha512-HCFMqDHreLYg4jjTWL//pW1GzZZMn3p7HDbwS2y7y5m0L6p8hEaOEixC3tEzwyVV7VP1VGjqxMvxfa360q8+Tg==} + engines: {node: ^20.9.0 || >=22.0.0} + peerDependencies: + release-it: '>=18.0.0 || >=19.0.0' + + '@release-it/conventional-changelog@10.0.6': + resolution: {integrity: sha512-aUb0IkcsBTMcOH5PPQ9Jv9lEOOVu2+rSgkE1ny+dzsTziQm2BhDRAtaFK/dw/HflthuXMWrqhhyfJhAV1AOEPQ==} + engines: {node: ^20.12.0 || >=22.0.0} + peerDependencies: + release-it: ^18.0.0 || ^19.0.0 + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.13': + resolution: {integrity: sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==} + + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + + '@rolldown/pluginutils@1.0.0-rc.18': + resolution: {integrity: sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==} + + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} + + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/monaco@3.23.0': + resolution: {integrity: sha512-OCApTdAGTHMFUXSYwGztW6EnlxXsWNrpnGf+uO+AznE+khC6V1/8QjuJESIcvZUIq9iAp4ZCNYosZKSVj1Hctg==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@simple-libs/child-process-utils@1.0.2': + resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} + engines: {node: '>=18'} + + '@simple-libs/hosted-git-info@1.0.2': + resolution: {integrity: sha512-aAmGQdMH+ZinytKuA2832u0ATeOFNYNk4meBEXtB5xaPotUgggYNhq5tYU/v17wEbmTW5P9iHNqNrFyrhnqBAg==} + engines: {node: '>=18'} + + '@simple-libs/stream-utils@1.2.0': + resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} + engines: {node: '>=18'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tauri-apps/api@2.11.0': + resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==} + + '@tauri-apps/cli-darwin-arm64@2.9.5': + resolution: {integrity: sha512-P5XDyCwq3VbWGAplyfP/bgmuUITVDcypxgZUyX45SM7HbU1Nrkk0cNK1HCOkuNBAVVbWen2GUNWah/AiupHHXg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.9.5': + resolution: {integrity: sha512-JC9UfQ2ZKavx60dnNxsWztRF3oUH3dgPwN1WJ3/5RUy2aNwD/vXqvJAfNFZ4GWeQpoQ+PqJxduev0U4OMQonnA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.9.5': + resolution: {integrity: sha512-iCQm2Uvx8AheghfG/QUv1y8Ga9yquJt6xJwH1uF0x5KfmJmwBi8pHBvB924dDi59PS84qTdIBeJejQT00QX3Iw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.9.5': + resolution: {integrity: sha512-b6AW8Gr5nQOQIYH0TsUev7rEThGHIvsx192eElOmOz/dh33J4pninHK32laMj2hzHMJ27qmDq5vANL+wrFo9sg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-arm64-musl@2.9.5': + resolution: {integrity: sha512-/gRBMnphS9E8riZ0LIbBhZ9Oy16A2rx/g3DGR0DcDBvUtkLfbL0lMu4s+sY85nkn9An15+cZ1ZK6d7AIqWahLA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-linux-riscv64-gnu@2.9.5': + resolution: {integrity: sha512-NOzjPF9YIBodjdkFcJmqINT0k3YDoR5ANM/jg6Z6s3Zmk8ScN6inI60jTxcfgfWyITiKsPy7GJyYou3Cm2XNzw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-gnu@2.9.5': + resolution: {integrity: sha512-SfGbwgvTphM5y+J91NyU/psleMUlyyPkZyDCFg8WU1HX8DpKUT3Vwhb/W1xpUBGb56tJgGCO46FCVkr8w4Areg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-musl@2.9.5': + resolution: {integrity: sha512-ZfeoiASAOGDzyvN+TDAg8A1pCeS082h4uc0vZKvtWUN+9QBIMfz0yJwltAv+SN/afap6NS6DVkbPV3UVuI9V5A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-win32-arm64-msvc@2.9.5': + resolution: {integrity: sha512-ulg7irow+ekjaK4inFHVq7m1KQebDSYNb17DFKV+h+x7qnLZymz2gHK7df2u4YyEjqvzwRd3AJpU3HNxRurSFQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.9.5': + resolution: {integrity: sha512-6lF0k/Qduhn1Z3IOXlp2ts8jNOMIX4cK4Fbk3axGeX7LMcVVbOSEAFwbTqS8BKZDFac0WRS8N1C96+Ms5LOS1Q==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.9.5': + resolution: {integrity: sha512-Vg50U74x1A4b2iBVtDcAVPbI1XVuzSmwlduuBM1VewxtRaVj5GDzWnYtBcnuIk+VGzNApRDfDhraAXGaW2a/Gw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.9.5': + resolution: {integrity: sha512-z88tX6O6kwTgMjYozhNGbehzQyBazgXejyH784CwSfBOWm06xFcogd0PY/jhcPsqzJF9kLRIkmlQy+cqdrioOQ==} + engines: {node: '>= 10'} + hasBin: true + + '@tauri-apps/plugin-autostart@2.5.1': + resolution: {integrity: sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==} + + '@tauri-apps/plugin-barcode-scanner@2.4.2': + resolution: {integrity: sha512-7gKa5StXcvBzdT52fYRdq8TNiRq1ZvnjK+XYK847gnbYcoubTN00NOuR344rAgndWyTR9rtJAfQX7rvykLHnmw==} + + '@tauri-apps/plugin-clipboard-manager@2.3.2': + resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==} + + '@tauri-apps/plugin-dialog@2.7.1': + resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} + + '@tauri-apps/plugin-fs@2.5.1': + resolution: {integrity: sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==} + + '@tauri-apps/plugin-global-shortcut@2.3.1': + resolution: {integrity: sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==} + + '@tauri-apps/plugin-http@2.5.4': + resolution: {integrity: sha512-/i4U/9za3mrytTgfRn5RHneKubZE/dwRmshYwyMvNRlkWjvu1m4Ma72kcbVJMZFGXpkbl+qLyWMGrihtWB76Zg==} + + '@tauri-apps/plugin-log@2.8.0': + resolution: {integrity: sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==} + + '@tauri-apps/plugin-notification@2.3.3': + resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} + + '@tauri-apps/plugin-opener@2.5.4': + resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} + + '@tauri-apps/plugin-os@2.3.2': + resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} + + '@tauri-apps/plugin-process@2.3.1': + resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} + + '@tauri-apps/plugin-shell@2.3.5': + resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==} + + '@tauri-apps/plugin-sql@2.4.0': + resolution: {integrity: sha512-SIICc5JlnK6OrBZzOw7MmhXHPlmASpt5zLWIu10WW4kLr5cDYOXHdV2MoCgYQkgZLQfyBYgF3SQa5XCisUiQkw==} + + '@tauri-apps/plugin-updater@2.10.1': + resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/crypto-js@4.2.2': + resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + + '@types/normalize-package-data@2.4.4': + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + + '@types/parse-path@7.1.0': + resolution: {integrity: sha512-EULJ8LApcVEPbrfND0cRQqutIOdiIgJ1Mgrhpy755r14xMohPTEpkV/k28SJvuOs9bHRFW8x+KeDAEPiGQPB9Q==} + deprecated: This is a stub types definition. parse-path provides its own type definitions, so you do not need this installed. + + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.184.0': + resolution: {integrity: sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@unocss/config@66.6.8': + resolution: {integrity: sha512-f+a8OyhD7ZoK8Pa1b3Cbx1RQc3n5x+Qht/cHg3wh/g4DNQIjBI2EqwSLfBigWhdO96zIqFAdyTlO3onmrJwUOw==} + engines: {node: '>=14'} + + '@unocss/core@66.6.8': + resolution: {integrity: sha512-P9IlQfgms+8/nka7fBhiiWU4SPwrTNKbTdK0z1SLnttXMHHjsB2zpG+Vi1JQDpICfY9Y1/2pWtguPE+zeOVu9Q==} + + '@unocss/extractor-arbitrary-variants@66.6.8': + resolution: {integrity: sha512-cOXstpPTOLt/HYcL0OsqFkNau0e8ktZ5Q8fgnXBZjmLGmi+VzdESNlwxZyCXLuamZGnbrZ8lDsKdsGG7P1pMKQ==} + + '@unocss/inspector@66.6.8': + resolution: {integrity: sha512-g8uRzXDdmoNRjXX/mZP7m0rWXLtOimyOW7+VFK6FNxRWBmvIGYgTLHkutF6Wyh9lLPDYx3pkkEmfgL35BDT3Sg==} + + '@unocss/preset-mini@66.6.8': + resolution: {integrity: sha512-vAechrReO7LtWzFAeF54P7CintG2m65SlVlBsi1x2Ru7IdgUNJEHII0MfXUvf9r1x8vsIlhATyaqqtBVT6ps/w==} + + '@unocss/preset-wind3@66.6.8': + resolution: {integrity: sha512-WNTeDAYCatmEFjBJ4itUmz0TElBvNFqjh5i2/ianDJO/vkd+IYUb03jEPLUppVlvMhy8bN8AunP0AtW3Xf2psA==} + + '@unocss/reset@66.6.8': + resolution: {integrity: sha512-H+YP3ltizUiPO9FzFgFhv8WGsefO7fTgT1If1/9ritPDqZlvzTqMmjelhcq8D8MGoQ1RQBUvtkZ5HJoKVY0Tgw==} + + '@unocss/rule-utils@66.6.8': + resolution: {integrity: sha512-WR35L07mLP6PElD4hlUHo5KbQ48uz2HT/XCuJyAsHP+15Gv6539hPWA5SresPuva9r8rl+PeGIgMSIKf4A5Ihw==} + engines: {node: '>=14'} + + '@unocss/transformer-directives@66.6.8': + resolution: {integrity: sha512-9hC3mQ8eycliW/igI9le0LovTIMBKoL6crucTkr4MmWuNqICMvNxTmGj5Xh64olBPnascevFwam6xsy+J1lX4Q==} + + '@unocss/transformer-variant-group@66.6.8': + resolution: {integrity: sha512-+t7gJDW3W3z3/f8zBf0DfV2UZyGyFOwG5CIsIj5ofu3VJ91mKD/5ZAH8fD3cryXCBSqslj4yv+8R+BLV07T5AA==} + + '@unocss/vite@66.6.8': + resolution: {integrity: sha512-bXfEnEHdW7zTGLIYU16MsfKSFy3Q47Pevhrt5f9fOGzC4UI1JGkkoQSfoFpXZGliDrhoSFK4Msz9Jt43Ta4j+w==} + peerDependencies: + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0 + + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + + '@vant/area-data@2.1.0': + resolution: {integrity: sha512-wx9PrUX7wSUJiFcz8UrcvZfTjV6sTc+7SHcbjGQQzEcv5y+EwOo5uV4ZKdfrR5Hzcw4MA08LQdvXPSEb4nWbug==} + + '@vant/popperjs@1.3.0': + resolution: {integrity: sha512-hB+czUG+aHtjhaEmCJDuXOep0YTZjdlRR+4MSmIFnkCQIxJaXLQdSsR90XWvAI2yvKUI7TCGqR8pQg2RtvkMHw==} + + '@vant/use@1.6.0': + resolution: {integrity: sha512-PHHxeAASgiOpSmMjceweIrv2AxDZIkWXyaczksMoWvKV2YAYEhoizRuk/xFnKF+emUIi46TsQ+rvlm/t2BBCfA==} + peerDependencies: + vue: ^3.0.0 + + '@vitejs/plugin-vue-jsx@5.1.5': + resolution: {integrity: sha512-jIAsvHOEtWpslLOI2MeElGFxH7M8pM83BU/Tor4RLyiwH0FM4nUW3xdvbw20EeU9wc5IspQwMq225K3CMnJEpA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.0.0 + + '@vitejs/plugin-vue@6.0.6': + resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@vitest/coverage-v8@4.1.5': + resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==} + peerDependencies: + '@vitest/browser': 4.1.5 + vitest: 4.1.5 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/ui@4.1.5': + resolution: {integrity: sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==} + peerDependencies: + vitest: 4.1.5 + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vue-macros/common@3.1.2': + resolution: {integrity: sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==} + engines: {node: '>=20.19.0'} + peerDependencies: + vue: ^2.7.0 || ^3.2.25 + peerDependenciesMeta: + vue: + optional: true + + '@vue-office/docx@1.6.3': + resolution: {integrity: sha512-Cs+3CAaRBOWOiW4XAhTwwxJ0dy8cPIf6DqfNvYcD3YACiLwO4kuawLF2IAXxyijhbuOeoFsfvoVbOc16A/4bZA==} + peerDependencies: + '@vue/composition-api': ^1.7.1 + vue: ^2.0.0 || >=3.0.0 + vue-demi: ^0.14.6 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + '@vue-office/excel@1.7.14': + resolution: {integrity: sha512-pVUgt+emDQUnW7q22CfnQ+jl43mM/7IFwYzOg7lwOwPEbiVB4K4qEQf+y/bc4xGXz75w1/e3Kz3G6wAafmFBFg==} + peerDependencies: + '@vue/composition-api': ^1.7.1 + vue: ^2.0.0 || >=3.0.0 + vue-demi: ^0.14.6 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + '@vue-office/pdf@2.0.10': + resolution: {integrity: sha512-yHVLrMAKpMPBkhBwofFyGEtEeJF0Zd7oGmf56Pe5aj/xObdRq3E1CIZqTqhWJNgHV8oLQqaX0vs4p5T1zq+GIA==} + peerDependencies: + '@vue/composition-api': ^1.7.1 + vue: ^2.0.0 || >=3.0.0 + vue-demi: ^0.14.6 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + '@vue-office/pptx@1.0.1': + resolution: {integrity: sha512-+V7Kctzl6f6+Yk4NaD/wQGRIkqLWcowe0jEhPexWQb8Oilbzt1OyhWRWcMsxNDTdrgm6aMLP+0/tmw27cxddMg==} + peerDependencies: + '@vue/composition-api': ^1.7.1 + vue: ^2.0.0 || >=3.0.0 + vue-demi: ^0.14.6 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + '@vue/babel-helper-vue-transform-on@2.0.1': + resolution: {integrity: sha512-uZ66EaFbnnZSYqYEyplWvn46GhZ1KuYSThdT68p+am7MgBNbQ3hphTL9L+xSIsWkdktwhPYLwPgVWqo96jDdRA==} + + '@vue/babel-plugin-jsx@2.0.1': + resolution: {integrity: sha512-a8CaLQjD/s4PVdhrLD/zT574ZNPnZBOY+IhdtKWRB4HRZ0I2tXBi5ne7d9eCfaYwp5gU5+4KIyFTV1W1YL9xZA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + peerDependenciesMeta: + '@babel/core': + optional: true + + '@vue/babel-plugin-resolve-type@2.0.1': + resolution: {integrity: sha512-ybwgIuRGRRBhOU37GImDoWQoz+TlSqap65qVI6iwg/J7FfLTLmMf97TS7xQH9I7Qtr/gp161kYVdhr1ZMraSYQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@vue/compiler-core@3.5.33': + resolution: {integrity: sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==} + + '@vue/compiler-dom@3.5.33': + resolution: {integrity: sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==} + + '@vue/compiler-sfc@3.5.33': + resolution: {integrity: sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==} + + '@vue/compiler-ssr@3.5.33': + resolution: {integrity: sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/devtools-api@7.7.9': + resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + + '@vue/devtools-api@8.1.1': + resolution: {integrity: sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw==} + + '@vue/devtools-kit@7.7.9': + resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==} + + '@vue/devtools-kit@8.1.1': + resolution: {integrity: sha512-gVBaBv++i+adg4JpH71k9ppl4soyR7Y2McEqO5YNgv0BI1kMZ7BDX5gnwkZ5COYgiCyhejZG+yGNrBAjj6Coqg==} + + '@vue/devtools-shared@7.7.9': + resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==} + + '@vue/devtools-shared@8.1.1': + resolution: {integrity: sha512-+h4ttmJYl/txpxHKaoZcaKpC+pvckgLzIDiSQlaQ7kKthKh8KuwoLW2D8hPJEnqKzXOvu15UHEoGyngAXCz0EQ==} + + '@vue/language-core@3.2.8': + resolution: {integrity: sha512-9OiSPQFiAAWNVnXb0d2dcTmcKnFQamhuNES6ayyISrb/mwPWVgoGdAqSfCWqKhQpa3D5gDTcYD+w7ObiheZ81g==} + + '@vue/reactivity@3.5.33': + resolution: {integrity: sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==} + + '@vue/runtime-core@3.5.33': + resolution: {integrity: sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==} + + '@vue/runtime-dom@3.5.33': + resolution: {integrity: sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==} + + '@vue/server-renderer@3.5.33': + resolution: {integrity: sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==} + peerDependencies: + vue: 3.5.33 + + '@vue/shared@3.5.33': + resolution: {integrity: sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==} + + '@vue/test-utils@2.4.10': + resolution: {integrity: sha512-SmoZ5EA1kYiAFs9NkYdiFFQF+cSnUwnvlYEbY+DogWQZUiqOm/Y29eSbc5T6yi75SgSF9863SBeXniIEoPajCA==} + peerDependencies: + '@vue/compiler-dom': 3.x + '@vue/server-renderer': 3.x + vue: 3.x + peerDependenciesMeta: + '@vue/server-renderer': + optional: true + + '@vueuse/components@14.3.0': + resolution: {integrity: sha512-jnrJrecSfa8H+G6wtAwsCnMtKbKZDSpu5JZDuulZikWrHb6uuS5SyXP6M2b79tofxipm78VWDSzW+58pu1yglA==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/core@14.3.0': + resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@14.3.0': + resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==} + + '@vueuse/shared@14.3.0': + resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==} + peerDependencies: + vue: ^3.5.0 + + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@8.0.0: + resolution: {integrity: sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg==} + engines: {node: '>= 14'} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + alien-signals@2.0.8: + resolution: {integrity: sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA==} + + alien-signals@3.1.2: + resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-kit@2.2.0: + resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} + engines: {node: '>=20.19.0'} + + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + + ast-walker-scope@0.8.3: + resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==} + engines: {node: '>=20.19.0'} + + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.27: + resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} + engines: {node: '>=6.0.0'} + hasBin: true + + basic-ftp@5.3.1: + resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} + engines: {node: '>=10.0.0'} + + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + broadcast-channel@7.3.0: + resolution: {integrity: sha512-UHPhLBQKfQ8OmMFMpmPfO5dRakyA1vsfiDGWTYNvChYol65tbuhivPEGgZZiuetorvExdvxaWiBy/ym1Ty08yA==} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + c12@3.3.3: + resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + cachedir@2.3.0: + resolution: {integrity: sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==} + engines: {node: '>=6'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001791: + resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} + + chevrotain-allstar@0.4.3: + resolution: {integrity: sha512-2X4mkroolSMKqW+H22pyPMUVDqYZzPhephTmg/NODKb1IGYPHfxfhcW0EjS7wcPJNbze2i4vBWT7zT5FKF2lrQ==} + peerDependencies: + chevrotain: ^12.0.0 + + chevrotain@12.0.0: + resolution: {integrity: sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==} + engines: {node: '>=22.0.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + + cli-width@3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone-regexp@3.0.0: + resolution: {integrity: sha512-ujdnoq2Kxb8s3ItNBtnYeXdm07FcU0u8ARAT1lQ2YdMwQC+cdiXX8KoqMVuglztILivceTtp4ivqGSmEmhBUJw==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + colorthief@3.3.1: + resolution: {integrity: sha512-a3qzYXy51h6p3725pV8rnJwUBGTtvYQge2pVhKJwL+vETUD5pCi6VKmQyu51pBHdUbu/BPEXbwFLS0GnxXNhGA==} + hasBin: true + peerDependencies: + sharp: '>=0.33.0' + peerDependenciesMeta: + sharp: + optional: true + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + commitizen@4.3.1: + resolution: {integrity: sha512-gwAPAVTy/j5YcOOebcCRIijn+mSjWJC+IYKivTu6aG8Ei/scoXgfsMRnuAk6b0GRste2J4NGxVdMN3ZpfNaVaw==} + engines: {node: '>= 12'} + hasBin: true + + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + conventional-changelog-angular@8.3.1: + resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} + engines: {node: '>=18'} + + conventional-changelog-conventionalcommits@8.0.0: + resolution: {integrity: sha512-eOvlTO6OcySPyyyk8pKz2dP4jjElYunj9hn9/s0OB+gapTO8zwS9UQWrZ1pmF2hFs3vw1xhonOLGcGjy/zgsuA==} + engines: {node: '>=18'} + + conventional-changelog-preset-loader@5.0.0: + resolution: {integrity: sha512-SetDSntXLk8Jh1NOAl1Gu5uLiCNSYenB5tm0YVeZKePRIgDW9lQImromTwLa3c/Gae298tsgOM+/CYT9XAl0NA==} + engines: {node: '>=18'} + + conventional-changelog-writer@8.4.0: + resolution: {integrity: sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==} + engines: {node: '>=18'} + hasBin: true + + conventional-changelog@7.2.0: + resolution: {integrity: sha512-BEdgG+vPl53EVlTTk9sZ96aagFp0AQ5pw/ggiQMy2SClLbTo1r0l+8dSg79gkLOO5DS1Lswuhp5fWn6RwE+ivg==} + engines: {node: '>=18'} + hasBin: true + + conventional-commit-types@3.0.0: + resolution: {integrity: sha512-SmmCYnOniSsAa9GqWOeLqc179lfr5TRu5b4QFDkbsrJ5TZjPJx85wtOr3zn+1dbeNiXDKGPbZ72IKbPhLXh/Lg==} + + conventional-commits-filter@5.0.0: + resolution: {integrity: sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==} + engines: {node: '>=18'} + + conventional-commits-parser@6.4.0: + resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} + engines: {node: '>=18'} + hasBin: true + + conventional-recommended-bump@11.2.0: + resolution: {integrity: sha512-lqIdmw330QdMBgfL0e6+6q5OMKyIpy4OZNmepit6FS3GldhkG+70drZjuZ0A5NFpze5j85dlYs3GabQXl6sMHw==} + engines: {node: '>=18'} + hasBin: true + + convert-hrtime@5.0.0: + resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} + engines: {node: '>=12'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + + css-render@0.15.14: + resolution: {integrity: sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + csstype@3.0.11: + resolution: {integrity: sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.3: + resolution: {integrity: sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==} + engines: {node: '>=0.10'} + + cz-conventional-changelog@3.3.0: + resolution: {integrity: sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw==} + engines: {node: '>= 10'} + + cz-git@1.13.0: + resolution: {integrity: sha512-oTxNSypCvhfHJF7KmnO8lSDup4lYa/s6bbF1700vyONepDIMTcMMXQweSxE/7ANIm7hcv1ckz4HLitZNM02fCw==} + engines: {node: '>=v12.20.0'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + data-uri-to-buffer@7.0.0: + resolution: {integrity: sha512-CuRUx0TXGSbbWdEci3VK/XOZGP3n0P4pIKpsqpVtBqaIIuj3GKK8H45oAqA4Rg8FHipc+CzRdUzmD4YQXxv66Q==} + engines: {node: '>= 14'} + + date-fns-tz@3.2.0: + resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} + peerDependencies: + date-fns: ^3.0.0 || ^4.0.0 + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@0.7.0: + resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + degenerator@6.0.0: + resolution: {integrity: sha512-j5MdXdefrecJeSqTpUrgZd4fBsD2IxZx0JlJD+n1Q7+aTf7/HcyXSfHsicPW6ekPurX159v1ZYla6OJgSPh2Dw==} + engines: {node: '>= 14'} + peerDependencies: + quickjs-wasi: ^0.0.1 + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-file@1.0.0: + resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==} + engines: {node: '>=0.10.0'} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + detect-indent@7.0.1: + resolution: {integrity: sha512-Mc7QhQ8s+cLrnUfU/Ji94vG/r8M26m8f++vyres4ZoojaRDpZ1eSIh/EpzLNwlWuvzSZ3UbDFspjFvTDXe6e/g==} + engines: {node: '>=12.20'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + digest-wasm@0.1.4: + resolution: {integrity: sha512-pz4nYXZBTVJ3MDfjOv0ZG7Vyf6Nad5er94vyqsxTxZJQDyS9CiKbDgBTrLCM4N2/0C3cwhwPL2D/OJWPg3TE8Q==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + + dompurify@3.4.2: + resolution: {integrity: sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + driver.js@1.4.0: + resolution: {integrity: sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + editorconfig@1.0.7: + resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} + engines: {node: '>=14'} + hasBin: true + + electron-to-chromium@1.5.349: + resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + eruda@3.4.3: + resolution: {integrity: sha512-J2TsF4dXSspOXev5bJ6mljv0dRrxj21wklrDzbvPmYaEmVoC+2psylyRi70nUPFh1mTQfIBsSusUtAMZtUN+/w==} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-toolkit@1.46.1: + resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eta@4.5.1: + resolution: {integrity: sha512-EaNCGm+8XEIU7YNcc+THptWAO5NfKBHHARxt+wxZljj9bTr/+arRoOm9/MpGt4n6xn9fLnPFRSoLD0WFYGFUxQ==} + engines: {node: '>=20'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + evtd@0.2.4: + resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==} + + expand-tilde@2.0.2: + resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} + engines: {node: '>=0.10.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + + fast-content-type-parse@3.0.0: + resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + file-type@22.0.1: + resolution: {integrity: sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==} + engines: {node: '>=22'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-node-modules@2.1.3: + resolution: {integrity: sha512-UC2I2+nx1ZuOBclWVNdcnbDR5dlrOdVb7xNjmT/lHE+LsgztWks3dG7boJ37yTS/venXw84B/mAW9uHVoC5QRg==} + + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + + findup-sync@4.0.0: + resolution: {integrity: sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==} + engines: {node: '>= 8'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-timeout@0.1.1: + resolution: {integrity: sha512-0NVVC0TaP7dSTvn1yMiy6d6Q8gifzbvQafO46RtLG/kHJUBNd+pVRGOBoK44wNBvtSPUJRfdVvkFdD3p0xvyZg==} + engines: {node: '>=14.16'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + get-uri@7.0.0: + resolution: {integrity: sha512-ZsC7KQxm1Hra8yO0RvMZ4lGJT7vnBtSNpEHKq39MPN7vjuvCiu1aQ8rkXUaIXG1y/TSDez97Gmv04ibnYqCp/A==} + engines: {node: '>= 14'} + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + git-raw-commits@5.0.1: + resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} + engines: {node: '>=18'} + hasBin: true + + git-up@8.1.1: + resolution: {integrity: sha512-FDenSF3fVqBYSaJoYy1KSc2wosx0gCvKP+c+PRBht7cAaiCeQlBtfBDX9vgnNOHmdePlSFITVcn4pFfcgNvx3g==} + + git-url-parse@16.1.0: + resolution: {integrity: sha512-cPLz4HuK86wClEW7iDdeAKcCVlWXmrLpb2L+G9goW0Z1dtpNS6BXXSOckUTlJT/LDQViE1QZKstNORzHsLnobw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + global-directory@5.0.0: + resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} + engines: {node: '>=20'} + + global-modules@1.0.0: + resolution: {integrity: sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==} + engines: {node: '>=0.10.0'} + + global-prefix@1.0.2: + resolution: {integrity: sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==} + engines: {node: '>=0.10.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grapheme-splitter@1.0.4: + resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} + + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + happy-dom@20.9.0: + resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} + engines: {node: '>=20.0.0'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + homedir-polyfill@1.0.3: + resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} + engines: {node: '>=0.10.0'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + hosted-git-info@8.1.0: + resolution: {integrity: sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==} + engines: {node: ^18.17.0 || >=20.5.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + http-proxy-agent@8.0.0: + resolution: {integrity: sha512-7pose0uGgrCJeH2Qh4JcNhWZp3u/oNrWjNYDK4ydOLxOpTw8V8ogHFAmkz0VWq96JBFj4umVJpvmQi287rSYLg==} + engines: {node: '>= 14'} + + https-proxy-agent@8.0.0: + resolution: {integrity: sha512-YYeW+iCnAS3xhvj2dvVoWgsbca3RfQy/IlaNHHOtDmU0jMqPI9euIq3Y9BJETdxk16h9NHHCKqp/KB9nIMStCQ==} + engines: {node: '>= 14'} + + hula-emojis@1.3.12: + resolution: {integrity: sha512-E0mCtfGyLUe9+Pa6Ug5dJw8MDH259ZfxenbMYYyOEXyFkku/1UL4/akSJ6NauBf5mAM78UnKHgEcjWK/Yzn1yA==} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + immutable@5.1.5: + resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@5.0.0: + resolution: {integrity: sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==} + engines: {node: ^18.17.0 || >=20.5.0} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + inquirer@8.2.5: + resolution: {integrity: sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ==} + engines: {node: '>=12.0.0'} + + internal-ip@9.0.0: + resolution: {integrity: sha512-tZsNWgQ5xcpTRfAo6xwr+elUPvz9skGTQ2mDGsmJpm0SxkJDupDn3DTK9BX2kJz2Tn6s0gYlXl+tryMMAIMhJw==} + engines: {node: '>=20'} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ip-regex@5.0.0: + resolution: {integrity: sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-ip@5.0.1: + resolution: {integrity: sha512-FCsGHdlrOnZQcp0+XT5a+pYowf33itBalCl+7ovNXC/7o5BhIpG14M3OrpPPdBSIQJCm+0M5+9mO7S9VVTTCFw==} + engines: {node: '>=14.16'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-regexp@3.1.0: + resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} + engines: {node: '>=12'} + + is-ssh@1.4.1: + resolution: {integrity: sha512-JNeu1wQsHjyHgn9NcWTaXq6zWSR6hqE0++zhfZlkFBbScNkyvxCdeV8sRkSBaeLKxmbpR21brail63ACNxJ0Tg==} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-utf8@0.2.1: + resolution: {integrity: sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + issue-parser@7.0.1: + resolution: {integrity: sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==} + engines: {node: ^18.17 || >=20.6.1} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-with-bigint@3.5.8: + resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + katex@0.16.45: + resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + langium@4.2.3: + resolution: {integrity: sha512-sOPIi4hISFnY7twwV97ca1TsxpBtXq0URu/LL1AvxwccPG/RIBBlKS7a/f/EL6w8lTNaS0EFs/F+IdSOaqYpng==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + lint-staged@16.4.0: + resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} + engines: {node: '>=20.17'} + hasBin: true + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash.capitalize@4.2.1: + resolution: {integrity: sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.map@4.6.0: + resolution: {integrity: sha512-worNHGKLDetmcEYDvh2stPCrrQRkP20E4l0iIS7F8EvzMqBBi7ltvFN5m1HvTf1P7Jk1txKhvFcmYsCr8O2F1Q==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.uniqby@4.7.0: + resolution: {integrity: sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + longest@2.0.1: + resolution: {integrity: sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q==} + engines: {node: '>=0.10.0'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + + macos-release@3.4.0: + resolution: {integrity: sha512-wpGPwyg/xrSp4H4Db4xYSeAr6+cFQGHfspHzDUdYxswDnUW0L5Ov63UuJiSr8NMSpyaChO4u1n0MXUvVPtrN6A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + magic-string-ast@1.0.3: + resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} + engines: {node: '>=20.19.0'} + + magic-string@0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + markdown-it-container@4.0.0: + resolution: {integrity: sha512-HaNccxUH0l7BNGYbFbjmGpf5aLHAMTinqRZQAEQbMr2cdD3z91Q6kIo1oUn1CQndkT03jat6ckrdRYuwwqLlQw==} + + markdown-it-footnote@4.0.0: + resolution: {integrity: sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==} + + markdown-it-ins@4.0.0: + resolution: {integrity: sha512-sWbjK2DprrkINE4oYDhHdCijGT+MIDhEupjSHLXe5UXeVr5qmVxs/nTUVtgi0Oh/qtF+QKV0tNWDhQBEPxiMew==} + + markdown-it-mark@4.0.0: + resolution: {integrity: sha512-YLhzaOsU9THO/cal0lUjfMjrqSMPjjyjChYM7oyj4DnyaXEzA8gnW6cVJeyCrCVeyesrY2PlEdUYJSPFYL4Nkg==} + + markdown-it-sub@2.0.0: + resolution: {integrity: sha512-iCBKgwCkfQBRg2vApy9vx1C1Tu6D8XYo8NvevI3OlwzBRmiMtsJ2sXupBgEA7PPxiDwNni3qIUkhZ6j5wofDUA==} + + markdown-it-sup@2.0.0: + resolution: {integrity: sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA==} + + markdown-it-task-checkbox@1.0.6: + resolution: {integrity: sha512-7pxkHuvqTOu3iwVGmDPeYjQg+AIS9VQxzyLP9JCg9lBjgPAJXGEkChK6A2iFuj3tS0GV3HG2u5AMNhcQqwxpJw==} + + markdown-it-ts@1.0.0: + resolution: {integrity: sha512-hQT/yCYryC3jNs2wJ35R4m1zKcBxNuFaKCGzwpmq2OuMXMNbUK1oTwCxONIjy5lXWWG1UCNbGXe1nbTiWbH/iA==} + engines: {node: '>=18'} + + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + + markstream-core@1.0.0: + resolution: {integrity: sha512-V39W0rPgJ5Yj/XEl11LaKQxX9dYOI34RL729Zoi9oyy/Y6z6H+PJOsBFktu7gU9ZZxCsevWMb/Re2LgSKbWfRg==} + + markstream-vue@1.0.1-beta.1: + resolution: {integrity: sha512-45br3sbOQirIg0tmPMWRmdWk/vLE5aJtVx7cvLaa3e+klyYpILzQ7c4eOMq2EZkQTVewJhZHUVc2FTj+ujUJPg==} + peerDependencies: + '@antv/infographic': ^0.2.3 + '@terrastruct/d2': '>=0.1.33' + katex: '>=0.16.22' + mermaid: '>=11' + stream-markdown: '>=0.0.15' + stream-monaco: '>=0.0.41' + vue: '>=3.0.0' + vue-i18n: '>=9' + peerDependenciesMeta: + '@antv/infographic': + optional: true + '@terrastruct/d2': + optional: true + katex: + optional: true + mermaid: + optional: true + stream-markdown: + optional: true + stream-monaco: + optional: true + vue-i18n: + optional: true + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + merge@2.1.1: + resolution: {integrity: sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==} + + mermaid@11.14.0: + resolution: {integrity: sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==} + + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.7: + resolution: {integrity: sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + + naive-ui@2.44.1: + resolution: {integrity: sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==} + engines: {node: '>=20'} + peerDependencies: + vue: ^3.0.0 + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + netmask@2.1.1: + resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} + engines: {node: '>= 0.4.0'} + + new-github-release-url@2.0.0: + resolution: {integrity: sha512-NHDDGYudnvRutt/VhKFlX26IotXe1w0cmkDm6JGquh5bz/bDTw0LufSmH/GxTjEdpHEO+bVKFTwdrcGa/9XlKQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + normalize-package-data@7.0.1: + resolution: {integrity: sha512-linxNAT6M0ebEYZOx2tO6vBEFsVgnPpv+AVjk0wJHfaUIbq31Jm3T6vvZaarnOeWDh8ShnwXuaAyM7WT3RzErA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nypm@0.6.6: + resolution: {integrity: sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==} + engines: {node: '>=18'} + hasBin: true + + oblivious-set@2.0.0: + resolution: {integrity: sha512-QOUH5Xrsced9fKXaQTjWoDGKeS/Or7E2jB0FN63N4mkAO4qJdB7WR7e6qWAOHM5nk25FJ8TGjhP7DH4l6vFVLg==} + engines: {node: '>=16'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + ora@9.3.0: + resolution: {integrity: sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==} + engines: {node: '>=20'} + + os-name@7.0.0: + resolution: {integrity: sha512-/HfRU/lPPr4T2VigM+cvM3cU77es+XF4OEAa4aE5zpdvrxHGD2NmH0AFIWpMNAb+CsZL45rlcIO49Re0ZcRseg==} + engines: {node: '>=20'} + + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + p-event@7.1.0: + resolution: {integrity: sha512-/lkPs5W1aC3cp6vqZefpdosOn65J571sWodyfOQiF0+tmDCpU+H8Atwpu0vQROCVUlZuToDN5eyTLsMLLc54mg==} + engines: {node: '>=20'} + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-limit@7.3.0: + resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} + engines: {node: '>=20'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + pac-proxy-agent@8.0.0: + resolution: {integrity: sha512-HyCoVbyQ/nbVlQ/R6wBu0YXhbG2oAnEK5BQ3xMyj1OffQmU5NoOnpLzgPlKHaobUzz5NK0+AZHby4TdydAEBUA==} + engines: {node: '>= 14'} + + pac-resolver@8.0.0: + resolution: {integrity: sha512-SVNzOxVq2zuTew3WAt7U8UghwzJzuWYuJryd3y8FxyLTZdjVoCzY8kLP39PpEqQCDvlMWdQXwViu0sYT3eiU2w==} + engines: {node: '>= 14'} + peerDependencies: + quickjs-wasi: ^0.0.1 + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-passwd@1.0.0: + resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} + engines: {node: '>=0.10.0'} + + parse-path@7.1.0: + resolution: {integrity: sha512-EuCycjZtfPcjWk7KTksnJ5xPMvWGA/6i4zrLYhRG0hGvC3GPU/jGUj3Cy+ZR0v30duV3e23R95T1lE2+lsndSw==} + + parse-url@9.2.0: + resolution: {integrity: sha512-bCgsFI+GeGWPAvAiUv63ZorMeif3/U0zaXABGJbOWt5OH2KCaPHF6S+0ok4aqM9RuIPGyZdx9tR9l13PsW4AYQ==} + engines: {node: '>=14.13.0'} + + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pinia-plugin-persistedstate@4.7.1: + resolution: {integrity: sha512-WHOqh2esDlR3eAaknPbqXrkkj0D24h8shrDPqysgCFR6ghqP/fpFfJmMPJp0gETHsvrh9YNNg6dQfo2OEtDnIQ==} + peerDependencies: + '@nuxt/kit': '>=3.0.0' + '@pinia/nuxt': '>=0.10.0' + pinia: '>=3.0.0' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@pinia/nuxt': + optional: true + pinia: + optional: true + + pinia-shared-state@2.0.1: + resolution: {integrity: sha512-4Eq3aIPEzfXqFTiiJp+Kad3NfyHnqB58eKlbchE99vW0LXQKQEMio6M0weY2xzoSgYHrO46sfEliHw4mTVfOow==} + peerDependencies: + pinia: ^3.0.0 + + pinia@3.0.4: + resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} + peerDependencies: + typescript: '>=4.5.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + + postcss-pxtorem@6.1.0: + resolution: {integrity: sha512-ROODSNci9ADal3zUcPHOF/K83TiCgNSPXQFSbwyPHNV8ioHIE4SaC+FPOufd8jsr5jV2uIz29v1Uqy1c4ov42g==} + peerDependencies: + postcss: ^8.0.0 + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + powershell-utils@0.2.0: + resolution: {integrity: sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==} + engines: {node: '>=20'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + protocols@2.0.2: + resolution: {integrity: sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==} + + proxy-agent@7.0.0: + resolution: {integrity: sha512-okTgt79rHTvMHkr/Ney5rZpgCHh3g1g3tI5uhkgN5b7OeI3n0Q/ui1uv9OdrnZNJM9WIZJqZPh/UJs+YtO/TMQ==} + engines: {node: '>= 14'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quickjs-wasi@0.0.1: + resolution: {integrity: sha512-fBWNLTBkxkLAhe1AzF1hyXEvuA+N+vV1WMP2D6iiMUblvmOt8Pp5t8zUcgvz7aYA1ldUdxDlgUse15dmcKjkNg==} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + release-it@20.0.1: + resolution: {integrity: sha512-3ob1P1aV+3+ZOoR7qgobfYyMlQbpitzOK09iKTtQ145vFi4rWxlRTgHwtVl8kokCvqiF/cJPxRlfcmZmF5aDJA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + hasBin: true + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-dir@1.0.1: + resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sass@1.99.0: + resolution: {integrity: sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==} + engines: {node: '>=14.0.0'} + hasBin: true + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + seemly@0.3.10: + resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shiki-stream@0.1.4: + resolution: {integrity: sha512-4pz6JGSDmVTTkPJ/ueixHkFAXY4ySCc+unvCaDZV7hqq/sdJZirRxgIXSuNSKgiFlGTgRR97sdu2R8K55sPsrw==} + peerDependencies: + react: ^19.0.0 + solid-js: ^1.9.0 + vue: ^3.2.0 + peerDependenciesMeta: + react: + optional: true + solid-js: + optional: true + vue: + optional: true + + shiki@3.23.0: + resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@9.0.0: + resolution: {integrity: sha512-fFlbMlfsXhK02ZB8aZY7Hwxh/IHBV9b1Oq9bvBk6tkFWXvdAxUgA0wbw/NYR5liU3Y5+KI6U4FH3kYJt9QYv0w==} + engines: {node: '>= 14'} + + socks@2.8.8: + resolution: {integrity: sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sourcemap-codec@1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + deprecated: Please use @jridgewell/sourcemap-codec instead + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + stdin-discarder@0.3.2: + resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} + engines: {node: '>=18'} + + stream-markdown-parser@1.0.0: + resolution: {integrity: sha512-uOYQ8G9YFFtGM726fK77O/mJPYrwYHPy0DKBtk7bCPmkf06XwDVSOAFRTo2FsXnZ3vAaPBtxOgNGJ0CGieBcSQ==} + + stream-markdown@0.0.15: + resolution: {integrity: sha512-1WlzjZUb9W5BWZYMKCr2/exPVh5P7HIhHzkcYZczkXm0upiuN4zEddwjdckL+WSQWGGlv9bboXCqcTYCEgqexw==} + peerDependencies: + shiki: '>=3.13.0' + + stream-monaco@0.0.41: + resolution: {integrity: sha512-TkGqo94td8SRciHm9C3Fi8zfHHz6mxtcn/Z8kzPO47y0AJ5quDs4oXmw4lnxneAt6fARTK1JuEqnh8aJ+RxOvA==} + hasBin: true + peerDependencies: + monaco-editor: '>=0.52.2 <0.56.0' + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + super-regex@0.2.0: + resolution: {integrity: sha512-WZzIx3rC1CvbMDloLsVw0lkZVKJWbrkJ0k1ghKFmcnPrW1+jWbgTkTEWVtD9lMdmI4jZEz40+naBxl1dCUhXXw==} + engines: {node: '>=14.16'} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tauri-plugin-mic-recorder-api@2.0.0: + resolution: {integrity: sha512-04wqYCX4WIlYd6KUY7aS3+W4B5RtnSoVczaQCBSXKpQkEx9XdaaBN05XCee2unxGva0btSXBItFqQSdosnS4jQ==} + + tauri-plugin-safe-area-insets@0.1.0: + resolution: {integrity: sha512-NhtxSQdU7+laTJiO4WZFlTB1CBzRlNE5EuHflGAGXS55fiwOkkaWztfNishFQO+eZtb9i5jLHX3Yho48lnqcRQ==} + + three@0.184.0: + resolution: {integrity: sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + time-span@5.1.0: + resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} + engines: {node: '>=12'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tlbs-map-vue@1.3.2: + resolution: {integrity: sha512-CIRhxWPpLz0A2yCylRkYRj6LOEhGU9mV6pWil2XTAs+Yo/7EN+aqn+QM/4Y5TJLpN7yjMjhDDx8cCkfje5iatA==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@vue/composition-api': ^1.4.9 + vue: ^2.6.0 || >=3.0.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + treemate@0.3.11: + resolution: {integrity: sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + unconfig@7.5.0: + resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + undici@6.25.0: + resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} + engines: {node: '>=18.17'} + + undici@7.24.5: + resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==} + engines: {node: '>=20.18.1'} + + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + + unimport@5.7.0: + resolution: {integrity: sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==} + engines: {node: '>=18.12.0'} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unload@2.4.1: + resolution: {integrity: sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==} + + unplugin-auto-import@21.0.0: + resolution: {integrity: sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@nuxt/kit': ^4.0.0 + '@vueuse/core': '*' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@vueuse/core': + optional: true + + unplugin-utils@0.3.1: + resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} + engines: {node: '>=20.19.0'} + + unplugin-vue-components@32.0.0: + resolution: {integrity: sha512-uLdccgS7mf3pv1bCCP20y/hm+u1eOjAmygVkh+Oa70MPkzgl1eQv1L0CwdHNM3gscO8/GDMGIET98Ja47CBbZg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@nuxt/kit': ^3.2.2 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + unplugin@3.0.0: + resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} + engines: {node: ^20.19.0 || >=22.12.0} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + url-join@5.0.0: + resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + + vant@4.9.24: + resolution: {integrity: sha512-tP1A7Vjzv1/B1ljb95Jhv9Q9w6acaaZDJvy6wcKrwGgY0gQZlg+FXLZH/AIKZBE3xvYGDUsv/M7AuGcr/Pqd6A==} + peerDependencies: + vue: ^3.0.0 + + vdirs@0.1.8: + resolution: {integrity: sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==} + peerDependencies: + vue: ^3.0.11 + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite-plugin-vue-setup-extend@0.4.0: + resolution: {integrity: sha512-WMbjPCui75fboFoUTHhdbXzu4Y/bJMv5N9QT9a7do3wNMNHHqrk+Tn2jrSJU0LS5fGl/EG+FEDBYVUeWIkDqXQ==} + peerDependencies: + vite: '>=2.0.0' + + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vooks@0.2.12: + resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==} + peerDependencies: + vue: ^3.0.0 + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-component-type-helpers@3.2.8: + resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==} + + vue-cropper@1.1.4: + resolution: {integrity: sha512-5m98vBsCEI9rbS4JxELxXidtAui3qNyTHLHg67Qbn7g8cg+E6LcnC+hh3SM/p94x6mFh6KRxT1ttnta+wCYqWA==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-i18n@11.4.0: + resolution: {integrity: sha512-gxLVtcwdvOgwKSzkdb7nHKlW0N85A6aDNmHLnq6V+3w2/BXy/os5l71P7TIlgIQTxX0zJjiz89iImoHi51GieQ==} + engines: {node: '>= 16'} + peerDependencies: + vue: ^3.0.0 + + vue-router@5.0.6: + resolution: {integrity: sha512-9+kmUTGbKMyW9Asoy98IXXYIzrTMT7JDAdpDDeEkorHvybpUvBI2wsrSM5jFOXrFydpzRFJ9vAh+80DN2PGu9w==} + peerDependencies: + '@pinia/colada': '>=0.21.2' + '@vue/compiler-sfc': ^3.5.17 + pinia: ^3.0.4 + vue: ^3.5.0 + peerDependenciesMeta: + '@pinia/colada': + optional: true + '@vue/compiler-sfc': + optional: true + pinia: + optional: true + + vue-tsc@3.2.8: + resolution: {integrity: sha512-27vTLJ6Q2370obOd0PFYoYoKnmXJ521uUIedrs3Zhhhg/8YG10VOCMmwt+JQslatpAMTDbnWiitLnoD5VlIvog==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue-virtual-scroller@3.0.2: + resolution: {integrity: sha512-zneKxsBecfFBpH2NAk8zF0IWBRSYRaXnXK/dPQ/AnXE9xlbRyW6sSO9EUb5L5cSFz2Dpfb2giN30/y+/Xvu5PQ==} + peerDependencies: + vue: ^3.3.0 + + vue@3.5.33: + resolution: {integrity: sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + vueuc@0.4.65: + resolution: {integrity: sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==} + peerDependencies: + vue: ^3.0.11 + + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-vitals@5.2.0: + resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wildcard-match@5.1.4: + resolution: {integrity: sha512-wldeCaczs8XXq7hj+5d/F38JE2r7EXgb6WQDM84RVwxy81T/sxB5e9+uZLK9Q9oNz1mlvjut+QtvgaOQFPVq/g==} + + windows-release@7.1.1: + resolution: {integrity: sha512-0GBwC9WmR8Bm3WYiz3FC391054BsFHZ2gzBVdYj9uj5eIVYzbn/YPYCYW9SWdh9vwnLuzpn1UGwJKiMG4F236w==} + engines: {node: '>=20'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.8.4: + resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@actions/github@9.1.1': + dependencies: + '@actions/http-client': 3.0.2 + '@octokit/core': 7.0.6 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) + '@octokit/request': 10.0.8 + '@octokit/request-error': 7.1.0 + undici: 6.25.0 + + '@actions/http-client@3.0.2': + dependencies: + tunnel: 0.0.6 + undici: 6.25.0 + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.1.2 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.28.6': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + + '@biomejs/biome@2.4.14': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.14 + '@biomejs/cli-darwin-x64': 2.4.14 + '@biomejs/cli-linux-arm64': 2.4.14 + '@biomejs/cli-linux-arm64-musl': 2.4.14 + '@biomejs/cli-linux-x64': 2.4.14 + '@biomejs/cli-linux-x64-musl': 2.4.14 + '@biomejs/cli-win32-arm64': 2.4.14 + '@biomejs/cli-win32-x64': 2.4.14 + + '@biomejs/cli-darwin-arm64@2.4.14': + optional: true + + '@biomejs/cli-darwin-x64@2.4.14': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.14': + optional: true + + '@biomejs/cli-linux-arm64@2.4.14': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.14': + optional: true + + '@biomejs/cli-linux-x64@2.4.14': + optional: true + + '@biomejs/cli-win32-arm64@2.4.14': + optional: true + + '@biomejs/cli-win32-x64@2.4.14': + optional: true + + '@borewit/text-codec@0.2.2': {} + + '@braintree/sanitize-url@7.1.2': {} + + '@breezystack/lamejs@1.2.7': {} + + '@chenglou/pretext@0.0.5': {} + + '@chevrotain/cst-dts-gen@12.0.0': + dependencies: + '@chevrotain/gast': 12.0.0 + '@chevrotain/types': 12.0.0 + + '@chevrotain/gast@12.0.0': + dependencies: + '@chevrotain/types': 12.0.0 + + '@chevrotain/regexp-to-ast@12.0.0': {} + + '@chevrotain/types@12.0.0': {} + + '@chevrotain/utils@12.0.0': {} + + '@commitlint/cli@20.5.3(@types/node@25.6.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@6.0.3)': + dependencies: + '@commitlint/format': 20.5.0 + '@commitlint/lint': 20.5.3 + '@commitlint/load': 20.5.3(@types/node@25.6.0)(typescript@6.0.3) + '@commitlint/read': 20.5.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) + '@commitlint/types': 20.5.0 + tinyexec: 1.1.2 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - conventional-commits-filter + - conventional-commits-parser + - typescript + + '@commitlint/config-conventional@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-conventionalcommits: 8.0.0 + + '@commitlint/config-validator@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + ajv: 8.20.0 + + '@commitlint/ensure@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + es-toolkit: 1.46.1 + + '@commitlint/execute-rule@20.0.0': {} + + '@commitlint/format@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + picocolors: 1.1.1 + + '@commitlint/is-ignored@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + semver: 7.7.4 + + '@commitlint/lint@20.5.3': + dependencies: + '@commitlint/is-ignored': 20.5.0 + '@commitlint/parse': 20.5.0 + '@commitlint/rules': 20.5.3 + '@commitlint/types': 20.5.0 + + '@commitlint/load@20.5.3(@types/node@25.6.0)(typescript@6.0.3)': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/execute-rule': 20.0.0 + '@commitlint/resolve-extends': 20.5.3 + '@commitlint/types': 20.5.0 + cosmiconfig: 9.0.1(typescript@6.0.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@25.6.0)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3) + es-toolkit: 1.46.1 + is-plain-obj: 4.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@20.4.3': {} + + '@commitlint/parse@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-angular: 8.3.1 + conventional-commits-parser: 6.4.0 + + '@commitlint/read@20.5.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)': + dependencies: + '@commitlint/top-level': 20.4.3 + '@commitlint/types': 20.5.0 + git-raw-commits: 5.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) + minimist: 1.2.8 + tinyexec: 1.1.2 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@commitlint/resolve-extends@20.5.3': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/types': 20.5.0 + es-toolkit: 1.46.1 + global-directory: 5.0.0 + import-meta-resolve: 4.2.0 + resolve-from: 5.0.0 + + '@commitlint/rules@20.5.3': + dependencies: + '@commitlint/ensure': 20.5.3 + '@commitlint/message': 20.4.3 + '@commitlint/to-lines': 20.0.0 + '@commitlint/types': 20.5.0 + + '@commitlint/to-lines@20.0.0': {} + + '@commitlint/top-level@20.4.3': + dependencies: + escalade: 3.2.0 + + '@commitlint/types@20.5.0': + dependencies: + conventional-commits-parser: 6.4.0 + picocolors: 1.1.1 + + '@conventional-changelog/git-client@2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)': + dependencies: + '@simple-libs/child-process-utils': 1.0.2 + '@simple-libs/stream-utils': 1.2.0 + semver: 7.7.4 + optionalDependencies: + conventional-commits-filter: 5.0.0 + conventional-commits-parser: 6.4.0 + + '@css-render/plugin-bem@0.15.14(css-render@0.15.14)': + dependencies: + css-render: 0.15.14 + + '@css-render/vue3-ssr@0.15.14(vue@3.5.33(typescript@6.0.3))': + dependencies: + vue: 3.5.33(typescript@6.0.3) + + '@dimforge/rapier3d-compat@0.12.0': {} + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emotion/hash@0.8.0': {} + + '@fingerprintjs/fingerprintjs@5.2.0': {} + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@iarna/toml@3.0.0': {} + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.1': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.2 + + '@iconify/vue@5.0.0(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@iconify/types': 2.0.0 + vue: 3.5.33(typescript@6.0.3) + + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-s390x@1.0.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-s390x@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true + + '@img/sharp-wasm32@0.33.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-ia32@0.33.5': + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true + + '@inquirer/ansi@2.0.5': {} + + '@inquirer/checkbox@5.1.4(@types/node@25.6.0)': + dependencies: + '@inquirer/ansi': 2.0.5 + '@inquirer/core': 11.1.9(@types/node@25.6.0) + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/confirm@6.0.12(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 11.1.9(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/core@11.1.9(@types/node@25.6.0)': + dependencies: + '@inquirer/ansi': 2.0.5 + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@25.6.0) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/editor@5.1.1(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 11.1.9(@types/node@25.6.0) + '@inquirer/external-editor': 3.0.0(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/expand@5.0.13(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 11.1.9(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/external-editor@3.0.0(@types/node@25.6.0)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/figures@2.0.5': {} + + '@inquirer/input@5.0.12(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 11.1.9(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/number@4.0.12(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 11.1.9(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/password@5.0.12(@types/node@25.6.0)': + dependencies: + '@inquirer/ansi': 2.0.5 + '@inquirer/core': 11.1.9(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/prompts@8.4.2(@types/node@25.6.0)': + dependencies: + '@inquirer/checkbox': 5.1.4(@types/node@25.6.0) + '@inquirer/confirm': 6.0.12(@types/node@25.6.0) + '@inquirer/editor': 5.1.1(@types/node@25.6.0) + '@inquirer/expand': 5.0.13(@types/node@25.6.0) + '@inquirer/input': 5.0.12(@types/node@25.6.0) + '@inquirer/number': 4.0.12(@types/node@25.6.0) + '@inquirer/password': 5.0.12(@types/node@25.6.0) + '@inquirer/rawlist': 5.2.8(@types/node@25.6.0) + '@inquirer/search': 4.1.8(@types/node@25.6.0) + '@inquirer/select': 5.1.4(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/rawlist@5.2.8(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 11.1.9(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/search@4.1.8(@types/node@25.6.0)': + dependencies: + '@inquirer/core': 11.1.9(@types/node@25.6.0) + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/select@5.1.4(@types/node@25.6.0)': + dependencies: + '@inquirer/ansi': 2.0.5 + '@inquirer/core': 11.1.9(@types/node@25.6.0) + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@25.6.0) + optionalDependencies: + '@types/node': 25.6.0 + + '@inquirer/type@4.0.5(@types/node@25.6.0)': + optionalDependencies: + '@types/node': 25.6.0 + + '@intlify/core-base@11.4.0': + dependencies: + '@intlify/devtools-types': 11.4.0 + '@intlify/message-compiler': 11.4.0 + '@intlify/shared': 11.4.0 + + '@intlify/devtools-types@11.4.0': + dependencies: + '@intlify/core-base': 11.4.0 + '@intlify/shared': 11.4.0 + + '@intlify/message-compiler@11.4.0': + dependencies: + '@intlify/shared': 11.4.0 + source-map-js: 1.2.1 + + '@intlify/shared@11.4.0': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@juggle/resize-observer@3.4.0': {} + + '@mermaid-js/parser@1.1.0': + dependencies: + langium: 4.2.3 + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@octokit/auth-token@6.0.0': {} + + '@octokit/core@7.0.6': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.3 + '@octokit/request': 10.0.8 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 + + '@octokit/endpoint@11.0.3': + dependencies: + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/graphql@9.0.3': + dependencies: + '@octokit/request': 10.0.8 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/openapi-types@27.0.0': {} + + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + + '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + + '@octokit/request-error@7.1.0': + dependencies: + '@octokit/types': 16.0.0 + + '@octokit/request@10.0.8': + dependencies: + '@octokit/endpoint': 11.0.3 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + fast-content-type-parse: 3.0.0 + json-with-bigint: 3.5.8 + universal-user-agent: 7.0.3 + + '@octokit/rest@22.0.1': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) + '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) + + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + + '@one-ini/wasm@0.1.1': {} + + '@oxc-project/types@0.127.0': {} + + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.4 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + optional: true + + '@phun-ky/typeof@2.0.3': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@polka/url@1.0.0-next.29': {} + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@release-it/bumper@7.0.5(release-it@20.0.1(@types/node@25.6.0)(magicast@0.5.2))': + dependencies: + '@iarna/toml': 3.0.0 + cheerio: 1.2.0 + detect-indent: 7.0.1 + fast-glob: 3.3.3 + ini: 5.0.0 + js-yaml: 4.1.1 + lodash-es: 4.18.1 + release-it: 20.0.1(@types/node@25.6.0)(magicast@0.5.2) + semver: 7.7.4 + + '@release-it/conventional-changelog@10.0.6(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.0.1(@types/node@25.6.0)(magicast@0.5.2))': + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) + concat-stream: 2.0.0 + conventional-changelog: 7.2.0(conventional-commits-filter@5.0.0) + conventional-changelog-angular: 8.3.1 + conventional-changelog-conventionalcommits: 8.0.0 + conventional-recommended-bump: 11.2.0 + release-it: 20.0.1(@types/node@25.6.0)(magicast@0.5.2) + semver: 7.7.4 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.13': {} + + '@rolldown/pluginutils@1.0.0-rc.17': {} + + '@rolldown/pluginutils@1.0.0-rc.18': {} + + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/monaco@3.23.0': + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@simple-libs/child-process-utils@1.0.2': + dependencies: + '@simple-libs/stream-utils': 1.2.0 + + '@simple-libs/hosted-git-info@1.0.2': {} + + '@simple-libs/stream-utils@1.2.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@tauri-apps/api@2.11.0': {} + + '@tauri-apps/cli-darwin-arm64@2.9.5': + optional: true + + '@tauri-apps/cli-darwin-x64@2.9.5': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.9.5': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.9.5': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.9.5': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.9.5': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.9.5': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.9.5': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.9.5': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.9.5': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.9.5': + optional: true + + '@tauri-apps/cli@2.9.5': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.9.5 + '@tauri-apps/cli-darwin-x64': 2.9.5 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.9.5 + '@tauri-apps/cli-linux-arm64-gnu': 2.9.5 + '@tauri-apps/cli-linux-arm64-musl': 2.9.5 + '@tauri-apps/cli-linux-riscv64-gnu': 2.9.5 + '@tauri-apps/cli-linux-x64-gnu': 2.9.5 + '@tauri-apps/cli-linux-x64-musl': 2.9.5 + '@tauri-apps/cli-win32-arm64-msvc': 2.9.5 + '@tauri-apps/cli-win32-ia32-msvc': 2.9.5 + '@tauri-apps/cli-win32-x64-msvc': 2.9.5 + + '@tauri-apps/plugin-autostart@2.5.1': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-barcode-scanner@2.4.2': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-clipboard-manager@2.3.2': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-dialog@2.7.1': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-fs@2.5.1': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-global-shortcut@2.3.1': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-http@2.5.4': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-log@2.8.0': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-notification@2.3.3': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-opener@2.5.4': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-os@2.3.2': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-process@2.3.1': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-shell@2.3.5': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-sql@2.4.0': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-updater@2.10.1': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@tweenjs/tween.js@23.1.3': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/crypto-js@4.2.2': {} + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/geojson@7946.0.16': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/linkify-it@5.0.0': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.24 + + '@types/lodash@4.17.24': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + + '@types/normalize-package-data@2.4.4': {} + + '@types/parse-path@7.1.0': + dependencies: + parse-path: 7.1.0 + + '@types/stats.js@0.17.4': {} + + '@types/three@0.184.0': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.2 + meshoptimizer: 1.1.1 + + '@types/trusted-types@2.0.7': + optional: true + + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.21': {} + + '@types/webxr@0.5.24': {} + + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.6.0 + + '@ungap/structured-clone@1.3.0': {} + + '@unocss/config@66.6.8': + dependencies: + '@unocss/core': 66.6.8 + colorette: 2.0.20 + consola: 3.4.2 + unconfig: 7.5.0 + + '@unocss/core@66.6.8': {} + + '@unocss/extractor-arbitrary-variants@66.6.8': + dependencies: + '@unocss/core': 66.6.8 + + '@unocss/inspector@66.6.8': + dependencies: + '@unocss/core': 66.6.8 + '@unocss/rule-utils': 66.6.8 + colorette: 2.0.20 + gzip-size: 6.0.0 + sirv: 3.0.2 + + '@unocss/preset-mini@66.6.8': + dependencies: + '@unocss/core': 66.6.8 + '@unocss/extractor-arbitrary-variants': 66.6.8 + '@unocss/rule-utils': 66.6.8 + + '@unocss/preset-wind3@66.6.8': + dependencies: + '@unocss/core': 66.6.8 + '@unocss/preset-mini': 66.6.8 + '@unocss/rule-utils': 66.6.8 + + '@unocss/reset@66.6.8': {} + + '@unocss/rule-utils@66.6.8': + dependencies: + '@unocss/core': 66.6.8 + magic-string: 0.30.21 + + '@unocss/transformer-directives@66.6.8': + dependencies: + '@unocss/core': 66.6.8 + '@unocss/rule-utils': 66.6.8 + css-tree: 3.2.1 + + '@unocss/transformer-variant-group@66.6.8': + dependencies: + '@unocss/core': 66.6.8 + + '@unocss/vite@66.6.8(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4))': + dependencies: + '@jridgewell/remapping': 2.3.5 + '@unocss/config': 66.6.8 + '@unocss/core': 66.6.8 + '@unocss/inspector': 66.6.8 + chokidar: 5.0.0 + magic-string: 0.30.21 + pathe: 2.0.3 + tinyglobby: 0.2.16 + unplugin-utils: 0.3.1 + vite: 8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4) + + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + '@vant/area-data@2.1.0': {} + + '@vant/popperjs@1.3.0': {} + + '@vant/use@1.6.0(vue@3.5.33(typescript@6.0.3))': + dependencies: + vue: 3.5.33(typescript@6.0.3) + + '@vitejs/plugin-vue-jsx@5.1.5(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4))(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.18 + '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.0) + vite: 8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4) + vue: 3.5.33(typescript@6.0.3) + transitivePeerDependencies: + - supports-color + + '@vitejs/plugin-vue@6.0.6(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4))(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.13 + vite: 8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4) + vue: 3.5.33(typescript@6.0.3) + + '@vitest/coverage-v8@4.1.5(vitest@4.1.5)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.5 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(happy-dom@20.9.0)(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4)) + + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/ui@4.1.5(vitest@4.1.5)': + dependencies: + '@vitest/utils': 4.1.5 + fflate: 0.8.2 + flatted: 3.4.2 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vitest: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(happy-dom@20.9.0)(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4)) + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue-macros/common@3.1.2(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@vue/compiler-sfc': 3.5.33 + ast-kit: 2.2.0 + local-pkg: 1.1.2 + magic-string-ast: 1.0.3 + unplugin-utils: 0.3.1 + optionalDependencies: + vue: 3.5.33(typescript@6.0.3) + + '@vue-office/docx@1.6.3(vue-demi@0.14.10(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))': + dependencies: + vue: 3.5.33(typescript@6.0.3) + vue-demi: 0.14.10(vue@3.5.33(typescript@6.0.3)) + + '@vue-office/excel@1.7.14(vue-demi@0.14.10(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))': + dependencies: + vue: 3.5.33(typescript@6.0.3) + vue-demi: 0.14.10(vue@3.5.33(typescript@6.0.3)) + + '@vue-office/pdf@2.0.10(vue-demi@0.14.10(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))': + dependencies: + vue: 3.5.33(typescript@6.0.3) + vue-demi: 0.14.10(vue@3.5.33(typescript@6.0.3)) + + '@vue-office/pptx@1.0.1(vue-demi@0.14.10(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))': + dependencies: + vue: 3.5.33(typescript@6.0.3) + vue-demi: 0.14.10(vue@3.5.33(typescript@6.0.3)) + + '@vue/babel-helper-vue-transform-on@2.0.1': {} + + '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.0)': + dependencies: + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@vue/babel-helper-vue-transform-on': 2.0.1 + '@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.0) + '@vue/shared': 3.5.33 + optionalDependencies: + '@babel/core': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@vue/babel-plugin-resolve-type@2.0.1(@babel/core@7.29.0)': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/parser': 7.29.3 + '@vue/compiler-sfc': 3.5.33 + transitivePeerDependencies: + - supports-color + + '@vue/compiler-core@3.5.33': + dependencies: + '@babel/parser': 7.29.3 + '@vue/shared': 3.5.33 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.33': + dependencies: + '@vue/compiler-core': 3.5.33 + '@vue/shared': 3.5.33 + + '@vue/compiler-sfc@3.5.33': + dependencies: + '@babel/parser': 7.29.3 + '@vue/compiler-core': 3.5.33 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-ssr': 3.5.33 + '@vue/shared': 3.5.33 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.14 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.33': + dependencies: + '@vue/compiler-dom': 3.5.33 + '@vue/shared': 3.5.33 + + '@vue/devtools-api@6.6.4': {} + + '@vue/devtools-api@7.7.9': + dependencies: + '@vue/devtools-kit': 7.7.9 + + '@vue/devtools-api@8.1.1': + dependencies: + '@vue/devtools-kit': 8.1.1 + + '@vue/devtools-kit@7.7.9': + dependencies: + '@vue/devtools-shared': 7.7.9 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-kit@8.1.1': + dependencies: + '@vue/devtools-shared': 8.1.1 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@7.7.9': + dependencies: + rfdc: 1.4.1 + + '@vue/devtools-shared@8.1.1': {} + + '@vue/language-core@3.2.8': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.33 + '@vue/shared': 3.5.33 + alien-signals: 3.1.2 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.4 + + '@vue/reactivity@3.5.33': + dependencies: + '@vue/shared': 3.5.33 + + '@vue/runtime-core@3.5.33': + dependencies: + '@vue/reactivity': 3.5.33 + '@vue/shared': 3.5.33 + + '@vue/runtime-dom@3.5.33': + dependencies: + '@vue/reactivity': 3.5.33 + '@vue/runtime-core': 3.5.33 + '@vue/shared': 3.5.33 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.33(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@vue/compiler-ssr': 3.5.33 + '@vue/shared': 3.5.33 + vue: 3.5.33(typescript@6.0.3) + + '@vue/shared@3.5.33': {} + + '@vue/test-utils@2.4.10(@vue/compiler-dom@3.5.33)(@vue/server-renderer@3.5.33(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@vue/compiler-dom': 3.5.33 + js-beautify: 1.15.4 + vue: 3.5.33(typescript@6.0.3) + vue-component-type-helpers: 3.2.8 + optionalDependencies: + '@vue/server-renderer': 3.5.33(vue@3.5.33(typescript@6.0.3)) + + '@vueuse/components@14.3.0(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@vueuse/core': 14.3.0(vue@3.5.33(typescript@6.0.3)) + '@vueuse/shared': 14.3.0(vue@3.5.33(typescript@6.0.3)) + vue: 3.5.33(typescript@6.0.3) + + '@vueuse/core@14.3.0(vue@3.5.33(typescript@6.0.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.3.0 + '@vueuse/shared': 14.3.0(vue@3.5.33(typescript@6.0.3)) + vue: 3.5.33(typescript@6.0.3) + + '@vueuse/metadata@14.3.0': {} + + '@vueuse/shared@14.3.0(vue@3.5.33(typescript@6.0.3))': + dependencies: + vue: 3.5.33(typescript@6.0.3) + + abbrev@4.0.0: {} + + acorn@8.16.0: {} + + agent-base@8.0.0: {} + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + alien-signals@2.0.8: {} + + alien-signals@3.1.2: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + argparse@2.0.1: {} + + array-ify@1.0.0: {} + + assertion-error@2.0.1: {} + + ast-kit@2.2.0: + dependencies: + '@babel/parser': 7.29.3 + pathe: 2.0.3 + + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + + ast-v8-to-istanbul@1.0.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + ast-walker-scope@0.8.3: + dependencies: + '@babel/parser': 7.29.3 + ast-kit: 2.2.0 + + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + + async-validator@4.2.5: {} + + at-least-node@1.0.0: {} + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.27: {} + + basic-ftp@5.3.1: {} + + before-after-hook@4.0.0: {} + + birpc@2.9.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + boolbase@1.0.0: {} + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + broadcast-channel@7.3.0: + dependencies: + '@babel/runtime': 7.28.6 + oblivious-set: 2.0.0 + p-queue: 6.6.2 + unload: 2.4.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.27 + caniuse-lite: 1.0.30001791 + electron-to-chromium: 1.5.349 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + c12@3.3.3(magicast@0.5.2): + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.5.2 + + cachedir@2.3.0: {} + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001791: {} + + ccount@2.0.1: {} + + chai@6.2.2: {} + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + chardet@0.7.0: {} + + chardet@2.1.1: {} + + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.2.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.25.0 + whatwg-mimetype: 4.0.0 + + chevrotain-allstar@0.4.3(chevrotain@12.0.0): + dependencies: + chevrotain: 12.0.0 + lodash-es: 4.18.1 + + chevrotain@12.0.0: + dependencies: + '@chevrotain/cst-dts-gen': 12.0.0 + '@chevrotain/gast': 12.0.0 + '@chevrotain/regexp-to-ast': 12.0.0 + '@chevrotain/types': 12.0.0 + '@chevrotain/utils': 12.0.0 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + ci-info@4.4.0: {} + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.2: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-spinners@3.4.0: {} + + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.1 + + cli-width@3.0.0: {} + + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone-regexp@3.0.0: + dependencies: + is-regexp: 3.1.0 + + clone@1.0.4: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + optional: true + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + optional: true + + colorette@2.0.20: {} + + colorthief@3.3.1(sharp@0.33.5): + optionalDependencies: + sharp: 0.33.5 + + comma-separated-tokens@2.0.3: {} + + commander@10.0.1: {} + + commander@14.0.3: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + commitizen@4.3.1(@types/node@25.6.0)(typescript@6.0.3): + dependencies: + cachedir: 2.3.0 + cz-conventional-changelog: 3.3.0(@types/node@25.6.0)(typescript@6.0.3) + dedent: 0.7.0 + detect-indent: 6.1.0 + find-node-modules: 2.1.3 + find-root: 1.1.0 + fs-extra: 9.1.0 + glob: 7.2.3 + inquirer: 8.2.5 + is-utf8: 0.2.1 + lodash: 4.18.1 + minimist: 1.2.7 + strip-bom: 4.0.0 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + concat-map@0.0.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + consola@3.4.2: {} + + conventional-changelog-angular@8.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@8.0.0: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-preset-loader@5.0.0: {} + + conventional-changelog-writer@8.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + conventional-commits-filter: 5.0.0 + handlebars: 4.7.9 + meow: 13.2.0 + semver: 7.7.4 + + conventional-changelog@7.2.0(conventional-commits-filter@5.0.0): + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) + '@simple-libs/hosted-git-info': 1.0.2 + '@types/normalize-package-data': 2.4.4 + conventional-changelog-preset-loader: 5.0.0 + conventional-changelog-writer: 8.4.0 + conventional-commits-parser: 6.4.0 + fd-package-json: 2.0.0 + meow: 13.2.0 + normalize-package-data: 7.0.1 + transitivePeerDependencies: + - conventional-commits-filter + + conventional-commit-types@3.0.0: {} + + conventional-commits-filter@5.0.0: {} + + conventional-commits-parser@6.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 + + conventional-recommended-bump@11.2.0: + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) + conventional-changelog-preset-loader: 5.0.0 + conventional-commits-filter: 5.0.0 + conventional-commits-parser: 6.4.0 + meow: 13.2.0 + + convert-hrtime@5.0.0: {} + + convert-source-map@2.0.0: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + + cosmiconfig-typescript-loader@6.3.0(@types/node@25.6.0)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3): + dependencies: + '@types/node': 25.6.0 + cosmiconfig: 9.0.1(typescript@6.0.3) + jiti: 2.6.1 + typescript: 6.0.3 + + cosmiconfig@9.0.1(typescript@6.0.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 6.0.3 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypto-js@4.2.0: {} + + css-render@0.15.14: + dependencies: + '@emotion/hash': 0.8.0 + csstype: 3.0.11 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@6.2.2: {} + + csstype@3.0.11: {} + + csstype@3.2.3: {} + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.3): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.3 + + cytoscape-fcose@2.2.0(cytoscape@3.33.3): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.3 + + cytoscape@3.33.3: {} + + cz-conventional-changelog@3.3.0(@types/node@25.6.0)(typescript@6.0.3): + dependencies: + chalk: 2.4.2 + commitizen: 4.3.1(@types/node@25.6.0)(typescript@6.0.3) + conventional-commit-types: 3.0.0 + lodash.map: 4.6.0 + longest: 2.0.1 + word-wrap: 1.2.5 + optionalDependencies: + '@commitlint/load': 20.5.3(@types/node@25.6.0)(typescript@6.0.3) + transitivePeerDependencies: + - '@types/node' + - typescript + + cz-git@1.13.0: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + + data-uri-to-buffer@7.0.0: {} + + date-fns-tz@3.2.0(date-fns@4.1.0): + dependencies: + date-fns: 4.1.0 + + date-fns@4.1.0: {} + + dayjs@1.11.20: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dedent@0.7.0: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-lazy-prop@3.0.0: {} + + defu@6.1.7: {} + + degenerator@6.0.0(quickjs-wasi@0.0.1): + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + quickjs-wasi: 0.0.1 + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-file@1.0.0: {} + + detect-indent@6.1.0: {} + + detect-indent@7.0.1: {} + + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + digest-wasm@0.1.4: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dompurify@3.4.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + dotenv@17.4.2: {} + + driver.js@1.4.0: {} + + duplexer@0.1.2: {} + + eastasianwidth@0.2.0: {} + + editorconfig@1.0.7: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.9 + semver: 7.7.4 + + electron-to-chromium@1.5.349: {} + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + env-paths@2.2.1: {} + + environment@1.1.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + eruda@3.4.3: {} + + es-module-lexer@2.1.0: {} + + es-toolkit@1.46.1: {} + + escalade@3.2.0: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@5.0.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + esprima@4.0.1: {} + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + eta@4.5.1: {} + + eventemitter3@4.0.7: {} + + eventemitter3@5.0.4: {} + + evtd@0.2.4: {} + + expand-tilde@2.0.2: + dependencies: + homedir-polyfill: 1.0.3 + + expect-type@1.3.0: {} + + exsolve@1.0.8: {} + + external-editor@3.1.0: + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + + fast-content-type-parse@3.0.0: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.2: {} + + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fflate@0.8.2: {} + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + file-type@22.0.1: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-node-modules@2.1.3: + dependencies: + findup-sync: 4.0.0 + merge: 2.1.1 + + find-root@1.1.0: {} + + findup-sync@4.0.0: + dependencies: + detect-file: 1.0.0 + is-glob: 4.0.3 + micromatch: 4.0.8 + resolve-dir: 1.0.1 + + flatted@3.4.2: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-timeout@0.1.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.5.0: {} + + get-uri@7.0.0: + dependencies: + basic-ftp: 5.3.1 + data-uri-to-buffer: 7.0.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.7 + node-fetch-native: 1.6.7 + nypm: 0.6.6 + pathe: 2.0.3 + + git-raw-commits@5.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0): + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) + meow: 13.2.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + git-up@8.1.1: + dependencies: + is-ssh: 1.4.1 + parse-url: 9.2.0 + + git-url-parse@16.1.0: + dependencies: + git-up: 8.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-directory@5.0.0: + dependencies: + ini: 6.0.0 + + global-modules@1.0.0: + dependencies: + global-prefix: 1.0.2 + is-windows: 1.0.2 + resolve-dir: 1.0.1 + + global-prefix@1.0.2: + dependencies: + expand-tilde: 2.0.2 + homedir-polyfill: 1.0.3 + ini: 1.3.8 + is-windows: 1.0.2 + which: 1.3.1 + + graceful-fs@4.2.11: {} + + grapheme-splitter@1.0.4: {} + + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + + hachure-fill@0.5.2: {} + + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + happy-dom@20.9.0: + dependencies: + '@types/node': 25.6.0 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + highlight.js@11.11.1: {} + + homedir-polyfill@1.0.3: + dependencies: + parse-passwd: 1.0.0 + + hookable@5.5.3: {} + + hosted-git-info@8.1.0: + dependencies: + lru-cache: 10.4.3 + + html-escaper@2.0.2: {} + + html-void-elements@3.0.0: {} + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + http-proxy-agent@8.0.0: + dependencies: + agent-base: 8.0.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@8.0.0: + dependencies: + agent-base: 8.0.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + hula-emojis@1.3.12: {} + + husky@9.1.7: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + immutable@5.1.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.2.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ini@5.0.0: {} + + ini@6.0.0: {} + + inquirer@8.2.5: + dependencies: + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + external-editor: 3.1.0 + figures: 3.2.0 + lodash: 4.18.1 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.8.2 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 7.0.0 + + internal-ip@9.0.0: + dependencies: + is-ip: 5.0.1 + p-event: 7.1.0 + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + ip-address@10.2.0: {} + + ip-regex@5.0.0: {} + + is-arrayish@0.2.1: {} + + is-arrayish@0.3.4: + optional: true + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.5.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@1.0.0: {} + + is-interactive@2.0.0: {} + + is-ip@5.0.1: + dependencies: + ip-regex: 5.0.0 + super-regex: 0.2.0 + + is-number@7.0.0: {} + + is-obj@2.0.0: {} + + is-plain-obj@4.1.0: {} + + is-regexp@3.1.0: {} + + is-ssh@1.4.1: + dependencies: + protocols: 2.0.2 + + is-unicode-supported@0.1.0: {} + + is-unicode-supported@2.1.0: {} + + is-utf8@0.2.1: {} + + is-what@5.5.0: {} + + is-windows@1.0.2: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + issue-parser@7.0.1: + dependencies: + lodash.capitalize: 4.2.1 + lodash.escaperegexp: 4.1.2 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.uniqby: 4.7.0 + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@2.6.1: {} + + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.7 + glob: 10.5.0 + js-cookie: 3.0.5 + nopt: 9.0.0 + + js-cookie@3.0.5: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@1.0.0: {} + + json-with-bigint@3.5.8: {} + + json5@2.2.3: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + katex@0.16.45: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + + langium@4.2.3: + dependencies: + '@chevrotain/regexp-to-ast': 12.0.0 + chevrotain: 12.0.0 + chevrotain-allstar: 0.4.3(chevrotain@12.0.0) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + lint-staged@16.4.0: + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + picomatch: 4.0.4 + string-argv: 0.3.2 + tinyexec: 1.1.2 + yaml: 2.8.4 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.1 + quansync: 0.2.11 + + lodash-es@4.18.1: {} + + lodash.capitalize@4.2.1: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.map@4.6.0: {} + + lodash.merge@4.6.2: {} + + lodash.uniqby@4.7.0: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + longest@2.0.1: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru-cache@7.18.3: {} + + macos-release@3.4.0: {} + + magic-string-ast@1.0.3: + dependencies: + magic-string: 0.30.21 + + magic-string@0.25.9: + dependencies: + sourcemap-codec: 1.4.8 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + + markdown-it-container@4.0.0: {} + + markdown-it-footnote@4.0.0: {} + + markdown-it-ins@4.0.0: {} + + markdown-it-mark@4.0.0: {} + + markdown-it-sub@2.0.0: {} + + markdown-it-sup@2.0.0: {} + + markdown-it-task-checkbox@1.0.6: {} + + markdown-it-ts@1.0.0: + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + marked@14.0.0: {} + + marked@16.4.2: {} + + markstream-core@1.0.0: {} + + markstream-vue@1.0.1-beta.1(katex@0.16.45)(mermaid@11.14.0)(stream-markdown@0.0.15(shiki@3.23.0)(vue@3.5.33(typescript@6.0.3)))(stream-monaco@0.0.41(monaco-editor@0.55.1))(vue-i18n@11.4.0(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)): + dependencies: + '@chenglou/pretext': 0.0.5 + '@floating-ui/dom': 1.7.6 + markstream-core: 1.0.0 + stream-markdown-parser: 1.0.0 + vue: 3.5.33(typescript@6.0.3) + optionalDependencies: + katex: 0.16.45 + mermaid: 11.14.0 + stream-markdown: 0.0.15(shiki@3.23.0)(vue@3.5.33(typescript@6.0.3)) + stream-monaco: 0.0.41(monaco-editor@0.55.1) + vue-i18n: 11.4.0(vue@3.5.33(typescript@6.0.3)) + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdn-data@2.27.1: {} + + mdurl@2.0.0: {} + + meow@13.2.0: {} + + merge2@1.4.1: {} + + merge@2.1.1: {} + + mermaid@11.14.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.1 + '@mermaid-js/parser': 1.1.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.33.3 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.3) + cytoscape-fcose: 2.2.0(cytoscape@3.33.3) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.20 + dompurify: 3.4.2 + katex: 0.16.45 + khroma: 2.1.0 + lodash-es: 4.18.1 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.2.0 + uuid: 11.1.1 + + meshoptimizer@1.1.1: {} + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + mimic-function@5.0.1: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + + minimist@1.2.7: {} + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mitt@3.0.1: {} + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + mute-stream@0.0.8: {} + + mute-stream@3.0.0: {} + + naive-ui@2.44.1(vue@3.5.33(typescript@6.0.3)): + dependencies: + '@css-render/plugin-bem': 0.15.14(css-render@0.15.14) + '@css-render/vue3-ssr': 0.15.14(vue@3.5.33(typescript@6.0.3)) + '@types/lodash': 4.17.24 + '@types/lodash-es': 4.17.12 + async-validator: 4.2.5 + css-render: 0.15.14 + csstype: 3.2.3 + date-fns: 4.1.0 + date-fns-tz: 3.2.0(date-fns@4.1.0) + evtd: 0.2.4 + highlight.js: 11.11.1 + lodash: 4.18.1 + lodash-es: 4.18.1 + seemly: 0.3.10 + treemate: 0.3.11 + vdirs: 0.1.8(vue@3.5.33(typescript@6.0.3)) + vooks: 0.2.12(vue@3.5.33(typescript@6.0.3)) + vue: 3.5.33(typescript@6.0.3) + vueuc: 0.4.65(vue@3.5.33(typescript@6.0.3)) + + nanoid@3.3.12: {} + + neo-async@2.6.2: {} + + netmask@2.1.1: {} + + new-github-release-url@2.0.0: + dependencies: + type-fest: 2.19.0 + + node-addon-api@7.1.1: + optional: true + + node-fetch-native@1.6.7: {} + + node-releases@2.0.38: {} + + nopt@9.0.0: + dependencies: + abbrev: 4.0.0 + + normalize-package-data@7.0.1: + dependencies: + hosted-git-info: 8.1.0 + semver: 7.7.4 + validate-npm-package-license: 3.0.4 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nypm@0.6.6: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.1.2 + + oblivious-set@2.0.0: {} + + obug@2.1.1: {} + + ohash@2.0.11: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + ora@9.3.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.2 + string-width: 8.2.1 + + os-name@7.0.0: + dependencies: + macos-release: 3.4.0 + windows-release: 7.1.1 + + os-tmpdir@1.0.2: {} + + p-event@7.1.0: + dependencies: + p-timeout: 7.0.1 + + p-finally@1.0.0: {} + + p-limit@7.3.0: + dependencies: + yocto-queue: 1.2.2 + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + p-timeout@7.0.1: {} + + pac-proxy-agent@8.0.0: + dependencies: + agent-base: 8.0.0 + debug: 4.4.3 + get-uri: 7.0.0 + http-proxy-agent: 8.0.0 + https-proxy-agent: 8.0.0 + pac-resolver: 8.0.0(quickjs-wasi@0.0.1) + quickjs-wasi: 0.0.1 + socks-proxy-agent: 9.0.0 + transitivePeerDependencies: + - supports-color + + pac-resolver@8.0.0(quickjs-wasi@0.0.1): + dependencies: + degenerator: 6.0.0(quickjs-wasi@0.0.1) + netmask: 2.1.1 + quickjs-wasi: 0.0.1 + + package-json-from-dist@1.0.1: {} + + package-manager-detector@1.6.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-passwd@1.0.0: {} + + parse-path@7.1.0: + dependencies: + protocols: 2.0.2 + + parse-url@9.2.0: + dependencies: + '@types/parse-path': 7.1.0 + parse-path: 7.1.0 + + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-browserify@1.0.1: {} + + path-data-parser@0.1.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pinia-plugin-persistedstate@4.7.1(pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3))): + dependencies: + defu: 6.1.7 + optionalDependencies: + pinia: 3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)) + + pinia-shared-state@2.0.1(pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3))): + dependencies: + broadcast-channel: 7.3.0 + pinia: 3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)) + + pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)): + dependencies: + '@vue/devtools-api': 7.7.9 + vue: 3.5.33(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + + postcss-pxtorem@6.1.0(postcss@8.5.14): + dependencies: + postcss: 8.5.14 + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + powershell-utils@0.1.0: {} + + powershell-utils@0.2.0: {} + + prettier@3.8.3: {} + + property-information@7.1.0: {} + + proto-list@1.2.4: {} + + protocols@2.0.2: {} + + proxy-agent@7.0.0: + dependencies: + agent-base: 8.0.0 + debug: 4.4.3 + http-proxy-agent: 8.0.0 + https-proxy-agent: 8.0.0 + lru-cache: 7.18.3 + pac-proxy-agent: 8.0.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 9.0.0 + transitivePeerDependencies: + - supports-color + + proxy-from-env@1.1.0: {} + + punycode.js@2.3.1: {} + + quansync@0.2.11: {} + + quansync@1.0.0: {} + + queue-microtask@1.2.3: {} + + quickjs-wasi@0.0.1: {} + + rc9@2.1.2: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + + readdirp@5.0.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + release-it@20.0.1(@types/node@25.6.0)(magicast@0.5.2): + dependencies: + '@inquirer/prompts': 8.4.2(@types/node@25.6.0) + '@octokit/rest': 22.0.1 + '@phun-ky/typeof': 2.0.3 + async-retry: 1.3.3 + c12: 3.3.3(magicast@0.5.2) + ci-info: 4.4.0 + defu: 6.1.7 + eta: 4.5.1 + git-url-parse: 16.1.0 + issue-parser: 7.0.1 + lodash.merge: 4.6.2 + mime-types: 3.0.2 + new-github-release-url: 2.0.0 + open: 11.0.0 + ora: 9.3.0 + os-name: 7.0.0 + proxy-agent: 7.0.0 + semver: 7.7.4 + tinyglobby: 0.2.15 + undici: 7.24.5 + url-join: 5.0.0 + wildcard-match: 5.1.4 + yargs-parser: 22.0.0 + transitivePeerDependencies: + - '@types/node' + - magicast + - supports-color + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-dir@1.0.1: + dependencies: + expand-tilde: 2.0.2 + global-modules: 1.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + retry@0.13.1: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + robust-predicates@3.0.3: {} + + rolldown@1.0.0-rc.17: + dependencies: + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + run-applescript@7.1.0: {} + + run-async@2.4.1: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rw@1.3.3: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + sass@1.99.0: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.5 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.6 + + scule@1.3.0: {} + + seemly@0.3.10: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shiki-stream@0.1.4(vue@3.5.33(typescript@6.0.3)): + dependencies: + '@shikijs/core': 3.23.0 + optionalDependencies: + vue: 3.5.33(typescript@6.0.3) + + shiki@3.23.0: + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + optional: true + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + smart-buffer@4.2.0: {} + + socks-proxy-agent@9.0.0: + dependencies: + agent-base: 8.0.0 + debug: 4.4.3 + socks: 2.8.8 + transitivePeerDependencies: + - supports-color + + socks@2.8.8: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + sourcemap-codec@1.4.8: {} + + space-separated-tokens@2.0.2: {} + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.23 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + speakingurl@14.0.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + stdin-discarder@0.3.2: {} + + stream-markdown-parser@1.0.0: + dependencies: + markdown-it-container: 4.0.0 + markdown-it-footnote: 4.0.0 + markdown-it-ins: 4.0.0 + markdown-it-mark: 4.0.0 + markdown-it-sub: 2.0.0 + markdown-it-sup: 2.0.0 + markdown-it-task-checkbox: 1.0.6 + markdown-it-ts: 1.0.0 + + stream-markdown@0.0.15(shiki@3.23.0)(vue@3.5.33(typescript@6.0.3)): + dependencies: + shiki: 3.23.0 + shiki-stream: 0.1.4(vue@3.5.33(typescript@6.0.3)) + transitivePeerDependencies: + - react + - solid-js + - vue + + stream-monaco@0.0.41(monaco-editor@0.55.1): + dependencies: + '@shikijs/monaco': 3.23.0 + alien-signals: 2.0.8 + monaco-editor: 0.55.1 + shiki: 3.23.0 + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@4.0.0: {} + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + + stylis@4.4.0: {} + + super-regex@0.2.0: + dependencies: + clone-regexp: 3.0.0 + function-timeout: 0.1.1 + time-span: 5.1.0 + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tauri-plugin-mic-recorder-api@2.0.0: + dependencies: + '@tauri-apps/api': 2.11.0 + + tauri-plugin-safe-area-insets@0.1.0: + dependencies: + '@tauri-apps/api': 2.11.0 + + three@0.184.0: {} + + through@2.3.8: {} + + time-span@5.1.0: + dependencies: + convert-hrtime: 5.0.0 + + tinybench@2.9.0: {} + + tinyexec@1.1.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tlbs-map-vue@1.3.2(vue@3.5.33(typescript@6.0.3)): + dependencies: + fs-extra: 10.1.0 + vue: 3.5.33(typescript@6.0.3) + vue-demi: 0.14.10(vue@3.5.33(typescript@6.0.3)) + + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + totalist@3.0.1: {} + + treemate@0.3.11: {} + + trim-lines@3.0.1: {} + + ts-dedent@2.2.0: {} + + tslib@2.8.1: {} + + tunnel@0.0.6: {} + + type-fest@0.21.3: {} + + type-fest@2.19.0: {} + + typedarray@0.0.6: {} + + typescript@6.0.3: {} + + uc.micro@2.1.0: {} + + ufo@1.6.4: {} + + uglify-js@3.19.3: + optional: true + + uint8array-extras@1.5.0: {} + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + unconfig@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + defu: 6.1.7 + jiti: 2.6.1 + quansync: 1.0.0 + unconfig-core: 7.5.0 + + undici-types@7.19.2: {} + + undici@6.25.0: {} + + undici@7.24.5: {} + + undici@7.25.0: {} + + unimport@5.7.0: + dependencies: + acorn: 8.16.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.2 + pathe: 2.0.3 + picomatch: 4.0.4 + pkg-types: 2.3.1 + scule: 1.3.0 + strip-literal: 3.1.0 + tinyglobby: 0.2.16 + unplugin: 2.3.11 + unplugin-utils: 0.3.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + universal-user-agent@7.0.3: {} + + universalify@2.0.1: {} + + unload@2.4.1: {} + + unplugin-auto-import@21.0.0(@vueuse/core@14.3.0(vue@3.5.33(typescript@6.0.3))): + dependencies: + local-pkg: 1.1.2 + magic-string: 0.30.21 + picomatch: 4.0.4 + unimport: 5.7.0 + unplugin: 2.3.11 + unplugin-utils: 0.3.1 + optionalDependencies: + '@vueuse/core': 14.3.0(vue@3.5.33(typescript@6.0.3)) + + unplugin-utils@0.3.1: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.4 + + unplugin-vue-components@32.0.0(vue@3.5.33(typescript@6.0.3)): + dependencies: + chokidar: 5.0.0 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.2 + obug: 2.1.1 + picomatch: 4.0.4 + tinyglobby: 0.2.16 + unplugin: 3.0.0 + unplugin-utils: 0.3.1 + vue: 3.5.33(typescript@6.0.3) + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.16.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + unplugin@3.0.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + url-join@5.0.0: {} + + util-deprecate@1.0.2: {} + + uuid@11.1.1: {} + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + + vant@4.9.24(vue@3.5.33(typescript@6.0.3)): + dependencies: + '@vant/popperjs': 1.3.0 + '@vant/use': 1.6.0(vue@3.5.33(typescript@6.0.3)) + '@vue/shared': 3.5.33 + vue: 3.5.33(typescript@6.0.3) + + vdirs@0.1.8(vue@3.5.33(typescript@6.0.3)): + dependencies: + evtd: 0.2.4 + vue: 3.5.33(typescript@6.0.3) + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite-plugin-vue-setup-extend@0.4.0(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4)): + dependencies: + '@vue/compiler-sfc': 3.5.33 + magic-string: 0.25.9 + vite: 8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4) + + vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.6.0 + fsevents: 2.3.3 + jiti: 2.6.1 + sass: 1.99.0 + yaml: 2.8.4 + + vitest@4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(happy-dom@20.9.0)(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.10(@types/node@25.6.0)(jiti@2.6.1)(sass@1.99.0)(yaml@2.8.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.0 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + '@vitest/ui': 4.1.5(vitest@4.1.5) + happy-dom: 20.9.0 + transitivePeerDependencies: + - msw + + vooks@0.2.12(vue@3.5.33(typescript@6.0.3)): + dependencies: + evtd: 0.2.4 + vue: 3.5.33(typescript@6.0.3) + + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.1.0: {} + + vue-component-type-helpers@3.2.8: {} + + vue-cropper@1.1.4: {} + + vue-demi@0.14.10(vue@3.5.33(typescript@6.0.3)): + dependencies: + vue: 3.5.33(typescript@6.0.3) + + vue-i18n@11.4.0(vue@3.5.33(typescript@6.0.3)): + dependencies: + '@intlify/core-base': 11.4.0 + '@intlify/devtools-types': 11.4.0 + '@intlify/shared': 11.4.0 + '@vue/devtools-api': 6.6.4 + vue: 3.5.33(typescript@6.0.3) + + vue-router@5.0.6(@vue/compiler-sfc@3.5.33)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)): + dependencies: + '@babel/generator': 7.29.1 + '@vue-macros/common': 3.1.2(vue@3.5.33(typescript@6.0.3)) + '@vue/devtools-api': 8.1.1 + ast-walker-scope: 0.8.3 + chokidar: 5.0.0 + json5: 2.2.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.2 + muggle-string: 0.4.1 + pathe: 2.0.3 + picomatch: 4.0.4 + scule: 1.3.0 + tinyglobby: 0.2.16 + unplugin: 3.0.0 + unplugin-utils: 0.3.1 + vue: 3.5.33(typescript@6.0.3) + yaml: 2.8.4 + optionalDependencies: + '@vue/compiler-sfc': 3.5.33 + pinia: 3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)) + + vue-tsc@3.2.8(typescript@6.0.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 3.2.8 + typescript: 6.0.3 + + vue-virtual-scroller@3.0.2(vue@3.5.33(typescript@6.0.3)): + dependencies: + vue: 3.5.33(typescript@6.0.3) + + vue@3.5.33(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-sfc': 3.5.33 + '@vue/runtime-dom': 3.5.33 + '@vue/server-renderer': 3.5.33(vue@3.5.33(typescript@6.0.3)) + '@vue/shared': 3.5.33 + optionalDependencies: + typescript: 6.0.3 + + vueuc@0.4.65(vue@3.5.33(typescript@6.0.3)): + dependencies: + '@css-render/vue3-ssr': 0.15.14(vue@3.5.33(typescript@6.0.3)) + '@juggle/resize-observer': 3.4.0 + css-render: 0.15.14 + evtd: 0.2.4 + seemly: 0.3.10 + vdirs: 0.1.8(vue@3.5.33(typescript@6.0.3)) + vooks: 0.2.12(vue@3.5.33(typescript@6.0.3)) + vue: 3.5.33(typescript@6.0.3) + + walk-up-path@4.0.0: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + web-vitals@5.2.0: {} + + webpack-virtual-modules@0.6.2: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@3.0.0: {} + + whatwg-mimetype@4.0.0: {} + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wildcard-match@5.1.4: {} + + windows-release@7.1.1: + dependencies: + powershell-utils: 0.2.0 + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + ws@8.20.0: {} + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml@2.8.4: {} + + yargs-parser@21.1.1: {} + + yargs-parser@22.0.0: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@1.2.2: {} + + yoctocolors@2.1.2: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..a60ac75 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +packages: + - "." + +onlyBuiltDependencies: + - '@vue-office/docx' + - '@vue-office/excel' + - '@vue-office/pdf' + - '@vue-office/pptx' diff --git a/preview/HuLa-QR.png b/preview/HuLa-QR.png new file mode 100644 index 0000000..6a87af7 Binary files /dev/null and b/preview/HuLa-QR.png differ diff --git a/preview/img.png b/preview/img.png new file mode 100644 index 0000000..22372d2 Binary files /dev/null and b/preview/img.png differ diff --git a/preview/img2-1.webp b/preview/img2-1.webp new file mode 100644 index 0000000..2700000 Binary files /dev/null and b/preview/img2-1.webp differ diff --git a/preview/img2-10.webp b/preview/img2-10.webp new file mode 100644 index 0000000..aaccdf6 Binary files /dev/null and b/preview/img2-10.webp differ diff --git a/preview/img2-11.webp b/preview/img2-11.webp new file mode 100644 index 0000000..4e81aeb Binary files /dev/null and b/preview/img2-11.webp differ diff --git a/preview/img2-12.webp b/preview/img2-12.webp new file mode 100644 index 0000000..bdcb0b1 Binary files /dev/null and b/preview/img2-12.webp differ diff --git a/preview/img2-13.webp b/preview/img2-13.webp new file mode 100644 index 0000000..859f55e Binary files /dev/null and b/preview/img2-13.webp differ diff --git a/preview/img2-14.webp b/preview/img2-14.webp new file mode 100644 index 0000000..11aa883 Binary files /dev/null and b/preview/img2-14.webp differ diff --git a/preview/img2-15.webp b/preview/img2-15.webp new file mode 100644 index 0000000..40234d5 Binary files /dev/null and b/preview/img2-15.webp differ diff --git a/preview/img2-2.webp b/preview/img2-2.webp new file mode 100644 index 0000000..5797dfb Binary files /dev/null and b/preview/img2-2.webp differ diff --git a/preview/img2-3.webp b/preview/img2-3.webp new file mode 100644 index 0000000..9313b7f Binary files /dev/null and b/preview/img2-3.webp differ diff --git a/preview/img2-4.webp b/preview/img2-4.webp new file mode 100644 index 0000000..0d6f790 Binary files /dev/null and b/preview/img2-4.webp differ diff --git a/preview/img2-5.webp b/preview/img2-5.webp new file mode 100644 index 0000000..cf5ad99 Binary files /dev/null and b/preview/img2-5.webp differ diff --git a/preview/img2-6.webp b/preview/img2-6.webp new file mode 100644 index 0000000..1015ab7 Binary files /dev/null and b/preview/img2-6.webp differ diff --git a/preview/img2-7.webp b/preview/img2-7.webp new file mode 100644 index 0000000..62a317f Binary files /dev/null and b/preview/img2-7.webp differ diff --git a/preview/img2-8.webp b/preview/img2-8.webp new file mode 100644 index 0000000..8af6f1d Binary files /dev/null and b/preview/img2-8.webp differ diff --git a/preview/img2-9.webp b/preview/img2-9.webp new file mode 100644 index 0000000..c80cc38 Binary files /dev/null and b/preview/img2-9.webp differ diff --git a/preview/img3-1.webp b/preview/img3-1.webp new file mode 100644 index 0000000..d1d4d5f Binary files /dev/null and b/preview/img3-1.webp differ diff --git a/preview/img3-2.webp b/preview/img3-2.webp new file mode 100644 index 0000000..7b67eee Binary files /dev/null and b/preview/img3-2.webp differ diff --git a/preview/img3-3.webp b/preview/img3-3.webp new file mode 100644 index 0000000..ee311fc Binary files /dev/null and b/preview/img3-3.webp differ diff --git a/preview/img3-4.webp b/preview/img3-4.webp new file mode 100644 index 0000000..e721548 Binary files /dev/null and b/preview/img3-4.webp differ diff --git a/preview/img3-5.webp b/preview/img3-5.webp new file mode 100644 index 0000000..77dcea4 Binary files /dev/null and b/preview/img3-5.webp differ diff --git a/preview/img3-6.webp b/preview/img3-6.webp new file mode 100644 index 0000000..bfc0c87 Binary files /dev/null and b/preview/img3-6.webp differ diff --git a/preview/img3-7.webp b/preview/img3-7.webp new file mode 100644 index 0000000..2c55ed1 Binary files /dev/null and b/preview/img3-7.webp differ diff --git a/preview/img_1.png b/preview/img_1.png new file mode 100644 index 0000000..c8dcc53 Binary files /dev/null and b/preview/img_1.png differ diff --git a/preview/img_10.png b/preview/img_10.png new file mode 100644 index 0000000..45aa81f Binary files /dev/null and b/preview/img_10.png differ diff --git a/preview/img_2.png b/preview/img_2.png new file mode 100644 index 0000000..40dc4df Binary files /dev/null and b/preview/img_2.png differ diff --git a/preview/img_3.png b/preview/img_3.png new file mode 100644 index 0000000..df762ce Binary files /dev/null and b/preview/img_3.png differ diff --git a/preview/img_4.png b/preview/img_4.png new file mode 100644 index 0000000..962fca7 Binary files /dev/null and b/preview/img_4.png differ diff --git a/preview/img_5.png b/preview/img_5.png new file mode 100644 index 0000000..eda9d60 Binary files /dev/null and b/preview/img_5.png differ diff --git a/preview/img_6.png b/preview/img_6.png new file mode 100644 index 0000000..c197a0c Binary files /dev/null and b/preview/img_6.png differ diff --git a/preview/img_7.png b/preview/img_7.png new file mode 100644 index 0000000..fd462f9 Binary files /dev/null and b/preview/img_7.png differ diff --git a/preview/img_8.png b/preview/img_8.png new file mode 100644 index 0000000..d6692a1 Binary files /dev/null and b/preview/img_8.png differ diff --git a/preview/img_9.png b/preview/img_9.png new file mode 100644 index 0000000..3e324cb Binary files /dev/null and b/preview/img_9.png differ diff --git a/preview/qq.jpg b/preview/qq.jpg new file mode 100644 index 0000000..36c7e51 Binary files /dev/null and b/preview/qq.jpg differ diff --git a/preview/wx.png b/preview/wx.png new file mode 100644 index 0000000..019ec87 Binary files /dev/null and b/preview/wx.png differ diff --git a/preview/zfb.png b/preview/zfb.png new file mode 100644 index 0000000..858c318 Binary files /dev/null and b/preview/zfb.png differ diff --git a/preview/zs.jpg b/preview/zs.jpg new file mode 100644 index 0000000..59e4e0c Binary files /dev/null and b/preview/zs.jpg differ diff --git a/public/AI/QW.png b/public/AI/QW.png new file mode 100644 index 0000000..2a62907 Binary files /dev/null and b/public/AI/QW.png differ diff --git a/public/AI/deepseek.png b/public/AI/deepseek.png new file mode 100644 index 0000000..6cc5227 Binary files /dev/null and b/public/AI/deepseek.png differ diff --git a/public/AI/openai.svg b/public/AI/openai.svg new file mode 100644 index 0000000..1d9f606 --- /dev/null +++ b/public/AI/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/public/Mobile/1.png b/public/Mobile/1.png new file mode 100644 index 0000000..497f17a Binary files /dev/null and b/public/Mobile/1.png differ diff --git a/public/Mobile/2.png b/public/Mobile/2.png new file mode 100644 index 0000000..8eea1e5 Binary files /dev/null and b/public/Mobile/2.png differ diff --git a/public/Mobile/3.png b/public/Mobile/3.png new file mode 100644 index 0000000..c9b7ea5 Binary files /dev/null and b/public/Mobile/3.png differ diff --git a/public/Mobile/4.png b/public/Mobile/4.png new file mode 100644 index 0000000..b6ad3de Binary files /dev/null and b/public/Mobile/4.png differ diff --git a/public/avatar/001.webp b/public/avatar/001.webp new file mode 100644 index 0000000..81b378c Binary files /dev/null and b/public/avatar/001.webp differ diff --git a/public/avatar/002.webp b/public/avatar/002.webp new file mode 100644 index 0000000..dcf22e6 Binary files /dev/null and b/public/avatar/002.webp differ diff --git a/public/avatar/003.webp b/public/avatar/003.webp new file mode 100644 index 0000000..349579b Binary files /dev/null and b/public/avatar/003.webp differ diff --git a/public/avatar/004.webp b/public/avatar/004.webp new file mode 100644 index 0000000..4de3b6a Binary files /dev/null and b/public/avatar/004.webp differ diff --git a/public/avatar/005.webp b/public/avatar/005.webp new file mode 100644 index 0000000..446688b Binary files /dev/null and b/public/avatar/005.webp differ diff --git a/public/avatar/006.webp b/public/avatar/006.webp new file mode 100644 index 0000000..08ef340 Binary files /dev/null and b/public/avatar/006.webp differ diff --git a/public/avatar/007.webp b/public/avatar/007.webp new file mode 100644 index 0000000..c44294a Binary files /dev/null and b/public/avatar/007.webp differ diff --git a/public/avatar/008.webp b/public/avatar/008.webp new file mode 100644 index 0000000..d3aa046 Binary files /dev/null and b/public/avatar/008.webp differ diff --git a/public/avatar/009.webp b/public/avatar/009.webp new file mode 100644 index 0000000..87848ab Binary files /dev/null and b/public/avatar/009.webp differ diff --git a/public/avatar/010.webp b/public/avatar/010.webp new file mode 100644 index 0000000..99a8bea Binary files /dev/null and b/public/avatar/010.webp differ diff --git a/public/avatar/011.webp b/public/avatar/011.webp new file mode 100644 index 0000000..2a4033d Binary files /dev/null and b/public/avatar/011.webp differ diff --git a/public/avatar/012.webp b/public/avatar/012.webp new file mode 100644 index 0000000..f09b997 Binary files /dev/null and b/public/avatar/012.webp differ diff --git a/public/avatar/013.webp b/public/avatar/013.webp new file mode 100644 index 0000000..6202d59 Binary files /dev/null and b/public/avatar/013.webp differ diff --git a/public/avatar/014.webp b/public/avatar/014.webp new file mode 100644 index 0000000..d4d0801 Binary files /dev/null and b/public/avatar/014.webp differ diff --git a/public/avatar/015.webp b/public/avatar/015.webp new file mode 100644 index 0000000..8ec917c Binary files /dev/null and b/public/avatar/015.webp differ diff --git a/public/avatar/016.webp b/public/avatar/016.webp new file mode 100644 index 0000000..be5ae30 Binary files /dev/null and b/public/avatar/016.webp differ diff --git a/public/avatar/017.webp b/public/avatar/017.webp new file mode 100644 index 0000000..5d1db2b Binary files /dev/null and b/public/avatar/017.webp differ diff --git a/public/avatar/018.webp b/public/avatar/018.webp new file mode 100644 index 0000000..7fb93f2 Binary files /dev/null and b/public/avatar/018.webp differ diff --git a/public/avatar/019.webp b/public/avatar/019.webp new file mode 100644 index 0000000..e47e188 Binary files /dev/null and b/public/avatar/019.webp differ diff --git a/public/avatar/020.webp b/public/avatar/020.webp new file mode 100644 index 0000000..7055f27 Binary files /dev/null and b/public/avatar/020.webp differ diff --git a/public/avatar/021.webp b/public/avatar/021.webp new file mode 100644 index 0000000..4c8993d Binary files /dev/null and b/public/avatar/021.webp differ diff --git a/public/avatar/022.webp b/public/avatar/022.webp new file mode 100644 index 0000000..9d9e6dc Binary files /dev/null and b/public/avatar/022.webp differ diff --git a/public/emoji/alien-monster.webp b/public/emoji/alien-monster.webp new file mode 100644 index 0000000..da883a7 Binary files /dev/null and b/public/emoji/alien-monster.webp differ diff --git a/public/emoji/bug.webp b/public/emoji/bug.webp new file mode 100644 index 0000000..6e19190 Binary files /dev/null and b/public/emoji/bug.webp differ diff --git a/public/emoji/comet.webp b/public/emoji/comet.webp new file mode 100644 index 0000000..1a9ef5c Binary files /dev/null and b/public/emoji/comet.webp differ diff --git a/public/emoji/fire.webp b/public/emoji/fire.webp new file mode 100644 index 0000000..227bc29 Binary files /dev/null and b/public/emoji/fire.webp differ diff --git a/public/emoji/gear.webp b/public/emoji/gear.webp new file mode 100644 index 0000000..2efda80 Binary files /dev/null and b/public/emoji/gear.webp differ diff --git a/public/emoji/hammer-and-wrench.webp b/public/emoji/hammer-and-wrench.webp new file mode 100644 index 0000000..bf49e48 Binary files /dev/null and b/public/emoji/hammer-and-wrench.webp differ diff --git a/public/emoji/lipstick.webp b/public/emoji/lipstick.webp new file mode 100644 index 0000000..57563b8 Binary files /dev/null and b/public/emoji/lipstick.webp differ diff --git a/public/emoji/memo.webp b/public/emoji/memo.webp new file mode 100644 index 0000000..3f71377 Binary files /dev/null and b/public/emoji/memo.webp differ diff --git a/public/emoji/package.webp b/public/emoji/package.webp new file mode 100644 index 0000000..61879f7 Binary files /dev/null and b/public/emoji/package.webp differ diff --git a/public/emoji/party-popper.webp b/public/emoji/party-popper.webp new file mode 100644 index 0000000..09d1355 Binary files /dev/null and b/public/emoji/party-popper.webp differ diff --git a/public/emoji/recycling-symbol.webp b/public/emoji/recycling-symbol.webp new file mode 100644 index 0000000..44ab3ea Binary files /dev/null and b/public/emoji/recycling-symbol.webp differ diff --git a/public/emoji/right-arrow-curving-left.webp b/public/emoji/right-arrow-curving-left.webp new file mode 100644 index 0000000..40af63f Binary files /dev/null and b/public/emoji/right-arrow-curving-left.webp differ diff --git a/public/emoji/robot.webp b/public/emoji/robot.webp new file mode 100644 index 0000000..e94af32 Binary files /dev/null and b/public/emoji/robot.webp differ diff --git a/public/emoji/rocket.webp b/public/emoji/rocket.webp new file mode 100644 index 0000000..06b2fd0 Binary files /dev/null and b/public/emoji/rocket.webp differ diff --git a/public/emoji/test-tube.webp b/public/emoji/test-tube.webp new file mode 100644 index 0000000..f86cca8 Binary files /dev/null and b/public/emoji/test-tube.webp differ diff --git a/public/file/apk.svg b/public/file/apk.svg new file mode 100644 index 0000000..0af59f8 --- /dev/null +++ b/public/file/apk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/cad.svg b/public/file/cad.svg new file mode 100644 index 0000000..1d206bc --- /dev/null +++ b/public/file/cad.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/css.svg b/public/file/css.svg new file mode 100644 index 0000000..658dac0 --- /dev/null +++ b/public/file/css.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/dmg.svg b/public/file/dmg.svg new file mode 100644 index 0000000..d574ad7 --- /dev/null +++ b/public/file/dmg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/doc.svg b/public/file/doc.svg new file mode 100644 index 0000000..f224a89 --- /dev/null +++ b/public/file/doc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/docx.svg b/public/file/docx.svg new file mode 100644 index 0000000..f224a89 --- /dev/null +++ b/public/file/docx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/exe.svg b/public/file/exe.svg new file mode 100644 index 0000000..6a286b4 --- /dev/null +++ b/public/file/exe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/gif.svg b/public/file/gif.svg new file mode 100644 index 0000000..30ec925 --- /dev/null +++ b/public/file/gif.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/gitignore.svg b/public/file/gitignore.svg new file mode 100644 index 0000000..1507bfb --- /dev/null +++ b/public/file/gitignore.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/html.svg b/public/file/html.svg new file mode 100644 index 0000000..0a90a1a --- /dev/null +++ b/public/file/html.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/ipa.svg b/public/file/ipa.svg new file mode 100644 index 0000000..dfdbd23 --- /dev/null +++ b/public/file/ipa.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/iso.svg b/public/file/iso.svg new file mode 100644 index 0000000..187db2b --- /dev/null +++ b/public/file/iso.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/java.svg b/public/file/java.svg new file mode 100644 index 0000000..da00555 --- /dev/null +++ b/public/file/java.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/js.svg b/public/file/js.svg new file mode 100644 index 0000000..0928ff2 --- /dev/null +++ b/public/file/js.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/json.svg b/public/file/json.svg new file mode 100644 index 0000000..9ab6e3f --- /dev/null +++ b/public/file/json.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/jsx.svg b/public/file/jsx.svg new file mode 100644 index 0000000..734ee11 --- /dev/null +++ b/public/file/jsx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/less.svg b/public/file/less.svg new file mode 100644 index 0000000..02beeab --- /dev/null +++ b/public/file/less.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/md.svg b/public/file/md.svg new file mode 100644 index 0000000..c23c4fb --- /dev/null +++ b/public/file/md.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/mov.svg b/public/file/mov.svg new file mode 100644 index 0000000..44fbff9 --- /dev/null +++ b/public/file/mov.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/mp3.svg b/public/file/mp3.svg new file mode 100644 index 0000000..81c8343 --- /dev/null +++ b/public/file/mp3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/mp4.svg b/public/file/mp4.svg new file mode 100644 index 0000000..19daf82 --- /dev/null +++ b/public/file/mp4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/other.svg b/public/file/other.svg new file mode 100644 index 0000000..b671f6b --- /dev/null +++ b/public/file/other.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/pdf.svg b/public/file/pdf.svg new file mode 100644 index 0000000..685caf2 --- /dev/null +++ b/public/file/pdf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/ppt.svg b/public/file/ppt.svg new file mode 100644 index 0000000..2f0db47 --- /dev/null +++ b/public/file/ppt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/psd.svg b/public/file/psd.svg new file mode 100644 index 0000000..f93067f --- /dev/null +++ b/public/file/psd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/py.svg b/public/file/py.svg new file mode 100644 index 0000000..59ee0fe --- /dev/null +++ b/public/file/py.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/scss.svg b/public/file/scss.svg new file mode 100644 index 0000000..64ad710 --- /dev/null +++ b/public/file/scss.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/sql.svg b/public/file/sql.svg new file mode 100644 index 0000000..8ef9a66 --- /dev/null +++ b/public/file/sql.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/stylus.svg b/public/file/stylus.svg new file mode 100644 index 0000000..298e955 --- /dev/null +++ b/public/file/stylus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/svg.svg b/public/file/svg.svg new file mode 100644 index 0000000..6feab0a --- /dev/null +++ b/public/file/svg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/ts.svg b/public/file/ts.svg new file mode 100644 index 0000000..646e07f --- /dev/null +++ b/public/file/ts.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/txt.svg b/public/file/txt.svg new file mode 100644 index 0000000..69339b0 --- /dev/null +++ b/public/file/txt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/vue.svg b/public/file/vue.svg new file mode 100644 index 0000000..f2062f1 --- /dev/null +++ b/public/file/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/xls.svg b/public/file/xls.svg new file mode 100644 index 0000000..f8b98e1 --- /dev/null +++ b/public/file/xls.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/file/zip.svg b/public/file/zip.svg new file mode 100644 index 0000000..57a5b01 --- /dev/null +++ b/public/file/zip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/hula.png b/public/hula.png new file mode 100644 index 0000000..65f3a0d Binary files /dev/null and b/public/hula.png differ diff --git a/public/hula_favicon.ico b/public/hula_favicon.ico new file mode 100644 index 0000000..703a038 Binary files /dev/null and b/public/hula_favicon.ico differ diff --git a/public/hulaspark-badge.svg b/public/hulaspark-badge.svg new file mode 100644 index 0000000..9e72ae6 --- /dev/null +++ b/public/hulaspark-badge.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + HuLaSpark + diff --git a/public/icon.js b/public/icon.js new file mode 100644 index 0000000..817ce98 --- /dev/null +++ b/public/icon.js @@ -0,0 +1 @@ +!function(e){var t,n,d,o,i,a,r='';function c(){i||(i=!0,d())}t=function(){var e,t,n;(n=document.createElement("div")).innerHTML=r,r=null,(t=n.getElementsByTagName("svg")[0])&&(t.setAttribute("aria-hidden","true"),t.style.position="absolute",t.style.width=0,t.style.height=0,t.style.overflow="hidden",e=t,(n=document.body).firstChild?(t=n.firstChild).parentNode.insertBefore(e,t):n.appendChild(e))},document.addEventListener?["complete","loaded","interactive"].indexOf(document.readyState)>-1?setTimeout(t,0):(n=function(){document.removeEventListener("DOMContentLoaded",n,!1),t()},document.addEventListener("DOMContentLoaded",n,!1)):document.attachEvent&&(d=t,o=e.document,i=!1,(a=function(){try{o.documentElement.doScroll("left")}catch(e){return void setTimeout(a,50)}c()})(),o.onreadystatechange=function(){"complete"==o.readyState&&(o.onreadystatechange=null,c())})}(window); diff --git a/public/img/dispersion-bg.png b/public/img/dispersion-bg.png new file mode 100644 index 0000000..3b4aeff Binary files /dev/null and b/public/img/dispersion-bg.png differ diff --git a/public/login_bg.png b/public/login_bg.png new file mode 100644 index 0000000..8a3801d Binary files /dev/null and b/public/login_bg.png differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..07775d7 Binary files /dev/null and b/public/logo.png differ diff --git a/public/logoD.png b/public/logoD.png new file mode 100644 index 0000000..a3e083b Binary files /dev/null and b/public/logoD.png differ diff --git a/public/logoL.png b/public/logoL.png new file mode 100644 index 0000000..502fd1b Binary files /dev/null and b/public/logoL.png differ diff --git a/public/msgAction/bomb.png b/public/msgAction/bomb.png new file mode 100644 index 0000000..abfdd57 Binary files /dev/null and b/public/msgAction/bomb.png differ diff --git a/public/msgAction/clapping.png b/public/msgAction/clapping.png new file mode 100644 index 0000000..204e2c2 Binary files /dev/null and b/public/msgAction/clapping.png differ diff --git a/public/msgAction/enraged-face.png b/public/msgAction/enraged-face.png new file mode 100644 index 0000000..d770027 Binary files /dev/null and b/public/msgAction/enraged-face.png differ diff --git a/public/msgAction/exploding-head.png b/public/msgAction/exploding-head.png new file mode 100644 index 0000000..82ae84a Binary files /dev/null and b/public/msgAction/exploding-head.png differ diff --git a/public/msgAction/face-with-tears-of-joy.png b/public/msgAction/face-with-tears-of-joy.png new file mode 100644 index 0000000..a9b3d52 Binary files /dev/null and b/public/msgAction/face-with-tears-of-joy.png differ diff --git a/public/msgAction/flashlight.png b/public/msgAction/flashlight.png new file mode 100644 index 0000000..e783573 Binary files /dev/null and b/public/msgAction/flashlight.png differ diff --git a/public/msgAction/heart-on-fire.png b/public/msgAction/heart-on-fire.png new file mode 100644 index 0000000..123ab00 Binary files /dev/null and b/public/msgAction/heart-on-fire.png differ diff --git a/public/msgAction/like.png b/public/msgAction/like.png new file mode 100644 index 0000000..c5f8f76 Binary files /dev/null and b/public/msgAction/like.png differ diff --git a/public/msgAction/pocket-money.png b/public/msgAction/pocket-money.png new file mode 100644 index 0000000..7852cd7 Binary files /dev/null and b/public/msgAction/pocket-money.png differ diff --git a/public/msgAction/rose.png b/public/msgAction/rose.png new file mode 100644 index 0000000..61c65d8 Binary files /dev/null and b/public/msgAction/rose.png differ diff --git a/public/msgAction/slightly-frowning-face.png b/public/msgAction/slightly-frowning-face.png new file mode 100644 index 0000000..1bd00a3 Binary files /dev/null and b/public/msgAction/slightly-frowning-face.png differ diff --git a/public/msgAction/victory-hand.png b/public/msgAction/victory-hand.png new file mode 100644 index 0000000..6519805 Binary files /dev/null and b/public/msgAction/victory-hand.png differ diff --git a/public/sound/hula_bell.mp3 b/public/sound/hula_bell.mp3 new file mode 100644 index 0000000..0675c56 Binary files /dev/null and b/public/sound/hula_bell.mp3 differ diff --git a/public/sound/message.mp3 b/public/sound/message.mp3 new file mode 100644 index 0000000..0430b39 Binary files /dev/null and b/public/sound/message.mp3 differ diff --git a/public/status/IonBan.png b/public/status/IonBan.png new file mode 100644 index 0000000..531762a Binary files /dev/null and b/public/status/IonBan.png differ diff --git a/public/status/aiziji@2x.png b/public/status/aiziji@2x.png new file mode 100644 index 0000000..6a12097 Binary files /dev/null and b/public/status/aiziji@2x.png differ diff --git a/public/status/bangbangtang@2x.png b/public/status/bangbangtang@2x.png new file mode 100644 index 0000000..b412019 Binary files /dev/null and b/public/status/bangbangtang@2x.png differ diff --git a/public/status/banzhuan.png b/public/status/banzhuan.png new file mode 100644 index 0000000..dbb88c6 Binary files /dev/null and b/public/status/banzhuan.png differ diff --git a/public/status/bequiet@3x.png b/public/status/bequiet@3x.png new file mode 100644 index 0000000..e483dca Binary files /dev/null and b/public/status/bequiet@3x.png differ diff --git a/public/status/boring@3x.png b/public/status/boring@3x.png new file mode 100644 index 0000000..53d7dd0 Binary files /dev/null and b/public/status/boring@3x.png differ diff --git a/public/status/busy.png b/public/status/busy.png new file mode 100644 index 0000000..02bef5e Binary files /dev/null and b/public/status/busy.png differ diff --git a/public/status/chigua@2x.png b/public/status/chigua@2x.png new file mode 100644 index 0000000..fe5cdbf Binary files /dev/null and b/public/status/chigua@2x.png differ diff --git a/public/status/chuqulang2.png b/public/status/chuqulang2.png new file mode 100644 index 0000000..525d83c Binary files /dev/null and b/public/status/chuqulang2.png differ diff --git a/public/status/cloaking.png b/public/status/cloaking.png new file mode 100644 index 0000000..62962e3 Binary files /dev/null and b/public/status/cloaking.png differ diff --git a/public/status/crush.png b/public/status/crush.png new file mode 100644 index 0000000..e964e77 Binary files /dev/null and b/public/status/crush.png differ diff --git a/public/status/eating01@3x.png b/public/status/eating01@3x.png new file mode 100644 index 0000000..a50acdf Binary files /dev/null and b/public/status/eating01@3x.png differ diff --git a/public/status/emonew@2x.png b/public/status/emonew@2x.png new file mode 100644 index 0000000..d065c0a Binary files /dev/null and b/public/status/emonew@2x.png differ diff --git a/public/status/fish@2x.png b/public/status/fish@2x.png new file mode 100644 index 0000000..40ee8c8 Binary files /dev/null and b/public/status/fish@2x.png differ diff --git a/public/status/fullofyuanqi@3x.png b/public/status/fullofyuanqi@3x.png new file mode 100644 index 0000000..4eb5cb7 Binary files /dev/null and b/public/status/fullofyuanqi@3x.png differ diff --git a/public/status/game_3x.png b/public/status/game_3x.png new file mode 100644 index 0000000..92a32b4 Binary files /dev/null and b/public/status/game_3x.png differ diff --git a/public/status/ganzuoye.png b/public/status/ganzuoye.png new file mode 100644 index 0000000..264f12d Binary files /dev/null and b/public/status/ganzuoye.png differ diff --git a/public/status/gototravel.png b/public/status/gototravel.png new file mode 100644 index 0000000..bc22e97 Binary files /dev/null and b/public/status/gototravel.png differ diff --git a/public/status/guodongzhi.png b/public/status/guodongzhi.png new file mode 100644 index 0000000..89d3c83 Binary files /dev/null and b/public/status/guodongzhi.png differ diff --git a/public/status/guonianhuijia.png b/public/status/guonianhuijia.png new file mode 100644 index 0000000..f0c3291 Binary files /dev/null and b/public/status/guonianhuijia.png differ diff --git a/public/status/happytofly@3x.png b/public/status/happytofly@3x.png new file mode 100644 index 0000000..4ad9813 Binary files /dev/null and b/public/status/happytofly@3x.png differ diff --git a/public/status/hardtosay@3x.png b/public/status/hardtosay@3x.png new file mode 100644 index 0000000..5583534 Binary files /dev/null and b/public/status/hardtosay@3x.png differ diff --git a/public/status/imfine_3x.png b/public/status/imfine_3x.png new file mode 100644 index 0000000..8de39f2 Binary files /dev/null and b/public/status/imfine_3x.png differ diff --git a/public/status/jinli@2x.png b/public/status/jinli@2x.png new file mode 100644 index 0000000..e78e524 Binary files /dev/null and b/public/status/jinli@2x.png differ diff --git a/public/status/leave.png b/public/status/leave.png new file mode 100644 index 0000000..42003d1 Binary files /dev/null and b/public/status/leave.png differ diff --git a/public/status/luck@2x.png b/public/status/luck@2x.png new file mode 100644 index 0000000..cf0eeb5 Binary files /dev/null and b/public/status/luck@2x.png differ diff --git a/public/status/meizizi@3x.png b/public/status/meizizi@3x.png new file mode 100644 index 0000000..ab6dd51 Binary files /dev/null and b/public/status/meizizi@3x.png differ diff --git a/public/status/music@2x.png b/public/status/music@2x.png new file mode 100644 index 0000000..6f5a25c Binary files /dev/null and b/public/status/music@2x.png differ diff --git a/public/status/nandehutu.png b/public/status/nandehutu.png new file mode 100644 index 0000000..1cdb6b4 Binary files /dev/null and b/public/status/nandehutu.png differ diff --git a/public/status/offline.png b/public/status/offline.png new file mode 100644 index 0000000..7de3d5d Binary files /dev/null and b/public/status/offline.png differ diff --git a/public/status/online.png b/public/status/online.png new file mode 100644 index 0000000..a685d9a Binary files /dev/null and b/public/status/online.png differ diff --git a/public/status/qiuxingdazi.png b/public/status/qiuxingdazi.png new file mode 100644 index 0000000..75ecde9 Binary files /dev/null and b/public/status/qiuxingdazi.png differ diff --git a/public/status/relationship_3x.png b/public/status/relationship_3x.png new file mode 100644 index 0000000..b3e0373 Binary files /dev/null and b/public/status/relationship_3x.png differ diff --git a/public/status/signal_3x.png b/public/status/signal_3x.png new file mode 100644 index 0000000..243e698 Binary files /dev/null and b/public/status/signal_3x.png differ diff --git a/public/status/sleeping_3x.png b/public/status/sleeping_3x.png new file mode 100644 index 0000000..957bc26 Binary files /dev/null and b/public/status/sleeping_3x.png differ diff --git a/public/status/stayup_3x.png b/public/status/stayup_3x.png new file mode 100644 index 0000000..c2b2130 Binary files /dev/null and b/public/status/stayup_3x.png differ diff --git a/public/status/study_3x.png b/public/status/study_3x.png new file mode 100644 index 0000000..ffe8457 Binary files /dev/null and b/public/status/study_3x.png differ diff --git a/public/status/timi_3x.png b/public/status/timi_3x.png new file mode 100644 index 0000000..e80ac17 Binary files /dev/null and b/public/status/timi_3x.png differ diff --git a/public/status/tkong.png b/public/status/tkong.png new file mode 100644 index 0000000..f5eaefe Binary files /dev/null and b/public/status/tkong.png differ diff --git a/public/status/toohard@3x.png b/public/status/toohard@3x.png new file mode 100644 index 0000000..b07aa71 Binary files /dev/null and b/public/status/toohard@3x.png differ diff --git a/public/status/tv_3x.png b/public/status/tv_3x.png new file mode 100644 index 0000000..fe99b6f Binary files /dev/null and b/public/status/tv_3x.png differ diff --git a/public/status/wang_3x.png b/public/status/wang_3x.png new file mode 100644 index 0000000..7fb3fc6 Binary files /dev/null and b/public/status/wang_3x.png differ diff --git a/public/status/weather_3x.png b/public/status/weather_3x.png new file mode 100644 index 0000000..3089e8b Binary files /dev/null and b/public/status/weather_3x.png differ diff --git a/public/status/woxiangkaile.png b/public/status/woxiangkaile.png new file mode 100644 index 0000000..8faebd7 Binary files /dev/null and b/public/status/woxiangkaile.png differ diff --git a/public/status/xiadaxue.png b/public/status/xiadaxue.png new file mode 100644 index 0000000..461504b Binary files /dev/null and b/public/status/xiadaxue.png differ diff --git a/public/status/xiaojindou.png b/public/status/xiaojindou.png new file mode 100644 index 0000000..34f4593 Binary files /dev/null and b/public/status/xiaojindou.png differ diff --git a/public/status/ximao.png b/public/status/ximao.png new file mode 100644 index 0000000..1f8a582 Binary files /dev/null and b/public/status/ximao.png differ diff --git a/public/status/xinnianhao-xin1.png b/public/status/xinnianhao-xin1.png new file mode 100644 index 0000000..97bbc5c Binary files /dev/null and b/public/status/xinnianhao-xin1.png differ diff --git a/public/status/xinnianhao.png b/public/status/xinnianhao.png new file mode 100644 index 0000000..9167190 Binary files /dev/null and b/public/status/xinnianhao.png differ diff --git a/public/status/yiqiyuanmeng.png b/public/status/yiqiyuanmeng.png new file mode 100644 index 0000000..75eb6a1 Binary files /dev/null and b/public/status/yiqiyuanmeng.png differ diff --git a/public/status/youzaizai@3x.png b/public/status/youzaizai@3x.png new file mode 100644 index 0000000..31e3e58 Binary files /dev/null and b/public/status/youzaizai@3x.png differ diff --git a/public/status/yundongzhong@2x.png b/public/status/yundongzhong@2x.png new file mode 100644 index 0000000..1fc1ba7 Binary files /dev/null and b/public/status/yundongzhong@2x.png differ diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..f1bfd31 --- /dev/null +++ b/renovate.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "automerge": false, + "dependencyDashboard": true, + "ignoreDeps": [], + "labels": ["dependencies", "skip-ci"], + "postUpdateOptions": ["yarnDedupeHighest"], + "prConcurrentLimit": 30, + "prHourlyLimit": 4, + "rebaseWhen": "conflicted", + "schedule": "on sunday at 9:00am", + "timezone": "Asia/Shanghai", + "vulnerabilityAlerts": { + "labels": ["security"], + "automerge": true + }, + "rangeStrategy": "bump", + "lockFileMaintenance": { + "enabled": true, + "schedule": ["on sunday at 9:00am"] + }, + "packageRules": [ + { + "matchUpdateTypes": ["minor", "patch"], + "groupName": "non-major dependencies", + "groupSlug": "minor-patch-updates" + }, + { + "groupName": "Tauri dependencies", + "groupSlug": "tauri-deps", + "matchPackagePatterns": ["^@tauri-apps/"], + "matchManagers": ["npm"], + "rangeStrategy": "replace" + }, + { + "groupName": "UnoCSS dependencies", + "groupSlug": "unocss-deps", + "matchPackagePatterns": ["^@unocss/"], + "matchManagers": ["npm"], + "rangeStrategy": "replace" + }, + { + "description": "Rust crate updates", + "matchManagers": ["cargo"], + "groupName": "Rust dependencies", + "groupSlug": "rust-deps", + "enabled": true, + "registryUrls": ["https://crates.io"], + "rangeStrategy": "replace" + }, + { + "matchUpdateTypes": ["patch", "digest", "pin"], + "matchManagers": ["npm", "cargo"] + }, + { + "matchManagers": ["npm"], + "rangeStrategy": "bump" + }, + { + "matchManagers": ["cargo"], + "matchPaths": ["src-tauri/**"], + "rangeStrategy": "replace" + } + ], + "enabledManagers": ["npm", "cargo"], + "updateInternalDeps": true +} diff --git a/scripts/check-all.js b/scripts/check-all.js new file mode 100644 index 0000000..da97e8e --- /dev/null +++ b/scripts/check-all.js @@ -0,0 +1,62 @@ +import chalk from 'chalk' +import { execFileSync } from 'child_process' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +/** + * 执行单个检查脚本 + * @param {string} scriptPath - 脚本路径 + * @param {string} description - 检查描述 + */ +async function runScript(scriptPath, description) { + const startTime = performance.now() + console.log(chalk.blue(`\n[HuLa ${new Date().toLocaleTimeString()}] 开始${description}...\n`)) + + try { + execFileSync('node', [scriptPath], { stdio: 'inherit' }) + const duration = ((performance.now() - startTime) / 1000).toFixed(2) + console.log(chalk.green(`\n✅ ${description}完成 (${duration}s)\n`)) + return true + } catch (_error) { + console.error(chalk.red(`\n❌ ${description}失败`)) + return false + } +} + +async function main() { + console.log(chalk.cyan('正在检查HuLa需要的环境配置...\n')) + + /** @type {CheckItem[]} */ + const checks = [ + { + script: join(__dirname, 'check-local.js'), + description: '配置文件检查' + }, + { + script: join(__dirname, 'check-dependencies.js'), + description: '环境检查' + } + ] + + const startTime = performance.now() + + for (const check of checks) { + const success = await runScript(check.script, check.description) + if (!success) { + console.error(chalk.red(`\n${check.description}未通过,终止检查流程\n`)) + process.exit(1) + } + } + + const totalDuration = ((performance.now() - startTime) / 1000).toFixed(2) + console.log(chalk.green(`\n✨ 所有检查通过!总用时:${totalDuration}s\n`)) +} + +main().catch((error) => { + console.error(chalk.red('\n检查过程中发生错误:')) + console.error(chalk.yellow(error.stack || error)) + process.exit(1) +}) diff --git a/scripts/check-dependencies.js b/scripts/check-dependencies.js new file mode 100644 index 0000000..df59021 --- /dev/null +++ b/scripts/check-dependencies.js @@ -0,0 +1,275 @@ +import chalk from 'chalk' +import { execSync } from 'child_process' +import { existsSync } from 'fs' +import { platform } from 'os' + +// 环境安装指南 +const INSTALL_GUIDES = { + 'Node.js': 'https://nodejs.org/zh-cn/download/', + pnpm: 'https://pnpm.io/zh/installation', + Rust: 'https://www.rust-lang.org/tools/install', + 'WebView2 Runtime': 'https://developer.microsoft.com/microsoft-edge/webview2/' +} + +// 更新指南 +const UPDATE_GUIDES = { + Rust: '请运行 `rustup update` 命令更新 Rust 版本' +} + +// Windows 特定的检查路径(只检查默认安装路径) +const WINDOWS_PATHS = { + 'WebView2 Runtime': [ + 'C:\\Program Files (x86)\\Microsoft\\EdgeWebView\\Application', + 'C:\\Program Files\\Microsoft\\EdgeWebView\\Application' + ] +} + +// 错误信息映射 +const ERROR_MESSAGES = { + ENOENT: '命令未找到', + EPERM: '权限不足', + EACCES: '访问被拒绝' +} + +const checks = [ + { + name: 'Node.js', + command: 'node --version', + versionExtractor: (output) => output.replace('v', ''), + minVersion: '^20.19.0 || >=22.12.0', + isRequired: true + }, + { + name: 'pnpm', + command: 'pnpm --version', + versionExtractor: (output) => output.trim(), + minVersion: '10.0.0', + isRequired: true + }, + { + name: 'Rust', + command: 'rustc --version', + versionExtractor: (output) => output.split(' ')[1], + minVersion: '1.88.0', + isRequired: true + } +] + +/** + * 检查 WebView2 是否安装 + * @returns {boolean} + */ +const checkWebView2 = () => { + try { + // 检查注册表 + const regQuery = + 'reg query "HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" /v pv' + execSync(regQuery, { stdio: 'ignore' }) + return true + } catch { + // 如果注册表查询失败,检查文件路径 + return WINDOWS_PATHS['WebView2 Runtime'].some((path) => existsSync(path)) + } +} + +// Windows 特定的检查 +const windowsChecks = [ + { + name: 'WebView2 Runtime', + checkInstalled: checkWebView2, + isRequired: true + } +] + +/** + * 获取友好的错误提示 + * @param {Error} error 错误对象 + * @returns {string} 错误提示 + */ +const getFriendlyErrorMessage = (error) => { + const code = error.code || '' + return ERROR_MESSAGES[code] || error.message || '未知错误' +} + +/** + * 比较版本号 + * @param {string} version1 当前版本 + * @param {string} version2 所需版本 + * @returns {number} 1: version1 大, -1: version2 大, 0: 相等 + */ +const compareVersions = (version1, version2) => { + const v1 = version1.replace(/[^0-9.]/g, '').split('.') + const v2 = version2.replace(/[^0-9.]/g, '').split('.') + + for (let i = 0; i < Math.max(v1.length, v2.length); i++) { + const num1 = parseInt(v1[i] || '0', 10) + const num2 = parseInt(v2[i] || '0', 10) + if (num1 > num2) return 1 + if (num1 < num2) return -1 + } + return 0 +} + +/** + * 检查版本是否满足 ^ 范围(主版本相同,次版本和补丁版本可以更高) + * @param {string} version 当前版本 + * @param {string} requiredVersion 要求的版本 + * @returns {boolean} + */ +const satisfiesCaretRange = (version, requiredVersion) => { + const [vMajor, vMinor, vPatch] = version.split('.').map(Number) + const [rMajor, rMinor, rPatch] = requiredVersion.split('.').map(Number) + + // 主版本必须相同 + if (vMajor !== rMajor) return false + + // 次版本和补丁版本需要 >= 要求的版本 + if (vMinor > rMinor) return true + if (vMinor < rMinor) return false + return vPatch >= rPatch +} + +/** + * 检查版本是否满足版本范围要求(支持 ||、^、>= 语法) + * @param {string} version 当前版本 + * @param {string} range 版本范围(如 '^20.19.0 || >=22.12.0') + * @returns {boolean} + */ +const satisfiesVersionRange = (version, range) => { + // 处理 || 分隔的多个条件 + const conditions = range.split('||').map((s) => s.trim()) + + // 只要满足任一条件即可 + return conditions.some((condition) => { + if (condition.startsWith('^')) { + // 处理 ^ 语法:主版本相同,次版本和补丁版本可以更高 + const requiredVersion = condition.slice(1).trim() + return satisfiesCaretRange(version, requiredVersion) + } else if (condition.startsWith('>=')) { + // 处理 >= 语法 + const requiredVersion = condition.slice(2).trim() + return compareVersions(version, requiredVersion) >= 0 + } + // 默认使用 >= 比较 + return compareVersions(version, condition) >= 0 + }) +} + +function checkDependency(check) { + try { + const output = execSync(check.command).toString().trim() + const version = check.versionExtractor(output) + + // 判断版本是否有效 + let isVersionValid + if (check.minVersion.includes('||') || check.minVersion.startsWith('^')) { + // 如果包含 || 或 ^,使用新的版本范围判断逻辑 + isVersionValid = satisfiesVersionRange(version, check.minVersion) + } else { + // 否则使用简单的版本比较 + isVersionValid = compareVersions(version, check.minVersion) >= 0 + } + + if (isVersionValid) { + console.log(chalk.green(`✅ ${check.name} 版本 ${output} 已安装\n`)) + return true + } else { + console.log(chalk.yellow(`⚠️ ${check.name} 版本过低`)) + console.log(chalk.yellow(` 当前版本: ${output}`)) + console.log(chalk.yellow(` 需要版本: ${check.minVersion}`)) + + // 对 Rust 进行特殊处理,提示使用 rustup update + if (check.name === 'Rust') { + console.log(chalk.yellow(` ${UPDATE_GUIDES[check.name]}`)) + } + + console.log(chalk.gray(` 👉 升级指南: ${INSTALL_GUIDES[check.name]}`)) + return false + } + } catch (error) { + const errorMessage = getFriendlyErrorMessage(error) + console.log(chalk.red(`❌ ${check.name} 未安装`)) + console.log(chalk.red(` 原因: ${errorMessage}`)) + console.log(chalk.gray(` 👉 安装指南: ${INSTALL_GUIDES[check.name]}`)) + return false + } +} + +/** + * 检查 Windows 特定的依赖 + * @param {Object} check 检查项 + * @returns {Promise} 是否通过检查 + */ +async function checkWindowsDependency(check) { + try { + const isInstalled = check.checkInstalled() + if (isInstalled) { + const desc = check.description ? ` (${check.description})` : '' + console.log(chalk.green(`✅ ${check.name} 已安装${desc}`)) + return true + } else { + const desc = check.description ? ` (${check.description})` : '' + console.log(chalk.yellow(`⚠️ ${check.name} 未安装${desc}`)) + + // 如果有自动安装器,尝试自动安装 + if (check.installer) { + console.log(chalk.blue(` 正在尝试自动安装 ${check.name}...`)) + const installSuccess = await check.installer() + if (installSuccess) { + // 安装后再次检查 + if (check.checkInstalled()) { + console.log(chalk.green(`✅ ${check.name} 安装成功并已配置`)) + return true + } + // 即使检查失败,可能需要重启终端 + console.log(chalk.yellow(` ⚠️ ${check.name} 已安装,但可能需要重启终端才能生效`)) + return true + } + } + + if (check.isRequired) { + console.log(chalk.red(`❌ ${check.name} 是必需的依赖`)) + console.log(chalk.gray(` 👉 安装指南: ${INSTALL_GUIDES[check.name]}`)) + return false + } else { + console.log(chalk.yellow(` 跳过可选依赖 ${check.name}`)) + return true + } + } + } catch (error) { + const errorMessage = getFriendlyErrorMessage(error) + console.log(chalk.red(`❌ ${check.name} 检查失败`)) + console.log(chalk.red(` 原因: ${errorMessage}`)) + return !check.isRequired + } +} + +async function main() { + const isWindows = platform() === 'win32' + + // 执行基本检查 + const results = checks.map(checkDependency) + + // 在 Windows 上执行额外检查 + if (isWindows) { + console.log(chalk.blue(`\n[HuLa ${new Date().toLocaleTimeString()}] 正在检查 Windows 开发环境...\n`)) + for (const check of windowsChecks) { + const result = await checkWindowsDependency(check) + results.push(result) + } + } + + if (results.every(Boolean)) { + console.log(chalk.green('\n✅ 所有环境检查通过!')) + process.exit(0) + } else { + console.log(chalk.red('\n❌ 环境依赖检查失败,请按照上述提示安装或更新依赖。')) + process.exit(1) + } +} + +main().catch((error) => { + console.error(chalk.red('检查过程中发生错误:')) + console.error(chalk.yellow(error.stack || error)) + process.exit(1) +}) diff --git a/scripts/check-local.js b/scripts/check-local.js new file mode 100644 index 0000000..2218d76 --- /dev/null +++ b/scripts/check-local.js @@ -0,0 +1,32 @@ +import chalk from 'chalk' +import { existsSync, readFileSync, writeFileSync } from 'fs' +import { join } from 'path' + +// 用于检查和创建 src-tauri/configuration/local.yaml 配置文件 +const configDir = join(process.cwd(), 'src-tauri', 'configuration') +const localConfigPath = join(configDir, 'local.yaml') +const productionConfigPath = join(configDir, 'production.yaml') + +try { + if (existsSync(localConfigPath)) { + console.log(chalk.green('✅ 检测到 local.yaml 已存在,跳过创建')) + process.exit(0) + } + + let content = '' + + // 优先使用 production.yaml 作为模板,因为它包含更完整的配置 + if (existsSync(productionConfigPath)) { + content = readFileSync(productionConfigPath, 'utf8') + console.log(chalk.blue('📋 使用 production.yaml 作为模板')) + } else { + console.log(chalk.red('❌ 未找到任何配置文件模板')) + process.exit(1) + } + + writeFileSync(localConfigPath, content, 'utf8') + console.log(chalk.green('✨ 已创建 local.yaml 配置文件')) +} catch (error) { + console.log(chalk.red('\n❌ 处理 local.yaml 文件失败:'), error.message) + process.exit(1) +} diff --git a/scripts/interactive-build-inquirer.js b/scripts/interactive-build-inquirer.js new file mode 100644 index 0000000..6b8758b --- /dev/null +++ b/scripts/interactive-build-inquirer.js @@ -0,0 +1,401 @@ +#!/usr/bin/env node + +import { select } from '@inquirer/prompts' +import { spawn } from 'child_process' +import os from 'os' + +// 检测当前平台 +function getCurrentPlatform() { + const platform = os.platform() + switch (platform) { + case 'darwin': + return { platform: 'macos', name: 'macOS' } + case 'win32': + return { platform: 'windows', name: 'Windows' } + case 'linux': + return { platform: 'linux', name: 'Linux' } + default: + return { platform: 'unknown', name: '未知平台' } + } +} + +// 获取平台选择选项 +function getPlatformOptions() { + const currentPlatform = getCurrentPlatform() + + // 根据当前操作系统定义支持的平台 + const supportedPlatforms = { + macos: ['macos', 'ios', 'android'], // macOS 可以打包 macOS、iOS、Android + windows: ['windows', 'android'], // Windows 可以打包 Windows、Android + linux: ['linux', 'android'] // Linux 可以打包 Linux、Android + } + + const allPlatforms = [ + { + name: `MacOS${currentPlatform.platform === 'macos' ? ' (当前平台)' : ''}`, + value: 'macos', + description: '打包 macOS 应用', + isCurrent: currentPlatform.platform === 'macos' + }, + { + name: `Windows${currentPlatform.platform === 'windows' ? ' (当前平台)' : ''}`, + value: 'windows', + description: '打包 Windows 应用', + isCurrent: currentPlatform.platform === 'windows' + }, + { + name: `Linux${currentPlatform.platform === 'linux' ? ' (当前平台)' : ''}`, + value: 'linux', + description: '打包 Linux 应用', + isCurrent: currentPlatform.platform === 'linux' + }, + { + name: 'Android', + value: 'android', + description: '打包 Android APK', + isCurrent: false + }, + { + name: 'IOS', + value: 'ios', + description: '打包 IOS 应用', + isCurrent: false + }, + { + name: '取消', + value: 'cancel', + description: '退出打包', + isCurrent: false + } + ] + + // 获取当前系统支持的平台列表 + const supported = supportedPlatforms[currentPlatform.platform] || [] + + // 过滤出支持的平台,保留取消选项 + const platforms = allPlatforms.filter((platform) => supported.includes(platform.value) || platform.value === 'cancel') + + // 将当前平台排在第一位 + return platforms.sort((a, b) => { + if (a.isCurrent && !b.isCurrent) return -1 + if (!a.isCurrent && b.isCurrent) return 1 + return 0 + }) +} + +// 获取包格式选项 +function getBundleOptions(platform) { + switch (platform) { + case 'macos': + return [ + { + name: '📦 dmg 磁盘映像', + value: 'dmg', + description: '生成 .dmg 安装包 (推荐)', + command: 'tauri build --bundles dmg' + }, + { + name: '📁 app 应用包', + value: 'app', + description: '生成 .app 应用包', + command: 'tauri build --bundles app' + }, + { + name: '📦 全部格式', + value: 'all', + description: '生成所有支持的格式 (.app, .dmg)', + command: 'tauri build' + }, + { + name: '🔙 返回上一步', + value: 'back', + description: '返回平台选择', + command: null + } + ] + + case 'windows': + return [ + { + name: '📦 msi 安装包', + value: 'msi', + description: '生成 .msi 安装包 (推荐)', + command: 'tauri build --bundles msi' + }, + { + name: '📦 nsis 安装程序', + value: 'nsis', + description: '生成 NSIS 安装程序', + command: 'tauri build --bundles nsis' + }, + { + name: '📦 全部格式', + value: 'all', + description: '生成所有支持的格式', + command: 'tauri build' + }, + { + name: '🔙 返回上一步', + value: 'back', + description: '返回平台选择', + command: null + } + ] + + case 'linux': + return [ + { + name: '📦 deb 软件包', + value: 'deb', + description: '生成 .deb 软件包 (Ubuntu/Debian)', + command: 'tauri build --bundles deb' + }, + { + name: '📁 AppImage', + value: 'appimage', + description: '生成 .AppImage 便携应用', + command: 'tauri build --bundles appimage' + }, + { + name: '📦 rpm 软件包', + value: 'rpm', + description: '生成 .rpm 软件包 (RedHat/CentOS)', + command: 'tauri build --bundles rpm' + }, + { + name: '📦 全部格式', + value: 'all', + description: '生成所有支持的格式', + command: 'tauri build' + }, + { + name: '🔙 返回上一步', + value: 'back', + description: '返回平台选择', + command: null + } + ] + + case 'android': + return [ + { + name: '📱 apk 安装包', + value: 'apk', + description: '生成 Android APK 安装包', + command: 'tauri android build' + }, + { + name: '🔙 返回上一步', + value: 'back', + description: '返回平台选择', + command: null + } + ] + + case 'ios': + return [ + { + name: '📱 IOS 应用', + value: 'ios', + description: '生成 IOS 应用包', + command: 'tauri ios build --export-method app-store-connect' + }, + { + name: '🔙 返回上一步', + value: 'back', + description: '返回平台选择', + command: null + } + ] + + default: + return [] + } +} + +// 获取调试模式选项 +function getDebugOptions() { + return [ + { + name: '🚀 正式版本', + value: 'release', + description: '生成正式版本', + isDebug: false + }, + { + name: '🔧 调试版本', + value: 'debug', + description: '生成调试版本 (可弹出控制台)', + isDebug: true + }, + { + name: '🔙 返回上一步', + value: 'back', + description: '返回包格式选择', + isDebug: null + } + ] +} + +// 执行打包命令 +async function executeBuild(command, isDebug = false) { + // 如果是调试模式,添加 --debug 参数 + const finalCommand = isDebug ? `${command} --debug` : command + const [cmd, ...args] = finalCommand.split(' ') + + const child = spawn(cmd, args, { + stdio: 'inherit', // 直接继承父进程的 stdio,保留颜色输出 + shell: true + }) + + return new Promise((resolve, reject) => { + child.on('close', (code) => { + if (code === 0) { + console.log('\n🎉 打包完成') + resolve(code) + } else { + console.log(`\n❌ 打包失败,退出代码: ${code}`) + resolve(code) + } + }) + + child.on('error', (error) => { + console.error(`\n❌ 执行错误: ${error.message}`) + reject(error) + }) + }) +} + +// 选择平台的函数 +async function selectPlatform() { + const platformOptions = getPlatformOptions() + + const selectedPlatform = await select({ + message: '请选择要打包的平台:', + choices: platformOptions.map((option) => ({ + name: option.name, + value: option.value, + description: `\x1b[90m${option.description}\x1b[0m` + })), + pageSize: 8, + loop: false + }) + + if (selectedPlatform === 'cancel') { + console.log('\n👋 已取消打包') + process.exit(0) + } + + return { selectedPlatform, platformOptions } +} + +// 选择调试模式的函数 +async function selectDebugMode() { + const debugOptions = getDebugOptions() + + const selectedDebug = await select({ + message: '第三步:请选择版本类型:', + choices: debugOptions.map((option) => ({ + name: option.name, + value: option.value, + description: `\x1b[90m${option.description}\x1b[0m` + })), + pageSize: 4, + loop: false + }) + + if (selectedDebug === 'back') { + return 'back' + } + + const selectedOption = debugOptions.find((option) => option.value === selectedDebug) + return selectedOption.isDebug +} + +// 选择包格式的函数 +async function selectBundle(selectedPlatform) { + const bundleOptions = getBundleOptions(selectedPlatform) + + if (bundleOptions.length === 0) { + console.log('\n❌ 该平台暂不支持') + return 'back' // 返回平台选择 + } + + const selectedBundle = await select({ + message: `请选择${selectedPlatform}的打包格式:`, + choices: bundleOptions.map((option) => ({ + name: option.name, + value: option.value, + description: `\x1b[90m${option.description}\x1b[0m` + })), + pageSize: 6, + loop: false + }) + + if (selectedBundle === 'back') { + return 'back' // 返回上一步 + } + + // 找到选中的选项 + const selectedOption = bundleOptions.find((option) => option.value === selectedBundle) + + if (!selectedOption || !selectedOption.command) { + console.log('\n👋 已取消打包操作') + process.exit(0) + } + + return selectedOption +} + +async function main() { + try { + // 主循环 + while (true) { + // 第一步:选择平台 + const { selectedPlatform } = await selectPlatform() + + // 第二步:选择包格式 + while (true) { + const bundleResult = await selectBundle(selectedPlatform) + + // 如果返回 'back',返回平台选择 + if (bundleResult === 'back') { + break + } + + // 移动端平台(iOS 和 Android)直接打包正式版本,不需要选择调试模式 + const isMobilePlatform = selectedPlatform === 'ios' || selectedPlatform === 'android' + + if (isMobilePlatform) { + const exitCode = await executeBuild(bundleResult.command, false) + process.exit(exitCode) + } else { + // 桌面端平台需要选择调试模式 + // 第三步:选择调试模式 + while (true) { + const debugResult = await selectDebugMode() + + // 如果返回 'back',返回包格式选择 + if (debugResult === 'back') { + break + } + + const exitCode = await executeBuild(bundleResult.command, debugResult) + process.exit(exitCode) + } + } + } + } + } catch (error) { + if (error.name === 'ExitPromptError') { + // 用户按了 Ctrl+C + console.log('\n👋 已取消操作') + process.exit(0) + } else { + console.error('发生错误:', error) + process.exit(1) + } + } +} + +main() diff --git a/scripts/link-skills.js b/scripts/link-skills.js new file mode 100644 index 0000000..7d5e2a6 --- /dev/null +++ b/scripts/link-skills.js @@ -0,0 +1,314 @@ +import { checkbox } from '@inquirer/prompts' +import { openSync, promises as fs } from 'fs' +import path from 'path' +import { ReadStream, WriteStream } from 'node:tty' +import { styleText } from 'node:util' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const repoRoot = path.resolve(__dirname, '..') +const skillsRoot = path.join(repoRoot, 'skills') + +async function main() { + const shouldSkip = process.env.HULA_SKIP_SKILL_LINK || process.env.CI + const isLifecycle = Boolean(process.env.npm_lifecycle_event) + const canPrompt = Boolean(process.stdin.isTTY && process.stdout.isTTY) + const forcePrompt = process.env.HULA_FORCE_SKILL_LINK === '1' + + if (shouldSkip) { + console.log('已跳过技能链接:检测到 HULA_SKIP_SKILL_LINK 或 CI。') + return + } + + const homeDir = process.env.HOME || process.env.USERPROFILE + if (!homeDir) { + console.log('已跳过技能链接:未检测到 HOME 或 USERPROFILE。') + return + } + + let skillDirs = [] + try { + const entries = await fs.readdir(skillsRoot, { withFileTypes: true }) + skillDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) + } catch { + console.log(`已跳过技能链接:无法读取 ${skillsRoot}。`) + return + } + + if (skillDirs.length === 0) { + console.log(`已跳过技能链接:${skillsRoot} 下未找到技能目录。`) + return + } + + const codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex') + const claudeHome = process.env.CLAUDE_HOME || path.join(homeDir, '.claude') + + const targets = [ + { + id: 'codex', + name: 'Codex', + dir: path.join(codexHome, 'skills') + }, + { + id: 'claude', + name: 'Claude', + dir: path.join(claudeHome, 'skills') + } + ] + + const targetsWithStatus = [] + for (const target of targets) { + const status = await getTargetLinkStatus(target.dir, skillsRoot, skillDirs) + targetsWithStatus.push({ ...target, status }) + } + + if (targetsWithStatus.every((target) => target.status.allLinked)) { + console.log('已跳过技能链接:目标目录已全部链接。') + return + } + + if (isLifecycle && !forcePrompt) { + const pending = targetsWithStatus.filter((target) => !target.status.allLinked) + const pendingNames = pending.map((target) => target.name).join(', ') + console.log(`技能链接待处理:${pendingNames}。`) + console.log('请运行 `node scripts/link-skills.js` 进行链接。') + return + } + + if (!canPrompt && !forcePrompt) { + console.log('已跳过技能链接:当前为非交互终端。') + console.log('请在终端运行 `node scripts/link-skills.js` 进行链接。') + return + } + + let promptContext = null + let cleanupPromptContext = () => {} + + if (!canPrompt) { + const fallback = createConsolePromptContext() + if (!fallback) { + console.log('已跳过技能链接:无法打开控制台用于交互。') + return + } + promptContext = fallback.context + cleanupPromptContext = fallback.cleanup + } + + try { + const useChineseHelp = isChineseLocale() + const allTargetsValue = '__all__' + const keyLabelMap = useChineseHelp + ? new Map([ + ['space', '空格'], + ['⏎', '回车'] + ]) + : null + const actionLabelMap = useChineseHelp + ? new Map([ + ['navigate', '移动'], + ['select', '选择'], + ['all', '全选'], + ['invert', '反选'], + ['submit', '提交'] + ]) + : null + const selectedTargets = await checkbox( + { + message: '请选择要链接的目标:', + required: true, + theme: { + icon: { + checked: '✓', + unchecked: '○', + cursor: '❯' + }, + style: { + keysHelpTip: (keys) => + keys + .map(([key, action]) => { + const keyLabel = keyLabelMap?.get(key) ?? key + const actionLabel = actionLabelMap?.get(action) ?? action + return `${styleText('bold', keyLabel)} ${styleText('dim', actionLabel)}` + }) + .join(styleText('dim', ' · ')) + } + }, + choices: [ + { + name: '链接全部未链接目标', + value: allTargetsValue, + checked: false + }, + ...targetsWithStatus.map((target) => ({ + name: `${target.name} (${target.dir})`, + value: target.id, + checked: false, + disabled: target.status.allLinked ? '已链接' : false + })) + ] + }, + promptContext ?? undefined + ) + + const shouldLinkAll = selectedTargets.includes(allTargetsValue) + const selectedIds = shouldLinkAll + ? targetsWithStatus.filter((target) => !target.status.allLinked).map((target) => target.id) + : selectedTargets.filter((value) => value !== allTargetsValue) + + if (selectedIds.length === 0) { + console.log('已跳过技能链接:未选择目标。') + return + } + + for (const target of targetsWithStatus) { + if (!selectedIds.includes(target.id)) { + continue + } + await linkSkillsToTarget(target.dir, skillsRoot, skillDirs) + } + } finally { + cleanupPromptContext() + } +} + +main().catch((error) => { + console.log('技能链接失败。') + if (error instanceof Error) { + console.log(error.message) + } +}) + +function isChineseLocale() { + const candidates = [ + process.env.LC_ALL, + process.env.LC_MESSAGES, + process.env.LANG, + Intl.DateTimeFormat().resolvedOptions().locale + ].filter(Boolean) + + const locale = candidates.join(' ').toLowerCase() + return locale.includes('zh') +} + +function createConsolePromptContext() { + const isWindows = process.platform === 'win32' + const candidates = isWindows + ? [ + { input: '\\\\.\\CONIN$', output: '\\\\.\\CONOUT$' }, + { input: 'CONIN$', output: 'CONOUT$' } + ] + : [{ input: '/dev/tty', output: '/dev/tty' }] + + for (const candidate of candidates) { + try { + const inputFd = openSync(candidate.input, 'r') + const outputFd = openSync(candidate.output, 'w') + const input = new ReadStream(inputFd) + const output = new WriteStream(outputFd) + return { + context: { input, output }, + cleanup: () => { + input.destroy() + output.destroy() + } + } + } catch {} + } + + return null +} + +async function getTargetLinkStatus(targetDir, sourceRoot, skillNames) { + let linkedCount = 0 + + for (const skillName of skillNames) { + const source = path.join(sourceRoot, skillName) + const dest = path.join(targetDir, skillName) + if (await isSkillLinked(source, dest)) { + linkedCount += 1 + } + } + + return { + allLinked: linkedCount === skillNames.length + } +} + +async function isSkillLinked(source, dest) { + let destStat = null + try { + destStat = await fs.lstat(dest) + } catch (error) { + if (error.code !== 'ENOENT') { + console.log(`已跳过技能链接:无法检查 ${dest}。`) + } + return false + } + + if (!destStat.isSymbolicLink()) { + return false + } + + try { + const [destResolved, sourceResolved] = await Promise.all([fs.realpath(dest), fs.realpath(source)]) + return destResolved === sourceResolved + } catch { + return false + } +} + +async function linkSkillsToTarget(targetDir, sourceRoot, skillNames) { + await fs.mkdir(targetDir, { recursive: true }) + const sourceRootResolved = await fs.realpath(sourceRoot) + + for (const skillName of skillNames) { + const source = path.join(sourceRootResolved, skillName) + const dest = path.join(targetDir, skillName) + await ensureLink(source, dest) + } +} + +async function ensureLink(source, dest) { + const sourceResolved = await fs.realpath(source) + let destStat = null + + try { + destStat = await fs.lstat(dest) + } catch (error) { + if (error.code !== 'ENOENT') { + console.log(`已跳过技能链接:无法检查 ${dest}。`) + return + } + } + + if (destStat) { + if (destStat.isSymbolicLink()) { + try { + const destResolved = await fs.realpath(dest) + if (destResolved === sourceResolved) { + console.log(`技能链接已存在:${dest}`) + return + } + } catch { + console.log(`已跳过技能链接:${dest} 处的链接无效。`) + return + } + } + + console.log(`已跳过技能链接:${dest} 已存在。`) + return + } + + try { + if (process.platform === 'win32') { + await fs.symlink(sourceResolved, dest, 'junction') + } else { + await fs.symlink(sourceResolved, dest) + } + console.log(`已创建技能链接:${dest} -> ${sourceResolved}`) + } catch { + console.log(`技能链接失败:${dest}`) + } +} diff --git a/skills/hula-skill/SKILL.md b/skills/hula-skill/SKILL.md new file mode 100644 index 0000000..59107ad --- /dev/null +++ b/skills/hula-skill/SKILL.md @@ -0,0 +1,40 @@ +--- +name: hula-skill +description: "HuLa project skill for frontend (Vue 3 + Vite + UnoCSS + Naive UI/Vant), backend (Tauri v2 + Rust + SeaORM/SQLite), full-stack flows, and build/release work. Use when the user mentions hula or HuLa or requests changes in this repository; after triggering, ask which scope (frontend/backend/fullstack/build-release) to enable." +--- + +# HuLa Skill + +## Overview + +Enable consistent changes across the HuLa frontend, backend, full-stack flows, and build/release tasks with repo-specific conventions and resources. + +## Activation Gate + +Ask which scope to enable: frontend, backend, fullstack, or build-release. +Confirm platform (desktop or mobile), target area (view/component/store/command), and any constraints before editing. + +## Workflow + +1. Identify scope and platform. +2. Locate similar code paths and follow existing patterns. +3. Apply changes using repo conventions and available templates. +4. Update related layers (routes, stores, commands) when needed. +5. Propose or run checks/tests only when requested. + +## Scope Routing + +- Frontend: read `references/frontend.md` and `references/overview.md`; use `assets/templates/view-desktop.vue`, `assets/templates/view-mobile.vue`, `assets/templates/pinia-store.ts` as starters. +- Backend: read `references/backend.md` and `references/overview.md`; use `assets/templates/tauri-command.rs`. +- Fullstack: read `references/fullstack.md` plus frontend/backend references; use `assets/templates/tauri-command.ts`. +- Build/Release: read `references/build-release.md` and `references/checklists.md`. + +## Scripts + +Use `scripts/hula_summary.py` for quick repo context (views/stores counts and paths). +Use `scripts/hula_tauri_map.py` to list Tauri commands and frontend invoke usage. + +## References + +Use `references/overview.md` for stack, directories, aliases, and global conventions. +Use `references/checklists.md` for per-scope checklists. diff --git a/skills/hula-skill/assets/templates/pinia-store.ts b/skills/hula-skill/assets/templates/pinia-store.ts new file mode 100644 index 0000000..291a52b --- /dev/null +++ b/skills/hula-skill/assets/templates/pinia-store.ts @@ -0,0 +1,13 @@ +import { defineStore } from 'pinia' +import { StoresEnum } from '@/enums' + +// Add the enum entry in src/enums/index.ts before using this store. +export const useExampleStore = defineStore(StoresEnum.EXAMPLE, () => { + const items = ref([]) + + const addItem = (value: string) => { + items.value.push(value) + } + + return { items, addItem } +}) diff --git a/skills/hula-skill/assets/templates/tauri-command.rs b/skills/hula-skill/assets/templates/tauri-command.rs new file mode 100644 index 0000000..0be758f --- /dev/null +++ b/skills/hula-skill/assets/templates/tauri-command.rs @@ -0,0 +1,4 @@ +#[tauri::command] +pub async fn example_command(payload: String) -> Result { + Ok(payload) +} diff --git a/skills/hula-skill/assets/templates/tauri-command.ts b/skills/hula-skill/assets/templates/tauri-command.ts new file mode 100644 index 0000000..a930d0a --- /dev/null +++ b/skills/hula-skill/assets/templates/tauri-command.ts @@ -0,0 +1,7 @@ +import { TauriCommand } from '@/enums' +import { invokeWithErrorHandler } from '@/utils/TauriInvokeHandler' + +// Add the enum entry in src/enums/index.ts before using this command. +export const exampleCommand = async (payload: string): Promise => { + return await invokeWithErrorHandler(TauriCommand.EXAMPLE_COMMAND, { payload }) +} diff --git a/skills/hula-skill/assets/templates/view-desktop.vue b/skills/hula-skill/assets/templates/view-desktop.vue new file mode 100644 index 0000000..9bb724f --- /dev/null +++ b/skills/hula-skill/assets/templates/view-desktop.vue @@ -0,0 +1,15 @@ + + + diff --git a/skills/hula-skill/assets/templates/view-mobile.vue b/skills/hula-skill/assets/templates/view-mobile.vue new file mode 100644 index 0000000..06630e5 --- /dev/null +++ b/skills/hula-skill/assets/templates/view-mobile.vue @@ -0,0 +1,13 @@ + + + diff --git a/skills/hula-skill/references/backend.md b/skills/hula-skill/references/backend.md new file mode 100644 index 0000000..9da5c2b --- /dev/null +++ b/skills/hula-skill/references/backend.md @@ -0,0 +1,24 @@ +# Backend Guide (Tauri + Rust) + +## Command Locations + +- Shared commands: `src-tauri/src/command/` +- Desktop-specific: `src-tauri/src/desktops/` +- Mobile-specific: `src-tauri/src/mobiles/` + +## Add a New Command + +1. Define a `#[tauri::command]` function in an existing module or a new module. +2. If you add a new module, export it from `src-tauri/src/command/mod.rs`. +3. Register the command in `tauri::generate_handler![...]` inside `src-tauri/src/lib.rs`. +4. Keep signatures async when IO or DB is involved. + +## Database + +- Use SeaORM entities in `src-tauri/entity/`. +- Add schema changes via migrations in `src-tauri/migration/`. + +## Shared State + +- Access shared state via `tauri::State<'_, AppData>`. +- Keep long operations async to avoid blocking the runtime. diff --git a/skills/hula-skill/references/build-release.md b/skills/hula-skill/references/build-release.md new file mode 100644 index 0000000..b7e977b --- /dev/null +++ b/skills/hula-skill/references/build-release.md @@ -0,0 +1,17 @@ +# Build and Release Guide + +## Common Commands + +- Install deps: `pnpm install` +- Desktop dev: `pnpm tauri:dev` +- Desktop build: `pnpm tauri:build` +- Lint/format check: `pnpm check` +- Auto-fix: `pnpm check:write` +- Vue formatting: `pnpm format:vue` or `pnpm format:all` +- Tests: `pnpm test:run` +- Commit helper: `pnpm commit` + +## Notes + +- Use the registry configured in `.npmrc`. Override locally only if needed. +- Avoid committing secrets; use `.env.local` for personal tokens. diff --git a/skills/hula-skill/references/checklists.md b/skills/hula-skill/references/checklists.md new file mode 100644 index 0000000..83f5ea8 --- /dev/null +++ b/skills/hula-skill/references/checklists.md @@ -0,0 +1,27 @@ +# Checklists + +## Frontend Changes + +- Confirm platform (desktop/mobile) and target window/view. +- Reuse existing view/component patterns from nearby files. +- Update routes in `src/router/index.ts` when adding a new view. +- Add i18n keys under `locales/` when new user-facing strings appear. +- Use UnoCSS utilities and shared tokens from `src/styles/scss/global/variable.scss`. + +## Backend Changes + +- Add `#[tauri::command]` and register it in `src-tauri/src/lib.rs`. +- Export new command modules in `src-tauri/src/command/mod.rs` if added. +- Update SeaORM entities and migrations when schema changes. + +## Fullstack Changes + +- Add a `TauriCommand` enum entry if the frontend uses a named constant. +- Add or update a frontend wrapper in `src/services/tauriCommand.ts`. +- Use `invokeWithErrorHandler` for consistent error handling where needed. +- Validate payload and response types end-to-end. + +## Build/Release Work + +- Use the existing `pnpm` scripts for checks and builds. +- Avoid touching `.rules` unless explicitly asked. diff --git a/skills/hula-skill/references/frontend.md b/skills/hula-skill/references/frontend.md new file mode 100644 index 0000000..5977165 --- /dev/null +++ b/skills/hula-skill/references/frontend.md @@ -0,0 +1,43 @@ +# Frontend Guide + +## Default Stack + +- Vue 3 Composition API with `" + } else { + "HTTP/1.1 200 OK\r\n\r\n" + }; + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.flush().await; + } + } else { + // accept 失败,可能是 listener 关闭 + break; + } + } + }); + + // 4. 保存新任务句柄 + { + let mut handle_lock = state.handle.lock().map_err(|e| e.to_string())?; + *handle_lock = Some(handle); + } + + Ok(port) +} diff --git a/src-tauri/src/command/request_command.rs b/src-tauri/src/command/request_command.rs new file mode 100644 index 0000000..a861065 --- /dev/null +++ b/src-tauri/src/command/request_command.rs @@ -0,0 +1,268 @@ +use migration::{Migrator, MigratorTrait}; +use tauri::{AppHandle, Emitter, Manager, State}; + +use crate::{ + AppData, + command::message_command::check_user_init_and_fetch_messages, + command::token_helper::capture_token_snapshot_direct, + configuration::get_configuration, + im_request_client::{ImRequest, ImUrl}, + repository::{im_message_repository::reset_table_initialization_flags, im_user_repository}, + vo::vo::{LoginReq, LoginResp}, +}; + +#[tauri::command] +pub async fn login_command( + data: LoginReq, + state: State<'_, AppData>, + app_handle: AppHandle, +) -> Result, String> { + if data.is_auto_login { + // 自动登录逻辑 + if let Some(uid) = &data.uid { + // 先切换到用户专属数据库 + switch_to_user_database(&state, &app_handle, uid).await?; + + // 从数据库获取用户的 refresh_token + let db_result = + im_user_repository::get_user_tokens(&*state.db_conn.read().await, uid).await; + match db_result { + Ok(Some((token, refresh_token))) => { + if refresh_token.is_empty() { + let check_result: Result, anyhow::Error> = { + let mut rc = state.rc.lock().await; + rc.token = Some(token.clone()); + rc.im_request( + ImUrl::CheckToken, + None::, + None::, + ) + .await + }; + + if check_result.is_ok() { + let login_resp = LoginResp { + token, + client: "".to_string(), + refresh_token: refresh_token.clone(), + expire: "".to_string(), + uid: uid.clone(), + }; + handle_login_success(&login_resp, &state, data.async_data).await?; + return Ok(Some(login_resp)); + } + } + + // 使用 start_refresh_token 刷新登录(不会添加 token 头,适合自动登录场景) + let refresh_result = { + let mut rc = state.rc.lock().await; + // 设置 ImRequestClient 内部的 refresh_token + rc.refresh_token = Some(refresh_token.clone()); + rc.start_refresh_token().await + }; + + match refresh_result { + Ok(()) => { + // 从 ImRequestClient 中获取刷新后的 token + let rc = state.rc.lock().await; + let new_token = rc.token.clone().unwrap_or_default(); + let new_refresh_token = rc.refresh_token.clone().unwrap_or_default(); + drop(rc); + + // 保存新的 token 信息到数据库 + im_user_repository::save_user_tokens( + &*state.db_conn.read().await, + uid, + &new_token, + &new_refresh_token, + ) + .await + .ok(); + + // 转换为 LoginResp 格式返回 + let login_resp = LoginResp { + token: new_token, + client: "".to_string(), + refresh_token: new_refresh_token, + expire: "".to_string(), + uid: uid.clone(), + }; + + handle_login_success(&login_resp, &state, data.async_data).await?; + + return Ok(Some(login_resp)); + } + Err(e) => { + let err_str = e.to_string(); + if err_str.contains("network_error") { + return Err("网络连接失败,请检查网络后重试".to_string()); + } + } + } + } + Ok(None) => {} + Err(_) => {} + }; + // 自动登录失败,返回错误让前端切换到手动登录 + return Err("自动登录失败,请手动登录".to_string()); + } else { + return Err("自动登录缺少用户ID".to_string()); + } + } else { + // 手动登录逻辑 + let async_data = data.async_data; + let res = { + let mut rc = state.rc.lock().await; + rc.login(data).await.map_err(|e| e.to_string())? + }; // 锁在这里被释放 + + // 登录成功后处理用户信息和token保存 + if let Some(login_resp) = &res { + // 先切换到用户专属数据库 + switch_to_user_database(&state, &app_handle, &login_resp.uid).await?; + handle_login_success(login_resp, &state, async_data).await?; + } + + Ok(res) + } +} + +/// 切换到用户专属数据库 +async fn switch_to_user_database( + state: &State<'_, AppData>, + app_handle: &AppHandle, + uid: &str, +) -> Result<(), String> { + // 获取配置 + let configuration = get_configuration(app_handle) + .map_err(|e| format!("Failed to load configuration: {}", e))?; + + // 创建新的数据库连接 + let new_db = configuration + .database + .connection_string(app_handle, Some(uid)) + .await + .map_err(|e| e.to_string())?; + + // 执行数据库迁移 + if let Err(e) = Migrator::up(&new_db, None).await { + tracing::warn!("Database migration warning for user {}: {}", uid, e); + } + + // 重置表初始化标志,确保新数据库会创建必要的表 + reset_table_initialization_flags(); + + // 替换数据库连接 + { + let mut db_guard = state.db_conn.write().await; + *db_guard = new_db; + } + + Ok(()) +} + +async fn handle_login_success( + login_resp: &LoginResp, + state: &State<'_, AppData>, + async_data: bool, +) -> Result<(), String> { + // 从登录响应中获取用户标识,这里使用 uid 作为 uid + let uid = &login_resp.uid; + + // 设置用户信息 + let mut user_info = state.user_info.lock().await; + user_info.uid = login_resp.uid.clone(); + user_info.token = login_resp.token.clone(); + user_info.refresh_token = login_resp.refresh_token.clone(); + // 保存 token 信息到数据库 + im_user_repository::save_user_tokens( + &*state.db_conn.read().await, + uid, + &login_resp.token, + &login_resp.refresh_token, + ) + .await + .map_err(|e| e.to_string())?; + + let mut client = state.rc.lock().await; + check_user_init_and_fetch_messages( + &mut client, + &*state.db_conn.read().await, + uid, + async_data, + false, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + +#[tauri::command] +#[cfg_attr(mobile, allow(unused_variables))] +pub async fn im_request_command( + state: State<'_, AppData>, + url: String, + body: Option, + params: Option, + app_handle: tauri::AppHandle, +) -> Result, String> { + let user_uid = state.user_info.lock().await.uid.clone(); + let mut rc = state.rc.lock().await; + + // 记录请求前的 token,用于检测是否被刷新 + let old_tokens = capture_token_snapshot_direct(&rc); + + if let Ok(url) = url.parse::() { + let result: Result, anyhow::Error> = + rc.im_request(url, body, params).await; + + // 无论请求成功还是失败,都检查 token 是否被刷新,如果是则保存到数据库 + // 这确保了即使请求重试后失败,刷新后的 token 也能被持久化 + if let (Some(new_token), Some(new_refresh_token)) = + (rc.token.clone(), rc.refresh_token.clone()) + { + if old_tokens.token != rc.token || old_tokens.refresh_token != rc.refresh_token { + if !user_uid.is_empty() { + im_user_repository::save_user_tokens( + &*state.db_conn.read().await, + &user_uid, + &new_token, + &new_refresh_token, + ) + .await + .ok(); + } + } + } + + match result { + Ok(data) => { + return Ok(data); + } + Err(e) => { + if e.to_string().contains("请重新登录") { + if user_uid.is_empty() { + } else { + if app_handle.get_webview_window("home").is_some() { + if let Err(err) = app_handle.emit_to("home", "relogin", ()) { + let _ = err; + } + } else if app_handle.get_webview_window("mobile-home").is_some() { + if let Err(err) = app_handle.emit_to("mobile-home", "relogin", ()) { + let _ = err; + } + } else { + if let Err(err) = app_handle.emit("relogin", ()) { + let _ = err; + } + } + } + } + return Err(e.to_string()); + } + } + } else { + return Err(format!("Invalid URL: {}", url)); + } +} diff --git a/src-tauri/src/command/room_member_command.rs b/src-tauri/src/command/room_member_command.rs new file mode 100644 index 0000000..357a7af --- /dev/null +++ b/src-tauri/src/command/room_member_command.rs @@ -0,0 +1,272 @@ +use crate::AppData; +use crate::command::token_helper::{capture_token_snapshot_arc, persist_token_if_refreshed_arc}; +use crate::error::CommonError; +use crate::pojo::common::{CursorPageParam, CursorPageResp, Page, PageParam}; +use crate::repository::im_room_member_repository::update_my_room_info as update_my_room_info_db; +use crate::vo::vo::MyRoomInfoReq; + +use entity::{im_room, im_room_member}; +use tracing::{error, info}; + +use crate::im_request_client::{ImRequestClient, ImUrl}; +use crate::repository::im_room_member_repository; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::sync::Arc; +use tauri::State; +use tokio::sync::Mutex; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RoomMemberResponse { + pub id: String, + pub room_id: Option, + pub uid: Option, + pub account: Option, + pub my_name: Option, + pub active_status: Option, + #[serde(rename = "roleId")] + pub group_role: Option, + pub loc_place: Option, + pub last_opt_time: i64, + pub create_time: Option, + pub name: String, + pub avatar: Option, + pub user_state_id: Option, + #[serde(rename = "wearingItemId")] + pub wearing_item_id: Option, + #[serde(rename = "itemIds")] + pub item_ids: Option>, + pub linked_gitee: Option, + pub linked_github: Option, +} + +#[tauri::command] +pub async fn update_my_room_info( + my_room_info: MyRoomInfoReq, + state: State<'_, AppData>, +) -> Result<(), String> { + let result: Result<(), CommonError> = async { + // 获取当前用户信息 + let user_info = state.user_info.lock().await; + let uid = user_info.uid.clone(); + drop(user_info); + + let old_tokens = capture_token_snapshot_arc(&state.rc).await; + + // 调用后端接口更新房间信息 + let _resp: Option = state + .rc + .lock() + .await + .im_request( + ImUrl::UpdateMyRoomInfo, + Some(my_room_info.clone()), + None::, + ) + .await?; + + persist_token_if_refreshed_arc(&old_tokens, &state.rc, &state.db_conn, &uid).await; + + // 更新本地数据库 + update_my_room_info_db( + &*state.db_conn.read().await, + &my_room_info.my_name, + &my_room_info.id, + &uid, + &uid, + ) + .await + .map_err(|e| { + anyhow::anyhow!( + "[{}:{}] Failed to update local database: {}", + file!(), + line!(), + e + ) + })?; + Ok(()) + } + .await; + + match result { + Ok(members) => Ok(members), + Err(e) => { + error!("Failed to update room information: {:?}", e); + Err(e.to_string()) + } + } +} + +/// 获取room_id的房间的所有成员列表 +#[tauri::command] +pub async fn get_room_members( + room_id: String, + state: State<'_, AppData>, +) -> Result, String> { + info!("Calling to get all member list of room with room_id"); + let uid = state.user_info.lock().await.uid.clone(); + let result: Result, CommonError> = async { + let mut members = fetch_and_update_room_members( + room_id.clone(), + state.rc.clone(), + state.db_conn.clone(), + &uid, + ) + .await?; + + sort_room_members(&mut members); + + Ok(members) + } + .await; + + match result { + Ok(members) => Ok(members), + Err(e) => { + error!("Failed to get all room member data: {:?}", e); + Err(e.to_string()) + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CursorPageRoomMemberParam { + room_id: String, + #[serde(flatten)] + cursor_page_param: CursorPageParam, +} + +// 游标分页查询数据 +#[tauri::command] +pub async fn cursor_page_room_members( + param: CursorPageRoomMemberParam, + state: State<'_, AppData>, +) -> Result>, String> { + // 获取当前登录用户的 uid + let login_uid = { + let user_info = state.user_info.lock().await; + user_info.uid.clone() + }; + + let data = im_room_member_repository::cursor_page_room_members( + &*state.db_conn.read().await, + param.room_id, + param.cursor_page_param, + &login_uid, + ) + .await + .map_err(|e| e.to_string())?; + Ok(data) +} + +// 从本地数据库分页查询群房间数据,如果为空则从后端获取 +#[tauri::command] +pub async fn page_room( + page_param: PageParam, + state: State<'_, AppData>, +) -> Result, String> { + let uid = state.user_info.lock().await.uid.clone(); + let result: Result, CommonError> = async { + // 直接调用后端接口获取数据,不保存到数据库 + let data = + fetch_rooms_from_backend(page_param, state.rc.clone(), state.db_conn.clone(), &uid) + .await?; + + Ok(data) + } + .await; + + match result { + Ok(page_data) => Ok(page_data), + Err(e) => { + error!("Failed to get paginated room data: {:?}", e); + Err(e.to_string()) + } + } +} + +/// 从后端获取房间数据(不保存到数据库) +async fn fetch_rooms_from_backend( + page_param: PageParam, + request_client: Arc>, + db_conn: Arc>, + uid: &str, +) -> Result, CommonError> { + let old_tokens = capture_token_snapshot_arc(&request_client).await; + + let resp: Option> = { + let mut client = request_client.lock().await; + client + .im_request( + ImUrl::GroupList, + None::, + Some(page_param), + ) + .await? + }; + + persist_token_if_refreshed_arc(&old_tokens, &request_client, &db_conn, uid).await; + + if let Some(data) = resp { + Ok(data) + } else { + Err(CommonError::UnexpectedError(anyhow::anyhow!( + "No data returned from backend" + ))) + } +} + +/// 对房间成员列表进行排序:按角色优先,再按在线状态,最后按名称字母序 +fn sort_room_members(members: &mut Vec) { + members.sort_by(|a, b| { + let role_cmp = match (a.group_role, b.group_role) { + (Some(a_role), Some(b_role)) if a_role != b_role => a_role.cmp(&b_role), + _ => Ordering::Equal, + }; + if role_cmp != Ordering::Equal { + return role_cmp; + } + + let a_status = a.active_status.unwrap_or(u8::MAX); + let b_status = b.active_status.unwrap_or(u8::MAX); + if a_status != b_status { + return a_status.cmp(&b_status); + } + + let a_name = a.name.to_lowercase(); + let b_name = b.name.to_lowercase(); + a_name.cmp(&b_name) + }); +} + +/// 异步更新房间成员数据 +async fn fetch_and_update_room_members( + room_id: String, + request_client: Arc>, + db_conn: Arc>, + uid: &str, +) -> Result, CommonError> { + let old_tokens = capture_token_snapshot_arc(&request_client).await; + + let resp: Option> = request_client + .lock() + .await + .im_request( + ImUrl::GroupListMember, + None::, + Some(serde_json::json!({ + "roomId": room_id + })), + ) + .await?; + + persist_token_if_refreshed_arc(&old_tokens, &request_client, &db_conn, uid).await; + + if let Some(data) = resp { + return Ok(data); + } + + Ok(Vec::new()) +} diff --git a/src-tauri/src/command/setting_command.rs b/src-tauri/src/command/setting_command.rs new file mode 100644 index 0000000..8eab041 --- /dev/null +++ b/src-tauri/src/command/setting_command.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; +use tauri::State; +use tracing::info; + +use crate::{AppData, configuration::Settings}; + +#[tauri::command] +pub async fn get_settings(state: State<'_, AppData>) -> Result { + Ok(state.config.lock().await.clone()) +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSettingsParams { + base_url: String, + ws_url: String, +} + +#[tauri::command] +pub async fn update_settings( + state: State<'_, AppData>, + settings: UpdateSettingsParams, +) -> Result<(), String> { + let mut config = state.config.lock().await; + config.backend.base_url = settings.base_url.clone(); + config.backend.ws_url = settings.ws_url; + info!("update settings: {:?}", config); + state + .rc + .lock() + .await + .set_base_url(settings.base_url.clone()); + Ok(()) +} diff --git a/src-tauri/src/command/token_helper.rs b/src-tauri/src/command/token_helper.rs new file mode 100644 index 0000000..1fa003f --- /dev/null +++ b/src-tauri/src/command/token_helper.rs @@ -0,0 +1,202 @@ +use crate::im_request_client::ImRequestClient; +use crate::repository::im_user_repository; +use sea_orm::DatabaseConnection; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use tracing::{info, warn}; + +#[derive(Clone, Debug)] +pub struct TokenSnapshot { + pub token: Option, + pub refresh_token: Option, +} + +/// Token 刷新检测器 +/// 用于在 im_request 调用前后检测 token 是否被刷新,并自动持久化到数据库 +pub struct TokenRefreshGuard { + old_tokens: TokenSnapshot, +} + +impl TokenRefreshGuard { + /// 在 im_request 调用前创建,记录当前的 token 和 refresh_token + pub async fn before_request(client: &Mutex) -> Self { + let old_tokens = capture_token_snapshot(client).await; + Self { old_tokens } + } + + /// 在 im_request 调用后检查 token 是否被刷新,如果是则持久化到数据库 + pub async fn persist_if_refreshed( + &self, + client: &Mutex, + db_conn: &RwLock, + uid: &str, + ) { + if uid.is_empty() { + return; + } + + let (new_token, new_refresh_token, token_changed) = { + let c = client.lock().await; + let changed = self.old_tokens.token != c.token + || self.old_tokens.refresh_token != c.refresh_token; + (c.token.clone(), c.refresh_token.clone(), changed) + }; + + if token_changed { + if let (Some(token), Some(refresh_token)) = (new_token, new_refresh_token) { + match im_user_repository::save_user_tokens( + &*db_conn.read().await, + uid, + &token, + &refresh_token, + ) + .await + { + Ok(_) => { + info!( + "[TOKEN_PERSIST] SUCCESS: tokens saved for uid: {}, token_len: {}, refresh_len: {}", + uid, + token.len(), + refresh_token.len() + ); + } + Err(e) => { + warn!( + "[TOKEN_PERSIST] FAILED: error saving tokens for uid {}: {}", + uid, e + ); + } + } + } + } + } +} + +/// 便捷函数:在 im_request 后检查并持久化刷新的 token +/// +/// # Arguments +/// * `old_tokens` - 请求前的 token 快照 +/// * `client` - ImRequestClient 的锁 +/// * `db_conn` - 数据库连接的锁 +/// * `uid` - 用户 ID +pub async fn persist_token_if_refreshed( + old_tokens: &TokenSnapshot, + client: &Mutex, + db_conn: &RwLock, + uid: &str, +) { + if uid.is_empty() { + return; + } + + let (new_token, new_refresh_token, token_changed) = { + let c = client.lock().await; + let changed = old_tokens.token != c.token || old_tokens.refresh_token != c.refresh_token; + (c.token.clone(), c.refresh_token.clone(), changed) + }; + + if token_changed { + if let (Some(token), Some(refresh_token)) = (new_token, new_refresh_token) { + match im_user_repository::save_user_tokens( + &*db_conn.read().await, + uid, + &token, + &refresh_token, + ) + .await + { + Ok(_) => { + info!( + "[TOKEN_PERSIST] SUCCESS: tokens saved for uid: {}, token_len: {}, refresh_len: {}", + uid, + token.len(), + refresh_token.len() + ); + } + Err(e) => { + warn!( + "[TOKEN_PERSIST] FAILED: error saving tokens for uid {}: {}", + uid, e + ); + } + } + } + } +} + +/// 便捷函数:获取当前的 token 快照用于后续比较 +pub async fn capture_token_snapshot(client: &Mutex) -> TokenSnapshot { + let c = client.lock().await; + TokenSnapshot { + token: c.token.clone(), + refresh_token: c.refresh_token.clone(), + } +} + +pub async fn capture_token_snapshot_arc(client: &Arc>) -> TokenSnapshot { + capture_token_snapshot(client.as_ref()).await +} + +pub fn capture_token_snapshot_direct(client: &ImRequestClient) -> TokenSnapshot { + TokenSnapshot { + token: client.token.clone(), + refresh_token: client.refresh_token.clone(), + } +} + +/// 便捷函数:获取当前的 refresh_token 用于后续比较 +pub async fn capture_refresh_token(client: &Mutex) -> Option { + client.lock().await.refresh_token.clone() +} + +/// 便捷函数:使用 Arc 包装的类型 +pub async fn persist_token_if_refreshed_arc( + old_tokens: &TokenSnapshot, + client: &Arc>, + db_conn: &Arc>, + uid: &str, +) { + persist_token_if_refreshed(old_tokens, client.as_ref(), db_conn.as_ref(), uid).await +} + +pub async fn capture_refresh_token_arc(client: &Arc>) -> Option { + capture_refresh_token(client.as_ref()).await +} + +/// 便捷函数:使用直接引用的 ImRequestClient(不需要 Mutex) +pub async fn persist_token_if_refreshed_direct( + old_tokens: &TokenSnapshot, + client: &ImRequestClient, + db_conn: &DatabaseConnection, + uid: &str, +) { + if uid.is_empty() { + return; + } + + let token_changed = + old_tokens.token != client.token || old_tokens.refresh_token != client.refresh_token; + + if token_changed { + if let (Some(token), Some(refresh_token)) = + (client.token.clone(), client.refresh_token.clone()) + { + match im_user_repository::save_user_tokens(db_conn, uid, &token, &refresh_token).await { + Ok(_) => { + info!( + "[TOKEN_PERSIST] SUCCESS: tokens saved for uid: {}, token_len: {}, refresh_len: {}", + uid, + token.len(), + refresh_token.len() + ); + } + Err(e) => { + warn!( + "[TOKEN_PERSIST] FAILED: error saving tokens for uid {}: {}", + uid, e + ); + } + } + } + } +} diff --git a/src-tauri/src/command/upload_command.rs b/src-tauri/src/command/upload_command.rs new file mode 100644 index 0000000..f70893c --- /dev/null +++ b/src-tauri/src/command/upload_command.rs @@ -0,0 +1,328 @@ +use bytes::Bytes; +use futures_util::stream::try_unfold; +use md5::{Digest, Md5}; +use serde::Deserialize; +use serde::Serialize; +use std::{collections::HashMap, path::PathBuf}; +use tauri::{AppHandle, Manager, ipc::Channel, path::BaseDirectory}; +use tokio::{fs::File, io::AsyncReadExt}; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UploadProgressPayload { + pub progress_total: u64, + pub total: u64, +} + +#[derive(Deserialize)] +struct QiniuMkblkResponse { + ctx: String, +} + +#[derive(Deserialize)] +struct QiniuMkfileResponse { + key: Option, +} + +#[tauri::command] +pub async fn upload_file_put( + app_handle: AppHandle, + url: String, + path: String, + base_dir: Option, + headers: Option>, + on_progress: Channel, +) -> Result<(), String> { + let file_path = resolve_upload_path(&app_handle, &path, base_dir.as_deref())?; + upload_put(url, file_path, headers.unwrap_or_default(), on_progress).await +} + +#[tauri::command] +pub async fn qiniu_upload_resumable( + app_handle: AppHandle, + path: String, + base_dir: Option, + token: String, + domain: String, + scene: Option, + account: Option, + storage_prefix: Option, + enable_deduplication: Option, + on_progress: Channel, +) -> Result { + let file_path = resolve_upload_path(&app_handle, &path, base_dir.as_deref())?; + let file_name = file_path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| "Failed to determine file name".to_string())? + .to_string(); + + qiniu_resumable_upload( + file_path, + token, + domain, + scene.unwrap_or_else(|| "chat".to_string()), + account, + storage_prefix, + enable_deduplication.unwrap_or(false), + file_name, + on_progress, + ) + .await +} + +fn resolve_upload_path( + app_handle: &AppHandle, + path: &str, + base_dir: Option<&str>, +) -> Result { + let path_buf = PathBuf::from(path); + if path_buf.is_absolute() { + return Ok(path_buf); + } + + let Some(base_dir) = base_dir else { + return Ok(path_buf); + }; + + let base_dir = match base_dir { + "AppCache" | "appCache" | "app_cache" => BaseDirectory::AppCache, + "AppData" | "appData" | "app_data" => BaseDirectory::AppData, + _ => { + return Err(format!( + "Unsupported baseDir: {base_dir}, expected AppCache/AppData" + )); + } + }; + + app_handle + .path() + .resolve(path, base_dir) + .map_err(|e| format!("Failed to resolve file path: {e}")) +} + +async fn upload_put( + url: String, + file_path: PathBuf, + headers: HashMap, + on_progress: Channel, +) -> Result<(), String> { + let file = File::open(&file_path) + .await + .map_err(|e| format!("Failed to open file: {e}"))?; + let total = file + .metadata() + .await + .map_err(|e| format!("Failed to read file metadata: {e}"))? + .len(); + + let chunk_size: usize = 4 * 1024 * 1024; + let stream = try_unfold( + (file, 0_u64, on_progress), + move |(mut file, mut transferred, on_progress)| async move { + let mut buf = vec![0u8; chunk_size]; + let read = file.read(&mut buf).await?; + + if read == 0 { + return Ok::<_, std::io::Error>(None); + } + + buf.truncate(read); + transferred = transferred.saturating_add(read as u64); + + let _ = on_progress.send(UploadProgressPayload { + progress_total: transferred, + total, + }); + + Ok(Some((Bytes::from(buf), (file, transferred, on_progress)))) + }, + ); + + let client = reqwest::Client::new(); + let mut request = client + .put(url) + .header(reqwest::header::CONTENT_LENGTH, total) + .body(reqwest::Body::wrap_stream(stream)); + + for (key, value) in headers { + request = request.header(key, value); + } + + let response = request + .send() + .await + .map_err(|e| format!("Upload request failed: {e}"))?; + + if response.status().is_success() { + Ok(()) + } else { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + Err(format!("Upload failed with status {status}: {body}")) + } +} + +fn hex_lower(bytes: &[u8]) -> String { + const LUT: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &b in bytes { + out.push(LUT[(b >> 4) as usize] as char); + out.push(LUT[(b & 0x0f) as usize] as char); + } + out +} + +fn build_qiniu_key(options: QiniuKeyOptions<'_>) -> String { + let timestamp = options.timestamp_ms; + + if options.total_size > options.chunk_threshold { + let prefix = options + .storage_prefix + .filter(|p| !p.is_empty()) + .unwrap_or(options.scene); + return format!("{prefix}/{timestamp}_{}", options.file_name); + } + + if options.enable_deduplication { + if let (Some(account), Some(md5_hex)) = (options.account, options.md5_hex) { + let suffix = options.file_name.split('.').last().unwrap_or(""); + return format!("{}/{}/{}.{}", options.scene, account, md5_hex, suffix); + } + } + + format!("{}/{timestamp}_{}", options.scene, options.file_name) +} + +struct QiniuKeyOptions<'a> { + scene: &'a str, + account: Option<&'a str>, + storage_prefix: Option<&'a str>, + enable_deduplication: bool, + total_size: u64, + chunk_threshold: u64, + file_name: &'a str, + timestamp_ms: u128, + md5_hex: Option<&'a str>, +} + +async fn qiniu_resumable_upload( + file_path: PathBuf, + token: String, + domain: String, + scene: String, + account: Option, + storage_prefix: Option, + enable_deduplication: bool, + file_name: String, + on_progress: Channel, +) -> Result { + use base64::Engine; + use base64::engine::general_purpose::STANDARD; + + let domain = domain.trim_end_matches('/').to_string(); + let mut file = File::open(&file_path) + .await + .map_err(|e| format!("Failed to open file: {e}"))?; + let total = file + .metadata() + .await + .map_err(|e| format!("Failed to read file metadata: {e}"))? + .len(); + + let chunk_size: u64 = 4 * 1024 * 1024; + let chunk_threshold: u64 = 4 * 1024 * 1024; + let timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + + let should_hash = + enable_deduplication && total <= chunk_threshold && account.as_deref().is_some(); + let mut hasher = should_hash.then(Md5::new); + let mut contexts: Vec = Vec::new(); + + let client = reqwest::Client::new(); + let mut transferred: u64 = 0; + + while transferred < total { + let remaining = total.saturating_sub(transferred); + let len = std::cmp::min(chunk_size, remaining) as usize; + let mut buf = vec![0u8; len]; + file.read_exact(&mut buf) + .await + .map_err(|e| format!("Failed to read file: {e}"))?; + + if let Some(ref mut h) = hasher { + h.update(&buf); + } + + let mkblk_url = format!("{domain}/mkblk/{len}"); + let response = client + .post(mkblk_url) + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .header(reqwest::header::AUTHORIZATION, format!("UpToken {token}")) + .body(Bytes::from(buf)) + .send() + .await + .map_err(|e| format!("Upload request failed: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("mkblk failed with status {status}: {body}")); + } + + let payload = response + .json::() + .await + .map_err(|e| format!("Failed to parse mkblk response: {e}"))?; + + contexts.push(payload.ctx); + transferred = transferred.saturating_add(len as u64); + let _ = on_progress.send(UploadProgressPayload { + progress_total: transferred, + total, + }); + } + + let md5_hex = hasher.map(|h| { + let digest = h.finalize(); + hex_lower(digest.as_ref()) + }); + let key = build_qiniu_key(QiniuKeyOptions { + scene: &scene, + account: account.as_deref(), + storage_prefix: storage_prefix.as_deref(), + enable_deduplication, + total_size: total, + chunk_threshold, + file_name: &file_name, + timestamp_ms, + md5_hex: md5_hex.as_deref(), + }); + let encoded_key = STANDARD.encode(&key); + + let mkfile_url = format!("{domain}/mkfile/{total}/key/{encoded_key}"); + let response = client + .post(mkfile_url) + .header(reqwest::header::CONTENT_TYPE, "text/plain") + .header(reqwest::header::AUTHORIZATION, format!("UpToken {token}")) + .body(contexts.join(",")) + .send() + .await + .map_err(|e| format!("Finalize request failed: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("mkfile failed with status {status}: {body}")); + } + + let payload = response + .json::() + .await + .map_err(|e| format!("Failed to parse mkfile response: {e}"))?; + + Ok(payload.key.unwrap_or(key)) +} diff --git a/src-tauri/src/command/user_command.rs b/src-tauri/src/command/user_command.rs new file mode 100644 index 0000000..99eeb90 --- /dev/null +++ b/src-tauri/src/command/user_command.rs @@ -0,0 +1,171 @@ +use crate::AppData; +use crate::repository::im_user_repository; +use chrono::Local; +use entity::im_user; +use entity::prelude::ImUserEntity; +use sea_orm::ActiveValue::Set; +use sea_orm::ColumnTrait; +use sea_orm::EntityTrait; +use sea_orm::IntoActiveModel; +use sea_orm::QueryFilter; +use serde::{Deserialize, Serialize}; +use tauri::State; +use tracing::{debug, info}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SaveUserInfoRequest { + uid: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UpdateTokenRequest { + token: String, + refresh_token: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TokenResponse { + token: Option, + refresh_token: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UpdateUserTokenRequest { + uid: String, + token: String, + refresh_token: String, +} + +#[tauri::command] +pub async fn save_user_info( + user_info: SaveUserInfoRequest, + state: State<'_, AppData>, +) -> Result<(), String> { + let db = state.db_conn.read().await; + + // 检查用户是否存在 + let exists = ImUserEntity::find() + .filter(im_user::Column::Id.eq(&user_info.uid)) + .one(&*db) + .await + .map_err(|err| format!("Failed to query user: {}", err))?; + + if exists.is_none() { + info!("User does not exist, preparing to insert new user"); + + let user = im_user::ActiveModel { + id: Set(user_info.uid.clone()), + // TODO 这里先设置为 true,后续需要根据配置调整 + is_init: Set(true), + ..Default::default() + }; + + im_user::Entity::insert(user) + .exec(&*db) + .await + .map_err(|err| format!("Failed to insert user: {}", err))?; + } else { + debug!("User already exists, no need to insert"); + } + Ok(()) +} + +#[tauri::command] +pub async fn update_user_last_opt_time(state: State<'_, AppData>) -> Result<(), String> { + info!("Updating user last operation time"); + let db = state.db_conn.read().await; + + let uid = state.user_info.lock().await.uid.clone(); + + // 检查用户是否存在 + let user = ImUserEntity::find() + .filter(im_user::Column::Id.eq(uid.clone())) + .one(&*db) + .await + .map_err(|err| format!("Failed to query user: {}", err))?; + + if let Some(user) = user { + let mut active_model = user.into_active_model(); + active_model.last_opt_time = Set(Some(Local::now().timestamp_millis())); + + ImUserEntity::update(active_model) + .exec(&*db) + .await + .map_err(|err| format!("Failed to update user last operation time: {}", err))?; + } + + Ok(()) +} + +/// 获取用户的 token 和 refreshToken +#[tauri::command] +pub async fn get_user_tokens(state: State<'_, AppData>) -> Result { + info!("Getting user token info"); + + let user_info = state.user_info.lock().await; + + let response = TokenResponse { + token: user_info.token.clone().into(), + refresh_token: user_info.refresh_token.clone().into(), + }; + + info!("Successfully retrieved user token info: {:?}", response); + Ok(response) +} + +#[tauri::command] +pub async fn remove_tokens(state: State<'_, AppData>) -> Result<(), String> { + info!("Removing user token info"); + + let mut rc = state.rc.lock().await; + + rc.token = None; + rc.refresh_token = None; + + info!("Successfully removed user token info"); + Ok(()) +} + +#[tauri::command] +pub async fn update_token( + req: UpdateUserTokenRequest, + state: State<'_, AppData>, +) -> Result<(), String> { + info!("Updating user token"); + let refresh_token = if req.refresh_token.is_empty() { + let current_refresh = state.user_info.lock().await.refresh_token.clone(); + if current_refresh.is_empty() { + "".to_string() + } else { + current_refresh + } + } else { + req.refresh_token.clone() + }; + { + let mut user_info = state.user_info.lock().await; + user_info.uid = req.uid.clone(); + user_info.token = req.token.clone(); + user_info.refresh_token = refresh_token.clone(); + } + { + let mut rc = state.rc.lock().await; + rc.token = Some(req.token.clone()); + if !refresh_token.is_empty() { + rc.refresh_token = Some(refresh_token.clone()); + } + } + im_user_repository::save_user_tokens( + &*state.db_conn.read().await, + &req.uid, + &req.token, + &refresh_token, + ) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/src-tauri/src/common/files_meta.rs b/src-tauri/src/common/files_meta.rs new file mode 100644 index 0000000..9395458 --- /dev/null +++ b/src-tauri/src/common/files_meta.rs @@ -0,0 +1,54 @@ +use mime_guess::from_path; +use serde::Serialize; +use std::path::PathBuf; + +#[derive(Serialize)] +pub struct FileMeta { + name: String, + path: String, + file_type: String, + mime_type: String, + exists: bool, +} + +#[tauri::command] +pub async fn get_files_meta(files_path: Vec) -> Result, String> { + let mut files_meta: Vec = Vec::with_capacity(files_path.len()); + + for original_path in files_path { + let path_buf = PathBuf::from(&original_path); + let is_url = original_path.starts_with("http://") || original_path.starts_with("https://"); + + let name = path_buf + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + let file_type = path_buf + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_string(); + + let mime_type = from_path(&path_buf).first_or_octet_stream().to_string(); + + let exists = if is_url { false } else { path_buf.exists() }; + + let stored_path = if is_url { + original_path.clone() + } else { + path_buf.to_string_lossy().to_string() + }; + + files_meta.push(FileMeta { + name, + path: stored_path, + file_type, + mime_type, + exists, + }); + } + + Ok(files_meta) +} diff --git a/src-tauri/src/common/init.rs b/src-tauri/src/common/init.rs new file mode 100644 index 0000000..891f76a --- /dev/null +++ b/src-tauri/src/common/init.rs @@ -0,0 +1,68 @@ +use tauri::{Runtime, plugin::TauriPlugin}; +use tauri_plugin_log::fern::colors::{Color, ColoredLevelConfig}; +use tauri_plugin_log::{Target, TargetKind}; + +pub trait CustomInit { + fn init_plugin(self) -> Self; +} + +/// 构建平台特定的日志插件 +fn build_log_plugin() -> TauriPlugin { + let builder = tauri_plugin_log::Builder::new() + .level(tracing::log::LevelFilter::Info) + .timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal) + .level_for("sqlx", tracing::log::LevelFilter::Warn) + .level_for("sqlx::query", tracing::log::LevelFilter::Warn) + .level_for("sea_orm", tracing::log::LevelFilter::Warn) + .level_for("hula_app_lib", tracing::log::LevelFilter::Debug) + // 过滤掉无用的 tauri 内部日志 + .level_for("tauri", tracing::log::LevelFilter::Warn) + .level_for("tauri::manager", tracing::log::LevelFilter::Warn) + .level_for("tauri::event", tracing::log::LevelFilter::Warn) + .level_for("tauri::plugin", tracing::log::LevelFilter::Warn) + .level_for("tauri::ipc", tracing::log::LevelFilter::Warn) + .level_for("tao", tracing::log::LevelFilter::Warn) + .level_for("wry", tracing::log::LevelFilter::Warn) + .level_for("tracing::span", tracing::log::LevelFilter::Warn) + .targets([ + Target::new(TargetKind::Stdout), + Target::new(TargetKind::Webview), + Target::new(TargetKind::LogDir { + file_name: Some("logs".to_string()), + }), + ]) + .with_colors(ColoredLevelConfig { + error: Color::Red, + warn: Color::Yellow, + debug: Color::White, + info: Color::Green, + trace: Color::White, + }); + + // #[cfg(desktop)] + // { + // builder = builder.skip_logger(); + // } + + builder.build() +} + +/// 初始化公共插件(所有平台通用) +pub fn init_common_plugins(builder: tauri::Builder) -> tauri::Builder { + let builder = builder + .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_upload::init()) + .plugin(tauri_plugin_sql::Builder::new().build()) + .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_mic_recorder::init()); + + // 添加日志插件 + builder.plugin(build_log_plugin()) +} diff --git a/src-tauri/src/common/mod.rs b/src-tauri/src/common/mod.rs new file mode 100644 index 0000000..4db8e2c --- /dev/null +++ b/src-tauri/src/common/mod.rs @@ -0,0 +1,2 @@ +pub mod files_meta; +pub mod init; diff --git a/src-tauri/src/configuration.rs b/src-tauri/src/configuration.rs new file mode 100644 index 0000000..4388f4f --- /dev/null +++ b/src-tauri/src/configuration.rs @@ -0,0 +1,351 @@ +use crate::error::CommonError; +use sea_orm::{ConnectOptions, Database, DatabaseConnection}; +use std::io::Read; +use std::path::PathBuf; +use std::time::Duration; +use tauri::{AppHandle, Manager}; +use tracing::info; + +// 应用程序设置结构体 +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)] +pub struct Settings { + pub database: DatabaseSettings, + pub backend: BackendSettings, + pub youdao: Option, + pub tencent: Option, + pub minio: Option, + pub ice_server: Option, +} + +// 数据库配置设置 +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)] +pub struct DatabaseSettings { + pub sqlite_file: String, +} + +// 后端服务配置设置 +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)] +pub struct BackendSettings { + pub base_url: String, + pub ws_url: String, +} + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)] +pub struct Youdao { + pub app_key: String, + pub app_secret: String, +} + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)] +pub struct IceServer { + pub urls: Vec, + pub username: String, + pub credential: String, +} + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)] +pub struct Tencent { + pub api_key: String, + pub secret_id: String, + pub map_key: String, +} + +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)] +pub struct MinioSettings { + pub endpoint: String, + pub bucket: String, + pub access_key: String, + pub secret_key: String, + pub region: String, + pub download_domain: String, +} + +// 应用程序运行环境枚举 +#[derive(Debug)] +pub enum Environment { + Local, + Production, +} + +impl DatabaseSettings { + /// 根据用户ID生成数据库文件名 + /// 如果提供了用户ID,则生成 `db_{uid}.sqlite` 格式的文件名 + /// 否则使用默认的 `db.sqlite` + fn get_db_filename(uid: Option<&str>) -> String { + match uid { + Some(id) if !id.is_empty() => format!("db_{}.sqlite", id), + _ => "db.sqlite".to_string(), + } + } + + /// 创建数据库连接 + /// 根据不同的运行环境(桌面开发、移动端、桌面生产)选择合适的数据库路径 + /// 并配置数据库连接选项,返回数据库连接实例 + /// + /// # 参数 + /// * `app_handle` - Tauri应用句柄,用于获取应用路径 + /// * `uid` - 可选的用户ID,用于生成用户专属的数据库文件 + /// + /// # 返回值 + /// * `Ok(DatabaseConnection)` - 成功时返回数据库连接 + /// * `Err(CommonError)` - 失败时返回错误信息 + pub async fn connection_string( + &self, + app_handle: &AppHandle, + uid: Option<&str>, + ) -> Result { + let db_filename = Self::get_db_filename(uid); + info!("Database filename: {}", db_filename); + + // 数据库路径配置: + let db_path = if cfg!(debug_assertions) && cfg!(desktop) { + // 桌面端开发环境:使用项目根目录(src-tauri) + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push(&db_filename); + path + } else { + match app_handle.path().app_data_dir() { + Ok(app_data_dir) => { + if let Err(create_err) = std::fs::create_dir_all(&app_data_dir) { + tracing::warn!("Failed to create app_data_dir: {}", create_err); + } + let db_path = app_data_dir.join(&db_filename); + info!("Using app_data_dir database path: {:?}", db_path); + db_path + } + Err(e) => { + let error_msg = format!("Failed to get app_data_dir: {}", e); + tracing::error!("{}", error_msg); + return Err(CommonError::RequestError(error_msg).into()); + } + } + }; + info!("Database path: {:?}", db_path); + + if db_path.exists() { + let mut header = [0u8; 16]; + let need_repair = match std::fs::File::open(&db_path) { + Ok(mut f) => { + let _ = f.read(&mut header); + header != *b"SQLite format 3\0" + } + Err(_) => false, + }; + if need_repair { + let backup = db_path.with_extension("corrupted"); + let _ = + std::fs::rename(&db_path, &backup).or_else(|_| std::fs::remove_file(&db_path)); + } + } + + let db_url = format!("sqlite:{}?mode=rwc", db_path.display()); + + // 配置数据库连接选项 + let mut opt = ConnectOptions::new(db_url); + opt.max_connections(20) // 降低最大连接数,避免资源浪费 + .min_connections(2) // 降低最小连接数 + .connect_timeout(Duration::from_secs(30)) // 增加连接超时时间 + .acquire_timeout(Duration::from_secs(30)) // 增加获取连接超时时间 + .idle_timeout(Duration::from_secs(600)) // 10分钟空闲超时 + .max_lifetime(Duration::from_secs(1800)) // 30分钟连接生命周期,避免频繁重建 + // 启用 SQL 日志记录,但只在 debug 模式下 + .sqlx_logging(cfg!(debug_assertions)) + .sqlx_logging_level(tracing::log::LevelFilter::Info); + + match Database::connect(opt).await { + Ok(db) => Ok(db), + Err(e) => { + let msg = e.to_string(); + if msg.contains("file is not a database") || msg.contains("code: 26") { + let _ = std::fs::remove_file(&db_path); + let mut opt2 = + ConnectOptions::new(format!("sqlite:{}?mode=rwc", db_path.display())); + opt2.max_connections(20) + .min_connections(2) + .connect_timeout(Duration::from_secs(30)) + .acquire_timeout(Duration::from_secs(30)) + .idle_timeout(Duration::from_secs(600)) + .max_lifetime(Duration::from_secs(1800)) + .sqlx_logging(cfg!(debug_assertions)) + .sqlx_logging_level(tracing::log::LevelFilter::Info); + let db = Database::connect(opt2) + .await + .map_err(|e| anyhow::anyhow!("Database connection failed: {}", e))?; + Ok(db) + } else { + Err(anyhow::anyhow!("Database connection failed: {}", e).into()) + } + } + } + } +} + +impl Environment { + /// 将Environment枚举转换为字符串 + /// 用于文件名和路径构建 + /// + /// # 返回值 + /// * `&'static str` - 对应的环境字符串 + pub fn as_str(&self) -> &'static str { + match self { + Environment::Local => "local", + Environment::Production => "production", + } + } +} + +impl TryFrom for Environment { + type Error = String; + + /// 从字符串解析Environment枚举 + /// 支持大小写不敏感的解析 + /// + /// # 参数 + /// * `s` - 要解析的字符串 + /// + /// # 返回值 + /// * `Ok(Environment)` - 解析成功时返回环境枚举 + /// * `Err(String)` - 解析失败时返回错误信息 + fn try_from(s: String) -> Result { + match s.to_lowercase().as_str() { + "local" => Ok(Self::Local), + "production" => Ok(Self::Production), + other => Err(format!( + "{} is not a supported environment. Use either `local` or `production`.", + other + )), + } + } +} + +/// 获取应用程序配置 +/// 根据APP_ENVIRONMENT环境变量确定运行环境,按优先级加载配置: +/// 1. 桌面开发环境:文件系统配置文件 +/// 2. 其他环境:资源目录配置文件 +/// 3. 回退:编译时嵌入的配置文件 +/// +/// # 参数 +/// * `app_handle` - Tauri应用句柄 +/// +/// # 返回值 +/// * `Ok(Settings)` - 成功时返回配置设置 +/// * `Err(config::ConfigError)` - 失败时返回配置错误 +pub fn get_configuration(app_handle: &AppHandle) -> Result { + #[cfg(not(target_os = "android"))] + { + let is_desktop_dev = cfg!(debug_assertions) && cfg!(desktop); + + let config_path_buf = get_config_path_buf(app_handle, is_desktop_dev)?; + + let settings = config::Config::builder() + .add_source(config::File::from(config_path_buf.0)) + .add_source(config::File::from(config_path_buf.1)) + .add_source( + config::Environment::with_prefix("APP") + .prefix_separator("_") + .separator("__"), + ) + .build()?; + + settings.try_deserialize::() + } + + #[cfg(target_os = "android")] + { + let _ = app_handle; + // 读取 base.yaml 内容 + let base_content = std::str::from_utf8(include_bytes!("../configuration/base.yaml")) + .map_err(|e| config::ConfigError::Message(e.to_string()))?; + + // 构建 base 配置对象 + let base_config = config::Config::builder() + .add_source(config::File::from_str( + base_content, + config::FileFormat::Yaml, + )) + .build()?; + + // 获取 active_config 字段 + let active_config = base_config.get_string("active_config").map_err(|_| { + config::ConfigError::Message( + "Missing or invalid 'active_config' in base.yaml".to_string(), + ) + })?; + + // 校验 active_config 合法性 + if active_config != "local" && active_config != "production" { + return Err(config::ConfigError::Message( + "Only \"local\" or \"production\" can be specified in active_config".to_string(), + )); + } + + // 加载对应的配置文件内容 + let config_file_bytes: &[u8] = match active_config.as_str() { + "local" => include_bytes!("../configuration/local.yaml").as_ref(), + "production" => include_bytes!("../configuration/production.yaml").as_ref(), + _ => return Err(config::ConfigError::Message("Invalid active_config".into())), // 这里可以支持更多的环境配置 + }; + + let active_content = std::str::from_utf8(config_file_bytes) + .map_err(|e| config::ConfigError::Message(e.to_string()))?; + + // 构建最终配置对象 + config::Config::builder() + .add_source(config::File::from_str( + base_content, + config::FileFormat::Yaml, + )) + .add_source(config::File::from_str( + active_content, + config::FileFormat::Yaml, + )) + .add_source( + config::Environment::with_prefix("APP") + .prefix_separator("_") + .separator("__"), + ) + .build()? + .try_deserialize::() + } +} + +fn get_config_path_buf( + app_handle: &AppHandle, + is_desktop_dev: bool, +) -> Result<(PathBuf, PathBuf), config::ConfigError> { + let dir = if is_desktop_dev { + let base_path = std::env::current_dir().map_err(|e| { + config::ConfigError::Message(format!("Failed to get current dir: {}", e)) + })?; + + base_path.join("configuration") + } else { + app_handle + .path() + .resource_dir() + .map_err(|e| config::ConfigError::NotFound(format!("resource not find: {}", e)))? + .join("configuration") + }; + + let base_path = dir.join("base.yaml"); + + #[cfg(not(target_os = "android"))] + let base_config = config::Config::builder() + .add_source(config::File::from(base_path.clone())) + .build()?; + + #[cfg(target_os = "android")] + let base_config = { + let content = std::str::from_utf8(include_bytes!("../configuration/base.yaml")) + .map_err(|e| config::ConfigError::Message(e.to_string()))?; + + config::Config::builder() + .add_source(config::File::from_str(content, config::FileFormat::Yaml)) + .build()? + }; + + let active_config = base_config.get_string("active_config")?; + println!("active_config: {:?}", active_config); + let active_config_path_buf = dir.clone().join(active_config); + Ok((base_path, active_config_path_buf)) +} diff --git a/src-tauri/src/desktops/app_event.rs b/src-tauri/src/desktops/app_event.rs new file mode 100644 index 0000000..4fd7f08 --- /dev/null +++ b/src-tauri/src/desktops/app_event.rs @@ -0,0 +1,18 @@ +#[cfg(target_os = "macos")] +use tauri::{AppHandle, Manager, RunEvent, Runtime}; + +/// 当应用在 macOS 系统上重新打开时,如果没有可见窗口,会优先显示已存在的 home 窗口。 +#[cfg(target_os = "macos")] +pub fn handle_app_event(app_handle: &AppHandle, event: RunEvent) { + match event { + RunEvent::Reopen { .. } => { + // 直接尝试获取 home 窗口,如果存在则显示并置于最顶部 + if let Some(home_window) = app_handle.get_webview_window("home") { + let _ = home_window.show(); + let _ = home_window.unminimize(); + let _ = home_window.set_focus(); + } + } + _ => {} + } +} diff --git a/src-tauri/src/desktops/common_cmd.rs b/src-tauri/src/desktops/common_cmd.rs new file mode 100644 index 0000000..077c4f5 --- /dev/null +++ b/src-tauri/src/desktops/common_cmd.rs @@ -0,0 +1,849 @@ +#![allow(unexpected_cfgs)] +use base64::Engine as _; +use base64::engine::general_purpose; +use screenshots::Screen; +#[cfg(target_os = "macos")] +use std::cell::RefCell; +use std::cmp; +#[cfg(target_os = "macos")] +use std::collections::HashMap; +#[cfg(target_os = "macos")] +use std::ffi::CStr; +#[cfg(target_os = "macos")] +use std::ffi::CString; +#[cfg(target_os = "macos")] +use std::sync::Arc; +#[cfg(target_os = "macos")] +use std::sync::Mutex; +#[cfg(target_os = "macos")] +use std::sync::OnceLock; +#[cfg(target_os = "macos")] +use std::sync::atomic::AtomicBool; +#[cfg(target_os = "macos")] +use std::sync::atomic::AtomicU64; +#[cfg(target_os = "macos")] +use std::sync::atomic::Ordering; +use std::thread; +use std::time::Duration; +use tauri::AppHandle; +use tauri::LogicalSize; +use tauri::Manager; +use tauri::ResourceId; +use tauri::Runtime; +use tauri::Webview; +use tauri::path::BaseDirectory; + +#[cfg(target_os = "macos")] +use block2::RcBlock; +#[cfg(target_os = "macos")] +use objc2::rc::Retained; +#[cfg(target_os = "macos")] +use objc2::runtime::ProtocolObject; +#[cfg(target_os = "macos")] +use objc2_app_kit::NSWindow; +#[cfg(target_os = "macos")] +use objc2_core_foundation::CGPoint; +#[cfg(target_os = "macos")] +use objc2_core_foundation::CGRect; +#[cfg(target_os = "macos")] +use objc2_foundation::NSNotification; +#[cfg(target_os = "macos")] +use objc2_foundation::NSNotificationCenter; +#[cfg(target_os = "macos")] +use objc2_foundation::NSNotificationName; +#[cfg(target_os = "macos")] +use objc2_foundation::NSObjectProtocol; +#[cfg(target_os = "windows")] +use serde::Serialize; +#[cfg(target_os = "macos")] +use std::ptr::NonNull; +#[cfg(target_os = "macos")] +use tauri::WebviewWindow; +#[cfg(target_os = "macos")] +use tauri::async_runtime::JoinHandle; + +#[cfg(target_os = "macos")] +struct TrafficLightsLiveResizeTask { + seq: u64, + spacing: f64, + handle: JoinHandle<()>, +} + +#[cfg(target_os = "macos")] +static TRAFFIC_LIGHTS_LIVE_RESIZE_TASKS: OnceLock< + Mutex>, +> = OnceLock::new(); +#[cfg(target_os = "macos")] +static TRAFFIC_LIGHTS_LIVE_RESIZE_SEQ: AtomicU64 = AtomicU64::new(1); + +#[cfg(target_os = "macos")] +fn traffic_lights_live_resize_tasks() -> &'static Mutex> +{ + TRAFFIC_LIGHTS_LIVE_RESIZE_TASKS.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(target_os = "macos")] +static TRAFFIC_LIGHTS_DESIRED_SPACING: OnceLock>> = OnceLock::new(); + +#[cfg(target_os = "macos")] +fn traffic_lights_desired_spacing() -> &'static Mutex> { + TRAFFIC_LIGHTS_DESIRED_SPACING.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(target_os = "macos")] +fn get_desired_traffic_lights_spacing(window_label: &str) -> f64 { + traffic_lights_desired_spacing() + .lock() + .ok() + .and_then(|m| m.get(window_label).copied()) + .unwrap_or(6.0) +} + +#[cfg(target_os = "macos")] +thread_local! { + static TRAFFIC_LIGHTS_RESIZE_OBSERVERS: RefCell> = + RefCell::new(HashMap::new()); +} + +#[cfg(target_os = "macos")] +struct TrafficLightsResizeObserverEntry { + window_ptr: usize, + resize_observer: Retained>, + move_observer: Retained>, +} + +#[cfg(target_os = "macos")] +static IS_MACOS_26_OR_LATER: OnceLock = OnceLock::new(); + +#[cfg(target_os = "macos")] +fn is_macos_26_or_later() -> bool { + *IS_MACOS_26_OR_LATER.get_or_init(|| { + let product_version = sysctl_string("kern.osproductversion"); + let product_major = product_version + .as_deref() + .and_then(|s| s.split('.').next()) + .and_then(|s| s.parse::().ok()); + + let darwin_release = unsafe { + let mut uts: libc::utsname = std::mem::zeroed(); + if libc::uname(&mut uts) != 0 { + None + } else { + Some( + CStr::from_ptr(uts.release.as_ptr()) + .to_string_lossy() + .to_string(), + ) + } + }; + let darwin_major = darwin_release + .as_deref() + .and_then(|s| s.split('.').next()) + .and_then(|s| s.parse::().ok()); + + let enabled = product_major + .map(|v| v >= 26) + .unwrap_or_else(|| darwin_major.map(|v| v >= 25).unwrap_or(false)); + + tracing::info!( + "[TrafficLights] version detect: product={:?}, darwin={:?}, enable_native_observer={}", + product_version, + darwin_release, + enabled + ); + + enabled + }) +} + +#[cfg(target_os = "macos")] +fn sysctl_string(name: &str) -> Option { + let name = CString::new(name).ok()?; + let mut size: libc::size_t = 0; + unsafe { + if libc::sysctlbyname( + name.as_ptr(), + std::ptr::null_mut(), + &mut size, + std::ptr::null_mut(), + 0, + ) != 0 + || size == 0 + { + return None; + } + } + + let mut buf = vec![0u8; size as usize]; + unsafe { + if libc::sysctlbyname( + name.as_ptr(), + buf.as_mut_ptr().cast(), + &mut size, + std::ptr::null_mut(), + 0, + ) != 0 + { + return None; + } + } + + if let Some(pos) = buf.iter().position(|&b| b == 0) { + buf.truncate(pos); + } + + String::from_utf8(buf).ok() +} + +#[cfg(target_os = "macos")] +fn ensure_traffic_lights_resize_observer( + handle: &AppHandle, + window_label: &str, +) -> Result<(), String> { + if !is_macos_26_or_later() { + return Ok(()); + } + let label = window_label.to_string(); + let handle_for_main = handle.clone(); + + handle + .run_on_main_thread(move || { + let Ok(webview_window) = get_webview_window(&handle_for_main, label.as_str()) else { + return; + }; + let Ok(ns_window) = get_nswindow_from_webview_window(&webview_window) else { + return; + }; + let window_ptr = (&*ns_window as *const NSWindow) as usize; + + TRAFFIC_LIGHTS_RESIZE_OBSERVERS.with(|cell| { + let notification_center = NSNotificationCenter::defaultCenter(); + + { + let mut map = cell.borrow_mut(); + if let Some(existing) = map.get(label.as_str()) { + if existing.window_ptr == window_ptr { + return; + } + } + if let Some(existing) = map.remove(label.as_str()) { + let _: () = unsafe { + objc2::msg_send![ + ¬ification_center, + removeObserver: &*existing.resize_observer + ] + }; + let _: () = unsafe { + objc2::msg_send![ + ¬ification_center, + removeObserver: &*existing.move_observer + ] + }; + } + } + + let resize_name = NSNotificationName::from_str("NSWindowDidResizeNotification"); + let handle_for_resize = handle_for_main.clone(); + let label_for_resize = label.clone(); + let resize_block = RcBlock::new(move |_notification: NonNull| { + let _ = apply_macos_traffic_lights_spacing_default( + label_for_resize.as_str(), + &handle_for_resize, + ); + }); + + let resize_observer = unsafe { + notification_center.addObserverForName_object_queue_usingBlock( + Some(&*resize_name), + Some(&ns_window), + None, + &resize_block, + ) + }; + + let move_name = NSNotificationName::from_str("NSWindowDidMoveNotification"); + let handle_for_move = handle_for_main.clone(); + let label_for_move = label.clone(); + let move_block = RcBlock::new(move |_notification: NonNull| { + let _ = apply_macos_traffic_lights_spacing_default( + label_for_move.as_str(), + &handle_for_move, + ); + }); + + let move_observer = unsafe { + notification_center.addObserverForName_object_queue_usingBlock( + Some(&*move_name), + Some(&ns_window), + None, + &move_block, + ) + }; + + cell.borrow_mut().insert( + label, + TrafficLightsResizeObserverEntry { + window_ptr, + resize_observer, + move_observer, + }, + ); + }); + }) + .map_err(|e| format!("Failed to run on main thread: {}", e))?; + + Ok(()) +} + +/// Windows文本缩放信息结构体 +#[cfg(target_os = "windows")] +#[derive(Serialize)] +pub struct WindowsScaleInfo { + /// 系统DPI + pub system_dpi: u32, + /// 系统缩放比例 + pub system_scale: f64, + /// 文本缩放比例 + pub text_scale: f64, + /// 是否检测到文本缩放 + pub has_text_scaling: bool, +} +// // 定义用户信息结构体 +// #[derive(Debug, Clone, Serialize)] +// pub struct UserInfo { +// user_id: i64, +// username: String, +// token: String, +// portrait: String, +// is_sign: bool, +// } + +// impl Default for UserInfo { +// fn default() -> Self { +// UserInfo { +// user_id: -1, +// username: String::new(), +// token: String::new(), +// portrait: String::new(), +// is_sign: false, +// } +// } +// } + +// // 全局变量 +// lazy_static! { +// static ref USER_INFO: Arc> = Arc::new(RwLock::new(UserInfo::default())); +// } + +#[tauri::command] +pub fn default_window_icon( + webview: Webview, + app: AppHandle, +) -> Option { + app.default_window_icon().cloned().map(|icon| { + let mut resources_table = webview.resources_table(); + resources_table.add(icon.to_owned()) + }) +} + +#[tauri::command] +pub fn screenshot(x: &str, y: &str, width: &str, height: &str) -> Result { + let screen = Screen::from_point(100, 100).map_err(|e| format!("获取屏幕信息失败: {}", e))?; + + let x = x + .parse::() + .map_err(|_| "无效的 x 坐标参数".to_string())?; + let y = y + .parse::() + .map_err(|_| "无效的 y 坐标参数".to_string())?; + let width = width + .parse::() + .map_err(|_| "无效的宽度参数".to_string())?; + let height = height + .parse::() + .map_err(|_| "无效的高度参数".to_string())?; + + let image = screen + .capture_area(x, y, width, height) + .map_err(|e| format!("截图失败: {}", e))?; + + let buffer = image.as_raw(); + let base64_str = general_purpose::STANDARD_NO_PAD.encode(buffer); + Ok(base64_str) +} + +#[tauri::command] +pub fn audio(filename: &str, handle: AppHandle) -> Result<(), String> { + let path = "audio/".to_string() + filename; + + thread::spawn(move || { + if let Err(e) = play_audio_internal(&path, &handle) { + tracing::error!("Audio playback failed: {}", e); + } + }); + + Ok(()) +} + +fn play_audio_internal(path: &str, handle: &AppHandle) -> Result<(), String> { + use rodio::Decoder; + use std::fs::File; + use std::io::BufReader; + + let audio_path = handle + .path() + .resolve(path, BaseDirectory::Resource) + .map_err(|e| format!("解析音频路径失败: {}", e))?; + + let audio = File::open(audio_path).map_err(|e| format!("打开音频文件失败: {}", e))?; + + let file = BufReader::new(audio); + let stream = rodio::DeviceSinkBuilder::open_default_sink() + .map_err(|e| format!("创建音频输出流失败: {}", e))?; + let source = Decoder::new(file).map_err(|e| format!("解码音频文件失败: {}", e))?; + + let player = rodio::Player::connect_new(stream.mixer()); + player.append(source); + player.sleep_until_end(); + Ok(()) +} + +#[tauri::command] +pub fn set_height(height: u32, handle: AppHandle) -> Result<(), String> { + let home_window = handle + .get_webview_window("home") + .ok_or("未找到 home 窗口")?; + + let sf = home_window + .scale_factor() + .map_err(|e| format!("获取窗口缩放因子失败: {}", e))?; + + let out_size = home_window + .inner_size() + .map_err(|e| format!("获取窗口尺寸失败: {}", e))?; + + home_window + .set_size(LogicalSize::new( + out_size.to_logical(sf).width, + cmp::max(out_size.to_logical(sf).height, height), + )) + .map_err(|e| format!("设置窗口高度失败: {}", e))?; + + Ok(()) +} + +/// 设置 macOS 交通灯按钮的可见性 +#[cfg(target_os = "macos")] +fn set_traffic_lights_hidden( + ns_window: &NSWindow, + hidden: bool, + btn: objc2_app_kit::NSWindowButton, +) { + if let Some(button) = ns_window.standardWindowButton(btn) { + button.setHidden(hidden); + } +} + +#[cfg(target_os = "macos")] +fn set_window_movable_state(ns_window: &NSWindow, movable: bool) { + ns_window.setMovable(movable); + ns_window.setMovableByWindowBackground(movable); +} + +#[cfg(target_os = "macos")] +fn apply_traffic_lights_spacing(ns_window: &NSWindow, spacing: f64) -> Result<(), String> { + use objc2_app_kit::NSWindowButton; + + let close = ns_window + .standardWindowButton(NSWindowButton::CloseButton) + .ok_or_else(|| "CloseButton not found".to_string())?; + let minimize = ns_window + .standardWindowButton(NSWindowButton::MiniaturizeButton) + .ok_or_else(|| "MiniaturizeButton not found".to_string())?; + let zoom = ns_window + .standardWindowButton(NSWindowButton::ZoomButton) + .ok_or_else(|| "ZoomButton not found".to_string())?; + + let close_frame: CGRect = unsafe { objc2::msg_send![&*close, frame] }; + let min_frame: CGRect = unsafe { objc2::msg_send![&*minimize, frame] }; + let zoom_frame: CGRect = unsafe { objc2::msg_send![&*zoom, frame] }; + + let new_min_x = close_frame.origin.x + close_frame.size.width + spacing; + let new_zoom_x = new_min_x + min_frame.size.width + spacing; + + let _: () = unsafe { + objc2::msg_send![ + &*minimize, + setFrameOrigin: CGPoint { + x: new_min_x, + y: min_frame.origin.y + } + ] + }; + let _: () = unsafe { + objc2::msg_send![ + &*zoom, + setFrameOrigin: CGPoint { + x: new_zoom_x, + y: zoom_frame.origin.y + } + ] + }; + + Ok(()) +} + +#[cfg(target_os = "macos")] +fn traffic_lights_needs_update(ns_window: &NSWindow, spacing: f64) -> Result { + use objc2_app_kit::NSWindowButton; + + let close = ns_window + .standardWindowButton(NSWindowButton::CloseButton) + .ok_or_else(|| "CloseButton not found".to_string())?; + let minimize = ns_window + .standardWindowButton(NSWindowButton::MiniaturizeButton) + .ok_or_else(|| "MiniaturizeButton not found".to_string())?; + let zoom = ns_window + .standardWindowButton(NSWindowButton::ZoomButton) + .ok_or_else(|| "ZoomButton not found".to_string())?; + + let close_frame: CGRect = unsafe { objc2::msg_send![&*close, frame] }; + let min_frame: CGRect = unsafe { objc2::msg_send![&*minimize, frame] }; + let zoom_frame: CGRect = unsafe { objc2::msg_send![&*zoom, frame] }; + + let expected_min_x = close_frame.origin.x + close_frame.size.width + spacing; + let expected_zoom_x = expected_min_x + min_frame.size.width + spacing; + + let eps = 0.5_f64; + Ok((min_frame.origin.x - expected_min_x).abs() > eps + || (zoom_frame.origin.x - expected_zoom_x).abs() > eps) +} + +#[cfg(target_os = "macos")] +fn ensure_live_resize_traffic_lights_task( + handle: &AppHandle, + window_label: &str, + spacing: f64, +) -> Result<(), String> { + let seq = TRAFFIC_LIGHTS_LIVE_RESIZE_SEQ.fetch_add(1, Ordering::SeqCst); + let window_label = window_label.to_string(); + + { + let tasks = traffic_lights_live_resize_tasks(); + let mut pending = tasks + .lock() + .map_err(|_| "Failed to lock traffic lights tasks".to_string())?; + + if let Some(existing) = pending.get(&window_label) { + if (existing.spacing - spacing).abs() < 0.001 { + return Ok(()); + } + } + + if let Some(existing) = pending.remove(&window_label) { + existing.handle.abort(); + } + + let handle_for_task = handle.clone(); + let label_for_task = Arc::new(window_label.clone()); + let label_for_cleanup = window_label.clone(); + let alive = Arc::new(AtomicBool::new(true)); + let alive_for_task = alive.clone(); + let join = tauri::async_runtime::spawn(async move { + for _ in 0..600_u32 { + if !alive_for_task.load(Ordering::SeqCst) { + break; + } + tokio::time::sleep(Duration::from_millis(16)).await; + + let handle_for_main = handle_for_task.clone(); + let label_for_main = label_for_task.clone(); + let alive_for_main = alive_for_task.clone(); + let _ = handle_for_task.run_on_main_thread(move || { + let Some(webview_window) = + handle_for_main.get_webview_window(label_for_main.as_str()) + else { + alive_for_main.store(false, Ordering::SeqCst); + return; + }; + let Ok(ns_window) = get_nswindow_from_webview_window(&webview_window) else { + alive_for_main.store(false, Ordering::SeqCst); + return; + }; + + let Ok(needs) = traffic_lights_needs_update(&ns_window, spacing) else { + return; + }; + if needs { + let _ = apply_traffic_lights_spacing(&ns_window, spacing); + } + + let in_live_resize: bool = + unsafe { objc2::msg_send![&*ns_window, inLiveResize] }; + if !in_live_resize { + alive_for_main.store(false, Ordering::SeqCst); + } + }); + } + + let tasks = traffic_lights_live_resize_tasks(); + let _ = tasks.lock().map(|mut m| { + let is_latest = m + .get(&label_for_cleanup) + .map(|t| t.seq == seq) + .unwrap_or(false); + if is_latest { + m.remove(&label_for_cleanup); + } + }); + }); + + pending.insert( + window_label, + TrafficLightsLiveResizeTask { + seq, + spacing, + handle: join, + }, + ); + } + + Ok(()) +} + +#[cfg(target_os = "macos")] +pub(crate) fn apply_macos_traffic_lights_spacing_default( + window_label: &str, + handle: &AppHandle, +) -> Result<(), String> { + let webview_window = get_webview_window(&handle, window_label)?; + let ns_window = get_nswindow_from_webview_window(&webview_window)?; + let _ = ensure_traffic_lights_resize_observer(handle, window_label); + let spacing = get_desired_traffic_lights_spacing(window_label); + let in_live_resize: bool = unsafe { objc2::msg_send![&*ns_window, inLiveResize] }; + if in_live_resize { + if traffic_lights_needs_update(&ns_window, spacing)? { + let _ = apply_traffic_lights_spacing(&ns_window, spacing); + } + return ensure_live_resize_traffic_lights_task(handle, window_label, spacing); + } + if !traffic_lights_needs_update(&ns_window, spacing)? { + return Ok(()); + } + apply_traffic_lights_spacing(&ns_window, spacing) +} + +/// 隐藏Mac窗口的标题栏按钮(红绿灯按钮)和标题 +/// +/// # 参数 +/// * `window_label` - 窗口的标签名称 +/// * `hide_close_button` - 可选参数,是否隐藏关闭按钮,默认为false(不隐藏) +/// * `handle` - Tauri应用句柄 +/// +/// # 返回 +/// * `Result<(), String>` - 成功返回Ok(()), 失败返回错误信息 +#[tauri::command] +#[cfg(target_os = "macos")] +pub fn hide_title_bar_buttons( + window_label: &str, + hide_close_button: Option, + handle: AppHandle, +) -> Result<(), String> { + use objc2_app_kit::NSWindowButton; + + let webview_window = get_webview_window(&handle, window_label)?; + let ns_window = get_nswindow_from_webview_window(&webview_window)?; + + // 隐藏标题栏按钮的辅助函数 + set_traffic_lights_hidden(&ns_window, true, NSWindowButton::MiniaturizeButton); + set_traffic_lights_hidden(&ns_window, true, NSWindowButton::ZoomButton); + // 根据参数决定是否隐藏关闭按钮 + if hide_close_button.unwrap_or(false) { + set_traffic_lights_hidden(&ns_window, true, NSWindowButton::CloseButton); + } + + // 设置窗口不可拖动 + set_window_movable_state(&ns_window, false); + Ok(()) +} + +/// 恢复Mac窗口的标题栏按钮显示 +/// +/// # 参数 +/// * `window_label` - 窗口的标签名称 +/// * `handle` - Tauri应用句柄 +/// +/// # 返回 +/// * `Result<(), String>` - 成功返回Ok(()), 失败返回错误信息 +#[tauri::command] +#[cfg(target_os = "macos")] +pub fn show_title_bar_buttons(window_label: &str, handle: AppHandle) -> Result<(), String> { + use objc2_app_kit::NSWindowButton; + + let webview_window = get_webview_window(&handle, window_label)?; + let ns_window = get_nswindow_from_webview_window(&webview_window)?; + + // 显示所有标题栏按钮 + set_traffic_lights_hidden(&ns_window, false, NSWindowButton::CloseButton); + set_traffic_lights_hidden(&ns_window, false, NSWindowButton::MiniaturizeButton); + set_traffic_lights_hidden(&ns_window, false, NSWindowButton::ZoomButton); + // 恢复窗口可拖动 + set_window_movable_state(&ns_window, true); + Ok(()) +} + +#[tauri::command] +#[cfg(target_os = "macos")] +pub fn set_macos_traffic_lights_spacing( + window_label: &str, + spacing: f64, + handle: AppHandle, +) -> Result<(), String> { + if !(0.0..=30.0).contains(&spacing) { + return Err("Invalid spacing value".to_string()); + } + let _ = traffic_lights_desired_spacing() + .lock() + .map(|mut m| m.insert(window_label.to_string(), spacing)); + let webview_window = get_webview_window(&handle, window_label)?; + let ns_window = get_nswindow_from_webview_window(&webview_window)?; + let _ = ensure_traffic_lights_resize_observer(&handle, window_label); + apply_traffic_lights_spacing(&ns_window, spacing) +} + +/// 设置 macOS 窗口是否可拖动 +#[tauri::command] +#[cfg(target_os = "macos")] +pub fn set_window_movable( + window_label: &str, + movable: bool, + handle: AppHandle, +) -> Result<(), String> { + let webview_window = get_webview_window(&handle, window_label)?; + let ns_window = get_nswindow_from_webview_window(&webview_window)?; + set_window_movable_state(&ns_window, movable); + Ok(()) +} + +/// 设置 macOS 窗口级别为屏幕保护程序级别,以覆盖菜单栏 +#[tauri::command] +#[cfg(target_os = "macos")] +pub fn set_window_level_above_menubar(window_label: &str, handle: AppHandle) -> Result<(), String> { + let webview_window = get_webview_window(&handle, window_label)?; + let ns_window = get_nswindow_from_webview_window(&webview_window)?; + + // 设置窗口级别为屏幕保护程序级别 (1000),高于菜单栏 + ns_window.setLevel(objc2_app_kit::NSScreenSaverWindowLevel); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn get_webview_window( + handle: &AppHandle, + window_label: &str, +) -> Result, String> { + handle + .get_webview_window(window_label) + .ok_or_else(|| format!("Window '{}' not found", window_label)) +} + +#[cfg(target_os = "macos")] +fn get_nswindow_from_webview_window( + webview_window: &WebviewWindow, +) -> Result, String> { + webview_window + .ns_window() + .map_err(|e| format!("Failed to get NSWindow: {}", e)) + .map(|ptr| unsafe { Retained::retain(ptr as *mut NSWindow) })? + .ok_or_else(|| "Failed to retain NSWindow".to_string()) +} + +/// 获取Windows系统和文本缩放信息 +#[tauri::command] +#[cfg(target_os = "windows")] +pub fn get_windows_scale_info() -> Result { + use windows::Win32::UI::HiDpi::GetDpiForSystem; + + unsafe { + // 获取系统DPI + let system_dpi = GetDpiForSystem(); + let standard_dpi = 96.0; // Windows标准DPI + let system_scale = system_dpi as f64 / standard_dpi; + + // 从注册表读取文本缩放设置 + let text_scale = get_text_scale_from_registry().unwrap_or(1.0); + + // 检测是否存在文本缩放 (容差为1%) + let has_text_scaling = (text_scale - 1.0).abs() > 0.01; + + Ok(WindowsScaleInfo { + system_dpi, + system_scale, + text_scale, + has_text_scaling, + }) + } +} + +/// 从Windows注册表读取文本缩放设置 +#[cfg(target_os = "windows")] +unsafe fn get_text_scale_from_registry() -> Result { + use windows::Win32::System::Registry::HKEY; + use windows::Win32::System::Registry::HKEY_CURRENT_USER; + use windows::Win32::System::Registry::KEY_READ; + use windows::Win32::System::Registry::REG_DWORD; + use windows::Win32::System::Registry::RegCloseKey; + use windows::Win32::System::Registry::RegOpenKeyExW; + use windows::Win32::System::Registry::RegQueryValueExW; + use windows::core::w; + + // 尝试多个可能的注册表位置 + let registry_paths = [ + w!("Control Panel\\Desktop\\WindowMetrics"), + w!("Software\\Microsoft\\Accessibility"), + w!("Control Panel\\Desktop"), + ]; + + let value_names = [ + w!("TextScaleFactor"), + w!("TextScaleFactor"), + w!("LogPixels"), + ]; + + for (i, &subkey) in registry_paths.iter().enumerate() { + let mut hkey: HKEY = HKEY::default(); + let result = + unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey, Some(0), KEY_READ, &mut hkey) }; + + if result.is_ok() { + let value_name = value_names[i]; + let mut data: u32 = 0; + let mut data_size = std::mem::size_of::() as u32; + let mut value_type = REG_DWORD; + + let result = unsafe { + RegQueryValueExW( + hkey, + value_name, + None, + Some(&mut value_type), + Some(&mut data as *mut u32 as *mut u8), + Some(&mut data_size), + ) + }; + + let _ = unsafe { RegCloseKey(hkey) }; + + if result.is_ok() && value_type == REG_DWORD && data > 0 { + if i == 2 { + // LogPixels 是DPI值,需要特殊处理 + return Ok(1.0); // LogPixels不是文本缩放,返回默认值 + } else { + // TextScaleFactor 是百分比值 (100 = 100%, 150 = 150%) + return Ok(data as f64 / 100.0); + } + } + } + } + Ok(1.0) +} diff --git a/src-tauri/src/desktops/directory_scanner.rs b/src-tauri/src/desktops/directory_scanner.rs new file mode 100644 index 0000000..a9478f1 --- /dev/null +++ b/src-tauri/src/desktops/directory_scanner.rs @@ -0,0 +1,268 @@ +use async_walkdir::WalkDir as AsyncWalkDir; +use futures::stream::StreamExt; +use serde::Serialize; +use std::path::PathBuf; +use std::time::Instant; +use sysinfo::Disks; +use tauri::{AppHandle, Emitter}; +use tokio::sync::broadcast; + +#[derive(Serialize)] +pub struct DirectoryInfo { + pub path: String, + pub total_size: u64, + pub disk_mount_point: String, + pub disk_total_space: u64, + pub disk_used_space: u64, + pub disk_usage_percentage: f64, + pub usage_percentage: f64, +} + +#[derive(Serialize, Clone)] +pub struct DirectoryScanProgress { + pub current_path: String, + pub files_processed: u64, + pub total_size: u64, + pub elapsed_time: u64, + pub elapsed_seconds: f64, + pub progress_percentage: f64, +} + +lazy_static::lazy_static! { + static ref CANCEL_SENDER: broadcast::Sender<()> = { + let (tx, _) = broadcast::channel(1); + tx + }; +} + +/// 带进度事件的目录大小扫描 +async fn get_directory_size_with_progress( + directory_path: String, + handle: AppHandle, +) -> Result { + // 添加总体超时机制,防止扫描过大目录时无限期运行 + let timeout_duration = tokio::time::Duration::from_secs(300); // 5分钟超时 + + match tokio::time::timeout( + timeout_duration, + scan_directory_internal(directory_path, handle), + ) + .await + { + Ok(result) => result, + Err(_) => { + tracing::warn!("Directory scan timeout, automatically cancelled"); + Err("目录扫描超时,请尝试扫描较小的目录".to_string()) + } + } +} + +async fn scan_directory_internal(directory_path: String, handle: AppHandle) -> Result { + let path = PathBuf::from(&directory_path); + + if !path.exists() { + return Err("目录不存在".to_string()); + } + + if !path.is_dir() { + return Err("指定路径不是目录".to_string()); + } + + // 创建取消接收器 + let mut cancel_receiver = CANCEL_SENDER.subscribe(); + + let start_time = Instant::now(); + + // 1.快速预扫描计算总文件数 + let mut total_files = 0u64; + let mut entries = AsyncWalkDir::new(&path); + + let _ = handle.emit( + "directory-scan-progress", + &DirectoryScanProgress { + current_path: "正在统计文件数量...".to_string(), + files_processed: 0, + total_size: 0, + elapsed_time: 0, + elapsed_seconds: 0.0, + progress_percentage: 0.0, + }, + ); + + loop { + tokio::select! { + // 检查取消信号 + _ = cancel_receiver.recv() => { + let _ = handle.emit("directory-scan-cancelled", ()); + return Err("扫描已取消".to_string()); + } + // 处理文件扫描 + entry = entries.next() => { + match entry { + Some(Ok(entry)) => { + if entry.file_type().await.map_or(false, |ft| ft.is_file()) { + total_files += 1; + } + } + Some(Err(_)) => continue, + None => break, // 扫描完成 + } + } + } + } + + // 2.实际扫描并计算准确进度 + let mut total_size = 0u64; + let mut files_processed = 0u64; + let mut last_progress_time = Instant::now(); + + let mut entries = AsyncWalkDir::new(&path); + + loop { + tokio::select! { + // 检查取消信号 + _ = cancel_receiver.recv() => { + let _ = handle.emit("directory-scan-cancelled", ()); + return Err("扫描已取消".to_string()); + } + // 处理文件扫描 + entry = entries.next() => { + match entry { + Some(Ok(entry)) => { + if entry.file_type().await.map_or(false, |ft| ft.is_file()) { + match entry.metadata().await { + Ok(metadata) => { + total_size = total_size.saturating_add(metadata.len()); + files_processed += 1; + + // 进度更新:每200ms或每100个文件发送一次 + let now = Instant::now(); + if now.duration_since(last_progress_time).as_millis() > 200 + || files_processed % 100 == 0 + { + last_progress_time = now; + + // 只在需要发送事件时才转换路径 + let current_path = entry.path().to_string_lossy().to_string(); + + let elapsed = now.duration_since(start_time).as_millis() as u64; + let elapsed_seconds = elapsed as f64 / 1000.0; + + // 计算精确的进度百分比 + let progress_percentage = if total_files > 0 { + (files_processed as f64 / total_files as f64) * 100.0 + } else { + 0.0 + }; + + let progress = DirectoryScanProgress { + current_path: current_path.clone(), + files_processed, + total_size, + elapsed_time: elapsed, + elapsed_seconds, + progress_percentage, + }; + + let _ = handle.emit("directory-scan-progress", &progress); + } + } + Err(_) => continue, + } + } + } + Some(Err(_)) => continue, + None => break, // 扫描完成 + } + } + } + } + + let final_elapsed = start_time.elapsed().as_millis() as u64; + let final_elapsed_seconds = final_elapsed as f64 / 1000.0; + + let final_progress = DirectoryScanProgress { + current_path: "扫描完成".to_string(), + files_processed, + total_size, + elapsed_time: final_elapsed, + elapsed_seconds: final_elapsed_seconds, + progress_percentage: 100.0, + }; + + let _ = handle.emit("directory-scan-complete", &final_progress); + + Ok(total_size) +} + +/// 取消目录扫描 +#[tauri::command] +pub fn cancel_directory_scan() -> Result<(), String> { + // 发送取消信号,立即中断所有正在进行的扫描 + let _ = CANCEL_SENDER.send(()); + Ok(()) +} + +/// 带进度事件的目录使用信息 +#[tauri::command] +pub async fn get_directory_usage_info_with_progress( + directory_path: String, + handle: AppHandle, +) -> Result { + let total_size = get_directory_size_with_progress(directory_path.clone(), handle).await?; + + let path = PathBuf::from(&directory_path); + let path_str = path.to_string_lossy().to_string(); + + let disks = Disks::new_with_refreshed_list(); + + let mut best_match = String::new(); + let mut best_match_len = 0; + let mut disk_total_space = 0u64; + let mut disk_available_space = 0u64; + + for disk in &disks { + let mount_point = disk.mount_point().to_string_lossy().to_string(); + + // Windows下路径比较需要处理路径分隔符 + let normalized_path = path_str.replace('\\', "/"); + let normalized_mount_point = mount_point.replace('\\', "/"); + + if normalized_path.starts_with(&normalized_mount_point) + && mount_point.len() > best_match_len + { + best_match_len = mount_point.len(); + disk_total_space = disk.total_space(); + disk_available_space = disk.available_space(); + best_match = mount_point; + } + } + + if best_match.is_empty() { + return Err("无法找到目录所在的磁盘".to_string()); + } + + let usage_percentage = if disk_total_space > 0 { + (total_size as f64 / disk_total_space as f64) * 100.0 + } else { + 0.0 + }; + + // 计算磁盘已用空间和使用占比 + let disk_used_space = disk_total_space.saturating_sub(disk_available_space); + let disk_usage_percentage = if disk_total_space > 0 { + (disk_used_space as f64 / disk_total_space as f64) * 100.0 + } else { + 0.0 + }; + + Ok(DirectoryInfo { + path: directory_path, + total_size, + disk_mount_point: best_match, + disk_total_space, + disk_used_space, + disk_usage_percentage, + usage_percentage, + }) +} diff --git a/src-tauri/src/desktops/init.rs b/src-tauri/src/desktops/init.rs new file mode 100644 index 0000000..a75ee68 --- /dev/null +++ b/src-tauri/src/desktops/init.rs @@ -0,0 +1,147 @@ +use crate::common::init::{CustomInit, init_common_plugins}; +#[cfg(target_os = "macos")] +use crate::desktops::common_cmd::apply_macos_traffic_lights_spacing_default; +use tauri::{Manager, Runtime, WindowEvent}; +use tauri_plugin_autostart::MacosLauncher; + +pub trait DesktopCustomInit { + fn init_webwindow_event(self) -> Self; + + fn init_window_event(self) -> Self; +} + +impl CustomInit for tauri::Builder { + // 初始化插件 + fn init_plugin(self) -> Self { + let builder = init_common_plugins(self); + + // 桌面端特有的插件 + #[cfg(desktop)] + let builder = builder + .plugin(tauri_plugin_autostart::init( + MacosLauncher::LaunchAgent, + Some(vec!["--flag1", "--flag2"]), + )) + .plugin(tauri_plugin_single_instance::init(|app, _args, _cmd| { + let windows = app.webview_windows(); + // 优先显示已存在的home窗口 + for (name, window) in windows { + if name == "home" { + if let Err(e) = window.show() { + tracing::warn!("Failed to show home window: {}", e); + } + if let Err(e) = window.unminimize() { + tracing::warn!("Failed to unminimize home window: {}", e); + } + if let Err(e) = window.set_focus() { + tracing::warn!("Failed to focus home window: {}", e); + } + break; + } + } + })) + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) + .plugin(tauri_plugin_updater::Builder::new().build()); + + // #[cfg(debug_assertions)] + // let builder = builder.plugin(tauri_plugin_devtools::init()); + + builder + } +} + +impl DesktopCustomInit for tauri::Builder { + // 初始化web窗口事件 + fn init_webwindow_event(self) -> Self { + self.on_webview_event(|_, event| match event { + _ => (), + }) + } + + // 初始化系统窗口事件 + fn init_window_event(self) -> Self { + self.on_window_event(|window, event: &WindowEvent| match event { + WindowEvent::Focused(flag) => { + #[cfg(target_os = "macos")] + if *flag { + let app_handle = window.app_handle(); + let _ = apply_macos_traffic_lights_spacing_default(window.label(), &app_handle); + } + // 自定义系统托盘-实现托盘菜单失去焦点时隐藏 + #[cfg(not(target_os = "macos"))] + if !window.label().eq("tray") && *flag { + if let Some(tray_window) = window.app_handle().get_webview_window("tray") { + let _ = tray_window.hide(); + } + } + if window.label().eq("tray") && !flag { + if let Err(e) = window.hide() { + tracing::warn!("Failed to hide tray window: {}", e); + } + } + #[cfg(target_os = "windows")] + if !window.label().eq("notify") && *flag { + if let Some(notify_window) = window.app_handle().get_webview_window("notify") { + let _ = notify_window.hide(); + } + } + #[cfg(target_os = "windows")] + if window.label().eq("notify") && !flag { + if let Err(e) = window.hide() { + tracing::warn!("Failed to hide notify window: {}", e); + } + } + } + WindowEvent::CloseRequested { .. } => { + let app_handle = window.app_handle(); + let windows = app_handle.webview_windows(); + let win_label = window.label(); + + // 检查窗口是否是无效窗口(不重要的可退出的) + let is_ignored_window = + |name: &str| matches!(name, "checkupdate" | "capture" | "update" | "tray"); + + if win_label.eq("update") { + let state: tauri::State<'_, crate::AppData> = window.state(); + let user_info = state.user_info.clone(); + + let has_other_active_windows = + windows.iter().any(|(name, _)| !is_ignored_window(name)); + + let app_handle = app_handle.clone(); + + tauri::async_runtime::spawn(async move { + let user_info = user_info.lock().await; + let not_logg_in = user_info.uid.trim().is_empty(); + + // update 窗口关闭 + 未登录 + 没有其他有效窗口 => 退出程序 + if not_logg_in && !has_other_active_windows { + app_handle.exit(0); + } + }); + } + // 如果是login窗口被用户关闭,直接退出程序 + else if win_label.eq("login") { + // 检查是否有其他窗口存在,如果有home窗口,说明是登录成功后的正常关闭 + let has_home_or_update = windows + .iter() + .any(|(name, _)| matches!(name.as_str(), "home" | "update")); + + if !has_home_or_update { + // 没有home窗口,说明是用户直接关闭login窗口,退出程序 + window.app_handle().exit(0); + } + // 如果有home窗口,说明是登录成功后的正常关闭,允许关闭 + } + } + WindowEvent::Resized(_ps) => { + #[cfg(target_os = "macos")] + { + let app_handle = window.app_handle(); + let _ = apply_macos_traffic_lights_spacing_default(window.label(), &app_handle); + } + } + _ => (), + }) + } +} diff --git a/src-tauri/src/desktops/mod.rs b/src-tauri/src/desktops/mod.rs new file mode 100644 index 0000000..91d4c3f --- /dev/null +++ b/src-tauri/src/desktops/mod.rs @@ -0,0 +1,7 @@ +pub mod app_event; +pub mod common_cmd; +pub mod directory_scanner; +pub mod init; +pub mod tray; +pub mod video_thumbnail; +pub mod window_payload; diff --git a/src-tauri/src/desktops/tray.rs b/src-tauri/src/desktops/tray.rs new file mode 100644 index 0000000..308fbb4 --- /dev/null +++ b/src-tauri/src/desktops/tray.rs @@ -0,0 +1,202 @@ +#[cfg(target_os = "windows")] +use tauri::{ + Emitter, Manager, PhysicalPosition, Runtime, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, +}; + +#[cfg(target_os = "macos")] +use tauri::{ + Manager, Runtime, + menu::{MenuBuilder, MenuId, MenuItem, PredefinedMenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, +}; + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +use tauri::{ + Manager, PhysicalPosition, Runtime, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, +}; + +pub fn create_tray(app: &tauri::AppHandle) -> tauri::Result<()> { + #[cfg(target_os = "macos")] + { + // 为 macOS 创建原生菜单 + let open_id = MenuId::new("open_home"); + let exit_id = MenuId::new("exit_app"); + + // 创建分隔符和菜单项 + let separator1 = PredefinedMenuItem::separator(app)?; + let open_menu_item = + MenuItem::with_id(app, open_id.clone(), "打开主面板", true, None::<&str>)?; + let separator2 = PredefinedMenuItem::separator(app)?; + let exit_menu_item = MenuItem::with_id(app, exit_id.clone(), "退出", true, None::<&str>)?; + + // 构建菜单 + let tray_menu = MenuBuilder::new(app) + .items(&[&separator1, &open_menu_item, &separator2, &exit_menu_item]) + .build()?; + + let tray_handler = app.clone(); + let default_icon = match app.default_window_icon() { + Some(icon) => icon.clone(), + None => { + tracing::error!("Default window icon not found"); + return Err(tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "未找到默认窗口图标", + ))); + } + }; + + let _ = TrayIconBuilder::with_id("tray") + .tooltip("HuLa") + .icon(default_icon) + .menu(&tray_menu) // 直接设置菜单,让系统处理右键显示 + .show_menu_on_left_click(false) // 禁用左键显示菜单 + .on_menu_event(move |app, event| { + let id = event.id(); + if id == &open_id { + // 打开主面板 + let windows = app.webview_windows(); + + // 优先显示已存在的home窗口 + for (name, window) in windows { + if name == "home" { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + break; + } + } + } else if id == &exit_id { + // 退出应用 + let _ = tray_handler.exit(0); + } + }) + .on_tray_icon_event(move |tray, event| match event { + TrayIconEvent::Click { + id: _, + position: _, + rect: _, + button, + button_state, + } => match button { + MouseButton::Left if MouseButtonState::Up == button_state => { + // 左键点击直接打开主面板 + let windows = tray.app_handle().webview_windows(); + + // 优先显示已存在的home窗口 + for (name, window) in windows { + if name == "home" { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + break; + } + } + } + _ => {} + }, + _ => {} + }) + .build(app)?; + } + #[cfg(not(target_os = "macos"))] + { + // 为其他系统(非 macOS)创建托盘图标 + let default_icon = match app.default_window_icon() { + Some(icon) => icon.clone(), + None => { + tracing::error!("Default window icon not found"); + return Err(tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "未找到默认窗口图标", + ))); + } + }; + + let _ = TrayIconBuilder::with_id("tray") + .tooltip("HuLa") + .icon(default_icon) + .on_tray_icon_event(|tray, event| match event { + TrayIconEvent::Click { + id: _, + position, + rect: _, + button, + button_state, + } => match button { + MouseButton::Left => { + let windows = tray.app_handle().webview_windows(); + for (name, window) in windows { + if name == "login" || name == "home" { + if let Err(e) = window.show() { + tracing::warn!("Failed to show window {}: {}", name, e); + } + if let Err(e) = window.unminimize() { + tracing::warn!("Failed to unminimize window {}: {}", name, e); + } + if let Err(e) = window.set_focus() { + tracing::warn!("Failed to set focus on window {}: {}", name, e); + } + break; + } + } + } + MouseButton::Right if MouseButtonState::Down == button_state => { + // 状态栏图标按下右键时显示状态栏菜单 + if let Some(tray_window) = tray.app_handle().get_webview_window("tray") { + if let Ok(outer_size) = tray_window.outer_size() { + if let Err(e) = tray_window.set_position(PhysicalPosition::new( + position.x, + position.y - outer_size.height as f64, + )) { + tracing::warn!("Failed to set tray window position: {}", e); + return; + } + + let _ = tray_window.set_always_on_top(true); + let _ = tray_window.show(); + let _ = tray_window.set_focus(); + } + } else { + tracing::warn!("Tray window not found"); + } + } + _ => {} + }, + #[cfg(target_os = "windows")] + TrayIconEvent::Enter { + id: _, + position: _, + rect: _, + } => { + if let Ok(rect) = tray.rect() { + match tray.app_handle().emit_to("notify", "notify_enter", &rect) { + Ok(_) => { + tracing::info!("notify_enter event sent successfully"); + } + Err(e) => { + tracing::warn!("Failed to emit notify_enter event: {}", e); + } + } + } else { + tracing::warn!("Failed to get tray rect"); + } + } + #[cfg(target_os = "windows")] + TrayIconEvent::Leave { + id: _, + position: _, + rect: _, + } => { + if let Err(e) = tray.app_handle().emit_to("notify", "notify_leave", ()) { + tracing::warn!("Failed to emit notify_leave event: {}", e); + } + } + _ => {} + }) + .build(app); + } + Ok(()) +} diff --git a/src-tauri/src/desktops/video_thumbnail.rs b/src-tauri/src/desktops/video_thumbnail.rs new file mode 100644 index 0000000..fb55516 --- /dev/null +++ b/src-tauri/src/desktops/video_thumbnail.rs @@ -0,0 +1,464 @@ +use base64::{Engine as _, engine::general_purpose}; +use image::ImageFormat; +#[cfg(target_os = "macos")] +use image::ImageReader; +use serde::Serialize; +use std::path::Path; +#[cfg(target_os = "macos")] +use std::process::Command; +use tauri::Result as TauriResult; + +#[derive(Debug, Clone, Serialize)] +pub struct VideoThumbnailInfo { + pub thumbnail_base64: String, + pub width: u32, + pub height: u32, + #[cfg(target_os = "macos")] + pub duration: f64, +} + +/// 生成视频缩略图 +pub async fn generate_video_thumbnail( + video_path: &str, + target_time: Option, +) -> TauriResult { + let path = Path::new(video_path); + + if !path.exists() { + return Err(tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "视频文件不存在", + ))); + } + + // 根据平台选择不同的实现 + #[cfg(target_os = "macos")] + { + generate_thumbnail_macos(video_path, target_time).await + } + + #[cfg(target_os = "windows")] + { + generate_thumbnail_windows(video_path, target_time).await + } + + #[cfg(target_os = "linux")] + { + generate_thumbnail_linux(video_path, target_time).await + } + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + Err(tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "当前平台暂不支持视频缩略图生成", + ))) + } +} + +#[cfg(target_os = "macos")] +async fn generate_thumbnail_macos( + video_path: &str, + _target_time: Option, +) -> TauriResult { + use std::fs; + + // 检查视频文件是否存在 + if !Path::new(video_path).exists() { + return Err(tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("视频文件不存在: {}", video_path), + ))); + } + + // 使用 macOS 系统的 qlmanage 工具生成缩略图 + let temp_dir = std::env::temp_dir(); + + // 使用 qlmanage 生成缩略图 + let output = Command::new("qlmanage") + .args(&[ + "-t", + "-s", + "300", // 缩略图大小 + "-o", + temp_dir.to_str().ok_or_else(|| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Invalid temp directory path", + )) + })?, + video_path, + ]) + .output() + .map_err(|e| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!("执行 qlmanage 失败: {}", e), + )) + })?; + + if !output.status.success() { + return Err(tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!( + "qlmanage 执行失败: {}", + String::from_utf8_lossy(&output.stderr) + ), + ))); + } + + // 寻找生成的缩略图文件 + let video_file_stem = Path::new(video_path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("video"); + + // 列出临时目录中的所有文件来找缩略图 + let mut generated_thumbnail = None; + if let Ok(entries) = fs::read_dir(&temp_dir) { + for entry in entries { + if let Ok(entry) = entry { + if let Some(name) = entry.file_name().to_str() { + if name.contains(&video_file_stem) && name.ends_with(".png") { + generated_thumbnail = Some(entry.path()); + break; + } + } + } + } + } + + let thumbnail_path = if let Some(path) = generated_thumbnail { + path + } else { + // 如果没找到,尝试默认路径 + let default_path = temp_dir.join(format!("{}.png", video_file_stem)); + if default_path.exists() { + default_path + } else { + return Err(tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!( + "找不到生成的缩略图文件,video_file_stem: {}", + video_file_stem + ), + ))); + } + }; + + // 读取生成的缩略图 + let thumbnail_data = tokio::fs::read(&thumbnail_path).await.map_err(|e| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!("读取缩略图失败: {}", e), + )) + })?; + + // 获取图像尺寸 + let img = ImageReader::new(std::io::Cursor::new(&thumbnail_data)) + .with_guessed_format() + .map_err(|e| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!("读取图像格式失败: {}", e), + )) + })? + .decode() + .map_err(|e| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!("解码图像失败: {}", e), + )) + })?; + + let width = img.width(); + let height = img.height(); + + // 转换为 RGB 格式(去除透明度通道),然后转换为 JPEG + let rgb_img = img.to_rgb8(); + let mut jpeg_data = Vec::new(); + + image::DynamicImage::ImageRgb8(rgb_img) + .write_to(&mut std::io::Cursor::new(&mut jpeg_data), ImageFormat::Jpeg) + .map_err(|e| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!("转换为 JPEG 失败: {}", e), + )) + })?; + + // 转换为 base64 + let base64_string = general_purpose::STANDARD.encode(&jpeg_data); + + // 清理临时文件 + let _ = tokio::fs::remove_file(&thumbnail_path).await; + + // 获取视频时长(使用 mdls 命令) + let duration = get_video_duration_macos(video_path).await.unwrap_or(0.0); + + Ok(VideoThumbnailInfo { + thumbnail_base64: base64_string, + width, + height, + #[cfg(target_os = "macos")] + duration, + }) +} + +#[cfg(target_os = "macos")] +async fn get_video_duration_macos(video_path: &str) -> Option { + let output = Command::new("mdls") + .args(&["-name", "kMDItemDurationSeconds", video_path]) + .output() + .ok()?; + + if output.status.success() { + let output_str = String::from_utf8_lossy(&output.stdout); + // 解析输出格式: "kMDItemDurationSeconds = 123.456" + if let Some(duration_str) = output_str.split('=').nth(1) { + duration_str.trim().parse().ok() + } else { + None + } + } else { + None + } +} + +#[cfg(target_os = "windows")] +unsafe fn convert_hbitmap_to_image_data( + hbitmap: windows::Win32::Graphics::Gdi::HBITMAP, +) -> TauriResult<(u32, u32, Vec)> { + use windows::Win32::Foundation::HWND; + use windows::Win32::Graphics::Gdi::*; + + unsafe { + // 获取位图信息 + let mut bitmap = BITMAP::default(); + let result = GetObjectW( + hbitmap.into(), + std::mem::size_of::() as i32, + Some(&mut bitmap as *mut _ as *mut _), + ); + + if result == 0 { + return Err(tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + "获取位图信息失败", + ))); + } + + let width = bitmap.bmWidth as u32; + let height = bitmap.bmHeight as u32; + + // 创建设备上下文 + let hdc = GetDC(Some(HWND::default())); + if hdc.is_invalid() { + return Err(tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + "创建设备上下文失败", + ))); + } + + let mem_dc = CreateCompatibleDC(Some(hdc)); + if mem_dc.is_invalid() { + ReleaseDC(Some(HWND::default()), hdc); + return Err(tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + "创建兼容设备上下文失败", + ))); + } + + // 选择位图到内存DC + let old_bitmap = SelectObject(mem_dc, hbitmap.into()); + + // 准备位图信息头 + let mut bmp_info = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: width as i32, + biHeight: -(height as i32), // 负值表示自顶向下 + biPlanes: 1, + biBitCount: 24, // RGB 格式 + biCompression: BI_RGB.0, + biSizeImage: 0, + biXPelsPerMeter: 0, + biYPelsPerMeter: 0, + biClrUsed: 0, + biClrImportant: 0, + }, + bmiColors: [RGBQUAD::default()], + }; + + // 计算图像数据大小 + let stride = ((width * 3 + 3) / 4) * 4; // 4字节对齐 + let data_size = stride * height; + let mut image_data = vec![0u8; data_size as usize]; + + // 获取位图数据 + let result = GetDIBits( + mem_dc, + hbitmap, + 0, + height, + Some(image_data.as_mut_ptr() as *mut _), + &mut bmp_info, + DIB_RGB_COLORS, + ); + + // 清理资源 + SelectObject(mem_dc, old_bitmap); + let _ = DeleteDC(mem_dc); + ReleaseDC(Some(HWND::default()), hdc); + + if result == 0 { + return Err(tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + "获取位图数据失败", + ))); + } + + // 转换 BGR 到 RGB 并去除填充 + let mut rgb_data = Vec::with_capacity((width * height * 3) as usize); + for y in 0..height { + for x in 0..width { + let offset = (y * stride + x * 3) as usize; + if offset + 2 < image_data.len() { + // BGR 转 RGB + rgb_data.push(image_data[offset + 2]); // R + rgb_data.push(image_data[offset + 1]); // G + rgb_data.push(image_data[offset]); // B + } + } + } + + Ok((width, height, rgb_data)) + } +} + +#[cfg(target_os = "windows")] +async fn generate_thumbnail_windows( + video_path: &str, + _target_time: Option, +) -> TauriResult { + use windows::{Win32::Foundation::*, Win32::System::Com::*, Win32::UI::Shell::*, core::*}; + + // 检查视频文件是否存在 + if !Path::new(video_path).exists() { + return Err(tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("视频文件不存在: {}", video_path), + ))); + } + + // 初始化 COM + unsafe { + CoInitializeEx(None, COINIT_APARTMENTTHREADED) + .ok() + .map_err(|e| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!("初始化 COM 失败: {:?}", e), + )) + })?; + } + + let result = unsafe { + // 将文件路径转换为宽字符 + let video_path_wide: Vec = video_path + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let video_path_pcwstr = PCWSTR(video_path_wide.as_ptr()); + + // 创建 ShellItem + let shell_item: IShellItem = + SHCreateItemFromParsingName(video_path_pcwstr, None).map_err(|e| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!("创建 ShellItem 失败: {:?}", e), + )) + })?; + + // 获取缩略图 + let image_factory: IShellItemImageFactory = shell_item.cast().map_err(|e| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!("获取图像工厂失败: {:?}", e), + )) + })?; + + // 设置缩略图大小 + let size = SIZE { cx: 300, cy: 300 }; + + // 获取缩略图 HBITMAP + let hbitmap = image_factory + .GetImage(size, SIIGBF_RESIZETOFIT) + .map_err(|e| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!("获取缩略图失败,可能是视频格式不支持: {:?}", e), + )) + })?; + + // 将 HBITMAP 转换为图像数据 + convert_hbitmap_to_image_data(hbitmap) + }; + + // 清理 COM + unsafe { + CoUninitialize(); + } + + let (width, height, image_data) = result?; + + // 创建图像并转换为 JPEG + let img = image::RgbImage::from_raw(width, height, image_data).ok_or_else(|| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + "创建图像失败", + )) + })?; + + let mut jpeg_data = Vec::new(); + image::DynamicImage::ImageRgb8(img) + .write_to(&mut std::io::Cursor::new(&mut jpeg_data), ImageFormat::Jpeg) + .map_err(|e| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!("转换为 JPEG 失败: {}", e), + )) + })?; + + // 转换为 base64 + let base64_string = general_purpose::STANDARD.encode(&jpeg_data); + + Ok(VideoThumbnailInfo { + thumbnail_base64: base64_string, + width, + height, + }) +} + +#[cfg(target_os = "linux")] +async fn generate_thumbnail_linux( + _video_path: &str, + _target_time: Option, +) -> TauriResult { + // Linux 实现可以使用 gstreamer 或其他方案 + Err(tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Linux 平台暂不支持视频缩略图生成", + ))) +} + +/// Tauri 命令:生成视频缩略图 +#[tauri::command] +pub async fn get_video_thumbnail( + video_path: String, + target_time: Option, +) -> Result { + generate_video_thumbnail(&video_path, target_time) + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/desktops/window_payload.rs b/src-tauri/src/desktops/window_payload.rs new file mode 100644 index 0000000..8981b4d --- /dev/null +++ b/src-tauri/src/desktops/window_payload.rs @@ -0,0 +1,30 @@ +use std::collections::HashMap; + +use lazy_static::lazy_static; +use tokio::sync::Mutex; + +lazy_static! { + static ref PAYLOAD_CACHE: Mutex> = + Mutex::new(HashMap::new()); +} + +#[tauri::command] +pub async fn push_window_payload( + label: String, + payload: serde_json::Value, +) -> Option { + let mut payload_cache = PAYLOAD_CACHE.lock().await; + payload_cache.insert(label, payload) +} + +#[tauri::command] +pub async fn get_window_payload(label: String, once: bool) -> Option { + let mut payload_cache = PAYLOAD_CACHE.lock().await; + + if once { + payload_cache.remove(&label) + } else { + let cache = payload_cache.get(&label); + cache.cloned() + } +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs new file mode 100644 index 0000000..2575901 --- /dev/null +++ b/src-tauri/src/error.rs @@ -0,0 +1,36 @@ +#[derive(thiserror::Error)] +pub enum CommonError { + #[error(transparent)] + UnexpectedError(#[from] anyhow::Error), + #[error("Database error: {0}")] + DatabaseError(#[from] sea_orm::DbErr), + #[error("Request error: {0}")] + RequestError(String), + #[error("Token expired")] + TokenExpired, +} + +impl From for String { + fn from(err: CommonError) -> String { + err.to_string() + } +} + +impl std::fmt::Debug for CommonError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + error_chain_fmt(self, f) + } +} + +pub fn error_chain_fmt( + e: &impl std::error::Error, + f: &mut std::fmt::Formatter<'_>, +) -> std::fmt::Result { + writeln!(f, "{}\n", e)?; + let mut current = e.source(); + while let Some(cause) = current { + writeln!(f, "Caused by:\n\t{}", cause)?; + current = cause.source(); + } + Ok(()) +} diff --git a/src-tauri/src/im_request_client.rs b/src-tauri/src/im_request_client.rs new file mode 100644 index 0000000..ec0cf90 --- /dev/null +++ b/src-tauri/src/im_request_client.rs @@ -0,0 +1,1151 @@ +use std::str::FromStr; + +use base64::{Engine, prelude::BASE64_STANDARD}; +use reqwest::header; +use serde_json::json; +use tracing::error; + +use crate::{ + pojo::common::ApiResult, + vo::vo::{LoginReq, LoginResp}, +}; + +#[derive(Debug)] +pub struct ImRequestClient { + client: reqwest::Client, + base_url: String, + pub token: Option, + pub refresh_token: Option, +} + +impl ImRequestClient { + pub fn new(base_url: String) -> Result { + let mut headers = header::HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/json"), + ); + let basic_auth = BASE64_STANDARD.encode("luohuo_web_pro:luohuo_web_pro_secret"); + let basic_auth_value = header::HeaderValue::from_str(&basic_auth) + .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {}", e))?; + headers.insert(header::AUTHORIZATION, basic_auth_value); + + let client = reqwest::Client::builder() + .default_headers(headers) + .build() + .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {}", e))?; + + Ok(Self { + client: client, + base_url: base_url, + token: None, + refresh_token: None, + }) + } + + pub fn set_base_url(&mut self, base_url: String) { + self.base_url = base_url; + } + + /// 构建请求的公共方法(不发送请求) + /// + /// 提取了 URL 构建、token 添加、body/params 处理等公共逻辑 + /// + /// # 参数 + /// - `method`: HTTP 方法 + /// - `path`: API 路径 + /// - `body`: 请求体(可选) + /// - `params`: 查询参数(可选) + /// - `extra_headers`: 额外的请求头(可选) + fn build_request( + &self, + method: http::Method, + path: &str, + body: &Option, + params: &Option, + extra_headers: Option>, + ) -> reqwest::RequestBuilder { + let url = format!("{}/{}", self.base_url, path); + let mut request_builder = self.client.request(method, &url); + + // 设置 token 请求头 + if let Some(token) = &self.token { + request_builder = request_builder.header("token", token); + } + + // 添加额外的请求头 + if let Some(headers) = extra_headers { + for (key, value) in headers { + request_builder = request_builder.header(key, value); + } + } + + // 设置请求体 + if let Some(body) = body { + request_builder = request_builder.json(body); + } else { + request_builder = request_builder.json(&serde_json::json!({})); + } + + // 设置查询参数 + if let Some(params) = params { + request_builder = request_builder.query(params); + } + + request_builder + } + + pub async fn request< + T: serde::de::DeserializeOwned, + B: serde::Serialize, + C: serde::Serialize, + >( + &mut self, + method: http::Method, + path: &str, + body: Option, + params: Option, + ) -> Result, anyhow::Error> { + let mut retry_count = 0; + const MAX_RETRY_COUNT: u8 = 2; + let is_logout = path == ImUrl::Logout.get_url().1; + + loop { + // 使用 build_request 构建请求 + let request_builder = self.build_request(method.clone(), path, &body, ¶ms, None); + + // 发送请求,区分网络错误和业务错误 + let response = request_builder + .send() + .await + .map_err(|e| anyhow::anyhow!("network_error: {}", e))?; + let result: ApiResult = response + .json() + .await + .map_err(|e| anyhow::anyhow!("network_error: {}", e))?; + + let url = format!("{}/{}", self.base_url, path); + + match result.code { + Some(406) => { + if is_logout { + return Ok(result); + } + if retry_count >= MAX_RETRY_COUNT { + return Err(anyhow::anyhow!("token过期,刷新token失败")); + } + self.start_refresh_token().await?; + retry_count += 1; + continue; + } + Some(401) => { + if is_logout { + return Ok(result); + } + error!( + "{}; 方法: {}; 失败信息: {}", + &url, + method, + result.msg.clone().unwrap_or_default() + ); + return Err(anyhow::anyhow!("请重新登录")); + } + Some(200) => { + return Ok(result); + } + _ => { + error!( + "{}; 方法: {}; 失败信息: {}", + &url, + method, + result.msg.clone().unwrap_or_default() + ); + return Err(anyhow::anyhow!( + "{}", + result.msg.clone().unwrap_or_default() + )); + } + } + } + } + + /// 流式请求方法(用于 SSE 等流式响应) + /// + /// 与 `request` 方法的区别: + /// 1. 添加 `Accept: text/event-stream` 请求头 + /// 2. 返回 `reqwest::Response` 而不是解析 JSON + /// 3. 不支持自动 token 刷新重试(因为流式响应无法中断重试) + /// + /// # 参数 + /// - `method`: HTTP 方法 + /// - `path`: API 路径 + /// - `body`: 请求体(可选) + /// - `params`: 查询参数(可选) + /// + /// # 返回 + /// - `Ok(Response)`: 成功返回响应对象,可用于读取流式数据 + /// - `Err`: 请求失败或状态码非 2xx + pub async fn request_stream( + &mut self, + method: http::Method, + path: &str, + body: Option, + params: Option, + ) -> Result { + // 添加流式请求头 + let extra_headers = Some(vec![("Accept", "text/event-stream")]); + + // 使用 build_request 构建请求 + let request_builder = + self.build_request(method.clone(), path, &body, ¶ms, extra_headers); + + // 发送请求 + let response = request_builder.send().await?; + + // 检查响应状态(但不解析 JSON) + let status = response.status(); + if !status.is_success() { + let url = format!("{}/{}", self.base_url, path); + error!("流式请求失败,URL: {}, 状态码: {}", url, status); + + // 根据状态码返回不同的错误信息 + match status.as_u16() { + 406 => { + error!("Token expired in stream request"); + return Err(anyhow::anyhow!("token过期,请刷新后重试")); + } + 401 => { + error!("Unauthorized in stream request"); + return Err(anyhow::anyhow!("请重新登录")); + } + _ => { + return Err(anyhow::anyhow!("请求失败,状态码: {}", status)); + } + } + } + + Ok(response) + } + + pub async fn start_refresh_token(&mut self) -> Result<(), anyhow::Error> { + let url = format!("{}/{}", self.base_url, ImUrl::RefreshToken.get_url().1); + + let refresh_token = match self.refresh_token.clone() { + Some(token) if !token.is_empty() => token, + _ => return Err(anyhow::anyhow!("请重新登录")), + }; + let old_refresh_token = refresh_token.clone(); + + let body = json!({ + "refreshToken": refresh_token + }); + + let request_builder = self.client.request(http::Method::POST, &url); + let response = request_builder + .json(&body) + .send() + .await + .map_err(|e| anyhow::anyhow!("network_error: {}", e))?; + let result: ApiResult = response + .json() + .await + .map_err(|e| anyhow::anyhow!("network_error: {}", e))?; + + if !result.success { + // 服务器明确拒绝 refresh token,需要重新登录 + return Err(anyhow::anyhow!("请重新登录")); + } + + let data = match result.data { + Some(data) => data, + None => { + error!("刷新token失败: 响应缺少 data 字段"); + return Err(anyhow::anyhow!("请重新登录")); + } + }; + + let token = match data.get("token").and_then(|value| value.as_str()) { + Some(value) => value, + None => { + error!("刷新token失败: 响应缺少 token 字段"); + return Err(anyhow::anyhow!("请重新登录")); + } + }; + self.token = Some(token.to_owned()); + + let refresh_token = match data.get("refreshToken").and_then(|value| value.as_str()) { + Some(value) if !value.is_empty() => value.to_owned(), + _ => old_refresh_token, + }; + self.refresh_token = Some(refresh_token); + + Ok(()) + } + + pub async fn im_request< + T: serde::de::DeserializeOwned, + B: serde::Serialize, + C: serde::Serialize, + >( + &mut self, + url: ImUrl, + body: Option, + params: Option, + ) -> Result, anyhow::Error> { + let (method, path) = url.get_url(); + let result: ApiResult = self.request(method, path, body, params).await?; + Ok(result.data) + } +} + +impl ImRequest for ImRequestClient { + async fn login(&mut self, login_req: LoginReq) -> Result, anyhow::Error> { + let result: Option = self + .im_request(ImUrl::Login, Some(login_req), None::) + .await?; + + if let Some(data) = result.clone() { + self.token = Some(data.token.clone()); + self.refresh_token = Some(data.refresh_token.clone()); + } + + Ok(result) + } +} + +pub enum ImUrl { + Login, + RefreshToken, + ForgetPassword, + CheckToken, + Logout, + Register, + GetQiniuToken, + InitConfig, + StorageProvider, + MapCoordTranslate, + MapReverseGeocode, + MapStatic, + GetAssistantModelList, + SendCaptcha, + GetCaptcha, + Announcement, + EditAnnouncement, + DeleteAnnouncement, + PushAnnouncement, + GetAnnouncementList, + ApplyGroup, + SearchGroup, + UpdateMyRoomInfo, + UpdateRoomInfo, + GroupList, + GroupListMember, + GroupDetail, + GroupInfo, + RevokeAdmin, + AddAdmin, + ExitGroup, + AcceptInvite, + InviteList, + InviteGroupMember, + RemoveGroupMember, + CreateGroup, + Shield, + Notification, + DeleteSession, + SetSessionTop, + SessionDetailWithFriends, + SessionDetail, + GetMsgReadCount, + GetMsgReadList, + ModifyFriendRemark, + DeleteFriend, + SendAddFriendRequest, + HandleInvite, + NoticeUnReadCount, + RequestNoticePage, + GetContactList, + SearchFriend, + ChangeUserState, + GetAllUserState, + UploadAvatar, + GetEmoji, + DeleteEmoji, + AddEmoji, + RecallMsg, + BlockUser, + MarkMsg, + SetUserBadge, + ModifyUserInfo, + GetUserInfoDetail, + GetMsgList, + GetMsgPage, + GetAllUserBaseInfo, + GetBadgesBatch, + GetMemberStatistic, + GetBadgeList, + FeedDetail, + FeedList, + PushFeed, + DelFeed, + EditFeed, + GetFeedPermission, + // 朋友圈点赞相关 + FeedLikeToggle, + FeedLikeList, + FeedLikeCount, + FeedLikeHasLiked, + // 朋友圈评论相关 + FeedCommentAdd, + FeedCommentDelete, + FeedCommentList, + FeedCommentAll, + FeedCommentCount, + SendMsg, + SetHide, + GetFriendPage, + MarkMsgRead, + CheckEmail, + MergeMsg, + GetUserByIds, + GenerateQRCode, + CheckQRStatus, + ScanQRCode, + ConfirmQRCode, + MessageSend, + MessageSendStream, + MessageListByConversationId, + MessageDelete, + MessageDeleteByConversationId, + MessagePage, + MessageDeleteByAdmin, + MessageSaveGeneratedContent, + + ConversationCreateMy, + ConversationUpdateMy, + ConversationMyList, + ConversationGetMy, + ConversationDeleteMy, + ConversationDeleteByUnpinned, + ConversationPage, + ConversationDeleteByAdmin, + + ModelCreate, + ModelUpdate, + ModelDelete, + ModelGet, + ModelRemainingUsage, + ModelPage, + ModelSimpleList, + + ChatRoleMyPage, + ChatRoleGetMy, + ChatRoleCreateMy, + ChatRoleUpdateMy, + ChatRoleDeleteMy, + ChatRoleCategoryList, + ChatRoleCreate, + ChatRoleUpdate, + ChatRoleDelete, + ChatRoleGet, + ChatRolePage, + + ApiKeyCreate, + ApiKeyUpdate, + ApiKeyDelete, + ApiKeyGet, + ApiKeyPage, + ApiKeySimpleList, + ApiKeyBalance, + + PlatformList, + PlatformAddModel, + + ToolCreate, + ToolUpdate, + ToolDelete, + ToolGet, + ToolPage, + ToolSimpleList, + + ImageMyPage, + ImagePublicPage, + ImageGetMy, + ImageMyListByIds, + ImageDraw, + ImageDeleteMy, + ImageMidjourneyImagine, + ImageMidjourneyNotify, + ImageMidjourneyAction, + ImagePage, + ImageUpdate, + ImageDelete, + + VideoMyPage, + VideoGet, + VideoMyListByIds, + VideoGenerate, + VideoDeleteMy, + + AudioMyPage, + AudioGetMy, + AudioMyListByIds, + AudioGenerate, + AudioDeleteMy, + AudioVoices, + + KnowledgePage, + KnowledgeGet, + KnowledgeCreate, + KnowledgeUpdate, + KnowledgeDelete, + KnowledgeSimpleList, + + KnowledgeDocumentPage, + KnowledgeDocumentGet, + KnowledgeDocumentCreate, + KnowledgeDocumentCreateList, + KnowledgeDocumentUpdate, + KnowledgeDocumentUpdateStatus, + KnowledgeDocumentDelete, + + KnowledgeSegmentGet, + KnowledgeSegmentPage, + KnowledgeSegmentCreate, + KnowledgeSegmentUpdate, + KnowledgeSegmentUpdateStatus, + KnowledgeSegmentSplit, + KnowledgeSegmentGetProcessList, + KnowledgeSegmentSearch, + + MindMapGenerateStream, + MindMapDelete, + MindMapPage, + + MusicMyPage, + MusicGenerate, + MusicDeleteMy, + MusicGetMy, + MusicUpdateMy, + MusicPage, + MusicDelete, + MusicUpdate, + + WorkflowCreate, + WorkflowUpdate, + WorkflowDelete, + WorkflowGet, + WorkflowPage, + WorkflowTest, + + WriteGenerateStream, + WriteDelete, + WritePage, +} + +impl ImUrl { + pub fn get_url(&self) -> (http::Method, &str) { + match self { + // Token 相关 + ImUrl::Login => (http::Method::POST, "oauth/anyTenant/login"), + ImUrl::RefreshToken => (http::Method::POST, "oauth/anyTenant/refresh"), + ImUrl::ForgetPassword => (http::Method::PUT, "oauth/anyTenant/password"), + ImUrl::CheckToken => (http::Method::POST, "oauth/check"), + ImUrl::Logout => (http::Method::POST, "oauth/anyUser/logout"), + ImUrl::Register => (http::Method::POST, "oauth/anyTenant/registerByEmail"), + + // 系统相关 + ImUrl::GetQiniuToken => (http::Method::GET, "system/anyTenant/ossToken"), + ImUrl::InitConfig => (http::Method::GET, "system/anyTenant/config/init"), + ImUrl::StorageProvider => (http::Method::GET, "system/anyTenant/storage/provider"), + ImUrl::MapCoordTranslate => (http::Method::GET, "system/anyTenant/map/coord/translate"), + ImUrl::MapReverseGeocode => { + (http::Method::GET, "system/anyTenant/map/geocoder/reverse") + } + ImUrl::MapStatic => (http::Method::GET, "system/anyTenant/map/static"), + ImUrl::GetAssistantModelList => (http::Method::GET, "system/model/list"), + + // 验证码相关 + ImUrl::SendCaptcha => (http::Method::POST, "oauth/anyTenant/sendEmailCode"), + ImUrl::GetCaptcha => (http::Method::GET, "oauth/anyTenant/captcha"), + + // 群公告相关 + ImUrl::Announcement => (http::Method::GET, "im/room/announcement"), + ImUrl::EditAnnouncement => (http::Method::POST, "im/room/announcement/edit"), + ImUrl::DeleteAnnouncement => (http::Method::POST, "im/room/announcement/delete"), + ImUrl::PushAnnouncement => (http::Method::POST, "im/room/announcement/push"), + ImUrl::GetAnnouncementList => (http::Method::GET, "im/room/announcement/list"), + + // 群聊申请相关 + ImUrl::ApplyGroup => (http::Method::POST, "im/room/apply/group"), + + // 群聊搜索和管理 + ImUrl::SearchGroup => (http::Method::GET, "im/room/search"), + ImUrl::UpdateMyRoomInfo => (http::Method::POST, "im/room/updateMyRoomInfo"), + ImUrl::UpdateRoomInfo => (http::Method::POST, "im/room/updateRoomInfo"), + ImUrl::GroupList => (http::Method::GET, "im/room/group/list"), + ImUrl::GroupListMember => (http::Method::GET, "im/room/group/listMember"), + ImUrl::GroupDetail => (http::Method::GET, "im/room/group/detail"), + ImUrl::GroupInfo => (http::Method::GET, "im/room/group/info"), + + // 群聊管理员 + ImUrl::RevokeAdmin => (http::Method::DELETE, "im/room/group/admin"), + ImUrl::AddAdmin => (http::Method::PUT, "im/room/group/admin"), + + // 群聊成员管理 + ImUrl::ExitGroup => (http::Method::DELETE, "im/room/group/member/exit"), + ImUrl::AcceptInvite => (http::Method::POST, "im/room/group/invite/accept"), + ImUrl::InviteList => (http::Method::GET, "im/room/group/invite/list"), + ImUrl::InviteGroupMember => (http::Method::POST, "im/room/group/member"), + ImUrl::RemoveGroupMember => (http::Method::DELETE, "im/room/group/member"), + ImUrl::CreateGroup => (http::Method::POST, "im/room/group"), + + // 聊天会话相关 + ImUrl::Shield => (http::Method::POST, "im/chat/setShield"), + ImUrl::Notification => (http::Method::POST, "im/chat/notification"), + ImUrl::DeleteSession => (http::Method::DELETE, "im/chat/delete"), + ImUrl::SetSessionTop => (http::Method::POST, "im/chat/setTop"), + ImUrl::SessionDetailWithFriends => (http::Method::GET, "im/chat/contact/detail/friend"), + ImUrl::SessionDetail => (http::Method::GET, "im/chat/contact/detail"), + ImUrl::SetHide => (http::Method::POST, "im/chat/setHide"), + + // 消息已读未读 + ImUrl::GetMsgReadCount => (http::Method::GET, "im/chat/msg/read"), + ImUrl::MarkMsgRead => (http::Method::PUT, "im/chat/msg/read"), + ImUrl::SendMsg => (http::Method::POST, "im/chat/msg"), + ImUrl::GetMsgReadList => (http::Method::GET, "im/chat/msg/read/page"), + + // 好友相关 + ImUrl::ModifyFriendRemark => (http::Method::POST, "im/user/friend/updateRemark"), + ImUrl::DeleteFriend => (http::Method::DELETE, "im/user/friend"), + ImUrl::SendAddFriendRequest => (http::Method::POST, "im/room/apply/apply"), + ImUrl::HandleInvite => (http::Method::POST, "im/room/apply/handler/apply"), + ImUrl::NoticeUnReadCount => (http::Method::GET, "im/room/notice/unread"), + ImUrl::RequestNoticePage => (http::Method::GET, "im/room/notice/page"), + ImUrl::GetFriendPage => (http::Method::GET, "im/user/friend/page"), + ImUrl::GetContactList => (http::Method::GET, "im/chat/contact/list"), + ImUrl::SearchFriend => (http::Method::GET, "im/user/friend/search"), + + // 用户状态相关 + ImUrl::ChangeUserState => (http::Method::POST, "im/user/state/changeState"), + ImUrl::GetAllUserState => (http::Method::GET, "im/user/state/list"), + + // 用户信息相关 + ImUrl::UploadAvatar => (http::Method::POST, "im/user/avatar"), + ImUrl::GetEmoji => (http::Method::GET, "im/user/emoji/list"), + ImUrl::DeleteEmoji => (http::Method::DELETE, "im/user/emoji"), + ImUrl::AddEmoji => (http::Method::POST, "im/user/emoji"), + ImUrl::SetUserBadge => (http::Method::PUT, "im/user/badge"), + ImUrl::ModifyUserInfo => (http::Method::PUT, "im/user/info"), + ImUrl::GetUserInfoDetail => (http::Method::GET, "im/user/userInfo"), + ImUrl::GetBadgesBatch => (http::Method::POST, "im/user/badges/batch"), + ImUrl::GetBadgeList => (http::Method::GET, "im/user/badges"), + ImUrl::BlockUser => (http::Method::PUT, "im/user/black"), + + // 扫码登录相关 + ImUrl::GenerateQRCode => (http::Method::GET, "oauth/anyTenant/qr/generate"), + ImUrl::CheckQRStatus => (http::Method::GET, "oauth/anyTenant/qr/status/query"), + ImUrl::ScanQRCode => (http::Method::POST, "oauth/qrcode/scan"), + ImUrl::ConfirmQRCode => (http::Method::POST, "oauth/qrcode/confirm"), + + // 消息相关 + ImUrl::RecallMsg => (http::Method::PUT, "im/chat/msg/recall"), + ImUrl::MarkMsg => (http::Method::PUT, "im/chat/msg/mark"), + ImUrl::GetMsgPage => (http::Method::GET, "im/chat/msg/page"), + ImUrl::GetMsgList => (http::Method::POST, "im/chat/msg/list"), + ImUrl::GetMemberStatistic => (http::Method::GET, "im/chat/member/statistic"), + + // 朋友圈相关 + ImUrl::FeedList => (http::Method::POST, "im/feed/list"), + ImUrl::PushFeed => (http::Method::POST, "im/feed/pushFeed"), + ImUrl::GetFeedPermission => (http::Method::GET, "im/feed/getFeedPermission"), + ImUrl::EditFeed => (http::Method::POST, "im/feed/edit"), + ImUrl::DelFeed => (http::Method::POST, "im/feed/del"), + ImUrl::FeedDetail => (http::Method::GET, "im/feed/detail"), + // 朋友圈点赞相关 + ImUrl::FeedLikeToggle => (http::Method::POST, "im/feed/like/toggle"), + ImUrl::FeedLikeList => (http::Method::GET, "im/feed/like/list"), + ImUrl::FeedLikeCount => (http::Method::GET, "im/feed/like/count"), + ImUrl::FeedLikeHasLiked => (http::Method::GET, "im/feed/like/hasLiked"), + // 朋友圈评论相关 + ImUrl::FeedCommentAdd => (http::Method::POST, "im/feed/comment/add"), + ImUrl::FeedCommentDelete => (http::Method::POST, "im/feed/comment/delete"), + ImUrl::FeedCommentList => (http::Method::POST, "im/feed/comment/list"), + ImUrl::FeedCommentAll => (http::Method::GET, "im/feed/comment/all"), + ImUrl::FeedCommentCount => (http::Method::GET, "im/feed/comment/count"), + + // ai相关 - 聊天消息 + ImUrl::MessageSend => (http::Method::POST, "ai/chat/message/send"), + ImUrl::MessageSendStream => (http::Method::POST, "ai/chat/message/send-stream"), + ImUrl::MessageListByConversationId => { + (http::Method::GET, "ai/chat/message/list-by-conversation-id") + } + ImUrl::MessageDelete => (http::Method::DELETE, "ai/chat/message/delete"), + ImUrl::MessageDeleteByConversationId => ( + http::Method::DELETE, + "ai/chat/message/delete-by-conversation-id", + ), + ImUrl::MessagePage => (http::Method::GET, "ai/chat/message/page"), + ImUrl::MessageDeleteByAdmin => { + (http::Method::DELETE, "ai/chat/message/delete-by-admin") + } + ImUrl::MessageSaveGeneratedContent => { + (http::Method::POST, "ai/chat/message/save-generated-content") + } + + // AI 聊天对话 + ImUrl::ConversationCreateMy => (http::Method::POST, "ai/chat/conversation/create-my"), + ImUrl::ConversationUpdateMy => (http::Method::PUT, "ai/chat/conversation/update-my"), + ImUrl::ConversationMyList => (http::Method::GET, "ai/chat/conversation/my-list"), + ImUrl::ConversationGetMy => (http::Method::GET, "ai/chat/conversation/get-my"), + ImUrl::ConversationDeleteMy => (http::Method::DELETE, "ai/chat/conversation/delete-my"), + ImUrl::ConversationDeleteByUnpinned => ( + http::Method::DELETE, + "ai/chat/conversation/delete-by-unpinned", + ), + ImUrl::ConversationPage => (http::Method::GET, "ai/chat/conversation/page"), + ImUrl::ConversationDeleteByAdmin => { + (http::Method::DELETE, "ai/chat/conversation/delete-by-admin") + } + + // AI 模型相关接口 + ImUrl::ModelCreate => (http::Method::POST, "ai/model/create"), + ImUrl::ModelUpdate => (http::Method::PUT, "ai/model/update"), + ImUrl::ModelDelete => (http::Method::DELETE, "ai/model/delete"), + ImUrl::ModelGet => (http::Method::GET, "ai/model/get"), + ImUrl::ModelRemainingUsage => (http::Method::GET, "ai/model/get-remaining-usage"), + ImUrl::ModelPage => (http::Method::GET, "ai/model/page"), + ImUrl::ModelSimpleList => (http::Method::GET, "ai/model/simple-list"), + + // 聊天角色相关接口 + ImUrl::ChatRoleMyPage => (http::Method::GET, "ai/chat-role/my-page"), + ImUrl::ChatRoleGetMy => (http::Method::GET, "ai/chat-role/get-my"), + ImUrl::ChatRoleCreateMy => (http::Method::POST, "ai/chat-role/create-my"), + ImUrl::ChatRoleUpdateMy => (http::Method::PUT, "ai/chat-role/update-my"), + ImUrl::ChatRoleDeleteMy => (http::Method::DELETE, "ai/chat-role/delete-my"), + ImUrl::ChatRoleCategoryList => (http::Method::GET, "ai/chat-role/category-list"), + ImUrl::ChatRoleCreate => (http::Method::POST, "ai/chat-role/create"), + ImUrl::ChatRoleUpdate => (http::Method::PUT, "ai/chat-role/update"), + ImUrl::ChatRoleDelete => (http::Method::DELETE, "ai/chat-role/delete"), + ImUrl::ChatRoleGet => (http::Method::GET, "ai/chat-role/get"), + ImUrl::ChatRolePage => (http::Method::GET, "ai/chat-role/page"), + + // API 密钥相关接口 + ImUrl::ApiKeyCreate => (http::Method::POST, "ai/api-key/create"), + ImUrl::ApiKeyUpdate => (http::Method::PUT, "ai/api-key/update"), + ImUrl::ApiKeyDelete => (http::Method::DELETE, "ai/api-key/delete"), + ImUrl::ApiKeyGet => (http::Method::GET, "ai/api-key/get"), + ImUrl::ApiKeyPage => (http::Method::GET, "ai/api-key/page"), + ImUrl::ApiKeySimpleList => (http::Method::GET, "ai/api-key/simple-list"), + ImUrl::ApiKeyBalance => (http::Method::GET, "ai/api-key/balance"), + + // 平台相关接口 + ImUrl::PlatformList => (http::Method::GET, "ai/platform/list"), + ImUrl::PlatformAddModel => (http::Method::POST, "ai/platform/add-model"), + + // AI 工具相关接口 + ImUrl::ToolCreate => (http::Method::POST, "ai/tool/create"), + ImUrl::ToolUpdate => (http::Method::PUT, "ai/tool/update"), + ImUrl::ToolDelete => (http::Method::DELETE, "ai/tool/delete"), + ImUrl::ToolGet => (http::Method::GET, "ai/tool/get"), + ImUrl::ToolPage => (http::Method::GET, "ai/tool/page"), + ImUrl::ToolSimpleList => (http::Method::GET, "ai/tool/simple-list"), + + // AI 绘画 + ImUrl::ImageMyPage => (http::Method::GET, "ai/image/my-page"), + ImUrl::ImagePublicPage => (http::Method::GET, "ai/image/public-page"), + ImUrl::ImageGetMy => (http::Method::GET, "ai/image/get-my"), + ImUrl::ImageMyListByIds => (http::Method::GET, "ai/image/my-list-by-ids"), + ImUrl::ImageDraw => (http::Method::POST, "ai/image/draw"), + ImUrl::ImageDeleteMy => (http::Method::DELETE, "ai/image/delete-my"), + ImUrl::ImageMidjourneyImagine => (http::Method::POST, "ai/image/midjourney/imagine"), + ImUrl::ImageMidjourneyNotify => (http::Method::POST, "ai/image/midjourney/notify"), + ImUrl::ImageMidjourneyAction => (http::Method::POST, "ai/image/midjourney/action"), + ImUrl::ImagePage => (http::Method::GET, "ai/image/page"), + ImUrl::ImageUpdate => (http::Method::PUT, "ai/image/update"), + ImUrl::ImageDelete => (http::Method::DELETE, "ai/image/delete"), + + // AI 视频生成 + ImUrl::VideoMyPage => (http::Method::GET, "ai/video/my-page"), + ImUrl::VideoGet => (http::Method::GET, "ai/video/get"), + ImUrl::VideoMyListByIds => (http::Method::GET, "ai/video/my-list-by-ids"), + ImUrl::VideoGenerate => (http::Method::POST, "ai/video/generate"), + ImUrl::VideoDeleteMy => (http::Method::DELETE, "ai/video/delete-my"), + + // AI 音频生成 + ImUrl::AudioMyPage => (http::Method::GET, "ai/audio/my-page"), + ImUrl::AudioGetMy => (http::Method::GET, "ai/audio/get-my"), + ImUrl::AudioMyListByIds => (http::Method::GET, "ai/audio/my-list-by-ids"), + ImUrl::AudioGenerate => (http::Method::POST, "ai/audio/generate"), + ImUrl::AudioDeleteMy => (http::Method::DELETE, "ai/audio/delete-my"), + ImUrl::AudioVoices => (http::Method::GET, "ai/audio/voices"), + + // 知识库相关接口 + ImUrl::KnowledgePage => (http::Method::GET, "ai/knowledge/page"), + ImUrl::KnowledgeGet => (http::Method::GET, "ai/knowledge/get"), + ImUrl::KnowledgeCreate => (http::Method::POST, "ai/knowledge/create"), + ImUrl::KnowledgeUpdate => (http::Method::PUT, "ai/knowledge/update"), + ImUrl::KnowledgeDelete => (http::Method::DELETE, "ai/knowledge/delete"), + ImUrl::KnowledgeSimpleList => (http::Method::GET, "ai/knowledge/simple-list"), + + // 知识库文档相关接口 + ImUrl::KnowledgeDocumentPage => (http::Method::GET, "ai/knowledge/document/page"), + ImUrl::KnowledgeDocumentGet => (http::Method::GET, "ai/knowledge/document/get"), + ImUrl::KnowledgeDocumentCreate => (http::Method::POST, "ai/knowledge/document/create"), + ImUrl::KnowledgeDocumentCreateList => { + (http::Method::POST, "ai/knowledge/document/create-list") + } + ImUrl::KnowledgeDocumentUpdate => (http::Method::PUT, "ai/knowledge/document/update"), + ImUrl::KnowledgeDocumentUpdateStatus => { + (http::Method::PUT, "ai/knowledge/document/update-status") + } + ImUrl::KnowledgeDocumentDelete => { + (http::Method::DELETE, "ai/knowledge/document/delete") + } + + // 知识库文档片段相关接口 + ImUrl::KnowledgeSegmentGet => (http::Method::GET, "ai/knowledge/segment/get"), + ImUrl::KnowledgeSegmentPage => (http::Method::GET, "ai/knowledge/segment/page"), + ImUrl::KnowledgeSegmentCreate => (http::Method::POST, "ai/knowledge/segment/create"), + ImUrl::KnowledgeSegmentUpdate => (http::Method::PUT, "ai/knowledge/segment/update"), + ImUrl::KnowledgeSegmentUpdateStatus => { + (http::Method::PUT, "ai/knowledge/segment/update-status") + } + ImUrl::KnowledgeSegmentSplit => (http::Method::GET, "ai/knowledge/segment/split"), + ImUrl::KnowledgeSegmentGetProcessList => { + (http::Method::GET, "ai/knowledge/segment/get-process-list") + } + ImUrl::KnowledgeSegmentSearch => (http::Method::GET, "ai/knowledge/segment/search"), + + // AI 思维导图相关接口 + ImUrl::MindMapGenerateStream => (http::Method::POST, "ai/mind-map/generate-stream"), + ImUrl::MindMapDelete => (http::Method::DELETE, "ai/mind-map/delete"), + ImUrl::MindMapPage => (http::Method::GET, "ai/mind-map/page"), + + // 音乐 + ImUrl::MusicMyPage => (http::Method::GET, "ai/music/my-page"), + ImUrl::MusicGenerate => (http::Method::POST, "ai/music/generate"), + ImUrl::MusicDeleteMy => (http::Method::DELETE, "ai/music/delete-my"), + ImUrl::MusicGetMy => (http::Method::GET, "ai/music/get-my"), + ImUrl::MusicUpdateMy => (http::Method::POST, "ai/music/update-my"), + ImUrl::MusicPage => (http::Method::GET, "ai/music/page"), + ImUrl::MusicDelete => (http::Method::DELETE, "ai/music/delete"), + ImUrl::MusicUpdate => (http::Method::PUT, "ai/music/update"), + + // 工作流 + ImUrl::WorkflowCreate => (http::Method::POST, "ai/workflow/create"), + ImUrl::WorkflowUpdate => (http::Method::PUT, "ai/workflow/update"), + ImUrl::WorkflowDelete => (http::Method::DELETE, "ai/workflow/delete"), + ImUrl::WorkflowGet => (http::Method::GET, "ai/workflow/get"), + ImUrl::WorkflowPage => (http::Method::GET, "ai/workflow/page"), + ImUrl::WorkflowTest => (http::Method::POST, "ai/workflow/test"), + + // 写作 + ImUrl::WriteGenerateStream => (http::Method::POST, "ai/write/generate-stream"), + ImUrl::WriteDelete => (http::Method::DELETE, "ai/write/delete"), + ImUrl::WritePage => (http::Method::GET, "ai/write/page"), + + // 群成员信息 + ImUrl::GetAllUserBaseInfo => (http::Method::GET, "im/room/group/member/list"), + ImUrl::CheckEmail => (http::Method::GET, "oauth/anyTenant/checkEmail"), + + ImUrl::MergeMsg => (http::Method::POST, "im/room/mergeMessage"), + ImUrl::GetUserByIds => (http::Method::POST, "im/user/getUserByIds"), + } + } + + fn from_str(s: &str) -> Result { + match s { + // Token 相关 + "login" => Ok(ImUrl::Login), + "refreshToken" => Ok(ImUrl::RefreshToken), + "forgetPassword" => Ok(ImUrl::ForgetPassword), + "checkToken" => Ok(ImUrl::CheckToken), + "logout" => Ok(ImUrl::Logout), + "register" => Ok(ImUrl::Register), + + // 系统相关 + "getQiniuToken" => Ok(ImUrl::GetQiniuToken), + "initConfig" => Ok(ImUrl::InitConfig), + "storageProvider" => Ok(ImUrl::StorageProvider), + "mapCoordTranslate" => Ok(ImUrl::MapCoordTranslate), + "mapReverseGeocode" => Ok(ImUrl::MapReverseGeocode), + "mapStatic" => Ok(ImUrl::MapStatic), + "getAssistantModelList" => Ok(ImUrl::GetAssistantModelList), + + // 验证码相关 + "sendCaptcha" => Ok(ImUrl::SendCaptcha), + "getCaptcha" => Ok(ImUrl::GetCaptcha), + + // 群公告相关 + "announcement" => Ok(ImUrl::Announcement), + "editAnnouncement" => Ok(ImUrl::EditAnnouncement), + "deleteAnnouncement" => Ok(ImUrl::DeleteAnnouncement), + "pushAnnouncement" => Ok(ImUrl::PushAnnouncement), + "getAnnouncementList" => Ok(ImUrl::GetAnnouncementList), + + // 群聊申请相关 + "applyGroup" => Ok(ImUrl::ApplyGroup), + + // 群聊搜索和管理 + "searchGroup" => Ok(ImUrl::SearchGroup), + "updateMyRoomInfo" => Ok(ImUrl::UpdateMyRoomInfo), + "updateRoomInfo" => Ok(ImUrl::UpdateRoomInfo), + "groupList" => Ok(ImUrl::GroupList), + "groupDetail" => Ok(ImUrl::GroupDetail), + "groupInfo" => Ok(ImUrl::GroupInfo), + + // 群聊管理员 + "revokeAdmin" => Ok(ImUrl::RevokeAdmin), + "addAdmin" => Ok(ImUrl::AddAdmin), + + // 群聊成员管理 + "exitGroup" => Ok(ImUrl::ExitGroup), + "acceptInvite" => Ok(ImUrl::AcceptInvite), + "inviteList" => Ok(ImUrl::InviteList), + "inviteGroupMember" => Ok(ImUrl::InviteGroupMember), + "removeGroupMember" => Ok(ImUrl::RemoveGroupMember), + "createGroup" => Ok(ImUrl::CreateGroup), + + // 聊天会话相关 + "shield" => Ok(ImUrl::Shield), + "notification" => Ok(ImUrl::Notification), + "deleteSession" => Ok(ImUrl::DeleteSession), + "setSessionTop" => Ok(ImUrl::SetSessionTop), + "sessionDetailWithFriends" => Ok(ImUrl::SessionDetailWithFriends), + "sessionDetail" => Ok(ImUrl::SessionDetail), + + // 消息已读未读 + "getMsgReadCount" => Ok(ImUrl::GetMsgReadCount), + "getMsgReadList" => Ok(ImUrl::GetMsgReadList), + + // 好友相关 + "modifyFriendRemark" => Ok(ImUrl::ModifyFriendRemark), + "deleteFriend" => Ok(ImUrl::DeleteFriend), + "sendAddFriendRequest" => Ok(ImUrl::SendAddFriendRequest), + "handleInvite" => Ok(ImUrl::HandleInvite), + "noticeUnReadCount" => Ok(ImUrl::NoticeUnReadCount), + "requestNoticePage" => Ok(ImUrl::RequestNoticePage), + "getContactList" => Ok(ImUrl::GetContactList), + "searchFriend" => Ok(ImUrl::SearchFriend), + + // 用户状态相关 + "changeUserState" => Ok(ImUrl::ChangeUserState), + "getAllUserState" => Ok(ImUrl::GetAllUserState), + + // 用户信息相关 + "uploadAvatar" => Ok(ImUrl::UploadAvatar), + "getEmoji" => Ok(ImUrl::GetEmoji), + "deleteEmoji" => Ok(ImUrl::DeleteEmoji), + "addEmoji" => Ok(ImUrl::AddEmoji), + "setUserBadge" => Ok(ImUrl::SetUserBadge), + "ModifyUserInfo" => Ok(ImUrl::ModifyUserInfo), + "getUserInfoDetail" => Ok(ImUrl::GetUserInfoDetail), + "getBadgesBatch" => Ok(ImUrl::GetBadgesBatch), + "getBadgeList" => Ok(ImUrl::GetBadgeList), + "blockUser" => Ok(ImUrl::BlockUser), + + // 消息相关 + "recallMsg" => Ok(ImUrl::RecallMsg), + "markMsg" => Ok(ImUrl::MarkMsg), + "getMsgList" => Ok(ImUrl::GetMsgList), + "getMsgPage" => Ok(ImUrl::GetMsgPage), + "getMemberStatistic" => Ok(ImUrl::GetMemberStatistic), + + // 朋友圈相关 + "feedDetail" => Ok(ImUrl::FeedDetail), + "feedList" => Ok(ImUrl::FeedList), + "pushFeed" => Ok(ImUrl::PushFeed), + "delFeed" => Ok(ImUrl::DelFeed), + "editFeed" => Ok(ImUrl::EditFeed), + "getFeedPermission" => Ok(ImUrl::GetFeedPermission), + // 朋友圈点赞相关 + "feedLikeToggle" => Ok(ImUrl::FeedLikeToggle), + "feedLikeList" => Ok(ImUrl::FeedLikeList), + "feedLikeCount" => Ok(ImUrl::FeedLikeCount), + "feedLikeHasLiked" => Ok(ImUrl::FeedLikeHasLiked), + // 朋友圈评论相关 + "feedCommentAdd" => Ok(ImUrl::FeedCommentAdd), + "feedCommentDelete" => Ok(ImUrl::FeedCommentDelete), + "feedCommentList" => Ok(ImUrl::FeedCommentList), + "feedCommentAll" => Ok(ImUrl::FeedCommentAll), + "feedCommentCount" => Ok(ImUrl::FeedCommentCount), + + // 群成员信息 + "getAllUserBaseInfo" => Ok(ImUrl::GetAllUserBaseInfo), + "sendMsg" => Ok(ImUrl::SendMsg), + "setHide" => Ok(ImUrl::SetHide), + "getFriendPage" => Ok(ImUrl::GetFriendPage), + "markMsgRead" => Ok(ImUrl::MarkMsgRead), + "groupListMember" => Ok(ImUrl::GroupListMember), + "checkEmail" => Ok(ImUrl::CheckEmail), + "mergeMsg" => Ok(ImUrl::MergeMsg), + "getUserByIds" => Ok(ImUrl::GetUserByIds), + "generateQRCode" => Ok(ImUrl::GenerateQRCode), + "checkQRStatus" => Ok(ImUrl::CheckQRStatus), + "scanQRCode" => Ok(ImUrl::ScanQRCode), + "confirmQRCode" => Ok(ImUrl::ConfirmQRCode), + + // ================ AI 聊天消息 ================ + "messageSend" => Ok(ImUrl::MessageSend), + "messageSendStream" => Ok(ImUrl::MessageSendStream), + "messageListByConversationId" => Ok(ImUrl::MessageListByConversationId), + "messageDelete" => Ok(ImUrl::MessageDelete), + "messageDeleteByConversationId" => Ok(ImUrl::MessageDeleteByConversationId), + "messagePage" => Ok(ImUrl::MessagePage), + "messageDeleteByAdmin" => Ok(ImUrl::MessageDeleteByAdmin), + "messageSaveGeneratedContent" => Ok(ImUrl::MessageSaveGeneratedContent), + + // ================ AI 聊天对话 ================ + "conversationCreateMy" => Ok(ImUrl::ConversationCreateMy), + "conversationUpdateMy" => Ok(ImUrl::ConversationUpdateMy), + "conversationMyList" => Ok(ImUrl::ConversationMyList), + "conversationGetMy" => Ok(ImUrl::ConversationGetMy), + "conversationDeleteMy" => Ok(ImUrl::ConversationDeleteMy), + "conversationDeleteByUnpinned" => Ok(ImUrl::ConversationDeleteByUnpinned), + "conversationPage" => Ok(ImUrl::ConversationPage), + "conversationDeleteByAdmin" => Ok(ImUrl::ConversationDeleteByAdmin), + + // ================ AI 模型 ================ + "modelCreate" => Ok(ImUrl::ModelCreate), + "modelUpdate" => Ok(ImUrl::ModelUpdate), + "modelDelete" => Ok(ImUrl::ModelDelete), + "modelGet" => Ok(ImUrl::ModelGet), + "modelRemainingUsage" => Ok(ImUrl::ModelRemainingUsage), + "modelPage" => Ok(ImUrl::ModelPage), + "modelSimpleList" => Ok(ImUrl::ModelSimpleList), + + // ================ AI 聊天角色 ================ + "chatRoleMyPage" => Ok(ImUrl::ChatRoleMyPage), + "chatRoleGetMy" => Ok(ImUrl::ChatRoleGetMy), + "chatRoleCreateMy" => Ok(ImUrl::ChatRoleCreateMy), + "chatRoleUpdateMy" => Ok(ImUrl::ChatRoleUpdateMy), + "chatRoleDeleteMy" => Ok(ImUrl::ChatRoleDeleteMy), + "chatRoleCategoryList" => Ok(ImUrl::ChatRoleCategoryList), + "chatRoleCreate" => Ok(ImUrl::ChatRoleCreate), + "chatRoleUpdate" => Ok(ImUrl::ChatRoleUpdate), + "chatRoleDelete" => Ok(ImUrl::ChatRoleDelete), + "chatRoleGet" => Ok(ImUrl::ChatRoleGet), + "chatRolePage" => Ok(ImUrl::ChatRolePage), + + // ================ API 密钥 ================ + "apiKeyCreate" => Ok(ImUrl::ApiKeyCreate), + "apiKeyUpdate" => Ok(ImUrl::ApiKeyUpdate), + "apiKeyDelete" => Ok(ImUrl::ApiKeyDelete), + "apiKeyGet" => Ok(ImUrl::ApiKeyGet), + "apiKeyPage" => Ok(ImUrl::ApiKeyPage), + "apiKeySimpleList" => Ok(ImUrl::ApiKeySimpleList), + "apiKeyBalance" => Ok(ImUrl::ApiKeyBalance), + + // ================ 平台配置 ================ + "platformList" => Ok(ImUrl::PlatformList), + "platformAddModel" => Ok(ImUrl::PlatformAddModel), + + // ================ AI 工具 ================ + "toolCreate" => Ok(ImUrl::ToolCreate), + "toolUpdate" => Ok(ImUrl::ToolUpdate), + "toolDelete" => Ok(ImUrl::ToolDelete), + "toolGet" => Ok(ImUrl::ToolGet), + "toolPage" => Ok(ImUrl::ToolPage), + "toolSimpleList" => Ok(ImUrl::ToolSimpleList), + + // ================ AI 图像 ================ + "imageMyPage" => Ok(ImUrl::ImageMyPage), + "imagePublicPage" => Ok(ImUrl::ImagePublicPage), + "imageGetMy" => Ok(ImUrl::ImageGetMy), + "imageMyListByIds" => Ok(ImUrl::ImageMyListByIds), + "imageDraw" => Ok(ImUrl::ImageDraw), + "imageDeleteMy" => Ok(ImUrl::ImageDeleteMy), + "imageMidjourneyImagine" => Ok(ImUrl::ImageMidjourneyImagine), + "imageMidjourneyNotify" => Ok(ImUrl::ImageMidjourneyNotify), + "imageMidjourneyAction" => Ok(ImUrl::ImageMidjourneyAction), + "imagePage" => Ok(ImUrl::ImagePage), + "imageUpdate" => Ok(ImUrl::ImageUpdate), + "imageDelete" => Ok(ImUrl::ImageDelete), + + // ================ AI 视频生成 ================ + "videoMyPage" => Ok(ImUrl::VideoMyPage), + "videoGet" => Ok(ImUrl::VideoGet), + "videoMyListByIds" => Ok(ImUrl::VideoMyListByIds), + "videoGenerate" => Ok(ImUrl::VideoGenerate), + "videoDeleteMy" => Ok(ImUrl::VideoDeleteMy), + + // ================ AI 音频生成 ================ + "audioMyPage" => Ok(ImUrl::AudioMyPage), + "audioGetMy" => Ok(ImUrl::AudioGetMy), + "audioMyListByIds" => Ok(ImUrl::AudioMyListByIds), + "audioGenerate" => Ok(ImUrl::AudioGenerate), + "audioDeleteMy" => Ok(ImUrl::AudioDeleteMy), + "audioVoices" => Ok(ImUrl::AudioVoices), + + // ================ AI 知识库 ================ + "knowledgePage" => Ok(ImUrl::KnowledgePage), + "knowledgeGet" => Ok(ImUrl::KnowledgeGet), + "knowledgeCreate" => Ok(ImUrl::KnowledgeCreate), + "knowledgeUpdate" => Ok(ImUrl::KnowledgeUpdate), + "knowledgeDelete" => Ok(ImUrl::KnowledgeDelete), + "knowledgeSimpleList" => Ok(ImUrl::KnowledgeSimpleList), + + // ================ AI 知识库文档 ================ + "knowledgeDocumentPage" => Ok(ImUrl::KnowledgeDocumentPage), + "knowledgeDocumentGet" => Ok(ImUrl::KnowledgeDocumentGet), + "knowledgeDocumentCreate" => Ok(ImUrl::KnowledgeDocumentCreate), + "knowledgeDocumentCreateList" => Ok(ImUrl::KnowledgeDocumentCreateList), + "knowledgeDocumentUpdate" => Ok(ImUrl::KnowledgeDocumentUpdate), + "knowledgeDocumentUpdateStatus" => Ok(ImUrl::KnowledgeDocumentUpdateStatus), + "knowledgeDocumentDelete" => Ok(ImUrl::KnowledgeDocumentDelete), + + // ================ AI 知识库段落 ================ + "knowledgeSegmentGet" => Ok(ImUrl::KnowledgeSegmentGet), + "knowledgeSegmentPage" => Ok(ImUrl::KnowledgeSegmentPage), + "knowledgeSegmentCreate" => Ok(ImUrl::KnowledgeSegmentCreate), + "knowledgeSegmentUpdate" => Ok(ImUrl::KnowledgeSegmentUpdate), + "knowledgeSegmentUpdateStatus" => Ok(ImUrl::KnowledgeSegmentUpdateStatus), + "KnowledgeSegmentSplit" => Ok(ImUrl::KnowledgeSegmentSplit), + "KnowledgeSegmentGetProcessList" => Ok(ImUrl::KnowledgeSegmentGetProcessList), + "KnowledgeSegmentSearch" => Ok(ImUrl::KnowledgeSegmentSearch), + + // ================ AI 思维导图 ================ + "mindMapGenerateStream" => Ok(ImUrl::MindMapGenerateStream), + "mindMapDelete" => Ok(ImUrl::MindMapDelete), + "mindMapPage" => Ok(ImUrl::MindMapPage), + + // ================ AI 音乐 ================ + "musicMyPage" => Ok(ImUrl::MusicMyPage), + "musicGenerate" => Ok(ImUrl::MusicGenerate), + "musicDeleteMy" => Ok(ImUrl::MusicDeleteMy), + "musicGetMy" => Ok(ImUrl::MusicGetMy), + "musicUpdateMy" => Ok(ImUrl::MusicUpdateMy), + "musicPage" => Ok(ImUrl::MusicPage), + "musicDelete" => Ok(ImUrl::MusicDelete), + "musicUpdate" => Ok(ImUrl::MusicUpdate), + + // ================ AI 工作流 ================ + "workflowCreate" => Ok(ImUrl::WorkflowCreate), + "workflowUpdate" => Ok(ImUrl::WorkflowUpdate), + "workflowDelete" => Ok(ImUrl::WorkflowDelete), + "workflowGet" => Ok(ImUrl::WorkflowGet), + "workflowPage" => Ok(ImUrl::WorkflowPage), + "workflowTest" => Ok(ImUrl::WorkflowTest), + + // ================ AI 写作 ================ + "WriteGenerateStream" => Ok(ImUrl::WriteGenerateStream), + "WriteDelete" => Ok(ImUrl::WriteDelete), + "WritePage" => Ok(ImUrl::WritePage), + + // 未匹配的字符串 + _ => Err(anyhow::anyhow!("未知的URL类型: {}", s)), + } + } +} + +impl FromStr for ImUrl { + type Err = anyhow::Error; + fn from_str(s: &str) -> Result { + Self::from_str(s) + } +} + +pub trait ImRequest { + async fn login(&mut self, login_req: LoginReq) -> Result, anyhow::Error>; +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..541c3c7 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,555 @@ +// 桌面端依赖 +#[cfg(desktop)] +mod desktops; +use crate::common::files_meta::get_files_meta; +use crate::common::init::CustomInit; +#[cfg(desktop)] +use common_cmd::audio; +#[cfg(desktop)] +use common_cmd::default_window_icon; +#[cfg(target_os = "windows")] +use common_cmd::get_windows_scale_info; +#[cfg(target_os = "macos")] +use common_cmd::hide_title_bar_buttons; +#[cfg(desktop)] +use common_cmd::screenshot; +#[cfg(desktop)] +use common_cmd::set_height; +#[cfg(target_os = "macos")] +use common_cmd::set_macos_traffic_lights_spacing; +#[cfg(target_os = "macos")] +use common_cmd::set_window_level_above_menubar; +#[cfg(target_os = "macos")] +use common_cmd::set_window_movable; +#[cfg(target_os = "macos")] +use common_cmd::show_title_bar_buttons; +#[cfg(target_os = "macos")] +use desktops::app_event; +#[cfg(desktop)] +use desktops::common_cmd; +#[cfg(desktop)] +use desktops::directory_scanner; +#[cfg(desktop)] +use desktops::init; +#[cfg(desktop)] +use desktops::tray; +#[cfg(desktop)] +use desktops::video_thumbnail::get_video_thumbnail; +#[cfg(desktop)] +use desktops::window_payload::get_window_payload; +#[cfg(desktop)] +use desktops::window_payload::push_window_payload; +#[cfg(desktop)] +use directory_scanner::cancel_directory_scan; +#[cfg(desktop)] +use directory_scanner::get_directory_usage_info_with_progress; +#[cfg(desktop)] +use init::DesktopCustomInit; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use tauri_plugin_fs::FsExt; +pub mod command; +pub mod common; +pub mod configuration; +pub mod error; +mod im_request_client; +pub mod pojo; +pub mod repository; +pub mod timeout_config; +pub mod utils; +mod vo; +pub mod websocket; +#[cfg(target_os = "ios")] +mod webview_helper; + +use crate::command::app_state_command::is_app_state_ready; +use crate::command::request_command::im_request_command; +use crate::command::request_command::login_command; +use crate::command::room_member_command::cursor_page_room_members; +use crate::command::room_member_command::get_room_members; +use crate::command::room_member_command::page_room; +use crate::command::room_member_command::update_my_room_info; +use crate::command::setting_command::get_settings; +use crate::command::setting_command::update_settings; +use crate::command::user_command::remove_tokens; +use crate::configuration::Settings; +use crate::configuration::get_configuration; +use crate::error::CommonError; +use sea_orm::DatabaseConnection; +use serde::Deserialize; +use serde::Serialize; + +// 移动端依赖 +#[cfg(mobile)] +mod mobiles; +#[cfg(target_os = "ios")] +use mobiles::ios::badge::request_ios_badge_authorization; +#[cfg(target_os = "ios")] +use mobiles::ios::badge::set_ios_badge; +#[cfg(target_os = "ios")] +use mobiles::ios::trigger_haptic_feedback; +#[cfg(mobile)] +use mobiles::splash; + +#[derive(Debug)] +pub struct AppData { + db_conn: Arc>, + user_info: Arc>, + pub rc: Arc>, + pub config: Arc>, + frontend_task: Mutex, + backend_task: Mutex, + /// 限制对 SQLite 的写入并发,避免 database is locked + pub write_lock: Arc>, + /// 记录正在进行的 AI 流式任务 + pub stream_tasks: Arc>>>, +} + +pub(crate) static APP_STATE_READY: AtomicBool = AtomicBool::new(false); + +use crate::command::chat_history_command::query_chat_history; +use crate::command::contact_command::hide_contact_command; +use crate::command::contact_command::list_contacts_command; +use crate::command::database_command::switch_user_database; +use crate::command::file_manager_command::debug_message_stats; +use crate::command::file_manager_command::get_navigation_items; +use crate::command::file_manager_command::query_files; +use crate::command::message_command::delete_message; +use crate::command::message_command::delete_room_messages; +use crate::command::message_command::page_msg; +use crate::command::message_command::save_msg; +use crate::command::message_command::send_msg; +use crate::command::message_command::sync_messages; +use crate::command::message_command::update_message_recall_status; +use crate::command::message_mark_command::save_message_mark; +use crate::command::oauth_command::OauthServerState; +use crate::command::oauth_command::start_oauth_server; + +use tauri::AppHandle; +use tauri::Emitter; +#[cfg(desktop)] +use tauri::Listener; +use tauri::Manager; +use tokio::sync::Mutex; +use tokio::sync::RwLock; + +pub fn run() { + #[cfg(desktop)] + { + if let Err(e) = setup_desktop() { + tracing::error!("Failed to setup desktop application: {}", e); + std::process::exit(1); + } + } + #[cfg(mobile)] + { + setup_mobile(); + } +} + +#[cfg(desktop)] +fn setup_desktop() -> Result<(), CommonError> { + // 创建一个缓存实例 + // let cache: Cache = Cache::builder() + // // Time to idle (TTI): 30 minutes + // .time_to_idle(Duration::from_secs(30 * 60)) + // // Create the cache. + // .build(); + tauri::Builder::default() + .init_plugin() + .init_webwindow_event() + .init_window_event() + .setup(move |app| { + common_setup(app.handle().clone())?; + Ok(()) + }) + .invoke_handler(get_invoke_handlers()) + .build(tauri::generate_context!()) + .map_err(|e| { + CommonError::RequestError(format!("Failed to build tauri application: {}", e)) + })? + .run(|app_handle, event| { + #[cfg(target_os = "macos")] + app_event::handle_app_event(&app_handle, event); + #[cfg(not(target_os = "macos"))] + { + let _ = (app_handle, event); + } + }); + Ok(()) +} + +// 异步初始化应用数据 +async fn initialize_app_data( + app_handle: tauri::AppHandle, +) -> Result< + ( + Arc>, + Arc>, + Arc>, + Arc>, + ), + CommonError, +> { + use migration::Migrator; + use migration::MigratorTrait; + use tracing::info; + + // 加载配置 + let configuration = + Arc::new(Mutex::new(get_configuration(&app_handle).map_err(|e| { + anyhow::anyhow!("Failed to load configuration: {}", e) + })?)); + + // 初始化数据库连接 + let db: Arc> = Arc::new(RwLock::new( + configuration + .lock() + .await + .database + .connection_string(&app_handle, None) + .await?, + )); + + // 数据库迁移 + match Migrator::up(&*db.read().await, None).await { + Ok(_) => { + info!("Database migration completed"); + } + Err(e) => { + eprintln!("Warning: Database migration failed: {}", e); + } + } + + let rc: im_request_client::ImRequestClient = im_request_client::ImRequestClient::new( + configuration.lock().await.backend.base_url.clone(), + ) + .map_err(|e| anyhow::anyhow!("Failed to create request client: {}", e))?; + + // 创建用户信息 + let user_info = UserInfo { + token: Default::default(), + refresh_token: Default::default(), + uid: Default::default(), + }; + let user_info = Arc::new(Mutex::new(user_info)); + + Ok((db, user_info, Arc::new(Mutex::new(rc)), configuration)) +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct UserInfo { + pub token: String, + pub refresh_token: String, + pub uid: String, +} + +pub async fn build_request_client() -> Result { + let client = reqwest::Client::builder() + .build() + .map_err(|e| anyhow::anyhow!("Reqwest client error: {}", e))?; + Ok(client) +} + +/// 处理退出登录时的窗口管理逻辑 +/// +/// 该函数会: +/// - 关闭除 login/tray 外的大部分窗口 +/// - 隐藏但保留 capture/checkupdate 窗口 +/// - 优雅地处理窗口关闭过程中的错误 +#[cfg(desktop)] +pub async fn handle_logout_windows(app_handle: &tauri::AppHandle) { + tracing::info!("[LOGOUT] Starting to close windows and preserve capture/checkupdate windows"); + + let all_windows = app_handle.webview_windows(); + tracing::info!("[LOGOUT] Found {} windows", all_windows.len()); + + // 收集需要关闭的窗口和需要隐藏的窗口 + let mut windows_to_close = Vec::new(); + let mut windows_to_hide = Vec::new(); + + for (label, window) in all_windows { + match label.as_str() { + // 这些窗口完全不处理 + "login" | "tray" => { + tracing::info!("[LOGOUT] Skipping window: {}", label); + } + // 这些窗口只隐藏,不销毁 + "capture" | "checkupdate" => { + tracing::info!("[LOGOUT] Marking window for preservation: {}", label); + windows_to_hide.push((label, window)); + } + // 其他窗口需要关闭 + _ => { + tracing::info!("[LOGOUT] Marking window for closure: {}", label); + windows_to_close.push((label, window)); + } + } + } + + // 先隐藏需要保持的窗口 + for (label, window) in windows_to_hide { + tracing::info!("[LOGOUT] Hiding window (preserving): {}", label); + if let Err(e) = window.hide() { + tracing::warn!("[LOGOUT] Failed to hide window {}: {}", label, e); + } + } + + // 逐个关闭窗口,添加小延迟以避免并发关闭导致的错误 + for (label, window) in windows_to_close { + tracing::info!("[LOGOUT] Closing window: {}", label); + + // 先隐藏窗口,减少用户感知的延迟 + let _ = window.hide(); + + // 添加小延迟,让窗口有时间处理正在进行的操作 + // tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + + match window.destroy() { + Ok(_) => { + tracing::info!("[LOGOUT] Successfully closed window: {}", label); + } + Err(error) => { + // 检查窗口是否还存在 + if app_handle.get_webview_window(&label).is_none() { + tracing::info!( + "[LOGOUT] Window {} no longer exists, skipping closure", + label + ); + } else { + tracing::warn!( + "[LOGOUT] Warning when closing window {}: {} (this is usually normal)", + label, + error + ); + } + } + } + } + + tracing::info!( + "[LOGOUT] Logout completed - windows closed and capture/checkupdate windows preserved" + ); +} + +// 设置登出事件监听器 +#[cfg(desktop)] +fn setup_logout_listener(app_handle: tauri::AppHandle) { + let app_handle_clone = app_handle.clone(); + app_handle.listen("logout", move |_event| { + let app_handle = app_handle_clone.clone(); + tauri::async_runtime::spawn(async move { + handle_logout_windows(&app_handle).await; + }); + }); +} + +#[cfg(mobile)] +#[cfg_attr(mobile, tauri::mobile_entry_point)] +fn setup_mobile() { + splash::show(); + // 创建一个缓存实例 + // let cache: Cache = Cache::builder() + // // Time to idle (TTI): 30 minutes + // .time_to_idle(Duration::from_secs(30 * 60)) + // // Create the cache. + // .build(); + + if let Err(e) = tauri::Builder::default() + .init_plugin() + .setup(move |app| { + let app_handle = app.handle().clone(); + #[cfg(target_os = "ios")] + { + if let Some(webview_window) = app_handle.get_webview_window("mobile-home") { + webview_helper::initialize_keyboard_adjustment(&webview_window); + } else { + tracing::warn!("Mobile home webview window not found during setup"); + } + } + common_setup(app_handle)?; + tracing::info!("Mobile application setup completed successfully"); + Ok(()) + }) + .invoke_handler(get_invoke_handlers()) + .run(tauri::generate_context!()) + { + tracing::log::error!("Failed to run mobile application: {}", e); + std::process::exit(1); + } +} + +// 公共的 setup 函数 +fn common_setup(app_handle: AppHandle) -> Result<(), Box> { + let scope = app_handle.fs_scope(); + if let Err(e) = scope.allow_directory("configuration", false) { + tracing::warn!("Failed to allow configuration directory: {}", e); + } + + #[cfg(desktop)] + setup_logout_listener(app_handle.clone()); + + // 异步初始化应用数据,避免阻塞主线程 + match tauri::async_runtime::block_on(initialize_app_data(app_handle.clone())) { + Ok((db, user_info, rc, settings)) => { + // 使用 manage 方法在运行时添加状态 + app_handle.manage(AppData { + db_conn: db.clone(), + user_info: user_info.clone(), + rc: rc, + config: settings, + frontend_task: Mutex::new(false), + // 后端任务默认完成 + backend_task: Mutex::new(true), + write_lock: Arc::new(Mutex::new(())), + stream_tasks: Arc::new(Mutex::new(std::collections::HashMap::new())), + }); + app_handle.manage(OauthServerState::default()); + APP_STATE_READY.store(true, Ordering::SeqCst); + if let Err(e) = app_handle.emit("app-state-ready", ()) { + tracing::warn!("Failed to emit app-state-ready event: {}", e); + } + } + Err(e) => { + tracing::error!("Failed to initialize application data: {}", e); + return Err(format!("Failed to initialize app data: {}", e).into()); + } + } + + #[cfg(desktop)] + tray::create_tray(&app_handle)?; + Ok(()) +} + +// 公共的命令处理器函数 +fn get_invoke_handlers() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static +{ + use crate::command::ai_command::ai_message_cancel_stream; + use crate::command::ai_command::ai_message_send_stream; + use crate::command::markdown_command::get_readme_html; + use crate::command::markdown_command::parse_markdown; + #[cfg(mobile)] + use crate::command::set_complete; + use crate::command::upload_command::qiniu_upload_resumable; + use crate::command::upload_command::upload_file_put; + use crate::command::user_command::get_user_tokens; + use crate::command::user_command::save_user_info; + use crate::command::user_command::update_token; + use crate::command::user_command::update_user_last_opt_time; + #[cfg(target_os = "ios")] + use crate::mobiles::keyboard::set_webview_keyboard_adjustment; + #[cfg(mobile)] + use crate::mobiles::splash::hide_splash_screen; + use crate::websocket::commands::ws_disconnect; + use crate::websocket::commands::ws_force_reconnect; + use crate::websocket::commands::ws_get_app_background_state; + use crate::websocket::commands::ws_get_health; + use crate::websocket::commands::ws_get_state; + use crate::websocket::commands::ws_init_connection; + use crate::websocket::commands::ws_is_connected; + use crate::websocket::commands::ws_send_message; + use crate::websocket::commands::ws_set_app_background_state; + use crate::websocket::commands::ws_update_config; + + tauri::generate_handler![ + // 桌面端特定命令 + #[cfg(desktop)] + default_window_icon, + #[cfg(desktop)] + screenshot, + #[cfg(desktop)] + audio, + #[cfg(desktop)] + set_height, + #[cfg(desktop)] + get_video_thumbnail, + #[cfg(target_os = "macos")] + hide_title_bar_buttons, + #[cfg(target_os = "macos")] + show_title_bar_buttons, + #[cfg(target_os = "macos")] + set_macos_traffic_lights_spacing, + #[cfg(target_os = "macos")] + set_window_level_above_menubar, + #[cfg(target_os = "macos")] + set_window_movable, + #[cfg(desktop)] + push_window_payload, + #[cfg(desktop)] + get_window_payload, + get_files_meta, + #[cfg(desktop)] + get_directory_usage_info_with_progress, + #[cfg(desktop)] + cancel_directory_scan, + #[cfg(target_os = "windows")] + get_windows_scale_info, + // 通用命令(桌面端和移动端都支持) + save_user_info, + get_user_tokens, + update_token, + remove_tokens, + update_user_last_opt_time, + page_room, + get_room_members, + update_my_room_info, + cursor_page_room_members, + list_contacts_command, + hide_contact_command, + page_msg, + sync_messages, + send_msg, + save_msg, + delete_message, + delete_room_messages, + update_message_recall_status, + save_message_mark, + // 聊天历史相关命令 + query_chat_history, + // 文件管理相关命令 + query_files, + get_navigation_items, + debug_message_stats, + // WebSocket 相关命令 + ws_init_connection, + ws_disconnect, + ws_send_message, + ws_get_state, + ws_get_health, + ws_force_reconnect, + ws_update_config, + ws_is_connected, + ws_set_app_background_state, + ws_get_app_background_state, + login_command, + im_request_command, + get_settings, + update_settings, + // AI 相关命令 + ai_message_send_stream, + ai_message_cancel_stream, + // OAuth + start_oauth_server, + // Markdown 相关命令 + parse_markdown, + get_readme_html, + upload_file_put, + qiniu_upload_resumable, + #[cfg(mobile)] + set_complete, + #[cfg(mobile)] + hide_splash_screen, + #[cfg(target_os = "ios")] + set_ios_badge, + #[cfg(target_os = "ios")] + trigger_haptic_feedback, + #[cfg(target_os = "ios")] + request_ios_badge_authorization, + #[cfg(target_os = "ios")] + set_webview_keyboard_adjustment, + is_app_state_ready, + switch_user_database, + ] +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..a4ec4ae --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,21 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use dotenv::dotenv; + +#[cfg(target_os = "linux")] +use hula_app_lib::utils::linux_runtime_guard as runtime_guard; +#[cfg(target_os = "macos")] +use hula_app_lib::utils::macos_runtime_guard as runtime_guard; +#[cfg(target_os = "windows")] +use hula_app_lib::utils::win_runtime_guard as runtime_guard; + +fn main() -> std::io::Result<()> { + dotenv().ok(); + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + { + runtime_guard::apply_runtime_guards(); + } + hula_app_lib::run(); + Ok(()) +} diff --git a/src-tauri/src/mobiles/init.rs b/src-tauri/src/mobiles/init.rs new file mode 100644 index 0000000..d500935 --- /dev/null +++ b/src-tauri/src/mobiles/init.rs @@ -0,0 +1,18 @@ +use crate::common::init::{CustomInit, init_common_plugins}; +use tauri::Runtime; + +impl CustomInit for tauri::Builder { + // 初始化插件 + fn init_plugin(self) -> Self { + let builder = init_common_plugins(self); + + // 移动端特有的插件 + #[cfg(mobile)] + let builder = builder + .plugin(tauri_plugin_safe_area_insets::init()) + .plugin(tauri_plugin_hula::init()) + .plugin(tauri_plugin_barcode_scanner::init()); + + builder + } +} diff --git a/src-tauri/src/mobiles/ios/badge.rs b/src-tauri/src/mobiles/ios/badge.rs new file mode 100644 index 0000000..dd65db5 --- /dev/null +++ b/src-tauri/src/mobiles/ios/badge.rs @@ -0,0 +1,43 @@ +#[cfg(target_os = "ios")] +mod platform { + unsafe extern "C" { + fn hula_set_application_badge(count: i32); + fn hula_request_badge_authorization(); + } + + pub fn set_badge(count: Option) { + let sanitized = count.unwrap_or(0).min(99_999) as i32; + unsafe { hula_set_application_badge(sanitized) }; + } + + pub fn request_authorization() { + unsafe { hula_request_badge_authorization() }; + } +} + +#[cfg(not(target_os = "ios"))] +mod platform { + pub fn set_badge(_count: Option) { + // 非 iOS 平台无需处理 + } + + pub fn request_authorization() {} +} + +pub fn set_badge_count(count: Option) { + platform::set_badge(count); +} + +#[cfg(target_os = "ios")] +#[tauri::command] +pub fn set_ios_badge(count: Option) -> Result<(), String> { + set_badge_count(count); + Ok(()) +} + +#[cfg(target_os = "ios")] +#[tauri::command] +pub fn request_ios_badge_authorization() -> Result<(), String> { + platform::request_authorization(); + Ok(()) +} diff --git a/src-tauri/src/mobiles/ios/build_support.rs b/src-tauri/src/mobiles/ios/build_support.rs new file mode 100644 index 0000000..858a5a0 --- /dev/null +++ b/src-tauri/src/mobiles/ios/build_support.rs @@ -0,0 +1,63 @@ +use std::{env, process::Command}; + +pub(crate) fn add_clang_runtime_search_path() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("ios") { + return; + } + + println!("cargo:rerun-if-env-changed=DEVELOPER_DIR"); + println!("cargo:rerun-if-env-changed=SDKROOT"); + println!("cargo:rerun-if-env-changed=TARGET"); + + let target = env::var("TARGET").unwrap_or_default(); + let is_simulator = target.contains("apple-ios-sim") + || target.starts_with("x86_64-apple-ios") + || target.starts_with("i386-apple-ios"); + + let sdk = if is_simulator { + "iphonesimulator" + } else { + "iphoneos" + }; + + let Ok(output) = Command::new("xcrun") + .args(["--sdk", sdk, "clang", "-print-resource-dir"]) + .output() + else { + return; + }; + + if !output.status.success() { + return; + } + + let resource_dir = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if resource_dir.is_empty() { + return; + } + + let darwin_lib_dir = std::path::Path::new(&resource_dir) + .join("lib") + .join("darwin"); + if darwin_lib_dir.is_dir() { + println!( + "cargo:rustc-link-search=native={}", + darwin_lib_dir.display() + ); + } +} + +pub(crate) fn compile_ios_splash() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("ios") { + return; + } + + println!("cargo:rerun-if-changed=gen/apple/Sources/hula/SplashScreen.mm"); + println!("cargo:rerun-if-changed=gen/apple/Sources/hula/BadgeBridge.mm"); + + cc::Build::new() + .file("gen/apple/Sources/hula/SplashScreen.mm") + .file("gen/apple/Sources/hula/BadgeBridge.mm") + .flag("-fobjc-arc") + .compile("hula_ios_helpers"); +} diff --git a/src-tauri/src/mobiles/ios/mod.rs b/src-tauri/src/mobiles/ios/mod.rs new file mode 100644 index 0000000..4facd4b --- /dev/null +++ b/src-tauri/src/mobiles/ios/mod.rs @@ -0,0 +1,16 @@ +pub mod badge; + +/// 触发IOS选择震动反馈 +#[tauri::command] +#[cfg(target_os = "ios")] +pub fn trigger_haptic_feedback() -> Result<(), String> { + use objc2::MainThreadMarker; + use objc2_ui_kit::UISelectionFeedbackGenerator; + + if let Some(mtm) = MainThreadMarker::new() { + let generator = UISelectionFeedbackGenerator::new(mtm); + generator.prepare(); + generator.selectionChanged(); + }; + Ok(()) +} diff --git a/src-tauri/src/mobiles/keyboard.rs b/src-tauri/src/mobiles/keyboard.rs new file mode 100644 index 0000000..59cb0a8 --- /dev/null +++ b/src-tauri/src/mobiles/keyboard.rs @@ -0,0 +1,5 @@ +#[cfg(target_os = "ios")] +#[tauri::command] +pub fn set_webview_keyboard_adjustment(enabled: bool) { + crate::webview_helper::set_keyboard_adjustment(enabled); +} diff --git a/src-tauri/src/mobiles/mod.rs b/src-tauri/src/mobiles/mod.rs new file mode 100644 index 0000000..f3a677b --- /dev/null +++ b/src-tauri/src/mobiles/mod.rs @@ -0,0 +1,4 @@ +pub mod init; +pub mod ios; +pub mod keyboard; +pub mod splash; diff --git a/src-tauri/src/mobiles/splash.rs b/src-tauri/src/mobiles/splash.rs new file mode 100644 index 0000000..c3994db --- /dev/null +++ b/src-tauri/src/mobiles/splash.rs @@ -0,0 +1,74 @@ +#[cfg(target_os = "ios")] +mod platform { + unsafe extern "C" { + fn hula_show_splashscreen(); + fn hula_hide_splashscreen(); + } + + pub fn show() { + unsafe { hula_show_splashscreen() }; + } + + pub fn hide() { + unsafe { hula_hide_splashscreen() }; + } +} + +#[cfg(target_os = "android")] +mod platform { + use jni::{JavaVM, objects::JObject}; + + fn invoke(method: &str) -> Result<(), jni::errors::Error> { + let ctx = ndk_context::android_context(); + let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()) }?; + let mut env = vm.attach_current_thread()?; + let activity = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) }; + + // 直接调用Activity实例的方法,不使用静态方法 + let result = env.call_method(&activity, method, "()V", &[]); + + let _ = activity.into_raw(); + + result.map(|_| ()).map_err(|err| { + if env.exception_check().unwrap_or(false) { + let _ = env.exception_describe(); + let _ = env.exception_clear(); + } + err + }) + } + + pub fn show() { + if let Err(err) = invoke("show") { + tracing::error!("[Splashscreen] failed to show on Android: {}", err); + } + } + + pub fn hide() { + if let Err(err) = invoke("hide") { + tracing::error!("[Splashscreen] failed to hide on Android: {}", err); + } + } +} + +#[cfg(not(any(target_os = "ios", target_os = "android")))] +mod platform { + pub fn show() {} + pub fn hide() {} +} + +pub fn show() { + platform::show(); +} + +pub fn hide() { + platform::hide(); +} + +/// Tauri command: 隐藏启动画面(由前端调用) +#[tauri::command] +pub fn hide_splash_screen() -> Result<(), String> { + tracing::info!("hide_splash_screen called from frontend"); + hide(); + Ok(()) +} diff --git a/src-tauri/src/pojo/common.rs b/src-tauri/src/pojo/common.rs new file mode 100644 index 0000000..97724e2 --- /dev/null +++ b/src-tauri/src/pojo/common.rs @@ -0,0 +1,62 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PageParam { + pub current: u32, + pub size: u32, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CursorPageParam { + pub page_size: u32, + pub cursor: String, + pub create_id: Option, + pub create_time: Option, + pub update_time: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CursorPageResp { + pub cursor: String, + pub is_last: bool, + pub list: Option, + pub total: u64, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct LoginParam { + pub account: String, + pub password: String, + pub source: String, +} + +#[derive(Deserialize, Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ApiResult { + pub success: bool, + pub code: Option, + pub msg: Option, + pub version: Option, + pub data: Option, +} + +#[derive(serde::Deserialize, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Page { + pub records: Vec, + pub total: String, + pub size: String, +} + +#[derive(serde::Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct LoginResp { + pub uuid: Option, + pub token: String, + pub refresh_token: String, + pub client: String, +} diff --git a/src-tauri/src/pojo/mod.rs b/src-tauri/src/pojo/mod.rs new file mode 100644 index 0000000..34994bf --- /dev/null +++ b/src-tauri/src/pojo/mod.rs @@ -0,0 +1 @@ +pub mod common; diff --git a/src-tauri/src/repository/im_config_repository.rs b/src-tauri/src/repository/im_config_repository.rs new file mode 100644 index 0000000..138c3ed --- /dev/null +++ b/src-tauri/src/repository/im_config_repository.rs @@ -0,0 +1,120 @@ +use crate::error::CommonError; +use entity::im_config; +use sea_orm::QueryFilter; +use sea_orm::{ActiveModelTrait, ColumnTrait, IntoActiveModel, Set}; +use sea_orm::{DatabaseConnection, EntityTrait, TransactionTrait}; + +/// 获取配置列表 +pub async fn list_config( + db: &DatabaseConnection, + login_uid: &str, +) -> Result, CommonError> { + let list = im_config::Entity::find() + .filter(im_config::Column::LoginUid.eq(login_uid)) + .all(db) + .await?; + Ok(list) +} + +/// 根据配置键获取配置值 +pub async fn get_config_by_key( + db: &DatabaseConnection, + config_key: &str, + login_uid: &str, +) -> Result, CommonError> { + let config = im_config::Entity::find() + .filter(im_config::Column::ConfigKey.eq(config_key)) + .filter(im_config::Column::LoginUid.eq(login_uid)) + .one(db) + .await + .map_err(|e| anyhow::anyhow!("查询配置失败: {}", e))?; + Ok(config) +} + +/// 保存或更新配置 +pub async fn save_or_update_config( + db: &DatabaseConnection, + config_key: &str, + config_value: Option, + login_uid: &str, +) -> Result<(), CommonError> { + // 查找现有配置 + let existing_config = get_config_by_key(db, config_key, login_uid).await?; + + if let Some(config) = existing_config { + // 更新现有配置 + let mut config_active = config.into_active_model(); + config_active.config_value = Set(config_value); + config_active.update(db).await?; + } else { + // 创建新配置 + let new_config = im_config::Model { + id: 0, // 自增主键 + config_key: config_key.to_string(), + config_value, + login_uid: login_uid.to_string(), + }; + let mut config_active = new_config.into_active_model(); + config_active.id = sea_orm::NotSet; + config_active.insert(db).await?; + } + Ok(()) +} + +/// 批量保存配置 +pub async fn save_config_batch( + db: &DatabaseConnection, + configs: Vec, + login_uid: &str, +) -> Result<(), CommonError> { + if configs.is_empty() { + return Ok(()); + } + + // 使用事务确保操作的原子性 + let txn = db.begin().await?; + + // 先删除当前用户的现有配置数据 + im_config::Entity::delete_many() + .filter(im_config::Column::LoginUid.eq(login_uid)) + .exec(&txn) + .await + .map_err(|e| anyhow::anyhow!("删除现有配置数据失败: {}", e))?; + + // 批量插入新的配置数据 + let active_models: Vec = configs + .into_iter() + .map(|mut config| { + config.login_uid = login_uid.to_string(); + let mut active_model = config.into_active_model(); + active_model.id = sea_orm::NotSet; + active_model + }) + .collect(); + + if !active_models.is_empty() { + im_config::Entity::insert_many(active_models) + .exec(&txn) + .await + .map_err(|e| anyhow::anyhow!("批量插入配置数据失败: {}", e))?; + } + + // 提交事务 + txn.commit().await?; + Ok(()) +} + +/// 删除配置 +pub async fn delete_config( + db: &DatabaseConnection, + config_key: &str, + login_uid: &str, +) -> Result<(), CommonError> { + im_config::Entity::delete_many() + .filter(im_config::Column::ConfigKey.eq(config_key)) + .filter(im_config::Column::LoginUid.eq(login_uid)) + .exec(db) + .await + .map_err(|e| anyhow::anyhow!("删除配置失败: {}", e))?; + Ok(()) +} diff --git a/src-tauri/src/repository/im_contact_repository.rs b/src-tauri/src/repository/im_contact_repository.rs new file mode 100644 index 0000000..4b98773 --- /dev/null +++ b/src-tauri/src/repository/im_contact_repository.rs @@ -0,0 +1,97 @@ +use crate::error::CommonError; + +use entity::im_contact; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, QueryFilter, + Set, TransactionTrait, +}; +use tracing::info; + +pub async fn list_contact( + db: &DatabaseConnection, + login_uid: &str, +) -> Result, CommonError> { + info!("Querying database to get all conversations"); + let list = im_contact::Entity::find() + .filter(im_contact::Column::LoginUid.eq(login_uid)) + .all(db) + .await?; + Ok(list) +} + +/// 批量保存会话数据到本地数据库 +pub async fn save_contact_batch( + db: &DatabaseConnection, + contacts: Vec, + login_uid: &str, +) -> Result<(), CommonError> { + if contacts.is_empty() { + return Ok(()); + } + + // 使用事务确保操作的原子性 + let txn = db.begin().await?; + + // 先删除当前用户的现有会话数据 + im_contact::Entity::delete_many() + .filter(im_contact::Column::LoginUid.eq(login_uid)) + .exec(&txn) + .await + .map_err(|e| anyhow::anyhow!("Failed to delete existing contact data: {}", e))?; + + // 批量插入新的会话数据 + let active_models: Vec = contacts + .into_iter() + .map(|mut contact| { + contact.login_uid = login_uid.to_string(); + let active_model = contact.into_active_model(); + active_model + }) + .collect(); + + if !active_models.is_empty() { + im_contact::Entity::insert_many(active_models) + .exec(&txn) + .await + .map_err(|e| anyhow::anyhow!("Failed to batch insert contact data: {}", e))?; + } + + // 提交事务 + txn.commit().await?; + Ok(()) +} + +/// 更新联系人隐藏状态 +pub async fn update_contact_hide( + db: &DatabaseConnection, + room_id: &str, + hide: bool, + login_uid: &str, +) -> Result<(), CommonError> { + info!( + "Updating contact hide status: room_id={}, hide={}, login_uid={}", + room_id, hide, login_uid + ); + + // 查找对应的联系人记录 + let contact = im_contact::Entity::find() + .filter(im_contact::Column::RoomId.eq(room_id)) + .filter(im_contact::Column::LoginUid.eq(login_uid)) + .one(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to find contact record: {}", e))?; + + if let Some(contact) = contact { + let mut active_model: im_contact::ActiveModel = contact.into_active_model(); + active_model.hide = Set(Some(hide)); + + active_model + .update(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to update contact hide status: {}", e))?; + + info!("Successfully updated contact hide status"); + } + + Ok(()) +} diff --git a/src-tauri/src/repository/im_message_repository.rs b/src-tauri/src/repository/im_message_repository.rs new file mode 100644 index 0000000..557b338 --- /dev/null +++ b/src-tauri/src/repository/im_message_repository.rs @@ -0,0 +1,1011 @@ +use crate::error::CommonError; +use crate::pojo::common::{CursorPageParam, CursorPageResp}; +use chrono::Utc; +use entity::im_message; +use lazy_static::lazy_static; +use sea_orm::prelude::Expr; +use sea_orm::sea_query::{Alias, Value}; +use sea_orm::{ + ColumnTrait, Condition, ConnectionTrait, DatabaseConnection, DatabaseTransaction, EntityTrait, + IntoActiveModel, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, Statement, + TryIntoModel, +}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; + +use tracing::{debug, error, info}; + +lazy_static! { + static ref DELETED_TABLE_INITIALIZED: AtomicBool = AtomicBool::new(false); + static ref ROOM_CLEAR_TABLE_INITIALIZED: AtomicBool = AtomicBool::new(false); +} + +/// 重置表初始化标志,在切换数据库时调用 +pub fn reset_table_initialization_flags() { + DELETED_TABLE_INITIALIZED.store(false, Ordering::SeqCst); + ROOM_CLEAR_TABLE_INITIALIZED.store(false, Ordering::SeqCst); + info!("Table initialization flags have been reset"); +} + +#[derive(Clone)] +pub struct MessageWithThumbnail { + pub message: im_message::Model, + pub thumbnail_path: Option, +} + +impl MessageWithThumbnail { + pub fn new(message: im_message::Model, thumbnail_path: Option) -> Self { + Self { + message, + thumbnail_path, + } + } + + pub fn key(&self) -> (String, String) { + (self.message.id.clone(), self.message.login_uid.clone()) + } +} + +impl From for MessageWithThumbnail { + fn from(message: im_message::Model) -> Self { + Self { + message, + thumbnail_path: None, + } + } +} + +fn parse_message_id(id: &str) -> Option { + id.parse::().ok() +} + +async fn ensure_deleted_message_table(conn: &C) -> Result<(), CommonError> { + if DELETED_TABLE_INITIALIZED.load(Ordering::SeqCst) { + return Ok(()); + } + + let backend = conn.get_database_backend(); + let stmt = Statement::from_string( + backend, + "CREATE TABLE IF NOT EXISTS im_deleted_message ( + id TEXT NOT NULL, + room_id TEXT NOT NULL, + login_uid TEXT NOT NULL, + deleted_at INTEGER NOT NULL, + PRIMARY KEY (id, login_uid) + )" + .to_string(), + ); + conn.execute(stmt) + .await + .map_err(CommonError::DatabaseError)?; + DELETED_TABLE_INITIALIZED.store(true, Ordering::SeqCst); + Ok(()) +} + +async fn ensure_room_clear_table(conn: &C) -> Result<(), CommonError> { + if ROOM_CLEAR_TABLE_INITIALIZED.load(Ordering::SeqCst) { + return Ok(()); + } + + let backend = conn.get_database_backend(); + let stmt = Statement::from_string( + backend, + "CREATE TABLE IF NOT EXISTS im_room_clear_record ( + room_id TEXT NOT NULL, + login_uid TEXT NOT NULL, + cleared_at INTEGER NOT NULL, + last_cleared_msg_id TEXT, + PRIMARY KEY (room_id, login_uid) + )" + .to_string(), + ); + conn.execute(stmt) + .await + .map_err(CommonError::DatabaseError)?; + ROOM_CLEAR_TABLE_INITIALIZED.store(true, Ordering::SeqCst); + Ok(()) +} + +async fn should_skip_message_insert( + conn: &C, + message_id: &str, + room_id: &str, + login_uid: &str, + send_time: Option, +) -> Result { + ensure_deleted_message_table(conn).await?; + let backend = conn.get_database_backend(); + let deleted_stmt = Statement::from_sql_and_values( + backend, + "SELECT 1 FROM im_deleted_message WHERE id = ? AND login_uid = ? LIMIT 1", + vec![ + Value::from(message_id.to_string()), + Value::from(login_uid.to_string()), + ], + ); + if conn.query_one(deleted_stmt).await?.is_some() { + return Ok(true); + } + + ensure_room_clear_table(conn).await?; + let clear_stmt = Statement::from_sql_and_values( + backend, + "SELECT cleared_at, last_cleared_msg_id FROM im_room_clear_record WHERE room_id = ? AND login_uid = ? LIMIT 1", + vec![ + Value::from(room_id.to_string()), + Value::from(login_uid.to_string()), + ], + ); + + if let Some(row) = conn.query_one(clear_stmt).await? { + let cleared_at: i64 = row.try_get("", "cleared_at")?; + if let Some(send_time) = send_time { + if send_time <= cleared_at { + return Ok(true); + } + } + + let last_id: Option = row.try_get("", "last_cleared_msg_id")?; + if let Some(last_id) = last_id { + if let (Some(current), Ok(threshold)) = + (parse_message_id(message_id), last_id.parse::()) + { + if current <= threshold { + return Ok(true); + } + } + } + } + + Ok(false) +} + +fn collect_message_keys(messages: &[MessageWithThumbnail]) -> Vec<(String, String)> { + messages + .iter() + .map(|msg| (msg.message.id.clone(), msg.message.login_uid.clone())) + .collect() +} + +async fn fetch_thumbnail_map( + conn: &C, + keys: &[(String, String)], +) -> Result, CommonError> { + if keys.is_empty() { + return Ok(HashMap::new()); + } + + let backend = conn.get_database_backend(); + let mut sql = String::from("SELECT id, login_uid, thumbnail_path FROM im_message WHERE "); + let mut conditions = Vec::with_capacity(keys.len()); + let mut values = Vec::with_capacity(keys.len() * 2); + + for (id, login_uid) in keys { + conditions.push("(id = ? AND login_uid = ?)"); + values.push(Value::from(id.clone())); + values.push(Value::from(login_uid.clone())); + } + + sql.push_str(&conditions.join(" OR ")); + + let stmt = Statement::from_sql_and_values(backend, sql, values); + let rows = conn.query_all(stmt).await?; + + let mut map = HashMap::new(); + for row in rows { + let id: String = row.try_get("", "id")?; + let login_uid: String = row.try_get("", "login_uid")?; + let path: Option = row.try_get("", "thumbnail_path")?; + if let Some(path) = path { + map.insert((id, login_uid), path); + } + } + + Ok(map) +} + +async fn fetch_thumbnail_path( + conn: &C, + id: &str, + login_uid: &str, +) -> Result, CommonError> { + let backend = conn.get_database_backend(); + let stmt = Statement::from_sql_and_values( + backend, + "SELECT thumbnail_path FROM im_message WHERE id = ? AND login_uid = ? LIMIT 1", + vec![ + Value::from(id.to_string()), + Value::from(login_uid.to_string()), + ], + ); + + if let Some(row) = conn.query_one(stmt).await? { + let path: Option = row.try_get("", "thumbnail_path")?; + Ok(path) + } else { + Ok(None) + } +} + +async fn update_thumbnail_path( + conn: &C, + key: &(String, String), + path: Option<&str>, +) -> Result<(), CommonError> { + let backend = conn.get_database_backend(); + let value = match path { + Some(p) => Value::from(p.to_string()), + None => Value::String(None), + }; + + let stmt = Statement::from_sql_and_values( + backend, + "UPDATE im_message SET thumbnail_path = ? WHERE id = ? AND login_uid = ?", + vec![ + value, + Value::from(key.0.clone()), + Value::from(key.1.clone()), + ], + ); + + conn.execute(stmt).await?; + Ok(()) +} + +async fn enrich_models_with_thumbnails( + conn: &C, + messages: Vec, +) -> Result, CommonError> { + if messages.is_empty() { + return Ok(vec![]); + } + + let keys: Vec<(String, String)> = messages + .iter() + .map(|msg| (msg.id.clone(), msg.login_uid.clone())) + .collect(); + + let thumbnail_map = fetch_thumbnail_map(conn, &keys).await?; + let enriched = messages + .into_iter() + .map(|message| { + let key = (message.id.clone(), message.login_uid.clone()); + let path = thumbnail_map.get(&key).cloned(); + MessageWithThumbnail::new(message, path) + }) + .collect(); + + Ok(enriched) +} + +pub async fn save_all(db: &C, messages: Vec) -> Result<(), CommonError> +where + C: ConnectionTrait, +{ + // 为批量数据库操作添加超时机制 + let timeout_duration = tokio::time::Duration::from_secs(120); // 2分钟超时 + + match tokio::time::timeout(timeout_duration, save_all_internal(db, messages)).await { + Ok(result) => result, + Err(_) => { + error!("Batch save messages timeout"); + Err(CommonError::UnexpectedError(anyhow::anyhow!( + "Batch save messages operation timeout, please check database connection status" + ))) + } + } +} + +async fn save_all_internal( + db: &C, + messages: Vec, +) -> Result<(), CommonError> +where + C: ConnectionTrait, +{ + // SQLite 的变量限制通常是 999,为了安全起见,我们设置批次大小为 50 + // 考虑到需要先查询再删除再插入,减少批次大小以避免变量限制 + const BATCH_SIZE: usize = 50; + + // 如果数据量小于批次大小,直接处理 + if messages.len() <= BATCH_SIZE { + if !messages.is_empty() { + let count = messages.len(); + process_message_batch(db, messages).await?; + info!("Message processing completed, total {} items", count); + } + } else { + // 分批处理 + for (batch_index, chunk) in messages.chunks(BATCH_SIZE).enumerate() { + info!( + "Processing batch {} of messages, total {} items", + batch_index + 1, + chunk.len() + ); + + process_message_batch(db, chunk.to_vec()) + .await + .map_err(|e| { + anyhow::anyhow!( + "Failed to process batch {} of messages: {}", + batch_index + 1, + e + ) + })?; + } + + info!( + "All message batch processing completed, total {} items", + messages.len() + ); + } + Ok(()) +} + +/// 处理单批消息:检查存在性,删除已存在的,然后插入新的 +async fn process_message_batch( + db: &C, + messages: Vec, +) -> Result<(), CommonError> +where + C: ConnectionTrait, +{ + if messages.is_empty() { + return Ok(()); + } + + let mut filtered_messages = Vec::with_capacity(messages.len()); + for message in messages.into_iter() { + let skip = should_skip_message_insert( + db, + &message.message.id, + &message.message.room_id, + &message.message.login_uid, + message.message.send_time, + ) + .await?; + if !skip { + filtered_messages.push(message); + } + } + + if filtered_messages.is_empty() { + return Ok(()); + } + + let mut messages = filtered_messages; + + let message_keys = collect_message_keys(&messages); + let existing_thumbnail_map = fetch_thumbnail_map(db, &message_keys).await?; + + for message in &mut messages { + if message.thumbnail_path.is_none() { + if let Some(path) = existing_thumbnail_map.get(&message.key()) { + message.thumbnail_path = Some(path.clone()); + } + } + } + + // 查询已存在的消息 + let mut condition = sea_orm::Condition::any(); + for (id, login_uid) in &message_keys { + condition = condition.add( + sea_orm::Condition::all() + .add(im_message::Column::Id.eq(id.clone())) + .add(im_message::Column::LoginUid.eq(login_uid.clone())), + ); + } + + let existing_messages = im_message::Entity::find() + .filter(condition) + .all(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to query existing messages: {}", e))?; + + if !existing_messages.is_empty() { + let mut delete_condition = sea_orm::Condition::any(); + for msg in &existing_messages { + delete_condition = delete_condition.add( + sea_orm::Condition::all() + .add(im_message::Column::Id.eq(msg.id.clone())) + .add(im_message::Column::LoginUid.eq(msg.login_uid.clone())), + ); + } + + im_message::Entity::delete_many() + .filter(delete_condition) + .exec(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to delete existing messages: {}", e))?; + + debug!("Deleted {} existing messages", existing_messages.len()); + } + + // 插入新消息 + let active_models: Vec = messages + .iter() + .map(|message| message.message.clone().into_active_model()) + .collect(); + + im_message::Entity::insert_many(active_models) + .exec(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to batch insert messages: {}", e))?; + + for message in &messages { + update_thumbnail_path(db, &message.key(), message.thumbnail_path.as_deref()).await?; + } + + Ok(()) +} + +/// 根据房间ID进行游标分页查询消息(包含消息标记) +pub async fn cursor_page_messages( + db: &DatabaseConnection, + room_id: String, + cursor_page_param: CursorPageParam, + login_uid: &str, +) -> Result>, CommonError> { + // 查询总数 + let total = im_message::Entity::find() + .filter(im_message::Column::RoomId.eq(&room_id)) + .filter(im_message::Column::LoginUid.eq(login_uid)) + .count(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to query message count: {}", e))?; + + // 先查询消息主表,按 id 数值降序排序 + let mut message_query = im_message::Entity::find() + .filter(im_message::Column::RoomId.eq(&room_id)) + .filter(im_message::Column::LoginUid.eq(login_uid)) + .order_by_desc(Expr::col(im_message::Column::Id).cast_as(Alias::new("INTEGER"))) + .limit(cursor_page_param.page_size as u64); + + // 如果提供了游标,添加过滤条件 + if !cursor_page_param.cursor.is_empty() { + // 使用游标值过滤,获取小于该ID的记录(因为是降序排列) + message_query = message_query.filter(im_message::Column::Id.lt(&cursor_page_param.cursor)); + } + + // 先查询消息列表 + let messages = message_query + .all(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to query message list: {}", e))?; + + // 如果没有消息,直接返回空结果 + if messages.is_empty() { + return Ok(CursorPageResp { + cursor: String::new(), + is_last: true, + list: Some(vec![]), + total, + }); + } + + // 生成下一页的游标 + let next_cursor = if messages.len() < cursor_page_param.page_size as usize { + String::new() // 已经是最后一页 + } else { + messages + .last() + .map(|msg| msg.id.clone()) + .unwrap_or_default() + }; + + let is_last = messages.len() < cursor_page_param.page_size as usize; + + let enriched = enrich_models_with_thumbnails(db, messages).await?; + + Ok(CursorPageResp { + cursor: next_cursor, + is_last, + list: Some(enriched), + total, + }) +} + +/// 保存单个消息到数据库 +pub async fn save_message( + db: &DatabaseTransaction, + mut record: MessageWithThumbnail, +) -> Result { + if should_skip_message_insert( + db, + &record.message.id, + &record.message.room_id, + &record.message.login_uid, + record.message.send_time, + ) + .await? + { + return Ok(record); + } + + // 根据消息主键查找是否已存在 + let existing_message = im_message::Entity::find_by_id(( + record.message.id.clone(), + record.message.login_uid.clone(), + )) + .one(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to find message: {}", e))?; + + if record.thumbnail_path.is_none() { + record.thumbnail_path = + fetch_thumbnail_path(db, &record.message.id, &record.message.login_uid).await?; + } + + // 如果已存在,则先删除 + if existing_message.is_some() { + im_message::Entity::delete_by_id(( + record.message.id.clone(), + record.message.login_uid.clone(), + )) + .exec(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to delete existing message: {}", e))?; + } + + // 如果缺少,就填充time_block,使用统一的计算函数 + if record.message.time_block.is_none() { + if let Some(current_send_time) = record.message.send_time { + // 使用统一的 time_block 计算函数 + record.message.time_block = calculate_time_block( + db, + &record.message.room_id, + &record.message.id, + current_send_time, + &record.message.login_uid, + ) + .await?; + } + } + + let active_model = record.message.clone().into_active_model(); + im_message::Entity::insert(active_model).exec(db).await?; + update_thumbnail_path(db, &record.key(), record.thumbnail_path.as_deref()).await?; + Ok(record) +} + +pub async fn delete_message_by_id( + db: &DatabaseConnection, + message_id: &str, + login_uid: &str, +) -> Result { + let result = im_message::Entity::delete_many() + .filter(im_message::Column::Id.eq(message_id)) + .filter(im_message::Column::LoginUid.eq(login_uid)) + .exec(db) + .await?; + + Ok(result.rows_affected) +} + +pub async fn delete_messages_by_room( + db: &DatabaseConnection, + room_id: &str, + login_uid: &str, +) -> Result { + let result = im_message::Entity::delete_many() + .filter(im_message::Column::RoomId.eq(room_id)) + .filter(im_message::Column::LoginUid.eq(login_uid)) + .exec(db) + .await?; + + Ok(result.rows_affected) +} + +pub async fn get_room_max_message_id( + db: &DatabaseConnection, + room_id: &str, + login_uid: &str, +) -> Result, CommonError> { + let backend = db.get_database_backend(); + let stmt = Statement::from_sql_and_values( + backend, + "SELECT MAX(CAST(id AS INTEGER)) as max_id FROM im_message WHERE room_id = ? AND login_uid = ?", + vec![ + Value::from(room_id.to_string()), + Value::from(login_uid.to_string()), + ], + ); + + if let Some(row) = db.query_one(stmt).await? { + let max_id: Option = row.try_get("", "max_id")?; + Ok(max_id.map(|id| id.to_string())) + } else { + Ok(None) + } +} + +pub async fn get_room_id_by_message_id( + db: &DatabaseConnection, + message_id: &str, + login_uid: &str, +) -> Result, CommonError> { + let message = im_message::Entity::find_by_id((message_id.to_string(), login_uid.to_string())) + .one(db) + .await?; + + Ok(message.map(|model| model.room_id)) +} + +pub async fn record_deleted_message( + db: &DatabaseConnection, + message_id: &str, + room_id: &str, + login_uid: &str, +) -> Result<(), CommonError> { + ensure_deleted_message_table(db).await?; + let backend = db.get_database_backend(); + let stmt = Statement::from_sql_and_values( + backend, + "INSERT INTO im_deleted_message (id, room_id, login_uid, deleted_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(id, login_uid) DO UPDATE SET room_id = excluded.room_id, deleted_at = excluded.deleted_at", + vec![ + Value::from(message_id.to_string()), + Value::from(room_id.to_string()), + Value::from(login_uid.to_string()), + Value::from(Utc::now().timestamp_millis()), + ], + ); + db.execute(stmt).await.map_err(CommonError::DatabaseError)?; + Ok(()) +} + +pub async fn record_room_clear( + db: &DatabaseConnection, + room_id: &str, + login_uid: &str, + last_cleared_msg_id: Option, +) -> Result<(), CommonError> { + ensure_room_clear_table(db).await?; + let backend = db.get_database_backend(); + let stmt = Statement::from_sql_and_values( + backend, + "INSERT INTO im_room_clear_record (room_id, login_uid, cleared_at, last_cleared_msg_id) + VALUES (?, ?, ?, ?) + ON CONFLICT(room_id, login_uid) DO UPDATE SET cleared_at = excluded.cleared_at, last_cleared_msg_id = excluded.last_cleared_msg_id", + vec![ + Value::from(room_id.to_string()), + Value::from(login_uid.to_string()), + Value::from(Utc::now().timestamp_millis()), + match last_cleared_msg_id { + Some(id) => Value::from(id), + None => Value::String(None), + }, + ], + ); + db.execute(stmt).await.map_err(CommonError::DatabaseError)?; + Ok(()) +} + +/// 计算消息的 time_block +/// 判断当前消息与前一条消息的时间间隔,如果超过10分钟则返回间隔值 +/// 如果是房间的第一条消息,返回 Some(1) 表示始终显示时间 +pub async fn calculate_time_block( + db: &C, + room_id: &str, + current_msg_id: &str, + current_send_time: i64, + login_uid: &str, +) -> Result, CommonError> +where + C: ConnectionTrait, +{ + // 查找该房间的前一条消息(按发送时间排序,排除当前消息) + let last_message = im_message::Entity::find() + .filter(im_message::Column::RoomId.eq(room_id)) + .filter(im_message::Column::LoginUid.eq(login_uid)) + .filter(im_message::Column::Id.ne(current_msg_id)) + .filter(im_message::Column::SendTime.lt(current_send_time)) // 按发送时间比较 + .order_by_desc(im_message::Column::SendTime) // 按发送时间降序 + .limit(1) + .one(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to find last message: {}", e))?; + + if let Some(last_msg) = last_message { + if let Some(last_send_time) = last_msg.send_time { + let time_diff = current_send_time - last_send_time; + // 时间间隔阈值:10分钟 + const TIME_BLOCK_THRESHOLD_MS: i64 = 1000 * 60 * 10; + if time_diff >= TIME_BLOCK_THRESHOLD_MS { + return Ok(Some(time_diff)); + } + } + } else { + // 房间的第一条消息,始终显示时间 + return Ok(Some(1)); + } + + Ok(None) +} + +/// 更新消息发送状态 +pub async fn update_message_status( + db: &DatabaseConnection, + mut record: MessageWithThumbnail, + status: &str, + id: Option, + login_uid: String, +) -> Result { + let mut active_model: im_message::ActiveModel = + im_message::Entity::find_by_id((record.message.id.clone(), login_uid.clone())) + .one(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to find message: {}", e))? + .ok_or_else(|| CommonError::UnexpectedError(anyhow::anyhow!("Message not found")))? + .into_active_model(); + + // 使用统一的 time_block 计算函数 + if let Some(send_time) = record.message.send_time { + let time_block = calculate_time_block( + db, + &record.message.room_id, + &record.message.id, + send_time, + &login_uid, + ) + .await?; + + active_model.time_block = Set(time_block); + } + + active_model.send_status = Set(status.to_string()); + active_model.body = Set(record.message.body.clone()); + + let original_id = record.message.id.clone(); + + if status == "success" { + if let Some(message_id) = id { + active_model.id = Set(message_id.clone()); + record.message.id = message_id; + } else { + return Err(CommonError::RequestError( + "Message ID is None for successful status".to_string(), + )); + } + } + + im_message::Entity::update_many() + .set(active_model.clone()) + .filter(im_message::Column::Id.eq(original_id)) + .exec(db) + .await?; + + let updated_model = active_model.try_into_model()?; + record.message = updated_model; + update_thumbnail_path(db, &record.key(), record.thumbnail_path.as_deref()).await?; + Ok(record) +} + +/// 更新消息撤回状态 +pub async fn update_message_recall_status( + db: &DatabaseConnection, + message_id: &str, + message_type: u8, + message_body: &str, + login_uid: &str, +) -> Result<(), CommonError> { + info!( + "[RECALL] Updating message recall status in database, message_id: {}", + message_id + ); + + // 查找要更新的消息 + let existing_message = im_message::Entity::find() + .filter(im_message::Column::Id.eq(message_id)) + .filter(im_message::Column::LoginUid.eq(login_uid)) + .one(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to find message: {}", e))?; + + let message = existing_message + .ok_or_else(|| CommonError::UnexpectedError(anyhow::anyhow!("Message not found")))?; + + // 创建更新模型 + let mut active_model: im_message::ActiveModel = message.into_active_model(); + + // 更新消息类型和内容 + active_model.message_type = Set(Some(message_type)); + active_model.body = Set(Some(message_body.to_string())); + active_model.update_time = Set(Some(chrono::Utc::now().timestamp_millis())); + + // 执行更新 + im_message::Entity::update(active_model) + .exec(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to update message recall status: {}", e))?; + + info!( + "[RECALL] Successfully updated message recall status in database, message_id: {}", + message_id + ); + Ok(()) +} + +/// 支持消息类型筛选、关键词搜索、日期排序和分页 +pub async fn query_chat_history( + db: &DatabaseConnection, + condition: crate::command::chat_history_command::ChatHistoryQueryCondition, +) -> Result, CommonError> { + info!( + "查询聊天历史记录 - 房间: {}, 类型: {:?}, 关键词: {:?}", + condition.room_id, condition.message_type, condition.search_keyword + ); + + // 构建基础查询条件 + let mut conditions = Condition::all() + .add(im_message::Column::LoginUid.eq(&condition.login_uid)) + .add(im_message::Column::RoomId.eq(&condition.room_id)); + + // 消息类型筛选 + if let Some(ref message_types) = condition.message_type { + let type_condition = message_types + .iter() + .fold(Condition::any(), |acc, &msg_type| { + acc.add(im_message::Column::MessageType.eq(msg_type)) + }); + conditions = conditions.add(type_condition); + } + + // 关键词搜索(支持消息内容与文件名等字段) + if let Some(ref keyword) = condition.search_keyword { + let trimmed = keyword.trim(); + if !trimmed.is_empty() { + let keyword_pattern = format!("%{}%", trimmed); + let keyword_pattern_lower = format!("%{}%", trimmed.to_lowercase()); + + let mut keyword_condition = Condition::any(); + for json_path in ["$.content", "$.fileName", "$.url"] { + keyword_condition = keyword_condition.add(Expr::cust_with_values( + &format!("JSON_EXTRACT(body, '{}') LIKE ?", json_path), + [Value::from(keyword_pattern.clone())], + )); + } + + keyword_condition = keyword_condition.add(Expr::cust_with_values( + "LOWER(body) LIKE ?", + [Value::from(keyword_pattern_lower)], + )); + + conditions = conditions.add(keyword_condition); + } + } + + // 日期范围筛选 + if let Some(ref date_range) = condition.date_range { + if let Some(start_time) = date_range.start_time { + chrono::DateTime::from_timestamp_millis(start_time) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| "无效时间".to_string()); + conditions = conditions.add(im_message::Column::SendTime.gte(start_time)); + } + if let Some(end_time) = date_range.end_time { + chrono::DateTime::from_timestamp_millis(end_time) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| "无效时间".to_string()); + conditions = conditions.add(im_message::Column::SendTime.lte(end_time)); + } + } + + // 构建分页查询 + let mut query = im_message::Entity::find().filter(conditions); + + // 应用排序 + query = match condition.sort_order { + crate::command::chat_history_command::SortOrder::Asc => { + query.order_by_asc(im_message::Column::SendTime) + } + crate::command::chat_history_command::SortOrder::Desc => { + query.order_by_desc(im_message::Column::SendTime) + } + }; + + // 应用分页 + let offset = (condition.pagination.page.saturating_sub(1)) * condition.pagination.page_size; + query = query + .offset(offset as u64) + .limit(condition.pagination.page_size as u64); + + // 执行查询 + let messages = query + .all(db) + .await + .map_err(|e| anyhow::anyhow!("查询聊天历史记录失败: {}", e))?; + + enrich_models_with_thumbnails(db, messages).await +} + +/// 专门用于文件管理的查询函数,支持跨房间查询文件类型消息 +pub async fn query_file_messages( + db: &DatabaseConnection, + login_uid: &str, + room_id: Option<&str>, + message_types: Option<&[u8]>, + search_keyword: Option<&str>, + page: u32, + page_size: u32, +) -> Result, CommonError> { + // 构建基础查询条件 + let mut conditions = Condition::all().add(im_message::Column::LoginUid.eq(login_uid)); + + // 房间ID筛选(如果提供) + if let Some(room_id) = room_id { + conditions = conditions.add(im_message::Column::RoomId.eq(room_id)); + } + + // 消息类型筛选 + if let Some(message_types) = message_types { + let type_condition = message_types + .iter() + .fold(Condition::any(), |acc, &msg_type| { + acc.add(im_message::Column::MessageType.eq(msg_type)) + }); + conditions = conditions.add(type_condition); + } + + // 关键词搜索(搜索文件名、来源等关键信息) + if let Some(keyword) = search_keyword { + let trimmed_keyword = keyword.trim(); + if !trimmed_keyword.is_empty() { + let keyword_lower = trimmed_keyword.to_lowercase(); + let keyword_pattern = format!("%{}%", keyword_lower); + use sea_orm::sea_query::Value; + + let json_paths = [ + "$.fileName", + "$.file_name", + "$.name", + "$.originalFileName", + "$.originalName", + "$.original_name", + "$.title", + "$.fileTitle", + "$.url", + "$.downloadUrl", + "$.content", + ]; + + let mut keyword_condition = Condition::any(); + + for path in json_paths { + let expr = format!("LOWER(JSON_EXTRACT(body, '{}')) LIKE ?", path); + keyword_condition = keyword_condition.add(Expr::cust_with_values( + expr, + [Value::from(keyword_pattern.clone())], + )); + } + + // 兼容 JSON 中缺少字段时直接在 body 中检索 + keyword_condition = keyword_condition.add(Expr::cust_with_values( + "LOWER(body) LIKE ?", + [Value::from(keyword_pattern.clone())], + )); + + conditions = conditions.add(keyword_condition); + } + } + + // 构建分页查询 + let mut query = im_message::Entity::find().filter(conditions); + + // 按发送时间降序排序 + query = query.order_by_desc(im_message::Column::SendTime); + + // 应用分页 + let offset = (page.saturating_sub(1)) * page_size; + query = query.offset(offset as u64).limit(page_size as u64); + + // 执行查询 + let messages = query + .all(db) + .await + .map_err(|e| anyhow::anyhow!("查询文件消息失败: {}", e))?; + + enrich_models_with_thumbnails(db, messages).await +} diff --git a/src-tauri/src/repository/im_room_member_repository.rs b/src-tauri/src/repository/im_room_member_repository.rs new file mode 100644 index 0000000..074ba19 --- /dev/null +++ b/src-tauri/src/repository/im_room_member_repository.rs @@ -0,0 +1,312 @@ +use chrono; +use entity::{im_room, im_room_member}; +use sea_orm::EntityTrait; +use sea_orm::IntoActiveModel; +use sea_orm::PaginatorTrait; +use sea_orm::QuerySelect; +use sea_orm::TransactionTrait; +use sea_orm::{ActiveModelTrait, Set}; +use sea_orm::{ColumnTrait, DatabaseConnection, QueryFilter, QueryOrder}; +use tracing::{debug, info}; + +use crate::pojo::common::{CursorPageParam, CursorPageResp}; +use crate::{ + error::CommonError, + pojo::common::{Page, PageParam}, +}; + +pub async fn cursor_page_room_members( + db: &DatabaseConnection, + room_id: String, + cursor_page_param: CursorPageParam, + login_uid: &str, +) -> Result>, CommonError> { + // 查询总数 + let total = im_room_member::Entity::find() + .filter(im_room_member::Column::RoomId.eq(&room_id)) + .filter(im_room_member::Column::LoginUid.eq(login_uid)) + .count(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to query room member count: {}", e))?; + + let mut query = im_room_member::Entity::find() + .filter(im_room_member::Column::RoomId.eq(room_id)) + .filter(im_room_member::Column::LoginUid.eq(login_uid)) + .order_by_desc(im_room_member::Column::LastOptTime) + .limit(cursor_page_param.page_size as u64); + + // 如果提供了游标,解析游标值并添加过滤条件 + if !cursor_page_param.cursor.is_empty() { + // 从 cursor 中根据'_'分割最后一个字符串转为 i64 + let cursor_parts: Vec<&str> = cursor_page_param.cursor.split('_').collect(); + if let Some(last_part) = cursor_parts.last() { + if let Ok(cursor_value) = last_part.parse::() { + // 使用游标值过滤,获取小于该值的记录(因为是降序排列) + query = query.filter(im_room_member::Column::LastOptTime.lt(cursor_value)); + } + } + } + + let members = query + .all(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to query room members: {}", e))?; + + // 构建下一页游标和判断是否为最后一页 + let (next_cursor, is_last) = if members.len() < cursor_page_param.page_size as usize { + // 如果返回的记录数少于请求的页面大小,说明是最后一页 + (String::new(), true) + } else if let Some(last_member) = members.last() { + // 使用最后一条记录的 last_opt_time 构建下一页游标 + let next_cursor = format!("{}", last_member.last_opt_time); + (next_cursor, false) + } else { + (String::new(), true) + }; + + Ok(CursorPageResp { + cursor: next_cursor, + is_last, + list: Some(members), + total, + }) +} + +pub async fn get_room_page( + page_param: PageParam, + db: &DatabaseConnection, + login_uid: &str, +) -> Result, CommonError> { + // 计算偏移量 + let offset = (page_param.current - 1) * page_param.size; + + // 查询总数 + let total = im_room::Entity::find() + .filter(im_room::Column::LoginUid.eq(login_uid)) + .count(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to query room count: {}", e))?; + + // 分页查询数据 + let records = im_room::Entity::find() + .filter(im_room::Column::LoginUid.eq(login_uid)) + .offset(offset as u64) + .limit(page_param.size as u64) + .all(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to query room data: {}", e))?; + + Ok(Page { + records, + total: total.to_string(), + size: page_param.size.to_string(), + }) +} + +pub async fn save_room_batch( + db: &DatabaseConnection, + room_members: Vec, + login_uid: &str, +) -> Result<(), CommonError> { + use tokio::time::{Duration, timeout}; + + // 添加超时保护,避免长时间锁定 + let operation = async { + // 使用事务确保批量操作的原子性 + let txn = db.begin().await?; + + for mut member in room_members { + // 设置 login_uid + member.login_uid = login_uid.to_string(); + + // 检查记录是否已存在 + let existing = im_room::Entity::find() + .filter(im_room::Column::Id.eq(member.id.clone())) + .filter(im_room::Column::LoginUid.eq(member.login_uid.clone())) + .one(&txn) + .await?; + + if existing.is_none() { + // 如果记录不存在,执行插入 + let member_active = member.into_active_model(); + member_active + .insert(&txn) + .await + .map_err(|e| anyhow::anyhow!("Failed to insert room record: {}", e))?; + } + // 如果记录已存在,跳过插入 + } + + // 提交事务 + txn.commit() + .await + .map_err(|e| anyhow::anyhow!("Failed to commit room batch transaction: {}", e))?; + + Ok(()) + }; + + // 设置30秒超时 + match timeout(Duration::from_secs(30), operation).await { + Ok(result) => result.map_err(CommonError::UnexpectedError), + Err(_) => Err(CommonError::UnexpectedError(anyhow::anyhow!( + "Room batch operation timed out after 30 seconds" + ))), + } +} + +pub async fn get_room_members_by_room_id( + room_id: &str, + db: &DatabaseConnection, + login_uid: &str, +) -> Result, CommonError> { + let members = im_room_member::Entity::find() + .filter(im_room_member::Column::RoomId.eq(room_id)) + .filter(im_room_member::Column::LoginUid.eq(login_uid)) + .all(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to query room members: {}", e))?; + + Ok(members) +} + +pub async fn save_room_member_batch( + db: &DatabaseConnection, + room_members: Vec, + room_id: i64, + login_uid: &str, +) -> Result<(), CommonError> { + use tokio::time::{Duration, timeout}; + + // 添加超时保护,避免长时间锁定 + let operation = async { + // 使用事务确保操作的原子性 + let txn: sea_orm::DatabaseTransaction = db.begin().await?; + + // 先删除现有数据(使用更高效的方式) + let delete_result = im_room_member::Entity::delete_many() + .filter(im_room_member::Column::RoomId.eq(room_id.to_string())) + .filter(im_room_member::Column::LoginUid.eq(login_uid)) + .exec(&txn) + .await; + + match delete_result { + Ok(_) => { + debug!( + "Successfully deleted existing room members for room_id: {}, login_uid: {}", + room_id, login_uid + ); + } + Err(e) => { + // 如果删除失败,回滚事务 + let _ = txn.rollback().await; + return Err(anyhow::anyhow!( + "Failed to delete existing room members: {}", + e + )); + } + } + + // 保存新的room_members数据(批量插入) + if !room_members.is_empty() { + let room_members_count = room_members.len(); // 在移动之前保存长度 + let active_models: Vec = room_members + .into_iter() + .map(|member| { + let mut member_active = member.into_active_model(); + member_active.login_uid = Set(login_uid.to_string()); + member_active.room_id = Set(Some(room_id.to_string())); + member_active + }) + .collect(); + + let insert_result = im_room_member::Entity::insert_many(active_models) + .exec(&txn) + .await; + + match insert_result { + Ok(_) => { + debug!( + "Successfully inserted {} room members for room_id: {}", + room_members_count, room_id + ); + } + Err(e) => { + // 如果插入失败,回滚事务 + let _ = txn.rollback().await; + return Err(anyhow::anyhow!("Failed to insert room members: {}", e)); + } + } + } + + // 提交事务 + txn.commit() + .await + .map_err(|e| anyhow::anyhow!("Failed to commit transaction: {}", e))?; + + Ok(()) + }; + + // 设置5秒超时 + match timeout(Duration::from_secs(5), operation).await { + Ok(result) => result.map_err(CommonError::UnexpectedError), + Err(_) => Err(CommonError::UnexpectedError(anyhow::anyhow!( + "Database operation timed out after 5 seconds" + ))), + } +} + +pub async fn update_my_room_info( + db: &DatabaseConnection, + my_name: &str, + room_id: &str, + uid: &str, + login_uid: &str, +) -> Result<(), CommonError> { + // 根据 room_id、uid 和 login_uid 查找房间成员记录 + let member = im_room_member::Entity::find() + .filter(im_room_member::Column::RoomId.eq(room_id)) + .filter(im_room_member::Column::Uid.eq(uid)) + .filter(im_room_member::Column::LoginUid.eq(login_uid)) + .one(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to query room member record: {}", e))?; + + if let Some(member) = member { + debug!("Found room member record: {:?}", member); + // 如果找到记录,更新 my_name 字段 + let mut member_active = member.into_active_model(); + member_active.my_name = Set(Some(my_name.to_string())); + + member_active + .update(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to update room member record: {}", e))?; + info!("Successfully updated member room member information"); + Ok(()) + } else { + // 如果没有找到记录,创建一个新记录(仅包含必要字段) + debug!( + "Room member record not found, creating new record for room_id: {}, uid: {}", + room_id, uid + ); + + let new_member = im_room_member::ActiveModel { + id: Set(format!("{}_{}", room_id, uid)), // 使用 room_id + uid 作为主键 + room_id: Set(Some(room_id.to_string())), + uid: Set(Some(uid.to_string())), + my_name: Set(Some(my_name.to_string())), + login_uid: Set(login_uid.to_string()), + last_opt_time: Set(chrono::Utc::now().timestamp()), + name: Set(String::new()), // 设置默认空值,实际名称会在下次同步时更新 + ..Default::default() + }; + + new_member + .insert(db) + .await + .map_err(|e| anyhow::anyhow!("Failed to insert new room member record: {}", e))?; + + info!("Successfully created new room member record with my_name"); + Ok(()) + } +} diff --git a/src-tauri/src/repository/im_user_repository.rs b/src-tauri/src/repository/im_user_repository.rs new file mode 100644 index 0000000..c93fb6e --- /dev/null +++ b/src-tauri/src/repository/im_user_repository.rs @@ -0,0 +1,145 @@ +use crate::error::CommonError; +use entity::im_user; +use entity::prelude::ImUserEntity; +use sea_orm::{ActiveValue::Set, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter}; +use tracing::{error, info}; + +/// 更新用户的 is_init 状态 +pub async fn update_user_init_status( + db: &C, + login_uid: &str, + is_init: bool, +) -> Result<(), CommonError> +where + C: ConnectionTrait, +{ + let user_update = im_user::ActiveModel { + id: Set(login_uid.to_string()), + is_init: Set(is_init), + ..Default::default() + }; + + match ImUserEntity::update(user_update).exec(db).await { + Ok(_) => { + info!("User {} is_init status updated to {}", login_uid, is_init); + Ok(()) + } + Err(e) => { + error!("Failed to update user is_init status: {:?}", e); + Err(e.into()) + } + } +} + +/// 保存或更新用户的 token 信息 +pub async fn save_user_tokens( + db: &C, + login_uid: &str, + token: &str, + refresh_token: &str, +) -> Result<(), CommonError> +where + C: ConnectionTrait, +{ + // 检查用户是否已存在 + let existing_user = ImUserEntity::find() + .filter(im_user::Column::Id.eq(login_uid)) + .one(db) + .await + .map_err(|e| { + error!("Failed to query user for token update: {:?}", e); + CommonError::DatabaseError(e) + })?; + + let refresh_token_to_save = if refresh_token.is_empty() { + if let Some(user) = &existing_user { + if let Some(saved_refresh) = user.refresh_token.clone() { + if !saved_refresh.is_empty() { + saved_refresh + } else { + "".to_string() + } + } else { + "".to_string() + } + } else { + "".to_string() + } + } else { + refresh_token.to_string() + }; + + let user_update = if existing_user.is_some() { + // 用户存在,更新 token 信息 + im_user::ActiveModel { + id: Set(login_uid.to_string()), + token: Set(Some(token.to_string())), + refresh_token: Set(Some(refresh_token_to_save.clone())), + ..Default::default() + } + } else { + // 用户不存在,创建新用户并设置 token 信息 + im_user::ActiveModel { + id: Set(login_uid.to_string()), + token: Set(Some(token.to_string())), + refresh_token: Set(Some(refresh_token_to_save.clone())), + is_init: Set(true), // 新用户默认未初始化 + ..Default::default() + } + }; + + if existing_user.is_some() { + // 更新现有用户 + match ImUserEntity::update(user_update).exec(db).await { + Ok(_) => { + info!("User {} token info updated successfully", login_uid); + Ok(()) + } + Err(e) => { + error!("Failed to update user token info: {:?}", e); + Err(e.into()) + } + } + } else { + // 插入新用户 + match ImUserEntity::insert(user_update).exec(db).await { + Ok(_) => { + info!("New user {} created with token info", login_uid); + Ok(()) + } + Err(e) => { + error!("Failed to create user with token info: {:?}", e); + Err(e.into()) + } + } + } +} + +/// 获取用户的 token 信息 +pub async fn get_user_tokens( + db: &C, + login_uid: &str, +) -> Result, CommonError> +where + C: ConnectionTrait, +{ + let user = ImUserEntity::find() + .filter(im_user::Column::Id.eq(login_uid)) + .one(db) + .await + .map_err(|e| { + error!("Failed to query user tokens: {:?}", e); + CommonError::DatabaseError(e) + })?; + + match user { + Some(user) => { + if let (Some(token), Some(refresh_token)) = (user.token, user.refresh_token) { + Ok(Some((token, refresh_token))) + } else { + Ok(None) + } + } + None => Ok(None), + } +} diff --git a/src-tauri/src/repository/mod.rs b/src-tauri/src/repository/mod.rs new file mode 100644 index 0000000..131b0f8 --- /dev/null +++ b/src-tauri/src/repository/mod.rs @@ -0,0 +1,5 @@ +pub mod im_config_repository; +pub mod im_contact_repository; +pub mod im_message_repository; +pub mod im_room_member_repository; +pub mod im_user_repository; diff --git a/src-tauri/src/timeout_config.rs b/src-tauri/src/timeout_config.rs new file mode 100644 index 0000000..b46aaf4 --- /dev/null +++ b/src-tauri/src/timeout_config.rs @@ -0,0 +1,56 @@ +use std::time::Duration; + +/// 应用级别的超时配置 +pub struct TimeoutConfig; + +impl TimeoutConfig { + /// 数据库连接超时 + pub const DATABASE_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); + + /// 数据库操作超时 + pub const DATABASE_OPERATION_TIMEOUT: Duration = Duration::from_secs(120); + + /// HTTP 请求超时 + pub const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + + /// HTTP 连接超时 + pub const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + + /// 目录扫描超时 + pub const DIRECTORY_SCAN_TIMEOUT: Duration = Duration::from_secs(300); + + /// 应用初始化超时 + pub const APP_INIT_TIMEOUT: Duration = Duration::from_secs(60); + + /// 锁获取超时 + pub const LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_millis(100); + + /// Token 刷新超时 + pub const TOKEN_REFRESH_TIMEOUT: Duration = Duration::from_secs(30); +} + +/// 为异步操作添加超时的辅助函数 +pub async fn with_timeout( + future: F, + timeout: Duration, + operation_name: &str, +) -> Result +where + F: std::future::Future>, +{ + match tokio::time::timeout(timeout, future).await { + Ok(result) => result, + Err(_) => { + tracing::error!( + "Operation '{}' timed out after {:?}", + operation_name, + timeout + ); + Err(crate::error::CommonError::UnexpectedError(anyhow::anyhow!( + "Operation '{}' timed out after {:?}", + operation_name, + timeout + ))) + } + } +} diff --git a/src-tauri/src/utils/linux_runtime_guard.rs b/src-tauri/src/utils/linux_runtime_guard.rs new file mode 100644 index 0000000..48c72b6 --- /dev/null +++ b/src-tauri/src/utils/linux_runtime_guard.rs @@ -0,0 +1,67 @@ +use std::fs; + +/// 针对 Linux (WebKitGTK/Wry) 的运行时防护 +pub fn apply_runtime_guards() { + sanitize_sensitive_env(); + enforce_debugger_policy(); +} + +fn sanitize_sensitive_env() { + const BLOCKED_VARS: [&str; 9] = [ + "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", + "WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", + "WEBVIEW2_USER_DATA_FOLDER", + "WEBVIEW2_WAIT_FOR_SCRIPT_DEBUGGER", + "WEBKIT_FORCE_SANDBOX", + "WEBKIT_INSPECTOR_SERVER", + "GTK_DEBUG", + "LD_PRELOAD", + "LD_LIBRARY_PATH", + ]; + + BLOCKED_VARS.iter().for_each(|key| unsafe { + std::env::remove_var(key); + }); +} + +fn enforce_debugger_policy() { + #[cfg(not(debug_assertions))] + { + if set_dumpable(false).is_err() { + eprintln!("[HuLa] 无法设置 Linux dumpable 保护。"); + } + + if debugger_attached() { + eprintln!("[HuLa] 检测到调试器 (Linux),已终止启动。"); + std::process::exit(0); + } + } + + #[cfg(debug_assertions)] + { + if debugger_attached() { + eprintln!("[HuLa] 调试模式:检测到调试器附加 (Linux)。"); + } + } +} + +#[cfg(not(debug_assertions))] +fn set_dumpable(enabled: bool) -> Result<(), ()> { + unsafe { + if libc::prctl(libc::PR_SET_DUMPABLE, enabled as libc::c_ulong, 0, 0, 0) == -1 { + return Err(()); + } + Ok(()) + } +} + +fn debugger_attached() -> bool { + if let Ok(status) = fs::read_to_string("/proc/self/status") { + for line in status.lines() { + if let Some(value) = line.strip_prefix("TracerPid:") { + return value.trim() != "0"; + } + } + } + false +} diff --git a/src-tauri/src/utils/macos_runtime_guard.rs b/src-tauri/src/utils/macos_runtime_guard.rs new file mode 100644 index 0000000..7bceacd --- /dev/null +++ b/src-tauri/src/utils/macos_runtime_guard.rs @@ -0,0 +1,45 @@ +/// 针对 macOS (WKWebView) 的运行时防护 +pub fn apply_runtime_guards() { + sanitize_sensitive_env(); + enforce_debugger_policy(); +} + +fn sanitize_sensitive_env() { + const BLOCKED_VARS: [&str; 6] = [ + "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", + "WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", + "WEBVIEW2_USER_DATA_FOLDER", + "WEBVIEW2_WAIT_FOR_SCRIPT_DEBUGGER", + "WEBKIT_INSPECTOR_SERVER", + "DYLD_INSERT_LIBRARIES", + ]; + + BLOCKED_VARS.iter().for_each(|key| unsafe { + std::env::remove_var(key); + }); +} + +fn enforce_debugger_policy() { + #[cfg(not(debug_assertions))] + { + if prevent_debugger_attach().is_err() { + eprintln!("[HuLa] 无法设置调试防护 (macOS) 。"); + } + } + + #[cfg(debug_assertions)] + { + eprintln!("[HuLa] 调试构建:macOS 运行时防护仅记录提示,不阻断调试。"); + } +} + +#[cfg(not(debug_assertions))] +fn prevent_debugger_attach() -> Result<(), ()> { + unsafe { + // PT_DENY_ATTACH 会阻止后续调试器附加;若当前已被调试,系统会直接终止进程 + if libc::ptrace(libc::PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0) == -1 { + return Err(()); + } + Ok(()) + } +} diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs new file mode 100644 index 0000000..74ea91b --- /dev/null +++ b/src-tauri/src/utils/mod.rs @@ -0,0 +1,7 @@ +#[cfg(target_os = "linux")] +pub mod linux_runtime_guard; +#[cfg(target_os = "macos")] +pub mod macos_runtime_guard; +pub mod sql_debug; +#[cfg(target_os = "windows")] +pub mod win_runtime_guard; diff --git a/src-tauri/src/utils/sql_debug.rs b/src-tauri/src/utils/sql_debug.rs new file mode 100644 index 0000000..16c8217 --- /dev/null +++ b/src-tauri/src/utils/sql_debug.rs @@ -0,0 +1,102 @@ +use sea_orm::{DatabaseBackend, Statement}; +use tracing::info; + +/// SQL调试工具函数 +pub struct SqlDebug; + +impl SqlDebug { + /// 打印SQL语句和参数 + pub fn log_query(query: &T, backend: DatabaseBackend, label: &str) + where + T: sea_orm::QueryTrait, + { + let statement = query.build(backend); + + info!("[{}] SQL: {}", label, statement.sql); + if let Some(ref values) = statement.values { + info!("[{}] Parameters: {:?}", label, values); + + // 打印格式化的完整SQL(参数已替换) + let formatted_sql = Self::format_sql_with_values(&statement.sql, values); + info!("[{}] Complete SQL: {}", label, formatted_sql); + } else { + info!("[{}] Complete SQL: {}", label, statement.sql); + } + } + + /// 打印Statement + pub fn log_statement(statement: &Statement, label: &str) { + info!("[{}] SQL: {}", label, statement.sql); + if let Some(ref values) = statement.values { + info!("[{}] Parameters: {:?}", label, values); + + let formatted_sql = Self::format_sql_with_values(&statement.sql, values); + info!("[{}] Complete SQL: {}", label, formatted_sql); + } else { + info!("[{}] Complete SQL: {}", label, statement.sql); + } + } + + /// 将SQL参数替换到SQL语句中 + fn format_sql_with_values(sql: &str, values: &sea_orm::Values) -> String { + let mut formatted_sql = sql.to_string(); + + for value in values.0.iter() { + let value_str = match value { + sea_orm::Value::TinyInt(Some(v)) => v.to_string(), + sea_orm::Value::SmallInt(Some(v)) => v.to_string(), + sea_orm::Value::Int(Some(v)) => v.to_string(), + sea_orm::Value::BigInt(Some(v)) => v.to_string(), + sea_orm::Value::TinyUnsigned(Some(v)) => v.to_string(), + sea_orm::Value::SmallUnsigned(Some(v)) => v.to_string(), + sea_orm::Value::Unsigned(Some(v)) => v.to_string(), + sea_orm::Value::BigUnsigned(Some(v)) => v.to_string(), + sea_orm::Value::Float(Some(v)) => v.to_string(), + sea_orm::Value::Double(Some(v)) => v.to_string(), + sea_orm::Value::String(Some(v)) => format!("'{}'", v.replace("'", "''")), + sea_orm::Value::Char(Some(v)) => format!("'{}'", v), + sea_orm::Value::Bytes(Some(v)) => format!("'{}'", String::from_utf8_lossy(v)), + sea_orm::Value::Bool(Some(v)) => { + if *v { + "1".to_string() + } else { + "0".to_string() + } + } + sea_orm::Value::Json(Some(v)) => format!("'{}'", v.to_string().replace("'", "''")), + _ => "NULL".to_string(), + }; + + if let Some(pos) = formatted_sql.find('?') { + formatted_sql.replace_range(pos..pos + 1, &value_str); + } + } + + formatted_sql + } + + /// 简化的SQL日志记录 + pub fn log_simple(sql: &str, values: Option<&sea_orm::Values>, label: &str) { + info!("[{}] {}", label, sql); + if let Some(values) = values { + if !values.0.is_empty() { + info!("[{}] Parameters: {:?}", label, values); + } + } + } +} + +/// 便捷宏,用于快速打印SQL +#[macro_export] +macro_rules! log_sql { + ($query:expr, $label:expr) => { + $crate::utils::sql_debug::SqlDebug::log_query( + &$query, + sea_orm::DatabaseBackend::Sqlite, + $label, + ); + }; + ($query:expr, $backend:expr, $label:expr) => { + $crate::utils::sql_debug::SqlDebug::log_query(&$query, $backend, $label); + }; +} diff --git a/src-tauri/src/utils/win_runtime_guard.rs b/src-tauri/src/utils/win_runtime_guard.rs new file mode 100644 index 0000000..5f7d259 --- /dev/null +++ b/src-tauri/src/utils/win_runtime_guard.rs @@ -0,0 +1,52 @@ +use windows::{ + Win32::System::{ + Diagnostics::Debug::{CheckRemoteDebuggerPresent, IsDebuggerPresent}, + Threading::GetCurrentProcess, + }, + core::BOOL, +}; + +/// Windows 运行时防护:清理敏感环境变量 + 调试器检测 +pub fn apply_runtime_guards() { + sanitize_sensitive_env(); + enforce_debugger_policy(); +} + +fn sanitize_sensitive_env() { + const BLOCKED_VARS: [&str; 4] = [ + "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", + "WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", + "WEBVIEW2_USER_DATA_FOLDER", + "WEBVIEW2_WAIT_FOR_SCRIPT_DEBUGGER", + ]; + + for key in BLOCKED_VARS { + unsafe { std::env::remove_var(key) }; + } +} + +fn enforce_debugger_policy() { + if debugger_attached() { + eprintln!("[HuLa] 检测到调试器或远程调试会话,出于安全考虑终止启动。"); + + #[cfg(not(debug_assertions))] + { + std::process::exit(0); + } + } +} + +fn debugger_attached() -> bool { + unsafe { + if IsDebuggerPresent().as_bool() { + return true; + } + + let mut remote = BOOL(0); + if CheckRemoteDebuggerPresent(GetCurrentProcess(), &mut remote).is_ok() { + return remote.as_bool(); + } + + false + } +} diff --git a/src-tauri/src/vo/mod.rs b/src-tauri/src/vo/mod.rs new file mode 100644 index 0000000..393e736 --- /dev/null +++ b/src-tauri/src/vo/mod.rs @@ -0,0 +1,2 @@ +pub mod user_info; +pub mod vo; diff --git a/src-tauri/src/vo/user_info.rs b/src-tauri/src/vo/user_info.rs new file mode 100644 index 0000000..edb6a1f --- /dev/null +++ b/src-tauri/src/vo/user_info.rs @@ -0,0 +1,19 @@ +// use serde::{Deserialize, Serialize}; +// #[derive(Serialize, Deserialize)] +// pub struct UserInfoVO { +// pub uid: String, +// pub account: String, +// pub email: String, +// pub name: String, +// pub avatar: String, +// pub sex: Option, +// pub user_state_id: Option, +// pub modify_name_chance: Option, +// pub avatar_update_time: Option, +// pub context: bool, +// pub num: i32, +// pub update_time: Option, +// pub create_time: Option, +// pub token: String, +// pub client: String, +// } diff --git a/src-tauri/src/vo/vo.rs b/src-tauri/src/vo/vo.rs new file mode 100644 index 0000000..8f1b86f --- /dev/null +++ b/src-tauri/src/vo/vo.rs @@ -0,0 +1,49 @@ +use serde::{Deserialize, Serialize}; +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MyRoomInfoReq { + pub id: String, + // 我的群昵称 + pub my_name: String, + // 群备注 + pub remark: String, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ChatMessageReq { + pub id: String, + pub room_id: Option, + pub msg_type: Option, + pub body: Option, + pub skip: Option, + pub is_temp: Option, + pub is_push_message: Option, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct LoginReq { + pub grant_type: String, + pub system_type: String, + pub device_type: String, + pub client_id: String, + pub code: Option, + pub redirect_uri: Option, + pub account: String, + pub password: String, + #[serde(default)] + pub is_auto_login: bool, + pub async_data: bool, + pub uid: Option, // 用于自动登录时传递用户ID +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct LoginResp { + pub token: String, + pub client: String, + pub refresh_token: String, + pub uid: String, + pub expire: String, +} diff --git a/src-tauri/src/websocket/client.rs b/src-tauri/src/websocket/client.rs new file mode 100644 index 0000000..d1d64a8 --- /dev/null +++ b/src-tauri/src/websocket/client.rs @@ -0,0 +1,1194 @@ +use crate::AppData; +use crate::command::message_command::{SyncMessagesParam, sync_messages}; +use crate::websocket::commands::get_websocket_client_container; + +use super::types::*; +use anyhow::Result; +use chrono::Utc; +use futures_util::{sink::SinkExt, stream::StreamExt}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use tauri::{AppHandle, Emitter, Manager, State}; +use tokio::sync::{Mutex, RwLock, mpsc}; +use tokio::task::JoinHandle; +use tokio::time::{Duration, interval, sleep}; + +use tokio_tungstenite::{connect_async, tungstenite::protocol::Message}; +use tracing::{debug, error, info, warn}; +use url::Url; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AckMessage { + pub msg_id: String, + pub timestamp: i64, +} + +impl AckMessage { + pub fn new(msg_id: String) -> Self { + Self { + msg_id, + timestamp: Utc::now().timestamp_millis(), + } + } +} + +/// WebSocket 客户端 +#[derive(Clone)] +pub struct WebSocketClient { + config: Arc>, + state: Arc>, + app_handle: AppHandle, + + // 心跳相关 + last_pong_time: Arc, + consecutive_failures: Arc, + heartbeat_active: Arc, + + // 重连相关 + reconnect_attempts: Arc, + is_reconnecting: Arc, + + // 消息队列 + message_sender: Arc>>>, + pending_messages: Arc>>, + + // 连接控制 + should_stop: Arc, + + // 应用状态跟踪 + is_app_in_background: Arc, + last_foreground_time: Arc, + background_heartbeat_failures: Arc, + + // 连接状态标记 + is_ws_connected: Arc, + + // 连接互斥锁,防止并发连接 + connection_mutex: Arc>, + + // 任务句柄管理 + task_handles: Arc>>>, + + // 关闭信号发送器 + close_sender: Arc>>>, +} + +impl WebSocketClient { + pub fn new(app_handle: AppHandle) -> Self { + Self { + config: Arc::new(RwLock::new(WebSocketConfig::default())), + state: Arc::new(RwLock::new(ConnectionState::Disconnected)), + app_handle, + last_pong_time: Arc::new(AtomicU64::new(0)), + consecutive_failures: Arc::new(AtomicU32::new(0)), + heartbeat_active: Arc::new(AtomicBool::new(false)), + reconnect_attempts: Arc::new(AtomicU32::new(0)), + is_reconnecting: Arc::new(AtomicBool::new(false)), + message_sender: Arc::new(RwLock::new(None)), + pending_messages: Arc::new(RwLock::new(Vec::new())), + should_stop: Arc::new(AtomicBool::new(false)), + is_app_in_background: Arc::new(AtomicBool::new(false)), + last_foreground_time: Arc::new(AtomicU64::new( + chrono::Utc::now().timestamp_millis() as u64 + )), + background_heartbeat_failures: Arc::new(AtomicU32::new(0)), + is_ws_connected: Arc::new(AtomicBool::new(false)), + connection_mutex: Arc::new(Mutex::new(())), + task_handles: Arc::new(RwLock::new(Vec::new())), + close_sender: Arc::new(RwLock::new(None)), + } + } + + /// 初始化连接 + pub async fn connect(&self, config: WebSocketConfig) -> Result<()> { + // 获取连接锁,确保同时只有一个连接操作 + let _lock: tokio::sync::MutexGuard<'_, ()> = self.connection_mutex.lock().await; + info!( + "Initializing WebSocket connection to: {}", + config.server_url + ); + + // 在锁保护下再次检查连接状态 + if self.is_ws_connected.load(Ordering::SeqCst) { + warn!("WebSocket already connected, ignoring duplicate connection request"); + return Ok(()); + } + + // 更新配置 + *self.config.write().await = config; + self.should_stop.store(false, Ordering::SeqCst); + + // 开始连接循环 + self.connection_loop().await?; + + Ok(()) + } + + /// 断开连接 + pub async fn disconnect(&self) { + let _lock = self.connection_mutex.lock().await; + self.internal_disconnect().await; + } + + /// 内部断开连接方法(不获取锁) + pub async fn internal_disconnect(&self) { + info!("Disconnecting WebSocket connection"); + self.should_stop.store(true, Ordering::SeqCst); + + // 更新连接状态 + self.is_ws_connected.store(false, Ordering::SeqCst); + + // 取消所有任务 + let mut handles = self.task_handles.write().await; + let task_count = handles.len(); + for handle in handles.drain(..) { + handle.abort(); + } + info!("Cancelled {} async tasks", task_count); + + // 发送关闭信号以主动关闭 WebSocket 连接 + if let Some(close_sender) = self.close_sender.write().await.take() { + if let Err(_) = close_sender.send(()) { + warn!("Failed to send close signal, connection may already be closed"); + } else { + info!("WebSocket close signal sent"); + } + } + + // 清理消息发送器 + *self.message_sender.write().await = None; + + // 更新状态 + self.update_state(ConnectionState::Disconnected, false) + .await; + + // 重置计数器 + self.consecutive_failures.store(0, Ordering::SeqCst); + self.reconnect_attempts.store(0, Ordering::SeqCst); + self.heartbeat_active.store(false, Ordering::SeqCst); + + info!("WebSocket connection completely disconnected"); + } + + /// 发送消息 + pub async fn send_message(&self, data: serde_json::Value) -> Result<()> { + // 首先检查连接状态 + let current_state = self.get_state().await; + + match current_state { + ConnectionState::Connected => { + let sender = self.message_sender.read().await; + + if let Some(sender) = sender.as_ref() { + let message = Message::Text(data.to_string().into()); + sender.send(message.clone()).map_err(|e| { + anyhow::anyhow!("Failed to queue message for sending: {}", e) + })?; + info!("Message sent {:?}", message); + Ok(()) + } else { + warn!("Connection state is Connected but sender not ready, message queued"); + // 连接未完全建立,将消息加入待发队列 + let mut pending = self.pending_messages.write().await; + pending.push(data); + + // 限制队列长度 + if pending.len() > 100 { + pending.remove(0); + warn!("Pending queue full, dropping oldest message"); + } + + // 返回错误,让上层知道消息没有立即发送 + Err(anyhow::anyhow!( + "Connection not fully established, message queued" + )) + } + } + ConnectionState::Connecting | ConnectionState::Reconnecting => { + // 连接中,将消息加入待发队列 + let mut pending = self.pending_messages.write().await; + pending.push(data); + warn!( + "正在连接中,消息已加入待发队列 (队列长度: {})", + pending.len() + ); + + // 限制队列长度 + if pending.len() > 100 { + pending.remove(0); + warn!("Pending queue full, dropping oldest message"); + } + + Err(anyhow::anyhow!("WebSocket is connecting, message queued")) + } + _ => { + warn!("WebSocket 未连接 (状态: {:?}),无法发送消息", current_state); + Err(anyhow::anyhow!( + "WebSocket not connected (state: {:?})", + current_state + )) + } + } + } + + /// 获取连接健康状态 + pub async fn get_health_status(&self) -> ConnectionHealth { + let last_pong = self.last_pong_time.load(Ordering::SeqCst); + let failures = self.consecutive_failures.load(Ordering::SeqCst); + let now = chrono::Utc::now().timestamp_millis() as u64; + + let is_healthy = if last_pong == 0 { + // 如果还没有收到过pong,根据连接状态判断 + matches!(*self.state.read().await, ConnectionState::Connected) + } else { + now - last_pong < 30000 // 30秒内收到过pong认为健康 + }; + + ConnectionHealth { + is_healthy, + last_pong_time: if last_pong == 0 { + None + } else { + Some(last_pong) + }, + consecutive_failures: failures, + round_trip_time: None, // 可以在心跳时计算 + } + } + + /// 强制重连 + pub async fn force_reconnect(&self) -> Result<()> { + info!("Force reconnecting"); + + // 获取连接锁 + let _lock = self.connection_mutex.lock().await; + + // 标记为重连,以便前端显示同步提示 + self.is_reconnecting.store(true, Ordering::SeqCst); + + self.reconnect_attempts.store(0, Ordering::SeqCst); + + // 先断开当前连接 + self.internal_disconnect().await; + + // 重新连接 + let config = self.config.read().await.clone(); + + // 更新配置 + *self.config.write().await = config.clone(); + self.should_stop.store(false, Ordering::SeqCst); + + // 开始连接循环 + self.connection_loop().await + } + + /// 主连接循环 + async fn connection_loop(&self) -> Result<()> { + loop { + // 检查是否应该停止 + if self.should_stop.load(Ordering::SeqCst) { + info!("Received stop signal, exiting connection loop"); + break; + } + + match self.try_connect().await { + Ok(_) => { + info!("WebSocket connection established"); + self.reconnect_attempts.store(0, Ordering::SeqCst); + + // 监控连接状态,直到断开 + while self.is_ws_connected.load(Ordering::SeqCst) + && !self.should_stop.load(Ordering::SeqCst) + { + sleep(Duration::from_millis(100)).await; + } + + info!("Connection disconnected, preparing to reconnect..."); + self.is_reconnecting.store(true, Ordering::SeqCst); + self.update_state(ConnectionState::Reconnecting, true).await; + // 清理当前连接状态 + self.cleanup_connection_state().await; + + continue; + } + Err(e) => { + // 当 max_reconnect_attempts 为 0 时表示无限重连,避免溢出使用饱和加 + let attempts = self + .reconnect_attempts + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |val| { + Some(val.saturating_add(1)) + }) + .map(|old| old.saturating_add(1)) + .unwrap_or_else(|old| old.saturating_add(1)); + let config = self.config.read().await; + + error!( + " WebSocket connection failed (attempt {}/{}) : {}", + attempts, + if config.max_reconnect_attempts == 0 { + "∞".to_string() + } else { + config.max_reconnect_attempts.to_string() + }, + e + ); + + // 当 max_reconnect_attempts 为 0 时不做次数上限限制;否则遵循上限 + if config.max_reconnect_attempts > 0 + && attempts >= config.max_reconnect_attempts + { + self.emit_error( + "Too many connection failures, stopping retry".to_string(), + None, + ) + .await; + self.is_ws_connected.store(false, Ordering::SeqCst); + self.update_state(ConnectionState::Error, false).await; + return Err(anyhow::anyhow!("Max reconnection attempts reached")); + } + + // 指数退避延迟 + // 退避阶数做上限,避免 attempts 无限增长导致幂计算溢出 + let backoff_steps = attempts.saturating_sub(1).min(10); + let delay = std::cmp::min( + config.reconnect_delay_ms * (2_u64.pow(backoff_steps)), + 15000, // 最大15秒 + ); + + info!("Retrying connection in {}ms...", delay); + self.update_state(ConnectionState::Reconnecting, true).await; + sleep(Duration::from_millis(delay)).await; + } + } + } + Ok(()) + } + + /// 清理连接状态 + async fn cleanup_connection_state(&self) { + // 停止心跳 + self.heartbeat_active.store(false, Ordering::SeqCst); + + // 清理消息发送器 + *self.message_sender.write().await = None; + + // 清理关闭信号发送器 + *self.close_sender.write().await = None; + + // 重置连接状态 + self.is_ws_connected.store(false, Ordering::SeqCst); + + info!("Connection state cleaned up"); + } + + /// 尝试建立连接 + async fn try_connect(&self) -> Result<()> { + let config = self.config.read().await.clone(); + + // 构建连接URL + let mut url = Url::parse(&config.server_url) + .map_err(|e| anyhow::anyhow!("Invalid WebSocket URL '{}': {}", config.server_url, e))?; + + url.query_pairs_mut() + .append_pair("clientId", &config.client_id); + + if let Some(ref token) = config.token { + url.query_pairs_mut().append_pair("Token", token); + } + + let url_str = url.as_str(); + info!("Connecting to WebSocket: {}", url_str); + self.update_state(ConnectionState::Connecting, false).await; + + // 建立连接 + let (ws_stream, _) = connect_async(url_str) + .await + .map_err(|e| anyhow::anyhow!("Failed to connect to WebSocket '{}': {}", url_str, e))?; + + let (mut ws_sender, mut ws_receiver) = ws_stream.split(); + + // 创建消息通道 + let (msg_sender, mut msg_receiver) = mpsc::unbounded_channel(); + *self.message_sender.write().await = Some(msg_sender); + + // 创建关闭信号通道 + let (close_sender, mut close_receiver) = mpsc::unbounded_channel(); + *self.close_sender.write().await = Some(close_sender); + + // 更新连接状态 + let was_reconnecting = self.is_reconnecting.swap(false, Ordering::SeqCst); + self.update_state(ConnectionState::Connected, was_reconnecting) + .await; + + if was_reconnecting { + self.schedule_post_reconnect_sync(); + } + + // 标记为已连接 + self.is_ws_connected.store(true, Ordering::SeqCst); + + // 发送待发消息 + self.send_pending_messages().await?; + + // 启动心跳 + self.start_heartbeat().await; + + // 处理消息发送 + let message_sender_task = { + let should_stop = self.should_stop.clone(); + let is_ws_connected = self.is_ws_connected.clone(); + tokio::spawn(async move { + while !should_stop.load(Ordering::SeqCst) { + tokio::select! { + Some(message) = msg_receiver.recv() => { + if let Err(e) = ws_sender.send(message).await { + error!(" Failed to send message: {}", e); + is_ws_connected.store(false, Ordering::SeqCst); + break; + } + } + Some(_) = close_receiver.recv() => { + info!("Received close signal, actively closing WebSocket connection"); + if let Err(e) = ws_sender.close().await { + warn!("Error closing WebSocket connection: {}", e); + } else { + info!("WebSocket connection actively closed"); + } + break; + } + else => break, + } + } + }) + }; + + // 处理消息接收 + let message_receiver_task = { + let app_handle = self.app_handle.clone(); + let last_pong_time = self.last_pong_time.clone(); + let consecutive_failures = self.consecutive_failures.clone(); + let is_ws_connected = self.is_ws_connected.clone(); + + tokio::spawn(async move { + while let Some(msg) = ws_receiver.next().await { + match msg { + Ok(Message::Text(text)) => { + Self::handle_message_static( + text.to_string(), + &app_handle, + &last_pong_time, + &consecutive_failures, + ) + .await; + } + Ok(Message::Binary(data)) => { + if let Ok(text) = String::from_utf8(data.to_vec()) { + Self::handle_message_static( + text, + &app_handle, + &last_pong_time, + &consecutive_failures, + ) + .await; + } + } + Ok(Message::Close(_)) => { + info!("WebSocket connection closed"); + is_ws_connected.store(false, Ordering::SeqCst); + break; + } + Err(e) => { + error!(" WebSocket message receive error: {}", e); + is_ws_connected.store(false, Ordering::SeqCst); + break; + } + _ => {} + } + } + }) + }; + + // 启动后台任务监控 + let should_stop = self.should_stop.clone(); + let heartbeat_active = self.heartbeat_active.clone(); + let message_sender_ref = self.message_sender.clone(); + + let monitor_task = tokio::spawn(async move { + // 等待任务完成或停止信号 + tokio::select! { + _ = message_sender_task => { + info!("Message sending task ended"); + } + _ = message_receiver_task => { + info!("Message receiving task ended"); + } + _ = async { + while !should_stop.load(Ordering::SeqCst) { + sleep(Duration::from_millis(100)).await; + } + } => { + info!("Received stop signal"); + } + } + + // 清理 + heartbeat_active.store(false, Ordering::SeqCst); + *message_sender_ref.write().await = None; + }); + + // 保存监控任务句柄 + let mut handles = self.task_handles.write().await; + handles.push(monitor_task); + + info!("WebSocket connection and background tasks started"); + Ok(()) + } + + /// 处理收到的消息(静态方法,用于异步任务) + async fn handle_message_static( + text: String, + app_handle: &AppHandle, + last_pong_time: &Arc, + consecutive_failures: &Arc, + ) { + info!("Received message: {}", text); + + // 尝试解析心跳响应 + if let Ok(ws_msg) = serde_json::from_str::(&text) { + match ws_msg { + WsMessage::HeartbeatResponse { timestamp: _ } => { + let now = chrono::Utc::now().timestamp_millis() as u64; + last_pong_time.store(now, Ordering::SeqCst); + consecutive_failures.store(0, Ordering::SeqCst); + + info!("Received heartbeat response"); + + let health = ConnectionHealth { + is_healthy: true, + last_pong_time: Some(now), + consecutive_failures: 0, + round_trip_time: None, + }; + + let _ = app_handle.emit( + "websocket-event", + &WebSocketEvent::HeartbeatStatusChanged { health }, + ); + return; + } + _ => {} + } + } + + // 处理业务消息 + if let Ok(json_value) = serde_json::from_str::(&text) { + // 处理具体的业务消息类型 + Self::process_business_message(&json_value, app_handle).await; + + // 同时发送原始消息事件(保持兼容性) + let _ = app_handle.emit( + "websocket-event", + &WebSocketEvent::MessageReceived { + message: json_value, + }, + ); + } else { + // 非JSON消息,直接转发 + let _ = app_handle.emit( + "websocket-event", + &WebSocketEvent::MessageReceived { + message: serde_json::Value::String(text), + }, + ); + } + } + + pub async fn send_ack(&self, message_id: &str) -> Result<()> { + let ack_message = AckMessage::new(message_id.to_string()); + + let ack_json = serde_json::json!({ + "type": "15", + "data": ack_message + }); + + // 添加重试机制 + let max_retries = 3; + let mut retry_count = 0; + + while retry_count < max_retries { + match self.send_message(ack_json.clone()).await { + Ok(_) => { + info!( + "Sent ACK for message {} (attempt: {})", + message_id, + retry_count + 1 + ); + return Ok(()); + } + Err(e) => { + retry_count += 1; + if retry_count >= max_retries { + error!( + " Failed to send ACK for message {} after {} attempts: {}", + message_id, max_retries, e + ); + return Err(e); + } + + warn!( + "Failed to send ACK for message {} (attempt {}), retrying...: {}", + message_id, retry_count, e + ); + + // 指数退避 + tokio::time::sleep(Duration::from_millis(100 * 2u64.pow(retry_count as u32))) + .await; + } + } + } + + Err(anyhow::anyhow!("Failed to send ACK after all retries")) + } + + /// 处理业务消息类型 + async fn process_business_message(message: &serde_json::Value, app_handle: &AppHandle) { + // 提取消息类型 + let message_type = message.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + // 提取消息数据 + let data = message.get("data"); + + debug!("Processing business message type: {}", message_type); + + // 根据消息类型进行处理并发送对应的事件 + match message_type { + // 登录相关 + "loginQrCode" => { + info!("Getting login QR code"); + let _ = app_handle.emit("ws-login-qr-code", data); + } + "waitingAuthorize" => { + info!("Waiting for authorization"); + let _ = app_handle.emit("ws-waiting-authorize", data); + } + "loginSuccess" => { + info!("Login successful"); + let _ = app_handle.emit_to("home", "ws-login-success", data); + } + + // 消息相关 TODO 暂时只实现聊天消息的ack + "receiveMessage" => { + info!("Received message"); + + let client_container = get_websocket_client_container(); + let client_guard = client_container.read().await; + + if let Some(data_obj) = data { + if let Some(message_id) = data_obj + .get("message") + .and_then(|m| m.get("id")) + .and_then(|id| id.as_str()) + { + info!("回执 ACK: {}", message_id); + + if let Some(client) = client_guard.as_ref() { + match client.send_ack(message_id).await { + Ok(_) => { + info!("ACK sent successfully for message {}", message_id); + } + Err(e) => { + error!(" Failed to send ACK for message {}: {}", message_id, e); + } + }; + } else { + error!(" 回执失败"); + } + } + } + + let _ = app_handle.emit_to("home", "ws-receive-message", data); + } + "msgRecall" => { + info!("Message recalled"); + let _ = app_handle.emit_to("home", "ws-msg-recall", data); + } + "msgMarkItem" => { + info!("Message liked/disliked"); + let _ = app_handle.emit_to("home", "ws-msg-mark-item", data); + } + + // 用户状态相关 + "online" => { + info!("User online"); + let _ = app_handle.emit_to("home", "ws-online", data); + } + "offline" => { + info!("User offline"); + let _ = app_handle.emit_to("home", "ws-offline", data); + } + "userStateChange" => { + info!("User state changed"); + let _ = app_handle.emit_to("home", "ws-user-state-change", data); + } + // 通知总线 + "notifyEvent" => { + info!("新的notifyEvent"); + let _ = app_handle.emit_to("home", "ws-request-notify-event", data); + } + "groupSetAdmin" => { + let _ = app_handle.emit_to("home", "ws-group-set-admin-success", data); + } + // 好友相关 + "newApply" => { + info!("New apply request"); + let _ = app_handle.emit_to("home", "ws-request-new-apply", data); + } + "requestApprovalFriend" => { + info!("Friend request approved"); + let _ = app_handle.emit_to("home", "ws-request-approval-friend", data); + } + "memberChange" => { + info!("Member change"); + let _ = app_handle.emit_to("home", "ws-member-change", data); + } + + // 房间/群聊相关 + "roomInfoChange" => { + info!("Room info changed"); + let _ = app_handle.emit_to("home", "ws-room-info-change", data); + } + "myRoomInfoChange" => { + info!("My room info changed"); + let _ = app_handle.emit_to("home", "ws-my-room-info-change", data); + } + "roomGroupNoticeMsg" => { + info!("Group notice published"); + let _ = app_handle.emit_to("home", "ws-room-group-notice-msg", data); + } + "roomEditGroupNoticeMsg" => { + info!("✏️ Group notice edited"); + let _ = app_handle.emit_to("home", "ws-room-edit-group-notice-msg", data); + } + "roomDissolution" => { + info!("Room dissolved"); + let _ = app_handle.emit_to("home", "ws-room-dissolution", data); + } + + // 视频通话相关 + "VideoCallRequest" => { + info!("Received call request"); + let _ = app_handle.emit("ws-video-call-request", data); + } + "CallAccepted" => { + info!("Call accepted"); + let _ = app_handle.emit("ws-call-accepted", data); + } + "CallRejected" => { + info!(" Call rejected"); + let _ = app_handle.emit("ws-call-rejected", data); + } + "RoomClosed" => { + info!("Room closed"); + let _ = app_handle.emit("ws-room-closed", data); + } + "WEBRTC_SIGNAL" => { + info!("Signaling message"); + let _ = app_handle.emit("ws-webrtc-signal", data); + } + "JoinVideo" => { + info!("User joined video"); + let _ = app_handle.emit("ws-join-video", data); + } + "LeaveVideo" => { + info!("User left video"); + let _ = app_handle.emit("ws-leave-video", data); + } + "DROPPED" => { + info!("Call dropped"); + let _ = app_handle.emit("ws-dropped", data); + } + + "CANCEL" => { + info!("Call cancelled"); + let _ = app_handle.emit("ws-cancel", data); + } + + "TIMEOUT" => { + info!("Call timeout"); + let _ = app_handle.emit("ws-timeout", data); + } + + // 系统相关 + "tokenExpired" => { + warn!("Token expired"); + let _ = app_handle.emit("ws-token-expired", data); + } + "invalidUser" => { + warn!("Invalid user"); + let _ = app_handle.emit("ws-invalid-user", data); + } + + "deleteFriend" => { + warn!("Delete Friend"); + let _ = app_handle.emit("ws-delete-friend", data); + } + + // 朋友圈相关 + "feedSendMsg" => { + info!("Feed message received"); + let _ = app_handle.emit_to("home", "ws-feed-send-msg", data); + } + "feedNotify" => { + info!("Feed notification received (like/comment)"); + let _ = app_handle.emit_to("home", "ws-feed-notify", data); + } + + // 未知消息类型 + _ => { + warn!("Received unhandled message type: {}", message_type); + // 发送通用的未知消息事件 + let _ = app_handle.emit("ws-unknown-message", message); + } + } + } + + /// 启动心跳机制 + async fn start_heartbeat(&self) { + if self.heartbeat_active.swap(true, Ordering::SeqCst) { + return; // 已经在运行 + } + + let config = self.config.read().await.clone(); + let interval_ms = config.heartbeat_interval; + let timeout_ms = config.heartbeat_timeout; + + let heartbeat_task = { + let heartbeat_active = self.heartbeat_active.clone(); + let should_stop = self.should_stop.clone(); + let last_pong_time = self.last_pong_time.clone(); + let consecutive_failures = self.consecutive_failures.clone(); + let message_sender = self.message_sender.clone(); + let is_app_in_background = self.is_app_in_background.clone(); + let background_heartbeat_failures = self.background_heartbeat_failures.clone(); + let is_ws_connected = self.is_ws_connected.clone(); + + tokio::spawn(async move { + let mut heartbeat_interval = interval(Duration::from_millis(interval_ms)); + + while heartbeat_active.load(Ordering::SeqCst) && !should_stop.load(Ordering::SeqCst) + { + heartbeat_interval.tick().await; + + // 发送心跳 + let heartbeat_msg = WsMessage::Heartbeat; + if let Ok(json) = serde_json::to_value(&heartbeat_msg) { + let sender = message_sender.read().await; + if let Some(sender) = sender.as_ref() { + let message = Message::Text(json.to_string().into()); + if let Err(e) = sender.send(message) { + error!(" Failed to send heartbeat: {}", e); + break; + } + } else { + warn!("Heartbeat send failed: connection not established"); + break; + } + } + + // 检查心跳超时 + let last_pong = last_pong_time.load(Ordering::SeqCst); + if last_pong > 0 { + let now = chrono::Utc::now().timestamp_millis() as u64; + let time_since_pong = now - last_pong; + + // 根据应用状态调整超时策略 + let is_background = is_app_in_background.load(Ordering::SeqCst); + let effective_timeout = if is_background { + // 后台模式下更宽松的超时时间(2分钟) + 120000 + } else { + timeout_ms + }; + + if time_since_pong > effective_timeout { + let failures = if is_background { + background_heartbeat_failures.fetch_add(1, Ordering::SeqCst) + 1 + } else { + consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1 + }; + + warn!( + "Heartbeat timeout ({} mode, consecutive failures: {}, last heartbeat {}ms ago)", + if is_background { + "background" + } else { + "foreground" + }, + failures, + time_since_pong + ); + + // 后台模式下更宽松的重连策略 + let max_failures = if is_background { 5 } else { 3 }; + if failures >= max_failures { + error!("Consecutive heartbeat timeouts, triggering reconnection"); + // 心跳失败时标记连接断开 + is_ws_connected.store(false, Ordering::SeqCst); + break; + } + } + } + } + + info!("Heartbeat task ended"); + }) + }; + + // 保存心跳任务句柄 + let mut handles = self.task_handles.write().await; + handles.push(heartbeat_task); + } + + /// 发送待发消息 + async fn send_pending_messages(&self) -> Result<()> { + // 先取出所有待发消息 + let messages_to_send = { + let mut pending = self.pending_messages.write().await; + if pending.is_empty() { + return Ok(()); + } + + info!("Preparing to send {} pending messages", pending.len()); + pending.drain(..).collect::>() + }; + + // 获取发送器 + let sender = self.message_sender.read().await; + if let Some(sender) = sender.as_ref() { + let mut failed_messages = Vec::new(); + + // 尝试发送每条消息 + for message in messages_to_send { + let text_message = Message::Text(message.to_string().into()); + if let Err(e) = sender.send(text_message) { + error!(" Failed to send pending message: {}", e); + failed_messages.push(message); + } + } + + // 如果有失败的消息,重新加入队列 + if !failed_messages.is_empty() { + let mut pending = self.pending_messages.write().await; + for msg in failed_messages.into_iter().rev() { + pending.insert(0, msg); // 插入到队列前面 + } + return Err(anyhow::anyhow!("Some pending messages failed to send")); + } + + info!("All pending messages sent"); + } else { + // 发送器未就绪,将消息重新加入队列 + let mut pending = self.pending_messages.write().await; + for msg in messages_to_send.into_iter().rev() { + pending.insert(0, msg); + } + warn!("Sender not ready, messages re-queued"); + return Err(anyhow::anyhow!("Message sender not ready")); + } + + Ok(()) + } + + /// 更新连接状态 + async fn update_state(&self, new_state: ConnectionState, is_reconnection: bool) { + let mut state = self.state.write().await; + if *state != new_state { + *state = new_state.clone(); + drop(state); + + info!("Connection state changed: {:?}", new_state); + self.emit_event(WebSocketEvent::ConnectionStateChanged { + state: new_state, + is_reconnection, + }) + .await; + } + } + + /// 发送事件到前端 + async fn emit_event(&self, event: WebSocketEvent) { + if let Err(e) = self.app_handle.emit("websocket-event", &event) { + error!(" Failed to emit WebSocket event: {}", e); + } + } + + /// 发送错误事件 + async fn emit_error( + &self, + message: String, + details: Option>, + ) { + self.emit_event(WebSocketEvent::Error { message, details }) + .await; + } + + /// 获取当前状态 + pub async fn get_state(&self) -> ConnectionState { + self.state.read().await.clone() + } + + /// 更新配置 + pub async fn update_config(&self, new_config: WebSocketConfig) { + *self.config.write().await = new_config; + } + + /// 设置应用后台状态 + pub fn set_app_background_state(&self, is_background: bool) { + let was_background = self + .is_app_in_background + .swap(is_background, Ordering::SeqCst); + + if is_background && !was_background { + info!("App entered background mode"); + // 重置后台心跳失败计数 + self.background_heartbeat_failures + .store(0, Ordering::SeqCst); + } else if !is_background && was_background { + let now = chrono::Utc::now().timestamp_millis() as u64; + self.last_foreground_time.store(now, Ordering::SeqCst); + info!("App resumed from background to foreground"); + + // 检查是否需要重连 + tokio::spawn({ + let client = self.clone(); + async move { + client.check_and_recover_connection().await; + } + }); + } + } + + /// 检查并恢复连接(从后台恢复时调用) + async fn check_and_recover_connection(&self) { + let current_state = self.get_state().await; + let last_pong = self.last_pong_time.load(Ordering::SeqCst); + let now = chrono::Utc::now().timestamp_millis() as u64; + + info!( + "Checking connection state: {:?}, last heartbeat: {}ms ago", + current_state, + if last_pong > 0 { now - last_pong } else { 0 } + ); + + match current_state { + ConnectionState::Connected => { + // 检查心跳是否过期 + if last_pong > 0 && now - last_pong > 60000 { + // 60秒无心跳 + warn!("Connection may be lost, forcing reconnection"); + if let Err(e) = self.force_reconnect().await { + warn!("Auto-reconnection failed: {}", e); + // 通知前端需要重连 + if let Err(emit_err) = self.app_handle.emit( + "ws-connection-lost", + serde_json::json!({ + "reason": "auto_reconnect_failed", + "error": e.to_string(), + "timestamp": chrono::Utc::now().timestamp_millis() + }), + ) { + error!("Failed to emit connection lost event: {}", emit_err); + } + } + } else { + // 发送一个心跳来测试连接 + self.send_test_heartbeat().await; + } + } + ConnectionState::Disconnected | ConnectionState::Error => { + info!("Connection disconnected, attempting to reconnect"); + if let Err(e) = self.force_reconnect().await { + warn!("Auto-reconnection failed: {}", e); + // 通知前端需要重连 + if let Err(emit_err) = self.app_handle.emit( + "ws-connection-lost", + serde_json::json!({ + "reason": "auto_reconnect_failed", + "error": e.to_string(), + "timestamp": chrono::Utc::now().timestamp_millis() + }), + ) { + error!("Failed to emit connection lost event: {}", emit_err); + } + } + } + _ => { + info!( + "Connection state: {:?}, waiting for connection to complete", + current_state + ); + } + } + } + + /// 发送测试心跳 + async fn send_test_heartbeat(&self) { + let heartbeat_msg = WsMessage::Heartbeat; + if let Ok(json) = serde_json::to_value(&heartbeat_msg) { + match self.send_message(json).await { + Ok(_) => { + info!("Test heartbeat sent successfully"); + } + Err(e) => { + warn!("Test heartbeat failed: {}", e); + // 通过事件通知前端需要重连 + if let Err(emit_err) = self.app_handle.emit( + "ws-connection-lost", + serde_json::json!({ + "reason": "test_heartbeat_failed", + "error": e.to_string(), + "timestamp": chrono::Utc::now().timestamp_millis() + }), + ) { + error!("Failed to emit connection lost event: {}", emit_err); + } + } + } + } + } + + /// 获取应用后台状态 + pub fn is_app_in_background(&self) -> bool { + self.is_app_in_background.load(Ordering::SeqCst) + } + + /// 检查 WebSocket 是否已连接 + pub fn is_connected(&self) -> bool { + self.is_ws_connected.load(Ordering::SeqCst) + } + + fn schedule_post_reconnect_sync(&self) { + let app_handle = self.app_handle.clone(); + tokio::spawn(async move { + if let Err(err) = Self::run_sync_messages(&app_handle).await { + warn!("Post-reconnect message sync failed: {}", err); + } else { + info!("Post-reconnect message sync completed"); + } + }); + } + + async fn run_sync_messages(app_handle: &AppHandle) -> Result<(), String> { + let state: State<'_, AppData> = app_handle.state(); + + let params = Some(SyncMessagesParam { + async_data: Some(true), + full_sync: Some(false), + uid: None, + }); + + sync_messages(params, state).await + } +} diff --git a/src-tauri/src/websocket/commands.rs b/src-tauri/src/websocket/commands.rs new file mode 100644 index 0000000..f5747a3 --- /dev/null +++ b/src-tauri/src/websocket/commands.rs @@ -0,0 +1,290 @@ +use crate::AppData; + +use super::{client::WebSocketClient, types::*}; +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, OnceLock}; +use tauri::{AppHandle, State}; +use tokio::sync::RwLock; +use tracing::{error, info}; + +// 全局 WebSocket 客户端实例 +static GLOBAL_WS_CLIENT: OnceLock>>> = OnceLock::new(); + +/// 获取全局 WebSocket 客户端容器 +pub fn get_websocket_client_container() -> &'static Arc>> { + GLOBAL_WS_CLIENT.get_or_init(|| { + info!("Creating global WebSocket client container"); + Arc::new(RwLock::new(None)) + }) +} + +/// WebSocket 初始化参数 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitWsParams { + pub client_id: String, +} + +/// WebSocket 消息发送参数 +#[derive(Debug, Deserialize)] +pub struct SendMessageParams { + pub data: serde_json::Value, +} + +/// WebSocket 配置更新参数 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateConfigParams { + pub heartbeat_interval: Option, + pub heartbeat_timeout: Option, + pub max_reconnect_attempts: Option, + pub reconnect_delay_ms: Option, +} + +/// 成功响应结构 +#[derive(Debug, Serialize)] +pub struct SuccessResponse { + pub success: bool, + pub message: Option, +} + +impl SuccessResponse { + pub fn new() -> Self { + Self { + success: true, + message: None, + } + } +} + +/// 初始化 WebSocket 连接 +#[tauri::command] +pub async fn ws_init_connection( + app_handle: AppHandle, + params: InitWsParams, + state: State<'_, AppData>, +) -> Result { + info!("Received WebSocket initialization request"); + + let client_container = get_websocket_client_container(); + let rc = state.rc.lock().await; + + let config = WebSocketConfig { + server_url: state.config.lock().await.backend.ws_url.clone(), + client_id: params.client_id, + token: rc.token.clone(), + ..Default::default() + }; + + // 获取或创建客户端实例 + let client = { + let mut client_guard = client_container.write().await; + + // 检查是否已有客户端实例 + if let Some(existing_client) = client_guard.as_ref() { + // 如果已有客户端且已连接,直接返回成功 + if existing_client.is_connected() { + info!("WebSocket already connected, skipping duplicate connection"); + return Ok(SuccessResponse::new()); + } + + // 如果已有客户端但未连接,使用现有客户端 + info!("Reconnecting using existing WebSocket client instance"); + existing_client.clone() + } else { + // 如果没有客户端,创建新实例 + info!("Creating new WebSocket client instance"); + let new_client = WebSocketClient::new(app_handle); + *client_guard = Some(new_client.clone()); + new_client + } + }; + + tokio::spawn(async move { + match client.connect(config).await { + Ok(_) => { + info!("WebSocket connection initialized successfully"); + } + Err(e) => { + error!(" WebSocket connection initialization failed: {}", e); + } + } + }); + + Ok(SuccessResponse::new()) +} + +/// 断开 WebSocket 连接 +#[tauri::command] +pub async fn ws_disconnect(_app_handle: AppHandle) -> Result { + info!("Received WebSocket disconnect request"); + + let client_container = get_websocket_client_container(); + let mut client_guard = client_container.write().await; + + if let Some(client) = client_guard.take() { + client.internal_disconnect().await; + } + + info!("WebSocket connection disconnected"); + Ok(SuccessResponse::new()) +} + +/// 发送 WebSocket 消息 +#[tauri::command] +pub async fn ws_send_message( + _app_handle: AppHandle, + params: SendMessageParams, +) -> Result { + let client_container = get_websocket_client_container(); + let client_guard = client_container.read().await; + + if let Some(client) = client_guard.as_ref() { + match client.send_message(params.data).await { + Ok(_) => Ok(SuccessResponse::new()), + Err(e) => { + error!(" Failed to send message: {}", e); + Err(format!("发送失败: {}", e)) + } + } + } else { + error!(" WebSocket not initialized"); + Err("WebSocket 未初始化".to_string()) + } +} + +/// 获取连接状态 +#[tauri::command] +pub async fn ws_get_state(_app_handle: AppHandle) -> Result { + let client_container = get_websocket_client_container(); + let client_guard = client_container.read().await; + + if let Some(client) = client_guard.as_ref() { + Ok(client.get_state().await) + } else { + Ok(ConnectionState::Disconnected) + } +} + +/// 获取连接健康状态 +#[tauri::command] +pub async fn ws_get_health(_app_handle: AppHandle) -> Result { + let client_container = get_websocket_client_container(); + let client_guard = client_container.read().await; + + if let Some(client) = client_guard.as_ref() { + Ok(client.get_health_status().await) + } else { + Err("WebSocket 未初始化".to_string()) + } +} + +/// 强制重连 +#[tauri::command] +pub async fn ws_force_reconnect(_app_handle: AppHandle) -> Result { + info!("Received force reconnect request"); + + let client_container = get_websocket_client_container(); + let client_guard = client_container.read().await; + + if let Some(client) = client_guard.as_ref() { + match client.force_reconnect().await { + Ok(_) => { + info!("WebSocket reconnected successfully"); + Ok(SuccessResponse::new()) + } + Err(e) => { + error!(" WebSocket reconnection failed: {}", e); + Err(format!("重连失败: {}", e)) + } + } + } else { + error!(" WebSocket not initialized, cannot reconnect"); + Err("WebSocket 未初始化".to_string()) + } +} + +/// 更新 WebSocket 配置 +#[tauri::command] +pub async fn ws_update_config( + _app_handle: AppHandle, + params: UpdateConfigParams, +) -> Result { + info!("Updating WebSocket configuration"); + + let client_container = get_websocket_client_container(); + let client_guard = client_container.read().await; + + if let Some(client) = client_guard.as_ref() { + // 获取当前配置(这里需要添加获取当前配置的方法) + let mut config = WebSocketConfig::default(); + + // 更新配置 + if let Some(interval) = params.heartbeat_interval { + config.heartbeat_interval = interval; + } + if let Some(timeout) = params.heartbeat_timeout { + config.heartbeat_timeout = timeout; + } + if let Some(attempts) = params.max_reconnect_attempts { + config.max_reconnect_attempts = attempts; + } + if let Some(delay) = params.reconnect_delay_ms { + config.reconnect_delay_ms = delay; + } + + client.update_config(config).await; + info!("WebSocket configuration updated successfully"); + Ok(SuccessResponse::new()) + } else { + error!(" WebSocket not initialized, cannot update configuration"); + Err("WebSocket 未初始化".to_string()) + } +} + +/// 检查连接状态 +#[tauri::command] +pub async fn ws_is_connected(_app_handle: AppHandle) -> Result { + let client_container = get_websocket_client_container(); + let client_guard = client_container.read().await; + + if let Some(client) = client_guard.as_ref() { + Ok(client.is_connected()) + } else { + Ok(false) + } +} + +/// 设置应用后台状态 +#[tauri::command] +pub async fn ws_set_app_background_state( + _app_handle: AppHandle, + is_background: bool, +) -> Result { + info!( + "收到应用状态变更请求: {}", + if is_background { "后台" } else { "前台" } + ); + + let client_container = get_websocket_client_container(); + let client_guard = client_container.read().await; + + if let Some(client) = client_guard.as_ref() { + client.set_app_background_state(is_background); + } + + Ok(SuccessResponse::new()) +} + +/// 获取应用后台状态 +#[tauri::command] +pub async fn ws_get_app_background_state(_app_handle: AppHandle) -> Result { + let client_container = get_websocket_client_container(); + let client_guard = client_container.read().await; + + if let Some(client) = client_guard.as_ref() { + Ok(client.is_app_in_background()) + } else { + Ok(false) + } +} diff --git a/src-tauri/src/websocket/message.rs b/src-tauri/src/websocket/message.rs new file mode 100644 index 0000000..e3cb977 --- /dev/null +++ b/src-tauri/src/websocket/message.rs @@ -0,0 +1,204 @@ +use serde_json::Value; +use std::collections::HashMap; +use tracing::debug; + +/// 消息处理器 +/// 负责处理不同类型的 WebSocket 消息 +pub struct MessageProcessor { + message_handlers: HashMap>, +} + +impl MessageProcessor { + pub fn new() -> Self { + Self { + message_handlers: HashMap::new(), + } + } + + /// 注册消息处理器 + pub fn register_handler(&mut self, message_type: String, handler: F) + where + F: Fn(&Value) + Send + Sync + 'static, + { + self.message_handlers + .insert(message_type, Box::new(handler)); + } + + /// 处理接收到的消息 + pub fn process_message(&self, message: &Value) -> ProcessResult { + // 尝试解析消息类型 + if let Some(msg_type) = self.extract_message_type(message) { + debug!("Processing message type: {}", msg_type); + + // 查找对应的处理器 + if let Some(handler) = self.message_handlers.get(&msg_type) { + handler(message); + return ProcessResult::Handled; + } else { + debug!("No handler found for message type {}", msg_type); + } + } + + ProcessResult::Unhandled + } + + /// 提取消息类型 + fn extract_message_type(&self, message: &Value) -> Option { + message.get("type").and_then(|t| { + if let Some(s) = t.as_str() { + Some(s.to_string()) + } else if let Some(n) = t.as_u64() { + Some(n.to_string()) + } else { + None + } + }) + } + + /// 验证消息格式 + pub fn validate_message(&self, message: &Value) -> ValidationResult { + // 基本结构验证 + if !message.is_object() { + return ValidationResult::Invalid("Message must be an object".to_string()); + } + + // 检查必需字段 + if message.get("type").is_none() { + return ValidationResult::Invalid("Message must have a 'type' field".to_string()); + } + + ValidationResult::Valid + } + + /// 过滤敏感信息 + pub fn sanitize_message(&self, mut message: Value) -> Value { + // 移除可能的敏感字段 + if let Some(obj) = message.as_object_mut() { + let sensitive_keys = ["password", "token", "secret", "key"]; + for key in sensitive_keys { + if obj.contains_key(key) { + obj.insert(key.to_string(), Value::String("***".to_string())); + } + } + } + message + } +} + +impl Default for MessageProcessor { + fn default() -> Self { + let mut processor = Self::new(); + processor.register_default_handlers(); + processor + } +} + +impl MessageProcessor { + /// 注册默认的消息处理器 + fn register_default_handlers(&mut self) { + // 登录相关消息 + self.register_handler("1".to_string(), |msg| { + debug!("Processing login-related message: {:?}", msg); + }); + + // 心跳消息 + self.register_handler("2".to_string(), |_msg| { + debug!("Received heartbeat message"); + }); + + self.register_handler("3".to_string(), |_msg| { + debug!("Received heartbeat response"); + }); + + // 普通聊天消息 + self.register_handler("RECEIVE_MESSAGE".to_string(), |msg| { + debug!("Received chat message: {:?}", msg); + }); + + // 用户状态变化 + self.register_handler("USER_STATE_CHANGE".to_string(), |msg| { + debug!("User status changed: {:?}", msg); + }); + + // 视频通话相关 + self.register_handler("VideoCallRequest".to_string(), |msg| { + debug!("Received video call request: {:?}", msg); + }); + + self.register_handler("CallAccepted".to_string(), |msg| { + debug!("Call accepted: {:?}", msg); + }); + + self.register_handler("CallRejected".to_string(), |msg| { + debug!(" Call rejected: {:?}", msg); + }); + } +} + +/// 消息处理结果 +#[derive(Debug, PartialEq)] +pub enum ProcessResult { + Handled, + Unhandled, +} + +/// 消息验证结果 +#[derive(Debug, PartialEq)] +pub enum ValidationResult { + Valid, + Invalid(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_message_validation() { + let processor = MessageProcessor::new(); + + // 有效消息 + let valid_msg = json!({ + "type": "test", + "data": "some data" + }); + assert_eq!( + processor.validate_message(&valid_msg), + ValidationResult::Valid + ); + + // 无效消息 - 缺少 type + let invalid_msg = json!({ + "data": "some data" + }); + assert!(matches!( + processor.validate_message(&invalid_msg), + ValidationResult::Invalid(_) + )); + + // 无效消息 - 不是对象 + let invalid_msg2 = json!("not an object"); + assert!(matches!( + processor.validate_message(&invalid_msg2), + ValidationResult::Invalid(_) + )); + } + + #[test] + fn test_message_sanitization() { + let processor = MessageProcessor::new(); + + let sensitive_msg = json!({ + "type": "login", + "password": "secret123", + "token": "abc123", + "data": "normal data" + }); + + let sanitized = processor.sanitize_message(sensitive_msg); + assert_eq!(sanitized["password"], "***"); + assert_eq!(sanitized["token"], "***"); + assert_eq!(sanitized["data"], "normal data"); + } +} diff --git a/src-tauri/src/websocket/mod.rs b/src-tauri/src/websocket/mod.rs new file mode 100644 index 0000000..d323661 --- /dev/null +++ b/src-tauri/src/websocket/mod.rs @@ -0,0 +1,10 @@ +/// WebSocket 模块 +/// 提供 WebSocket 连接管理、心跳机制、消息处理等功能 +pub mod client; +pub mod commands; +pub mod message; +pub mod types; + +pub use client::WebSocketClient; +pub use message::MessageProcessor; +pub use types::*; diff --git a/src-tauri/src/websocket/types.rs b/src-tauri/src/websocket/types.rs new file mode 100644 index 0000000..2920a5e --- /dev/null +++ b/src-tauri/src/websocket/types.rs @@ -0,0 +1,121 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// WebSocket 连接状态 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ConnectionState { + Disconnected, + Connecting, + Connected, + Reconnecting, + Error, +} + +/// WebSocket 消息类型 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum WsMessage { + /// 心跳消息 (ping) + #[serde(rename = "2")] + Heartbeat, + /// 心跳响应 (pong) + #[serde(rename = "3", rename_all = "camelCase")] + HeartbeatResponse { timestamp: u64 }, + /// 普通消息 + Message { + #[serde(flatten)] + data: serde_json::Value, + }, +} + +/// WebSocket 响应消息类型 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WsResponseMessage { + #[serde(rename = "type")] + pub msg_type: u32, + pub data: Option, +} + +/// WebSocket 连接配置 +#[derive(Debug, Clone)] +pub struct WebSocketConfig { + pub server_url: String, + pub token: Option, + pub client_id: String, + pub heartbeat_interval: u64, + pub heartbeat_timeout: u64, + pub max_reconnect_attempts: u32, + pub reconnect_delay_ms: u64, +} + +impl Default for WebSocketConfig { + fn default() -> Self { + Self { + server_url: String::new(), + token: None, + client_id: String::new(), + heartbeat_interval: 9900, // 9.9秒 + heartbeat_timeout: 15000, // 15秒 + // 0 表示无限重连 + max_reconnect_attempts: 0, + reconnect_delay_ms: 1000, // 1秒 + } + } +} + +/// 连接健康状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectionHealth { + pub is_healthy: bool, + pub last_pong_time: Option, + pub consecutive_failures: u32, + pub round_trip_time: Option, +} + +/// WebSocket 事件 +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum WebSocketEvent { + ConnectionStateChanged { + state: ConnectionState, + is_reconnection: bool, + }, + MessageReceived { + message: serde_json::Value, + }, + HeartbeatStatusChanged { + health: ConnectionHealth, + }, + Error { + message: String, + details: Option>, + }, +} + +/// WebSocket 请求消息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WsRequest { + pub data: serde_json::Value, +} + +/// 重连配置 +#[derive(Debug, Clone)] +pub struct ReconnectConfig { + pub max_attempts: u32, + pub initial_delay_ms: u64, + pub max_delay_ms: u64, + pub backoff_multiplier: f64, +} + +impl Default for ReconnectConfig { + fn default() -> Self { + Self { + max_attempts: 10, + initial_delay_ms: 1000, + max_delay_ms: 15000, + backoff_multiplier: 1.5, + } + } +} diff --git a/src-tauri/src/webview_helper/ios.rs b/src-tauri/src/webview_helper/ios.rs new file mode 100644 index 0000000..57148f5 --- /dev/null +++ b/src-tauri/src/webview_helper/ios.rs @@ -0,0 +1,556 @@ +use std::{ + cell::RefCell, + ptr::NonNull, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, +}; + +use objc2::{ + DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, + rc::Retained, + runtime::{AnyObject, ProtocolObject}, +}; +use objc2_core_foundation::{CGPoint, CGRect}; +use objc2_foundation::{ + NSDictionary, NSNotification, NSNotificationCenter, NSNotificationName, NSNumber, NSObject, + NSObjectProtocol, NSString, NSTimeInterval, NSValue, +}; +use objc2_ui_kit::{ + UIColor, UIKeyboardDidShowNotification, UIKeyboardWillHideNotification, + UIKeyboardWillShowNotification, UIScrollView, UIScrollViewContentInsetAdjustmentBehavior, + UIScrollViewDelegate, UIView, UIViewAnimationOptions, UIWebView, +}; +use tauri::WebviewWindow; + +thread_local! { + static KEYBOARD_DELEGATE: RefCell> = RefCell::new(None); +} + +static SHOULD_ADJUST: AtomicBool = AtomicBool::new(false); + +/// 读取全局标记,确定当前键盘显示时的处理模式(调整或锁定)。 +#[inline] +fn should_adjust() -> bool { + SHOULD_ADJUST.load(Ordering::SeqCst) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum KeyboardHandlingMode { + None, + Adjust, + Lock, +} + +enum KeyboardDelegateHolder { + Adjust(Retained), + Lock(Retained), +} + +/// 初始化 iOS WebView 的键盘处理逻辑,注册通知并按模式调整布局或锁定滚动。 +pub fn initialize_keyboard_adjustment(webview_window: &WebviewWindow) { + let _ = webview_window.with_webview(|webview| unsafe { + #[allow(deprecated)] + let webview: &UIWebView = &*webview.inner().cast(); + let notification_center = NSNotificationCenter::defaultCenter(); + let main_thread = MainThreadMarker::from(webview); + let background_color = UIColor::colorWithRed_green_blue_alpha( + 243.0 / 255.0, + 251.0 / 255.0, + 248.0 / 255.0, + 1.0, + ); + + webview.setOpaque(false); + webview.setBackgroundColor(Some(&background_color)); + + let scroll_view_arc = Arc::new(webview.scrollView()); + scroll_view_arc.setBackgroundColor(Some(&background_color)); + let old_delegate_arc = Arc::new(Mutex::new(None)); + let handling_mode_arc = Arc::new(Mutex::new(KeyboardHandlingMode::None)); + + scroll_view_arc + .setContentInsetAdjustmentBehavior(UIScrollViewContentInsetAdjustmentBehavior::Never); + + // 记录原始状态,避免累积调整 + let keyboard_height_arc = Arc::new(Mutex::new(0f64)); + let original_frame_arc = Arc::new(Mutex::new(webview.frame())); + let original_inset_arc = Arc::new(Mutex::new(scroll_view_arc.contentInset())); + let webview_arc = Arc::new( + Retained::retain(webview as *const _ as *mut UIWebView) + .expect("Failed to retain UIWebView reference"), + ); + + let scroll_view_arc_observer = scroll_view_arc.clone(); + let old_delegate_arc_will_show = old_delegate_arc.clone(); + let keyboard_height_arc_observer = keyboard_height_arc.clone(); + let original_frame_arc_observer = original_frame_arc.clone(); + let original_inset_arc_observer = original_inset_arc.clone(); + let handling_mode_arc_observer = handling_mode_arc.clone(); + let webview_arc_observer = webview_arc.clone(); + // 监听键盘即将显示,依据模式调整布局和滚动。 + create_observer( + ¬ification_center, + &UIKeyboardWillShowNotification, + move |notification| { + let desired_mode = if should_adjust() { + KeyboardHandlingMode::Adjust + } else { + KeyboardHandlingMode::Lock + }; + + { + let mut mode = handling_mode_arc_observer.lock().unwrap(); + *mode = desired_mode; + } + + let mut old_delegate = old_delegate_arc_will_show.lock().unwrap(); + *old_delegate = scroll_view_arc_observer.delegate(); + + match desired_mode { + KeyboardHandlingMode::Adjust => { + unsafe { + let scroll_view_ptr = (&**scroll_view_arc_observer) + as *const UIScrollView + as *mut UIScrollView; + let _: () = msg_send![ + scroll_view_ptr, + setShowsVerticalScrollIndicator: true + ]; + let _: () = msg_send![ + scroll_view_ptr, + setShowsHorizontalScrollIndicator: true + ]; + let _: () = msg_send![scroll_view_ptr, setBounces: true]; + } + + let new_delegate = KeyboardScrollPreventDelegate::new( + main_thread, + scroll_view_arc_observer.clone(), + scroll_view_arc_observer.contentOffset(), + ); + + KEYBOARD_DELEGATE.with(|cell| { + *cell.borrow_mut() = Some(KeyboardDelegateHolder::Adjust(new_delegate)); + }); + + KEYBOARD_DELEGATE.with(|cell| { + if let Some(KeyboardDelegateHolder::Adjust(delegate)) = + cell.borrow().as_ref() + { + let delegate_obj = ProtocolObject::from_ref(&**delegate); + scroll_view_arc_observer.setDelegate(Some(delegate_obj)); + } + }); + } + KeyboardHandlingMode::Lock => { + let new_delegate = KeyboardLockDelegate::new( + main_thread, + scroll_view_arc_observer.clone(), + webview_arc_observer.clone(), + scroll_view_arc_observer.contentOffset(), + ); + + KEYBOARD_DELEGATE.with(|cell| { + *cell.borrow_mut() = Some(KeyboardDelegateHolder::Lock(new_delegate)); + }); + + KEYBOARD_DELEGATE.with(|cell| { + if let Some(KeyboardDelegateHolder::Lock(delegate)) = + cell.borrow().as_ref() + { + let delegate_obj = ProtocolObject::from_ref(&**delegate); + scroll_view_arc_observer.setDelegate(Some(delegate_obj)); + } + }); + + unsafe { + let scroll_view_ptr = (&**scroll_view_arc_observer) + as *const UIScrollView + as *mut UIScrollView; + let _: () = msg_send![ + scroll_view_ptr, + setShowsVerticalScrollIndicator: false + ]; + let _: () = msg_send![ + scroll_view_ptr, + setShowsHorizontalScrollIndicator: false + ]; + let _: () = msg_send![scroll_view_ptr, setBounces: false]; + } + + // 清理键盘高度,避免误触发还原逻辑 + let mut keyboard_height = keyboard_height_arc_observer.lock().unwrap(); + *keyboard_height = 0.0; + + // 在锁定模式下不需要执行下方的高度调整逻辑 + return; + } + KeyboardHandlingMode::None => unreachable!(), + }; + + let user_info: *mut NSDictionary = + msg_send![notification, userInfo]; + if user_info.is_null() { + return; + } + + let frame_key = NSString::from_str("UIKeyboardFrameEndUserInfoKey"); + let value: *mut NSValue = msg_send![user_info, objectForKey: &*frame_key]; + if value.is_null() { + return; + } + + let keyboard_rect: CGRect = msg_send![value, CGRectValue]; + let new_keyboard_height = keyboard_rect.size.height; + + let original_frame = *original_frame_arc_observer.lock().unwrap(); + let mut adjusted_frame = original_frame; + adjusted_frame.size.height -= new_keyboard_height; + + let original_inset = *original_inset_arc_observer.lock().unwrap(); + let mut adjusted_inset = original_inset; + adjusted_inset.bottom += new_keyboard_height; + + { + let mut keyboard_height = keyboard_height_arc_observer.lock().unwrap(); + *keyboard_height = new_keyboard_height as f64; + } + + let duration_key = NSString::from_str("UIKeyboardAnimationDurationUserInfoKey"); + let duration_value: *mut NSNumber = + msg_send![user_info, objectForKey: &*duration_key]; + let duration: NSTimeInterval = { + duration_value + .as_ref() + .map(|number| number.doubleValue() as NSTimeInterval) + .unwrap_or(0.25) + }; + + let curve_key = NSString::from_str("UIKeyboardAnimationCurveUserInfoKey"); + let curve_value: *mut NSNumber = msg_send![user_info, objectForKey: &*curve_key]; + let curve_bits = { + curve_value + .as_ref() + .map(|number| (number.integerValue() as u64) << 16) + .unwrap_or(UIViewAnimationOptions::CurveEaseInOut.bits() as u64) + }; + + let mut options = UIViewAnimationOptions::BeginFromCurrentState + | UIViewAnimationOptions::AllowUserInteraction; + options |= UIViewAnimationOptions::from_bits_retain(curve_bits as _); + + let scroll_view_for_animation = scroll_view_arc_observer.clone(); + let animation_block = block2::StackBlock::new(move || { + // Match the keyboard transition to avoid visible jumps. + scroll_view_for_animation.setContentInset(adjusted_inset); + scroll_view_for_animation.setScrollIndicatorInsets(adjusted_inset); + webview.setFrame(adjusted_frame); + }) + .copy(); + + unsafe { + UIView::animateWithDuration_delay_options_animations_completion( + duration, + 0.0, + options, + &animation_block, + None, + main_thread, + ); + } + }, + ); + + let scroll_view_arc_observer = scroll_view_arc.clone(); + let old_delegate_arc_will_hide = old_delegate_arc.clone(); + let keyboard_height_arc_observer = keyboard_height_arc.clone(); + let original_frame_arc_observer = original_frame_arc.clone(); + let original_inset_arc_observer = original_inset_arc.clone(); + let handling_mode_arc_observer = handling_mode_arc.clone(); + let webview_arc_observer = webview_arc.clone(); + // 监听键盘即将隐藏,恢复初始布局或结束编辑。 + create_observer( + ¬ification_center, + &UIKeyboardWillHideNotification, + move |notification| { + let current_mode = *handling_mode_arc_observer.lock().unwrap(); + + if current_mode == KeyboardHandlingMode::Adjust { + let keyboard_height = keyboard_height_arc_observer.lock().unwrap(); + if *keyboard_height <= 0.0 { + return; + } + drop(keyboard_height); + + let user_info: *mut NSDictionary = + msg_send![notification, userInfo]; + + let duration_key = NSString::from_str("UIKeyboardAnimationDurationUserInfoKey"); + let duration_value: *mut NSNumber = + msg_send![user_info, objectForKey: &*duration_key]; + let duration: NSTimeInterval = { + duration_value + .as_ref() + .map(|number| number.doubleValue() as NSTimeInterval) + .unwrap_or(0.25) + }; + + let curve_key = NSString::from_str("UIKeyboardAnimationCurveUserInfoKey"); + let curve_value: *mut NSNumber = + msg_send![user_info, objectForKey: &*curve_key]; + let curve_bits = { + curve_value + .as_ref() + .map(|number| (number.integerValue() as u64) << 16) + .unwrap_or(UIViewAnimationOptions::CurveEaseInOut.bits() as u64) + }; + + let mut options = UIViewAnimationOptions::BeginFromCurrentState + | UIViewAnimationOptions::AllowUserInteraction; + options |= UIViewAnimationOptions::from_bits_retain(curve_bits as _); + + let original_frame = *original_frame_arc_observer.lock().unwrap(); + let original_inset = *original_inset_arc_observer.lock().unwrap(); + + let scroll_view_for_animation = scroll_view_arc_observer.clone(); + let animation_block = block2::StackBlock::new(move || { + // 跟随键盘动画恢复初始布局 + scroll_view_for_animation.setContentInset(original_inset); + scroll_view_for_animation.setScrollIndicatorInsets(original_inset); + webview.setFrame(original_frame); + }) + .copy(); + + unsafe { + UIView::animateWithDuration_delay_options_animations_completion( + duration, + 0.0, + options, + &animation_block, + None, + main_thread, + ); + } + + let mut keyboard_height = keyboard_height_arc_observer.lock().unwrap(); + *keyboard_height = 0.0; + let mut mode = handling_mode_arc_observer.lock().unwrap(); + *mode = KeyboardHandlingMode::None; + } else if current_mode == KeyboardHandlingMode::Lock { + // 在锁定模式下,恢复滚动视图的默认行为并保持键盘隐藏 + let webview_ptr = + (&**webview_arc_observer) as *const UIWebView as *mut UIWebView; + let _: () = msg_send![webview_ptr, endEditing: true]; + + unsafe { + let scroll_view_ptr = (&**scroll_view_arc_observer) as *const UIScrollView + as *mut UIScrollView; + let _: () = msg_send![ + scroll_view_ptr, + setShowsVerticalScrollIndicator: true + ]; + let _: () = msg_send![ + scroll_view_ptr, + setShowsHorizontalScrollIndicator: true + ]; + let _: () = msg_send![scroll_view_ptr, setBounces: true]; + } + + KEYBOARD_DELEGATE.with(|cell| { + *cell.borrow_mut() = None; + }); + + let mut old_delegate = old_delegate_arc_will_hide.lock().unwrap(); + // 恢复原始 UIScrollViewDelegate,确保键盘逻辑结束后行为回到默认。 + if let Some(delegate) = old_delegate.take() { + scroll_view_arc_observer.setDelegate(Some(delegate.as_ref())); + } else { + scroll_view_arc_observer.setDelegate(None); + } + + let mut mode = handling_mode_arc_observer.lock().unwrap(); + *mode = KeyboardHandlingMode::None; + } + }, + ); + + let scroll_view_arc_observer = scroll_view_arc.clone(); + let old_delegate_arc_did_show = old_delegate_arc.clone(); + let keyboard_height_arc_observer = keyboard_height_arc.clone(); + let original_frame_arc_observer = original_frame_arc.clone(); + let original_inset_arc_observer = original_inset_arc.clone(); + let handling_mode_arc_observer = handling_mode_arc.clone(); + // 监听键盘已经显示,确保最终 frame/inset 精确同步。 + create_observer( + ¬ification_center, + &UIKeyboardDidShowNotification, + move |notification| { + let current_mode = *handling_mode_arc_observer.lock().unwrap(); + if current_mode != KeyboardHandlingMode::Adjust { + return; + } + + let user_info: *mut NSDictionary = + msg_send![notification, userInfo]; + if user_info.is_null() { + return; + } + + let key = NSString::from_str("UIKeyboardFrameEndUserInfoKey"); + let value: *mut NSValue = msg_send![user_info, objectForKey: &*key]; + if value.is_null() { + return; + } + + let keyboard_rect: CGRect = msg_send![value, CGRectValue]; + let new_keyboard_height = keyboard_rect.size.height; + + // 基于原始状态进行调整,避免累积 + let original_frame = original_frame_arc_observer.lock().unwrap(); + let mut adjusted_frame = *original_frame; + adjusted_frame.size.height -= new_keyboard_height; + webview.setFrame(adjusted_frame); + + // 基于原始 inset 进行调整 + let original_inset = original_inset_arc_observer.lock().unwrap(); + let mut adjusted_inset = *original_inset; + adjusted_inset.bottom += new_keyboard_height; + scroll_view_arc_observer.setContentInset(adjusted_inset); + scroll_view_arc_observer.setScrollIndicatorInsets(adjusted_inset); + + // 更新键盘高度记录 + let mut keyboard_height = keyboard_height_arc_observer.lock().unwrap(); + *keyboard_height = new_keyboard_height; + + let mut old_delegate = old_delegate_arc_did_show.lock().unwrap(); + // 键盘显示完毕后恢复原 delegate,避免累积的自定义 delegate 影响后续滚动事件。 + if let Some(delegate) = old_delegate.take() { + scroll_view_arc_observer.setDelegate(Some(delegate.as_ref())); + } else { + scroll_view_arc_observer.setDelegate(None); + } + + KEYBOARD_DELEGATE.with(|cell| { + *cell.borrow_mut() = None; + }); + }, + ); + }); +} + +/// 开关键盘调整模式,使后续通知回调依据该值决定策略。 +pub fn set_keyboard_adjustment(enabled: bool) { + SHOULD_ADJUST.store(enabled, Ordering::SeqCst); +} + +pub struct KeyboardScrollPreventDelegateState { + pub scroll_view: Arc>, + pub offset: CGPoint, +} + +define_class! { + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "KeyboardScrollPreventDelegate"] + #[ivars = KeyboardScrollPreventDelegateState] + pub(crate) struct KeyboardScrollPreventDelegate; + + unsafe impl NSObjectProtocol for KeyboardScrollPreventDelegate {} + + unsafe impl UIScrollViewDelegate for KeyboardScrollPreventDelegate { + #[unsafe(method(scrollViewDidScroll:))] + // 在滚动回调中强制恢复初始偏移量,避免用户拖动导致错位。 + unsafe fn scroll_view_did_scroll(&self, _scroll_view: &UIScrollView) { + self.ivars() + .scroll_view + .setContentOffset(self.ivars().offset); + } + } +} + +impl KeyboardScrollPreventDelegate { + /// 构建滚动阻止 delegate,保存目标滚动视图与初始偏移。 + fn new( + mtm: MainThreadMarker, + scroll_view: Arc>, + offset: CGPoint, + ) -> Retained { + let delegate = Self::alloc(mtm).set_ivars(KeyboardScrollPreventDelegateState { + scroll_view, + offset, + }); + + unsafe { msg_send![super(delegate), init] } + } +} + +pub struct KeyboardLockDelegateState { + pub scroll_view: Arc>, + pub webview: Arc>, + pub locked_offset: CGPoint, +} + +define_class! { + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "KeyboardLockDelegate"] + #[ivars = KeyboardLockDelegateState] + pub(crate) struct KeyboardLockDelegate; + + unsafe impl NSObjectProtocol for KeyboardLockDelegate {} + + unsafe impl UIScrollViewDelegate for KeyboardLockDelegate { + #[unsafe(method(scrollViewWillBeginDragging:))] + // 用户尝试拖动时立即结束编辑并恢复锁定偏移。 + unsafe fn scroll_view_will_begin_dragging(&self, _scroll_view: &UIScrollView) { + let ivars = self.ivars(); + let webview_ptr = + (&**ivars.webview) as *const UIWebView as *mut UIWebView; + let _: () = msg_send![webview_ptr, endEditing: true]; + ivars + .scroll_view + .setContentOffset(ivars.locked_offset); + } + + #[unsafe(method(scrollViewDidScroll:))] + unsafe fn scroll_view_did_scroll(&self, _scroll_view: &UIScrollView) { + let ivars = self.ivars(); + ivars + .scroll_view + .setContentOffset(ivars.locked_offset); + } + } +} + +impl KeyboardLockDelegate { + /// 构建滚动锁定 delegate,记录滚动视图、webview 与锁定偏移。 + fn new( + mtm: MainThreadMarker, + scroll_view: Arc>, + webview: Arc>, + offset: CGPoint, + ) -> Retained { + let delegate = Self::alloc(mtm).set_ivars(KeyboardLockDelegateState { + scroll_view, + webview, + locked_offset: offset, + }); + + unsafe { msg_send![super(delegate), init] } + } +} + +/// 封装通知监听注册,使用闭包处理收到的系统通知。 +fn create_observer( + center: &NSNotificationCenter, + name: &NSNotificationName, + handler: impl Fn(&NSNotification) + 'static, +) -> Retained> { + let block = block2::RcBlock::new(move |notification: NonNull| { + handler(unsafe { notification.as_ref() }); + }); + + unsafe { center.addObserverForName_object_queue_usingBlock(Some(name), None, None, &block) } +} diff --git a/src-tauri/src/webview_helper/mod.rs b/src-tauri/src/webview_helper/mod.rs new file mode 100644 index 0000000..fe2dd87 --- /dev/null +++ b/src-tauri/src/webview_helper/mod.rs @@ -0,0 +1,5 @@ +#[cfg(target_os = "ios")] +pub mod ios; + +#[cfg(target_os = "ios")] +pub use ios::{initialize_keyboard_adjustment, set_keyboard_adjustment}; diff --git a/src-tauri/tauri.android.conf.json b/src-tauri/tauri.android.conf.json new file mode 100644 index 0000000..35e84a8 --- /dev/null +++ b/src-tauri/tauri.android.conf.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "HuLa", + "version": "3.0.9", + "identifier": "com.hula.app", + "build": { + "beforeDevCommand": "pnpm dev", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist", + "devUrl": "http://127.0.0.1:5210" + }, + "bundle": { + "active": true, + "resources": [ + "configuration", + "draco" + ], + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico", + "icons/icon.png", + "icons/Square30x30Logo.png", + "icons/Square44x44Logo.png", + "icons/Square71x71Logo.png", + "icons/Square89x89Logo.png", + "icons/Square107x107Logo.png", + "icons/Square142x142Logo.png", + "icons/Square150x150Logo.png", + "icons/Square284x284Logo.png", + "icons/Square310x310Logo.png", + "icons/StoreLogo.png" + ], + "android": { + "minSdkVersion": 26 + } + }, + "app": { + "windows": [ + { + "title": "登录", + "label": "mobile-home", + "url": "/mobile/splashscreen", + "width": 800, + "height": 600, + "resizable": false, + "fullscreen": true + } + ], + "security": { + "csp": null + } + }, + "plugins": { + "statusBar": { + "backgroundColor": "transparent", + "style": "light" + }, + "safeAreaInsets": { + "enabled": true + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..2bfd88a --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "HuLa", + "version": "3.0.9", + "identifier": "com.hula.pc", + "build": { + "beforeDevCommand": "pnpm dev", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist", + "devUrl": "http://127.0.0.1:6130" + }, + "bundle": { + "category": "SocialNetworking", + "copyright": "HuLaSpark", + "licenseFile": "./copyright/License.rtf", + "resources": [ + "tray", + "configuration", + "draco", + "docs" + ], + "createUpdaterArtifacts": true, + "active": true, + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico", + "icons/icon.png", + "icons/Square30x30Logo.png", + "icons/Square44x44Logo.png", + "icons/Square71x71Logo.png", + "icons/Square89x89Logo.png", + "icons/Square107x107Logo.png", + "icons/Square142x142Logo.png", + "icons/Square150x150Logo.png", + "icons/Square284x284Logo.png", + "icons/Square310x310Logo.png", + "icons/StoreLogo.png" + ], + "windows": {}, + "macOS": {} + }, + "app": { + "withGlobalTauri": true, + "windows": [], + "security": { + "csp": null, + "capabilities": [ + "default-capability", + "mobile-capability", + "desktop-capability" + ], + "assetProtocol": { + "enable": true, + "scope": [ + "**" + ] + } + }, + "macOSPrivateApi": true + }, + "plugins": { + "updater": { + "active": true, + "windows": { + "installMode": "passive" + }, + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDk1NkNENEZFNzg1MjVFMEEKUldRS1hsSjQvdFJzbGJXcnNPNXBYZ2RlTmlRRFZYYVI3YXhiWGpYZXFwVUtucThZUnJHUGw5dVUK", + "endpoints": [ + "https://api.upgrade.toolsetlink.com/v1/tauri/upgrade?tauriKey=geShj8UB7zd1DyrM_YFNdg&versionName={{current_version}}&appointVersionName=&devModelKey=&devKey=&target={{target}}&arch={{arch}}", + "https://gitee.com/HuLaSpark/HuLa/releases/download/latest/latest.json" + ] + } + } +} diff --git a/src-tauri/tauri.ios.conf.json b/src-tauri/tauri.ios.conf.json new file mode 100644 index 0000000..3500e3d --- /dev/null +++ b/src-tauri/tauri.ios.conf.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "HuLa", + "version": "3.0.9", + "identifier": "com.hula.ios", + "build": { + "beforeDevCommand": "pnpm dev", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist", + "devUrl": "http://127.0.0.1:5210" + }, + "bundle": { + "active": true, + "resources": [ + "configuration", + "draco" + ], + "targets": "all", + "iOS": { + "minimumSystemVersion": "13.0" + } + }, + "app": { + "windows": [ + { + "title": "登录", + "label": "mobile-home", + "url": "/mobile/splashscreen", + "resizable": false, + "fullscreen": true + } + ], + "security": { + "csp": null + } + } +} diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json new file mode 100644 index 0000000..e39c392 --- /dev/null +++ b/src-tauri/tauri.linux.conf.json @@ -0,0 +1,103 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "HuLa", + "version": "3.0.9", + "identifier": "com.hula.pc", + "build": { + "beforeDevCommand": "pnpm dev", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist", + "devUrl": "http://127.0.0.1:6130" + }, + "bundle": { + "resources": [ + "tray", + "configuration", + "draco" + ], + "active": true, + "targets": [ + "deb", + "rpm" + ], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico", + "icons/icon.png", + "icons/Square30x30Logo.png", + "icons/Square44x44Logo.png", + "icons/Square71x71Logo.png", + "icons/Square89x89Logo.png", + "icons/Square107x107Logo.png", + "icons/Square142x142Logo.png", + "icons/Square150x150Logo.png", + "icons/Square284x284Logo.png", + "icons/Square310x310Logo.png", + "icons/StoreLogo.png" + ], + "linux": { + "appimage": { + "bundleMediaFramework": false, + "files": {} + }, + "deb": { + "files": {} + }, + "rpm": { + "epoch": 0, + "files": {}, + "release": "1" + } + } + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "title": "登录", + "label": "login", + "url": "/login", + "fullscreen": false, + "resizable": false, + "center": true, + "width": 320, + "height": 448, + "skipTaskbar": false, + "transparent": false, + "decorations": false, + "visible": false + }, + { + "label": "tray", + "url": "/tray", + "resizable": false, + "center": false, + "visible": false, + "width": 130, + "height": 44, + "alwaysOnTop": true, + "skipTaskbar": true, + "decorations": false + }, + { + "label": "checkupdate", + "url": "/checkupdate", + "resizable": false, + "center": true, + "closable": false, + "width": 500, + "height": 600, + "visible": false, + "decorations": false, + "hiddenTitle": true + } + ], + "security": { + "csp": null + }, + "macOSPrivateApi": true + } +} diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..b6826e0 --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "HuLa", + "version": "3.0.9", + "identifier": "com.hula.pc", + "build": { + "beforeDevCommand": "pnpm dev", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist", + "devUrl": "http://127.0.0.1:6130" + }, + "bundle": { + "resources": [ + "tray", + "configuration", + "Info.plist", + "draco", + "docs" + ], + "active": true, + "targets": [ + "app", + "dmg" + ], + "icon": [ + "icons/macos/light/32x32.png", + "icons/macos/light/128x128.png", + "icons/macos/light/128x128@2x.png", + "icons/macos/light/icon.icns", + "icons/macos/light/icon.ico", + "icons/macos/light/icon.png", + "icons/macos/light/Square30x30Logo.png", + "icons/macos/light/Square44x44Logo.png", + "icons/macos/light/Square71x71Logo.png", + "icons/macos/light/Square89x89Logo.png", + "icons/macos/light/Square107x107Logo.png", + "icons/macos/light/Square142x142Logo.png", + "icons/macos/light/Square150x150Logo.png", + "icons/macos/light/Square284x284Logo.png", + "icons/macos/light/Square310x310Logo.png", + "icons/macos/light/StoreLogo.png" + ], + "macOS": { + "dmg": { + "appPosition": { + "x": 180, + "y": 170 + }, + "applicationFolderPosition": { + "x": 480, + "y": 170 + }, + "windowSize": { + "height": 400, + "width": 660 + } + }, + "files": {}, + "hardenedRuntime": true, + "minimumSystemVersion": "10.13" + } + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "title": "登录", + "label": "login", + "url": "/login", + "fullscreen": false, + "resizable": false, + "center": true, + "width": 320, + "height": 448, + "skipTaskbar": false, + "transparent": false, + "visible": false, + "titleBarStyle": "Overlay", + "hiddenTitle": true + }, + { + "label": "capture", + "url": "/capture", + "fullscreen": false, + "transparent": true, + "resizable": false, + "skipTaskbar": true, + "decorations": false, + "visible": false, + "hiddenTitle": true, + "alwaysOnTop": true, + "focus": true, + "titleBarStyle": "Overlay", + "visibleOnAllWorkspaces": true + }, + { + "label": "checkupdate", + "url": "/checkupdate", + "resizable": false, + "width": 500, + "height": 150, + "alwaysOnTop": true, + "focus": true, + "skipTaskbar": true, + "visible": false, + "titleBarStyle": "Overlay", + "hiddenTitle": true + } + ], + "security": { + "csp": null + }, + "macOSPrivateApi": true + } +} diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 0000000..e04a1a2 --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -0,0 +1,130 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "HuLa", + "version": "3.0.9", + "identifier": "com.hula.pc", + "build": { + "beforeDevCommand": "pnpm dev", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist", + "devUrl": "http://127.0.0.1:6130" + }, + "bundle": { + "resources": [ + "tray", + "configuration", + "draco", + "docs" + ], + "active": true, + "targets": [ + "msi", + "nsis" + ], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico", + "icons/icon.png", + "icons/Square30x30Logo.png", + "icons/Square44x44Logo.png", + "icons/Square71x71Logo.png", + "icons/Square89x89Logo.png", + "icons/Square107x107Logo.png", + "icons/Square142x142Logo.png", + "icons/Square150x150Logo.png", + "icons/Square284x284Logo.png", + "icons/Square310x310Logo.png", + "icons/StoreLogo.png" + ], + "windows": { + "wix": { + "language": "zh-CN" + }, + "nsis": { + "installMode": "perMachine", + "installerIcon": "./icons/icon.ico", + "displayLanguageSelector": true, + "languages": [ + "SimpChinese" + ], + "template": "template/installer.nsi" + } + } + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "title": "登录", + "label": "login", + "url": "/login", + "fullscreen": false, + "resizable": false, + "center": true, + "width": 320, + "height": 448, + "skipTaskbar": false, + "transparent": true, + "visible": false, + "decorations": false + }, + { + "label": "tray", + "url": "/tray", + "resizable": false, + "center": false, + "visible": false, + "width": 130, + "height": 44, + "alwaysOnTop": true, + "skipTaskbar": true, + "decorations": false, + "transparent": true + }, + { + "label": "notify", + "url": "/notify", + "resizable": false, + "center": false, + "visible": false, + "width": 280, + "height": 140, + "alwaysOnTop": true, + "skipTaskbar": true, + "decorations": false, + "transparent": true + }, + { + "label": "capture", + "url": "/capture", + "fullscreen": true, + "transparent": true, + "resizable": false, + "skipTaskbar": true, + "decorations": false, + "visible": false, + "hiddenTitle": true + }, + { + "label": "checkupdate", + "url": "/checkupdate", + "resizable": false, + "width": 500, + "height": 150, + "alwaysOnTop": true, + "focus": true, + "skipTaskbar": true, + "visible": false, + "decorations": false, + "hiddenTitle": true + } + ], + "security": { + "csp": null + }, + "macOSPrivateApi": true + } +} diff --git a/src-tauri/template/FileAssociation.nsh b/src-tauri/template/FileAssociation.nsh new file mode 100644 index 0000000..0421535 --- /dev/null +++ b/src-tauri/template/FileAssociation.nsh @@ -0,0 +1,135 @@ +; from https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b +; fileassoc.nsh +; File association helper macros +; Written by Saivert +; +; Improved by Nikku. +; +; Features automatic backup system and UPDATEFILEASSOC macro for +; shell change notification. +; +; |> How to use <| +; To associate a file with an application so you can double-click it in explorer, use +; the APP_ASSOCIATE macro like this: +; +; Example: +; !insertmacro APP_ASSOCIATE "txt" "myapp.textfile" "Description of txt files" \ +; "$INSTDIR\myapp.exe,0" "Open with myapp" "$INSTDIR\myapp.exe $\"%1$\"" +; +; Never insert the APP_ASSOCIATE macro multiple times, it is only meant +; to associate an application with a single file and using the +; the "open" verb as default. To add more verbs (actions) to a file +; use the APP_ASSOCIATE_ADDVERB macro. +; +; Example: +; !insertmacro APP_ASSOCIATE_ADDVERB "myapp.textfile" "edit" "Edit with myapp" \ +; "$INSTDIR\myapp.exe /edit $\"%1$\"" +; +; To have access to more options when registering the file association use the +; APP_ASSOCIATE_EX macro. Here you can specify the verb and what verb is to be the +; standard action (default verb). +; +; Note, that this script takes into account user versus global installs. +; To properly work you must initialize the SHELL_CONTEXT variable via SetShellVarContext. +; +; And finally: To remove the association from the registry use the APP_UNASSOCIATE +; macro. Here is another example just to wrap it up: +; !insertmacro APP_UNASSOCIATE "txt" "myapp.textfile" +; +; |> Note <| +; When defining your file class string always use the short form of your application title +; then a period (dot) and the type of file. This keeps the file class sort of unique. +; Examples: +; Winamp.Playlist +; NSIS.Script +; Photoshop.JPEGFile +; +; |> Tech info <| +; The registry key layout for a global file association is: +; +; HKEY_LOCAL_MACHINE\Software\Classes +; <".ext"> = +; = <"description"> +; shell +; = <"menu-item text"> +; command = <"command string"> +; +; +; The registry key layout for a per-user file association is: +; +; HKEY_CURRENT_USER\Software\Classes +; <".ext"> = +; = <"description"> +; shell +; = <"menu-item text"> +; command = <"command string"> +; + +!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0" + + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}" + + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open" + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}` +!macroend + +!macro APP_ASSOCIATE_EX EXT FILECLASS DESCRIPTION ICON VERB DEFAULTVERB SHELLNEW COMMANDTEXT COMMAND + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0" + + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}" + StrCmp "${SHELLNEW}" "0" +2 + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}\ShellNew" "NullFile" "" + + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" `${DEFAULTVERB}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\${VERB}" "" `${COMMANDTEXT}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\${VERB}\command" "" `${COMMAND}` +!macroend + +!macro APP_ASSOCIATE_ADDVERB FILECLASS VERB COMMANDTEXT COMMAND + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\${VERB}" "" `${COMMANDTEXT}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\${VERB}\command" "" `${COMMAND}` +!macroend + +!macro APP_ASSOCIATE_REMOVEVERB FILECLASS VERB + DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}\shell\${VERB}` +!macroend + + +!macro APP_UNASSOCIATE EXT FILECLASS + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup` + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0" + + DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}` +!macroend + +!macro APP_ASSOCIATE_GETFILECLASS OUTPUT EXT + ReadRegStr ${OUTPUT} SHELL_CONTEXT "Software\Classes\.${EXT}" "" +!macroend + + +; !defines for use with SHChangeNotify +!ifdef SHCNE_ASSOCCHANGED +!undef SHCNE_ASSOCCHANGED +!endif +!define SHCNE_ASSOCCHANGED 0x08000000 +!ifdef SHCNF_FLUSH +!undef SHCNF_FLUSH +!endif +!define SHCNF_FLUSH 0x1000 + +!macro UPDATEFILEASSOC +; Using the system.dll plugin to call the SHChangeNotify Win32 API function so we +; can update the shell. + System::Call "shell32::SHChangeNotify(i,i,i,i) (${SHCNE_ASSOCCHANGED}, ${SHCNF_FLUSH}, 0, 0)" +!macroend diff --git a/src-tauri/template/installer.nsi b/src-tauri/template/installer.nsi new file mode 100644 index 0000000..33dc245 --- /dev/null +++ b/src-tauri/template/installer.nsi @@ -0,0 +1,954 @@ +Unicode true +ManifestDPIAware true +; Add in `dpiAwareness` `PerMonitorV2` to manifest for Windows 10 1607+ (note this should not affect lower versions since they should be able to ignore this and pick up `dpiAware` `true` set by `ManifestDPIAware true`) +; Currently undocumented on NSIS's website but is in the Docs folder of source tree, see +; https://github.com/kichik/nsis/blob/5fc0b87b819a9eec006df4967d08e522ddd651c9/Docs/src/attributes.but#L286-L300 +; https://github.com/tauri-apps/tauri/pull/10106 +ManifestDPIAwareness PerMonitorV2 + +!if "{{compression}}" == "none" + SetCompress off +!else + ; Set the compression algorithm. We default to LZMA. + SetCompressor /SOLID "{{compression}}" +!endif + +!include MUI2.nsh +!include FileFunc.nsh +!include x64.nsh +!include WordFunc.nsh +!include "utils.nsh" +!include "FileAssociation.nsh" +!include "Win\COM.nsh" +!include "Win\Propkey.nsh" +!include "StrFunc.nsh" +${StrCase} +${StrLoc} + +{{#if installer_hooks}} +!include "{{installer_hooks}}" +{{/if}} + +!define WEBVIEW2APPGUID "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" + +!define MANUFACTURER "{{manufacturer}}" +!define PRODUCTNAME "{{product_name}}" +!define VERSION "{{version}}" +!define VERSIONWITHBUILD "{{version_with_build}}" +!define HOMEPAGE "{{homepage}}" +!define INSTALLMODE "{{install_mode}}" +!define LICENSE "{{license}}" +!define INSTALLERICON "{{installer_icon}}" +!define SIDEBARIMAGE "{{sidebar_image}}" +!define HEADERIMAGE "{{header_image}}" +!define MAINBINARYNAME "{{main_binary_name}}" +!define MAINBINARYSRCPATH "{{main_binary_path}}" +!define BUNDLEID "{{bundle_id}}" +!define COPYRIGHT "{{copyright}}" +!define OUTFILE "{{out_file}}" +!define ARCH "{{arch}}" +!define ADDITIONALPLUGINSPATH "{{additional_plugins_path}}" +!define ALLOWDOWNGRADES "{{allow_downgrades}}" +!define DISPLAYLANGUAGESELECTOR "{{display_language_selector}}" +!define INSTALLWEBVIEW2MODE "{{install_webview2_mode}}" +!define WEBVIEW2INSTALLERARGS "{{webview2_installer_args}}" +!define WEBVIEW2BOOTSTRAPPERPATH "{{webview2_bootstrapper_path}}" +!define WEBVIEW2INSTALLERPATH "{{webview2_installer_path}}" +!define MINIMUMWEBVIEW2VERSION "{{minimum_webview2_version}}" +!define UNINSTKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCTNAME}" +!define MANUKEY "Software\${MANUFACTURER}" +!define MANUPRODUCTKEY "${MANUKEY}\${PRODUCTNAME}" +!define UNINSTALLERSIGNCOMMAND "{{uninstaller_sign_cmd}}" +!define ESTIMATEDSIZE "{{estimated_size}}" +!define STARTMENUFOLDER "{{start_menu_folder}}" +!define MUI_UNICON "{{installer_icon}}" + +Var PassiveMode +Var UpdateMode +Var NoShortcutMode +Var WixMode +Var OldMainBinaryName + +Name "${PRODUCTNAME}" +BrandingText "${COPYRIGHT}" +OutFile "${OUTFILE}" + +; We don't actually use this value as default install path, +; it's just for nsis to append the product name folder in the directory selector +; https://nsis.sourceforge.io/Reference/InstallDir +!define PLACEHOLDER_INSTALL_DIR "placeholder\${PRODUCTNAME}" +InstallDir "${PLACEHOLDER_INSTALL_DIR}" + +VIProductVersion "${VERSIONWITHBUILD}" +VIAddVersionKey "ProductName" "${PRODUCTNAME}" +VIAddVersionKey "FileDescription" "${PRODUCTNAME}" +VIAddVersionKey "LegalCopyright" "${COPYRIGHT}" +VIAddVersionKey "FileVersion" "${VERSION}" +VIAddVersionKey "ProductVersion" "${VERSION}" + +# additional plugins +!addplugindir "${ADDITIONALPLUGINSPATH}" + +; Uninstaller signing command +!if "${UNINSTALLERSIGNCOMMAND}" != "" + !uninstfinalize '${UNINSTALLERSIGNCOMMAND}' +!endif + +; Handle install mode, `perUser`, `perMachine` or `both` +!if "${INSTALLMODE}" == "perMachine" + RequestExecutionLevel admin +!endif + +!if "${INSTALLMODE}" == "currentUser" + RequestExecutionLevel user +!endif + +!if "${INSTALLMODE}" == "both" + !define MULTIUSER_MUI + !define MULTIUSER_INSTALLMODE_INSTDIR "${PRODUCTNAME}" + !define MULTIUSER_INSTALLMODE_COMMANDLINE + !if "${ARCH}" == "x64" + !define MULTIUSER_USE_PROGRAMFILES64 + !else if "${ARCH}" == "arm64" + !define MULTIUSER_USE_PROGRAMFILES64 + !endif + !define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_KEY "${UNINSTKEY}" + !define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_VALUENAME "CurrentUser" + !define MULTIUSER_INSTALLMODEPAGE_SHOWUSERNAME + !define MULTIUSER_INSTALLMODE_FUNCTION RestorePreviousInstallLocation + !define MULTIUSER_EXECUTIONLEVEL Highest + !include MultiUser.nsh +!endif + +; Installer icon +!if "${INSTALLERICON}" != "" + !define MUI_ICON "${INSTALLERICON}" +!endif + +; Installer sidebar image +!if "${SIDEBARIMAGE}" != "" + !define MUI_WELCOMEFINISHPAGE_BITMAP "${SIDEBARIMAGE}" +!endif + +; Installer header image +!if "${HEADERIMAGE}" != "" + !define MUI_HEADERIMAGE + !define MUI_HEADERIMAGE_BITMAP "${HEADERIMAGE}" +!endif + +; Define registry key to store installer language +!define MUI_LANGDLL_REGISTRY_ROOT "HKCU" +!define MUI_LANGDLL_REGISTRY_KEY "${MANUPRODUCTKEY}" +!define MUI_LANGDLL_REGISTRY_VALUENAME "Installer Language" + +; Installer pages, must be ordered as they appear +; 1. Welcome Page +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive +!insertmacro MUI_PAGE_WELCOME + +; 2. License Page (if defined) +!if "${LICENSE}" != "" + !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive + !insertmacro MUI_PAGE_LICENSE "${LICENSE}" +!endif + +; 3. Install mode (if it is set to `both`) +!if "${INSTALLMODE}" == "both" + !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive + !insertmacro MULTIUSER_PAGE_INSTALLMODE +!endif + +; 4. Custom page to ask user if he wants to reinstall/uninstall +; only if a previous installation was detected +Var ReinstallPageCheck +Page custom PageReinstall PageLeaveReinstall +Function PageReinstall + ; Uninstall previous WiX installation if exists. + ; + ; A WiX installer stores the installation info in registry + ; using a UUID and so we have to loop through all keys under + ; `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall` + ; and check if `DisplayName` and `Publisher` keys match ${PRODUCTNAME} and ${MANUFACTURER} + ; + ; This has a potential issue that there maybe another installation that matches + ; our ${PRODUCTNAME} and ${MANUFACTURER} but wasn't installed by our WiX installer, + ; however, this should be fine since the user will have to confirm the uninstallation + ; and they can chose to abort it if doesn't make sense. + StrCpy $0 0 + wix_loop: + EnumRegKey $1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" $0 + StrCmp $1 "" wix_loop_done ; Exit loop if there is no more keys to loop on + IntOp $0 $0 + 1 + ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "DisplayName" + ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "Publisher" + StrCmp "$R0$R1" "${PRODUCTNAME}${MANUFACTURER}" 0 wix_loop + ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "UninstallString" + ${StrCase} $R1 $R0 "L" + ${StrLoc} $R0 $R1 "msiexec" ">" + StrCmp $R0 0 0 wix_loop_done + StrCpy $WixMode 1 + StrCpy $R6 "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" + Goto compare_version + wix_loop_done: + + ; Check if there is an existing installation, if not, abort the reinstall page + ReadRegStr $R0 SHCTX "${UNINSTKEY}" "" + ReadRegStr $R1 SHCTX "${UNINSTKEY}" "UninstallString" + ${IfThen} "$R0$R1" == "" ${|} Abort ${|} + + ; Compare this installar version with the existing installation + ; and modify the messages presented to the user accordingly + compare_version: + StrCpy $R4 "$(older)" + ${If} $WixMode = 1 + ReadRegStr $R0 HKLM "$R6" "DisplayVersion" + ${Else} + ReadRegStr $R0 SHCTX "${UNINSTKEY}" "DisplayVersion" + ${EndIf} + ${IfThen} $R0 == "" ${|} StrCpy $R4 "$(unknown)" ${|} + + nsis_tauri_utils::SemverCompare "${VERSION}" $R0 + Pop $R0 + ; Reinstalling the same version + ${If} $R0 = 0 + StrCpy $R1 "$(alreadyInstalledLong)" + StrCpy $R2 "$(addOrReinstall)" + StrCpy $R3 "$(uninstallApp)" + !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(chooseMaintenanceOption)" + ; Upgrading + ${ElseIf} $R0 = 1 + StrCpy $R1 "$(olderOrUnknownVersionInstalled)" + StrCpy $R2 "$(uninstallBeforeInstalling)" + StrCpy $R3 "$(dontUninstall)" + !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(choowHowToInstall)" + ; Downgrading + ${ElseIf} $R0 = -1 + StrCpy $R1 "$(newerVersionInstalled)" + StrCpy $R2 "$(uninstallBeforeInstalling)" + !if "${ALLOWDOWNGRADES}" == "true" + StrCpy $R3 "$(dontUninstall)" + !else + StrCpy $R3 "$(dontUninstallDowngrade)" + !endif + !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(choowHowToInstall)" + ${Else} + Abort + ${EndIf} + + ; Skip showing the page if passive + ; + ; Note that we don't call this earlier at the begining + ; of this function because we need to populate some variables + ; related to current installed version if detected and whether + ; we are downgrading or not. + ${If} $PassiveMode = 1 + Call PageLeaveReinstall + ${Else} + nsDialogs::Create 1018 + Pop $R4 + ${IfThen} $(^RTL) = 1 ${|} nsDialogs::SetRTL $(^RTL) ${|} + + ${NSD_CreateLabel} 0 0 100% 24u $R1 + Pop $R1 + + ${NSD_CreateRadioButton} 30u 50u -30u 8u $R2 + Pop $R2 + ${NSD_OnClick} $R2 PageReinstallUpdateSelection + + ${NSD_CreateRadioButton} 30u 70u -30u 8u $R3 + Pop $R3 + ; Disable this radio button if downgrading and downgrades are disabled + !if "${ALLOWDOWNGRADES}" == "false" + ${IfThen} $R0 = -1 ${|} EnableWindow $R3 0 ${|} + !endif + ${NSD_OnClick} $R3 PageReinstallUpdateSelection + + ; Check the first radio button if this the first time + ; we enter this page or if the second button wasn't + ; selected the last time we were on this page + ${If} $ReinstallPageCheck <> 2 + SendMessage $R2 ${BM_SETCHECK} ${BST_CHECKED} 0 + ${Else} + SendMessage $R3 ${BM_SETCHECK} ${BST_CHECKED} 0 + ${EndIf} + + ${NSD_SetFocus} $R2 + nsDialogs::Show + ${EndIf} +FunctionEnd +Function PageReinstallUpdateSelection + ${NSD_GetState} $R2 $R1 + ${If} $R1 == ${BST_CHECKED} + StrCpy $ReinstallPageCheck 1 + ${Else} + StrCpy $ReinstallPageCheck 2 + ${EndIf} +FunctionEnd +Function PageLeaveReinstall + ${NSD_GetState} $R2 $R1 + + ; If migrating from Wix, always uninstall + ${If} $WixMode = 1 + Goto reinst_uninstall + ${EndIf} + + ; In update mode, always proceeds without uninstalling + ${If} $UpdateMode = 1 + Goto reinst_done + ${EndIf} + + ; $R0 holds whether same(0)/upgrading(1)/downgrading(-1) version + ; $R1 holds the radio buttons state: + ; 1 => first choice was selected + ; 0 => second choice was selected + ${If} $R0 = 0 ; Same version, proceed + ${If} $R1 = 1 ; User chose to add/reinstall + Goto reinst_done + ${Else} ; User chose to uninstall + Goto reinst_uninstall + ${EndIf} + ${ElseIf} $R0 = 1 ; Upgrading + ${If} $R1 = 1 ; User chose to uninstall + Goto reinst_uninstall + ${Else} + Goto reinst_done ; User chose NOT to uninstall + ${EndIf} + ${ElseIf} $R0 = -1 ; Downgrading + ${If} $R1 = 1 ; User chose to uninstall + Goto reinst_uninstall + ${Else} + Goto reinst_done ; User chose NOT to uninstall + ${EndIf} + ${EndIf} + + reinst_uninstall: + HideWindow + ClearErrors + + ${If} $WixMode = 1 + ReadRegStr $R1 HKLM "$R6" "UninstallString" + ExecWait '$R1' $0 + ${Else} + ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" + ReadRegStr $R1 SHCTX "${UNINSTKEY}" "UninstallString" + ${IfThen} $UpdateMode = 1 ${|} StrCpy $R1 "$R1 /UPDATE" ${|} ; append /UPDATE + ${IfThen} $PassiveMode = 1 ${|} StrCpy $R1 "$R1 /P" ${|} ; append /P + StrCpy $R1 "$R1 _?=$4" ; append uninstall directory + ExecWait '$R1' $0 + ${EndIf} + + BringToFront + + ${IfThen} ${Errors} ${|} StrCpy $0 2 ${|} ; ExecWait failed, set fake exit code + + ${If} $0 <> 0 + ${OrIf} ${FileExists} "$INSTDIR\${MAINBINARYNAME}.exe" + ; User cancelled wix uninstaller? return to select un/reinstall page + ${If} $WixMode = 1 + ${AndIf} $0 = 1602 + Abort + ${EndIf} + + ; User cancelled NSIS uninstaller? return to select un/reinstall page + ${If} $0 = 1 + Abort + ${EndIf} + + ; Other erros? show generic error message and return to select un/reinstall page + MessageBox MB_ICONEXCLAMATION "$(unableToUninstall)" + Abort + ${EndIf} + reinst_done: +FunctionEnd + +; 5. Choose install directory page +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive +!insertmacro MUI_PAGE_DIRECTORY + +; 6. Start menu shortcut page +Var AppStartMenuFolder +!if "${STARTMENUFOLDER}" != "" + !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive + !define MUI_STARTMENUPAGE_DEFAULTFOLDER "${STARTMENUFOLDER}" +!else + !define MUI_PAGE_CUSTOMFUNCTION_PRE Skip +!endif +!insertmacro MUI_PAGE_STARTMENU Application $AppStartMenuFolder + +; 7. Installation page +!insertmacro MUI_PAGE_INSTFILES + +; 8. Finish page +; +; Don't auto jump to finish page after installation page, +; because the installation page has useful info that can be used debug any issues with the installer. +!define MUI_FINISHPAGE_NOAUTOCLOSE +; Use show readme button in the finish page as a button create a desktop shortcut +!define MUI_FINISHPAGE_SHOWREADME +!define MUI_FINISHPAGE_SHOWREADME_TEXT "$(createDesktop)" +!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateOrUpdateDesktopShortcut +; Show run app after installation. +!define MUI_FINISHPAGE_RUN +!define MUI_FINISHPAGE_RUN_FUNCTION RunMainBinary +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive +!insertmacro MUI_PAGE_FINISH + +Function RunMainBinary + nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "" +FunctionEnd + +; Uninstaller Pages +; 1. Confirm uninstall page +Var DeleteAppDataCheckbox +Var DeleteAppDataCheckboxState +!define /ifndef WS_EX_LAYOUTRTL 0x00400000 +!define MUI_PAGE_CUSTOMFUNCTION_SHOW un.ConfirmShow +Function un.ConfirmShow ; Add add a `Delete app data` check box + ; $1 inner dialog HWND + ; $2 window DPI + ; $3 style + ; $4 x + ; $5 y + ; $6 width + ; $7 height + FindWindow $1 "#32770" "" $HWNDPARENT ; Find inner dialog + System::Call "user32::GetDpiForWindow(p r1) i .r2" + ${If} $(^RTL) = 1 + StrCpy $3 "${__NSD_CheckBox_EXSTYLE} | ${WS_EX_LAYOUTRTL}" + IntOp $4 50 * $2 + ${Else} + StrCpy $3 "${__NSD_CheckBox_EXSTYLE}" + IntOp $4 0 * $2 + ${EndIf} + IntOp $5 100 * $2 + IntOp $6 400 * $2 + IntOp $7 25 * $2 + IntOp $4 $4 / 96 + IntOp $5 $5 / 96 + IntOp $6 $6 / 96 + IntOp $7 $7 / 96 + System::Call 'user32::CreateWindowEx(i r3, w "${__NSD_CheckBox_CLASS}", w "$(deleteAppData)", i ${__NSD_CheckBox_STYLE}, i r4, i r5, i r6, i r7, p r1, i0, i0, i0) i .s' + Pop $DeleteAppDataCheckbox + SendMessage $HWNDPARENT ${WM_GETFONT} 0 0 $1 + SendMessage $DeleteAppDataCheckbox ${WM_SETFONT} $1 1 +FunctionEnd +!define MUI_PAGE_CUSTOMFUNCTION_LEAVE un.ConfirmLeave +Function un.ConfirmLeave + SendMessage $DeleteAppDataCheckbox ${BM_GETCHECK} 0 0 $DeleteAppDataCheckboxState +FunctionEnd +!define MUI_PAGE_CUSTOMFUNCTION_PRE un.SkipIfPassive +!insertmacro MUI_UNPAGE_CONFIRM + +; 2. Uninstalling Page +!insertmacro MUI_UNPAGE_INSTFILES + +;Languages +{{#each languages}} +!insertmacro MUI_LANGUAGE "{{this}}" +{{/each}} +!insertmacro MUI_RESERVEFILE_LANGDLL +{{#each language_files}} + !include "{{this}}" +{{/each}} + +Function .onInit + ${GetOptions} $CMDLINE "/P" $PassiveMode + ${IfNot} ${Errors} + StrCpy $PassiveMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/NS" $NoShortcutMode + ${IfNot} ${Errors} + StrCpy $NoShortcutMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/UPDATE" $UpdateMode + ${IfNot} ${Errors} + StrCpy $UpdateMode 1 + ${EndIf} + + !if "${DISPLAYLANGUAGESELECTOR}" == "true" + !insertmacro MUI_LANGDLL_DISPLAY + !endif + + !insertmacro SetContext + + ${If} $INSTDIR == "${PLACEHOLDER_INSTALL_DIR}" + ; Set default install location + !if "${INSTALLMODE}" == "perMachine" + ${If} ${RunningX64} + !if "${ARCH}" == "x64" + StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" + !else if "${ARCH}" == "arm64" + StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" + !else + StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" + !endif + ${Else} + StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" + ${EndIf} + !else if "${INSTALLMODE}" == "currentUser" + StrCpy $INSTDIR "$LOCALAPPDATA\${PRODUCTNAME}" + !endif + + Call RestorePreviousInstallLocation + ${EndIf} + + + !if "${INSTALLMODE}" == "both" + !insertmacro MULTIUSER_INIT + !endif +FunctionEnd + + +Section EarlyChecks + ; Abort silent installer if downgrades is disabled + !if "${ALLOWDOWNGRADES}" == "false" + ${If} ${Silent} + ; If downgrading + ${If} $R0 = -1 + System::Call 'kernel32::AttachConsole(i -1)i.r0' + ${If} $0 <> 0 + System::Call 'kernel32::GetStdHandle(i -11)i.r0' + System::call 'kernel32::SetConsoleTextAttribute(i r0, i 0x0004)' ; set red color + FileWrite $0 "$(silentDowngrades)" + ${EndIf} + Abort + ${EndIf} + ${EndIf} + !endif + +SectionEnd + +Section WebView2 + ; Check if Webview2 is already installed and skip this section + ${If} ${RunningX64} + ReadRegStr $4 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" + ${Else} + ReadRegStr $4 HKLM "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" + ${EndIf} + ${If} $4 == "" + ReadRegStr $4 HKCU "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" + ${EndIf} + + ${If} $4 == "" + ; Webview2 installation + ; + ; Skip if updating + ${If} $UpdateMode <> 1 + !if "${INSTALLWEBVIEW2MODE}" == "downloadBootstrapper" + Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" + DetailPrint "$(webview2Downloading)" + NSISdl::download "https://go.microsoft.com/fwlink/p/?LinkId=2124703" "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Pop $0 + ${If} $0 == "success" + DetailPrint "$(webview2DownloadSuccess)" + ${Else} + DetailPrint "$(webview2DownloadError)" + Abort "$(webview2AbortError)" + ${EndIf} + StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Goto install_webview2 + !endif + + !if "${INSTALLWEBVIEW2MODE}" == "embedBootstrapper" + Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" + File "/oname=$TEMP\MicrosoftEdgeWebview2Setup.exe" "${WEBVIEW2BOOTSTRAPPERPATH}" + DetailPrint "$(installingWebview2)" + StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Goto install_webview2 + !endif + + !if "${INSTALLWEBVIEW2MODE}" == "offlineInstaller" + Delete "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" + File "/oname=$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" "${WEBVIEW2INSTALLERPATH}" + DetailPrint "$(installingWebview2)" + StrCpy $6 "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" + Goto install_webview2 + !endif + + Goto webview2_done + + install_webview2: + DetailPrint "$(installingWebview2)" + ; $6 holds the path to the webview2 installer + ExecWait "$6 ${WEBVIEW2INSTALLERARGS} /install" $1 + ${If} $1 = 0 + DetailPrint "$(webview2InstallSuccess)" + ${Else} + DetailPrint "$(webview2InstallError)" + Abort "$(webview2AbortError)" + ${EndIf} + webview2_done: + ${EndIf} + ${Else} + !if "${MINIMUMWEBVIEW2VERSION}" != "" + ${VersionCompare} "${MINIMUMWEBVIEW2VERSION}" "$4" $R0 + ${If} $R0 = 1 + update_webview: + DetailPrint "$(installingWebview2)" + ${If} ${RunningX64} + ReadRegStr $R1 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate" "path" + ${Else} + ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\EdgeUpdate" "path" + ${EndIf} + ${If} $R1 == "" + ReadRegStr $R1 HKCU "SOFTWARE\Microsoft\EdgeUpdate" "path" + ${EndIf} + ${If} $R1 != "" + ; Chromium updater docs: https://source.chromium.org/chromium/chromium/src/+/main:docs/updater/user_manual.md + ; Modified from "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft EdgeWebView\ModifyPath" + ExecWait `"$R1" /install appguid=${WEBVIEW2APPGUID}&needsadmin=true` $1 + ${If} $1 = 0 + DetailPrint "$(webview2InstallSuccess)" + ${Else} + MessageBox MB_ICONEXCLAMATION|MB_ABORTRETRYIGNORE "$(webview2InstallError)" IDIGNORE ignore IDRETRY update_webview + Quit + ignore: + ${EndIf} + ${EndIf} + ${EndIf} + !endif + ${EndIf} +SectionEnd + +Section Install + SetOutPath $INSTDIR + + !ifmacrodef NSIS_HOOK_PREINSTALL + !insertmacro NSIS_HOOK_PREINSTALL + !endif + + !insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}" + + ; Copy main executable + File "${MAINBINARYSRCPATH}" + + ; Copy resources + {{#each resources_dirs}} + CreateDirectory "$INSTDIR\\{{this}}" + {{/each}} + {{#each resources}} + File /a "/oname={{this.[1]}}" "{{no-escape @key}}" + {{/each}} + + ; Copy external binaries + {{#each binaries}} + File /a "/oname={{this}}" "{{no-escape @key}}" + {{/each}} + + ; Create file associations + {{#each file_associations as |association| ~}} + {{#each association.ext as |ext| ~}} + !insertmacro APP_ASSOCIATE "{{ext}}" "{{or association.name ext}}" "{{association-description association.description ext}}" "$INSTDIR\${MAINBINARYNAME}.exe,0" "Open with ${PRODUCTNAME}" "$INSTDIR\${MAINBINARYNAME}.exe $\"%1$\"" + {{/each}} + {{/each}} + + ; Register deep links + {{#each deep_link_protocols as |protocol| ~}} + WriteRegStr SHCTX "Software\Classes\\{{protocol}}" "URL Protocol" "" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}" "" "URL:${BUNDLEID} protocol" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}\DefaultIcon" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\",0" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}\shell\open\command" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"" + {{/each}} + + ; Create uninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + ; Save $INSTDIR in registry for future installations + WriteRegStr SHCTX "${MANUPRODUCTKEY}" "" $INSTDIR + + !if "${INSTALLMODE}" == "both" + ; Save install mode to be selected by default for the next installation such as updating + ; or when uninstalling + WriteRegStr SHCTX "${UNINSTKEY}" $MultiUser.InstallMode 1 + !endif + + ; Remove old main binary if it doesn't match new main binary name + ReadRegStr $OldMainBinaryName SHCTX "${UNINSTKEY}" "MainBinaryName" + ${If} $OldMainBinaryName != "" + ${AndIf} $OldMainBinaryName != "${MAINBINARYNAME}.exe" + Delete "$INSTDIR\$OldMainBinaryName" + ${EndIf} + + ; Save current MAINBINARYNAME for future updates + WriteRegStr SHCTX "${UNINSTKEY}" "MainBinaryName" "${MAINBINARYNAME}.exe" + + ; Registry information for add/remove programs + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayName" "${PRODUCTNAME}" + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayIcon" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\"" + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayVersion" "${VERSION}" + WriteRegStr SHCTX "${UNINSTKEY}" "Publisher" "${MANUFACTURER}" + WriteRegStr SHCTX "${UNINSTKEY}" "InstallLocation" "$\"$INSTDIR$\"" + WriteRegStr SHCTX "${UNINSTKEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegDWORD SHCTX "${UNINSTKEY}" "NoModify" "1" + WriteRegDWORD SHCTX "${UNINSTKEY}" "NoRepair" "1" + + ${GetSize} "$INSTDIR" "/M=uninstall.exe /S=0K /G=0" $0 $1 $2 + IntOp $0 $0 + ${ESTIMATEDSIZE} + IntFmt $0 "0x%08X" $0 + WriteRegDWORD SHCTX "${UNINSTKEY}" "EstimatedSize" "$0" + + !if "${HOMEPAGE}" != "" + WriteRegStr SHCTX "${UNINSTKEY}" "URLInfoAbout" "${HOMEPAGE}" + WriteRegStr SHCTX "${UNINSTKEY}" "URLUpdateInfo" "${HOMEPAGE}" + WriteRegStr SHCTX "${UNINSTKEY}" "HelpLink" "${HOMEPAGE}" + !endif + + ; Create start menu shortcut + !insertmacro MUI_STARTMENU_WRITE_BEGIN Application + Call CreateOrUpdateStartMenuShortcut + !insertmacro MUI_STARTMENU_WRITE_END + + ; Create desktop shortcut for silent and passive installers + ; because finish page will be skipped + ${If} $PassiveMode = 1 + ${OrIf} ${Silent} + Call CreateOrUpdateDesktopShortcut + ${EndIf} + + !ifmacrodef NSIS_HOOK_POSTINSTALL + !insertmacro NSIS_HOOK_POSTINSTALL + !endif + + ; Auto close this page for passive mode + ${If} $PassiveMode = 1 + SetAutoClose true + ${EndIf} +SectionEnd + +Function .onInstSuccess + ; Check for `/R` flag only in silent and passive installers because + ; GUI installer has a toggle for the user to (re)start the app + ${If} $PassiveMode = 1 + ${OrIf} ${Silent} + ${GetOptions} $CMDLINE "/R" $R0 + ${IfNot} ${Errors} + ${GetOptions} $CMDLINE "/ARGS" $R0 + nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "$R0" + ${EndIf} + ${EndIf} +FunctionEnd + +Function un.onInit + !insertmacro SetContext + + !if "${INSTALLMODE}" == "both" + !insertmacro MULTIUSER_UNINIT + !endif + + !insertmacro MUI_UNGETLANGUAGE + + ${GetOptions} $CMDLINE "/P" $PassiveMode + ${IfNot} ${Errors} + StrCpy $PassiveMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/UPDATE" $UpdateMode + ${IfNot} ${Errors} + StrCpy $UpdateMode 1 + ${EndIf} +FunctionEnd + +Section Uninstall + + !ifmacrodef NSIS_HOOK_PREUNINSTALL + !insertmacro NSIS_HOOK_PREUNINSTALL + !endif + + !insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}" + + ; Delete the app directory and its content from disk + ; Copy main executable + Delete "$INSTDIR\${MAINBINARYNAME}.exe" + + ; Delete resources + {{#each resources}} + Delete "$INSTDIR\\{{this.[1]}}" + {{/each}} + + ; Delete external binaries + {{#each binaries}} + Delete "$INSTDIR\\{{this}}" + {{/each}} + + ; Delete app associations + {{#each file_associations as |association| ~}} + {{#each association.ext as |ext| ~}} + !insertmacro APP_UNASSOCIATE "{{ext}}" "{{or association.name ext}}" + {{/each}} + {{/each}} + + ; Delete deep links + {{#each deep_link_protocols as |protocol| ~}} + ReadRegStr $R7 SHCTX "Software\Classes\\{{protocol}}\shell\open\command" "" + ${If} $R7 == "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"" + DeleteRegKey SHCTX "Software\Classes\\{{protocol}}" + ${EndIf} + {{/each}} + + + ; Delete uninstaller + Delete "$INSTDIR\uninstall.exe" + + {{#each resources_ancestors}} + RMDir /REBOOTOK "$INSTDIR\\{{this}}" + {{/each}} + RMDir "$INSTDIR" + + ; Remove shortcuts if not updating + ${If} $UpdateMode <> 1 + !insertmacro DeleteAppUserModelId + + ; Remove start menu shortcut + !insertmacro MUI_STARTMENU_GETFOLDER Application $AppStartMenuFolder + !insertmacro IsShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + Delete "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + RMDir "$SMPROGRAMS\$AppStartMenuFolder" + ${EndIf} + !insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" + Delete "$SMPROGRAMS\${PRODUCTNAME}.lnk" + ${EndIf} + + ; Remove desktop shortcuts + !insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$DESKTOP\${PRODUCTNAME}.lnk" + Delete "$DESKTOP\${PRODUCTNAME}.lnk" + ${EndIf} + ${EndIf} + + ; Remove registry information for add/remove programs + !if "${INSTALLMODE}" == "both" + DeleteRegKey SHCTX "${UNINSTKEY}" + !else if "${INSTALLMODE}" == "perMachine" + DeleteRegKey HKLM "${UNINSTKEY}" + !else + DeleteRegKey HKCU "${UNINSTKEY}" + !endif + + ; Removes the Autostart entry for ${PRODUCTNAME} from the HKCU Run key if it exists. + ; This ensures the program does not launch automatically after uninstallation if it exists. + ; If it doesn't exist, it does nothing. + ; We do this when not updating (to preserve the registry value on updates) + ${If} $UpdateMode <> 1 + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}" + ${EndIf} + + ; Delete app data if the checkbox is selected + ; and if not updating + ${If} $DeleteAppDataCheckboxState = 1 + ${AndIf} $UpdateMode <> 1 + ; Clear the install location $INSTDIR from registry + DeleteRegKey SHCTX "${MANUPRODUCTKEY}" + DeleteRegKey /ifempty SHCTX "${MANUKEY}" + + ; Clear the install language from registry + DeleteRegValue HKCU "${MANUPRODUCTKEY}" "Installer Language" + DeleteRegKey /ifempty HKCU "${MANUPRODUCTKEY}" + DeleteRegKey /ifempty HKCU "${MANUKEY}" + + SetShellVarContext current + RmDir /r "$APPDATA\${BUNDLEID}" + RmDir /r "$LOCALAPPDATA\${BUNDLEID}" + ${EndIf} + + !ifmacrodef NSIS_HOOK_POSTUNINSTALL + !insertmacro NSIS_HOOK_POSTUNINSTALL + !endif + + ; Auto close if passive mode or updating + ${If} $PassiveMode = 1 + ${OrIf} $UpdateMode = 1 + SetAutoClose true + ${EndIf} +SectionEnd + +Function RestorePreviousInstallLocation + ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" + StrCmp $4 "" +2 0 + StrCpy $INSTDIR $4 +FunctionEnd + +Function Skip + Abort +FunctionEnd + +Function SkipIfPassive + ${IfThen} $PassiveMode = 1 ${|} Abort ${|} +FunctionEnd +Function un.SkipIfPassive + ${IfThen} $PassiveMode = 1 ${|} Abort ${|} +FunctionEnd + +Function CreateOrUpdateStartMenuShortcut + ; We used to use product name as MAINBINARYNAME + ; migrate old shortcuts to target the new MAINBINARYNAME + StrCpy $R0 0 + + !insertmacro IsShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + StrCpy $R0 1 + ${EndIf} + + !insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + StrCpy $R0 1 + ${EndIf} + + ${If} $R0 = 1 + Return + ${EndIf} + + ; Skip creating shortcut if in update mode or no shortcut mode + ; but always create if migrating from wix + ${If} $WixMode = 0 + ${If} $UpdateMode = 1 + ${OrIf} $NoShortcutMode = 1 + Return + ${EndIf} + ${EndIf} + + !if "${STARTMENUFOLDER}" != "" + CreateDirectory "$SMPROGRAMS\$AppStartMenuFolder" + CreateShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + !else + CreateShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\${PRODUCTNAME}.lnk" + !endif +FunctionEnd + +Function CreateOrUpdateDesktopShortcut + ; We used to use product name as MAINBINARYNAME + ; migrate old shortcuts to target the new MAINBINARYNAME + !insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Return + ${EndIf} + + ; Skip creating shortcut if in update mode or no shortcut mode + ; but always create if migrating from wix + ${If} $WixMode = 0 + ${If} $UpdateMode = 1 + ${OrIf} $NoShortcutMode = 1 + Return + ${EndIf} + ${EndIf} + + CreateShortcut "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk" +FunctionEnd diff --git a/src-tauri/template/utils.nsh b/src-tauri/template/utils.nsh new file mode 100644 index 0000000..3c5bf75 --- /dev/null +++ b/src-tauri/template/utils.nsh @@ -0,0 +1,184 @@ +; Change shell and registry context based on running +; architecture and chosen install mode. +!macro SetContext + !if "${INSTALLMODE}" == "currentUser" + SetShellVarContext current + !else if "${INSTALLMODE}" == "perMachine" + SetShellVarContext all + !endif + + ${If} ${RunningX64} + !if "${ARCH}" == "x64" + SetRegView 64 + !else if "${ARCH}" == "arm64" + SetRegView 64 + !else + SetRegView 32 + !endif + ${EndIf} +!macroend + +; Checks whether app is running or not and prompts to kill it. +!macro CheckIfAppIsRunning executableName productName + !define UniqueID ${__LINE__} + + ; Replace {{product_name}} placeholder in the messages with the passed product name + nsis_tauri_utils::StrReplace "$(appRunning)" "{{product_name}}" "${productName}" + Pop $R1 + nsis_tauri_utils::StrReplace "$(appRunningOkKill)" "{{product_name}}" "${productName}" + Pop $R2 + nsis_tauri_utils::StrReplace "$(failedToKillApp)" "{{product_name}}" "${productName}" + Pop $R3 + + !if "${INSTALLMODE}" == "currentUser" + nsis_tauri_utils::FindProcessCurrentUser "${executableName}" + !else + nsis_tauri_utils::FindProcess "${executableName}" + !endif + Pop $R0 + ${If} $R0 = 0 + IfSilent kill_${UniqueID} 0 + ${IfThen} $PassiveMode != 1 ${|} MessageBox MB_OKCANCEL $R2 IDOK kill_${UniqueID} IDCANCEL cancel_${UniqueID} ${|} + kill_${UniqueID}: + !if "${INSTALLMODE}" == "currentUser" + nsis_tauri_utils::KillProcessCurrentUser "${executableName}" + !else + nsis_tauri_utils::KillProcess "${executableName}" + !endif + Pop $R0 + Sleep 500 + ${If} $R0 = 0 + ${OrIf} $R0 = 2 + Goto app_check_done_${UniqueID} + ${Else} + IfSilent silent_${UniqueID} ui_${UniqueID} + silent_${UniqueID}: + System::Call 'kernel32::AttachConsole(i -1)i.r0' + ${If} $0 != 0 + System::Call 'kernel32::GetStdHandle(i -11)i.r0' + System::call 'kernel32::SetConsoleTextAttribute(i r0, i 0x0004)' ; set red color + FileWrite $0 "$R1$\n" + ${EndIf} + Abort + ui_${UniqueID}: + Abort $R3 + ${EndIf} + cancel_${UniqueID}: + Abort $R1 + ${EndIf} + app_check_done_${UniqueID}: + !undef UniqueID +!macroend + +; Sets AppUserModelId on a shortcut +!macro SetLnkAppUserModelId shortcut + !insertmacro ComHlpr_CreateInProcInstance ${CLSID_ShellLink} ${IID_IShellLink} r0 "" + ${If} $0 P<> 0 + ${IUnknown::QueryInterface} $0 '("${IID_IPersistFile}",.r1)' + ${If} $1 P<> 0 + ${IPersistFile::Load} $1 '("${shortcut}", ${STGM_READWRITE})' + ${IUnknown::QueryInterface} $0 '("${IID_IPropertyStore}",.r2)' + ${If} $2 P<> 0 + System::Call 'Oleaut32::SysAllocString(w "${BUNDLEID}") i.r3' + System::Call '*${SYSSTRUCT_PROPERTYKEY}(${PKEY_AppUserModel_ID})p.r4' + System::Call '*${SYSSTRUCT_PROPVARIANT}(${VT_BSTR},,&i4 $3)p.r5' + ${IPropertyStore::SetValue} $2 '($4,$5)' + + System::Call 'Oleaut32::SysFreeString($3)' + System::Free $4 + System::Free $5 + ${IPropertyStore::Commit} $2 "" + ${IUnknown::Release} $2 "" + ${IPersistFile::Save} $1 '("${shortcut}",1)' + ${EndIf} + ${IUnknown::Release} $1 "" + ${EndIf} + ${IUnknown::Release} $0 "" + ${EndIf} +!macroend + +; Deletes jump list entries and recent destinations +!macro DeleteAppUserModelId + !insertmacro ComHlpr_CreateInProcInstance ${CLSID_DestinationList} ${IID_ICustomDestinationList} r1 "" + ${If} $1 P<> 0 + ${ICustomDestinationList::DeleteList} $1 '("${BUNDLEID}")' + ${IUnknown::Release} $1 "" + ${EndIf} + !insertmacro ComHlpr_CreateInProcInstance ${CLSID_ApplicationDestinations} ${IID_IApplicationDestinations} r1 "" + ${If} $1 P<> 0 + ${IApplicationDestinations::SetAppID} $1 '("${BUNDLEID}")i.r0' + ${If} $0 >= 0 + ${IApplicationDestinations::RemoveAllDestinations} $1 '' + ${EndIf} + ${IUnknown::Release} $1 "" + ${EndIf} +!macroend + +; Unpins a shortcut from Start menu and Taskbar +; +; From https://stackoverflow.com/a/42816728/16993372 +!macro UnpinShortcut shortcut + !insertmacro ComHlpr_CreateInProcInstance ${CLSID_StartMenuPin} ${IID_IStartMenuPinnedList} r0 "" + ${If} $0 P<> 0 + System::Call 'SHELL32::SHCreateItemFromParsingName(ws, p0, g "${IID_IShellItem}", *p0r1)' "${shortcut}" + ${If} $1 P<> 0 + ${IStartMenuPinnedList::RemoveFromList} $0 '(r1)' + ${IUnknown::Release} $1 "" + ${EndIf} + ${IUnknown::Release} $0 "" + ${EndIf} +!macroend + +; Set target path for a .lnk shortcut +!macro SetShortcutTarget shortcut target + !insertmacro ComHlpr_CreateInProcInstance ${CLSID_ShellLink} ${IID_IShellLink} r0 "" + ${If} $0 P<> 0 + ${IUnknown::QueryInterface} $0 '("${IID_IPersistFile}",.r1)' + ${If} $1 P<> 0 + ${IPersistFile::Load} $1 '("${shortcut}", ${STGM_READWRITE})' + ${IShellLink::SetPath} $0 '(w "${target}")' + ${IPersistFile::Save} $1 '("${shortcut}",1)' + ${IUnknown::Release} $1 "" + ${EndIf} + ${IUnknown::Release} $0 "" + ${EndIf} +!macroend + +!define /ifndef MAX_PATH 260 +!define /ifndef SLGP_RAWPATH 0x4 + +; Test if a .lnk shortcut's target is target, +; use Pop to get the result, 1 is yes, 0 is no, +; note that this macro modifies $0, $1, $2, $3 +; +; Exmaple usage: +; !insertmacro "IsShortCutTarget" "C:\Users\Public\Desktop\App.lnk" "C:\Program Files\App\App.exe" +; Pop $0 +; ${If} $0 = 1 +; MessageBox MB_OK "shortcut target matches" +; ${EndIf} +!macro IsShortcutTarget shortcut target + ; $0: IShellLink + ; $1: IPersistFile + ; $2: Target path + ; $3: Return value + + StrCpy $3 0 + !insertmacro ComHlpr_CreateInProcInstance ${CLSID_ShellLink} ${IID_IShellLink} r0 "" + ${If} $0 P<> 0 + ${IUnknown::QueryInterface} $0 '("${IID_IPersistFile}", .r1)' + ${If} $1 P<> 0 + ${IPersistFile::Load} $1 '("${shortcut}", ${STGM_READ})' + System::Alloc MAX_PATH + Pop $2 + ${IShellLink::GetPath} $0 '(.r2, ${MAX_PATH}, 0, ${SLGP_RAWPATH})' + ${If} $2 == "${target}" + StrCpy $3 1 + ${EndIf} + System::Free $2 + ${IUnknown::Release} $1 "" + ${EndIf} + ${IUnknown::Release} $0 "" + ${EndIf} + Push $3 +!macroend diff --git a/src-tauri/tray/icon.png b/src-tauri/tray/icon.png new file mode 100644 index 0000000..cf44cfd Binary files /dev/null and b/src-tauri/tray/icon.png differ diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..ea401ca --- /dev/null +++ b/src/App.vue @@ -0,0 +1,815 @@ + + + diff --git a/src/agreement/privacy.ts b/src/agreement/privacy.ts new file mode 100644 index 0000000..3097778 --- /dev/null +++ b/src/agreement/privacy.ts @@ -0,0 +1,33 @@ +export const content = ` +HuLa 隐私保护指引 +一、引言 + HuLaSpark 开源组织(以下简称 “我们”)非常重视你的隐私保护。本隐私保护指引旨在向你说明我们在提供 HuLa 即时通讯系统(以下简称 “本服务”)过程中如何收集、使用、存储和共享你的个人信息。请你在使用本服务前仔细阅读本指引。 +二、个人信息的收集 + 1.注册信息 + 当你注册 HuLa 账号时,我们会收集你的手机号码、电子邮箱地址、用户名、密码等信息,以便为你创建和管理账号。 + 2.使用信息 + 在你使用本服务过程中,我们会收集你的使用记录,包括但不限于消息内容、聊天记录、登录时间、IP 地址等,以便为你提供更好的服务和优化用户体验。 + 3.设备信息 + 我们会收集你的设备信息,如设备型号、操作系统版本、设备标识符等,用于设备适配和安全防护。 +三、个人信息的使用 + 1.我们会使用你提供的个人信息为你提供本服务,包括但不限于身份验证、消息推送、个性化设置等。 + 2.我们可能会使用你的个人信息进行数据分析和统计,以改进服务质量和优化功能。 + 3.在获得你的同意后,我们可能会使用你的个人信息向你发送相关的营销信息。 +四、个人信息的存储 + 1.我们会将你的个人信息存储在安全的服务器上,并采取必要的技术和管理措施保护你的信息安全。 + 2.我们会根据法律法规的要求和业务需要,保留你的个人信息一段合理的时间。 +五、个人信息的共享 + 1.我们不会将你的个人信息共享给第三方,除非符合以下情况: + 获得你的明确同意; + 为了提供本服务的必要,与合作伙伴共享; + 根据法律法规的要求或司法机关的指令提供。 + 2.若与第三方共享你的个人信息,我们会要求第三方采取必要的措施保护你的信息安全。 +六、你的权利 + 1.访问和修改:你有权访问和修改自己的个人信息。你可以通过本服务的设置功能进行操作。 + 2.删除:在符合法律法规的情况下,你有权要求我们删除你的个人信息。 + 3.拒绝营销:你有权拒绝我们向你发送的营销信息。 +七、安全措施 + 我们采取了一系列安全措施来保护你的个人信息,包括但不限于数据加密、访问控制、安全审计等。但请你注意,互联网并非绝对安全的环境,我们不能保证信息的绝对安全。 +八、隐私保护指引的变更 + 我们有权根据业务需要对本隐私保护指引进行变更。变更后的指引将在我们的官方网站或应用内公布。若你继续使用本服务,即视为你同意变更后的指引。 +` diff --git a/src/agreement/server.ts b/src/agreement/server.ts new file mode 100644 index 0000000..561162d --- /dev/null +++ b/src/agreement/server.ts @@ -0,0 +1,30 @@ +export const content = ` +服务协议 +一、引言 +欢迎使用 HuLa 即时通讯系统(以下简称 “本服务”)。本服务协议(以下简称 “协议”)是你与 HuLaSpark 开源组织(以下简称 “我们”)之间就使用本服务所达成的协议。请你在使用本服务前仔细阅读本协议的所有条款,如果你不同意本协议的任何条款,请勿使用本服务。 +二、服务内容 + 我们通过 HuLa 为你提供即时通讯服务,包括但不限于文字、语音、图片、视频等消息的发送和接收,以及群组聊天、好友管理等功能。我们会不断改进和优化服务,可能会增加或减少某些功能。 +三、使用规则 + 1.账号注册与使用 + 你需要提供真实、准确、完整的个人信息进行账号注册。如果信息发生变更,你应及时更新。 + 你应对自己的账号和密码负责,不得将账号转让、出租或出借给他人使用。若因账号使用不当导致的任何损失,由你自行承担。 + 2.内容发布 + 你在使用本服务时发布的内容应遵守法律法规和社会公德,不得发布违法、违规、侵权、淫秽、暴力、歧视等不良内容。 + 我们有权对你发布的内容进行审核,若发现违反规定的内容,有权采取删除、限制账号使用等措施。 + 3.遵守法律法规 + 你在使用本服务时应遵守中华人民共和国的法律法规,不得利用本服务进行任何违法犯罪活动。 +四、服务变更、中断或终止 + 我们可能会根据业务需要对服务进行变更、升级或维护,可能会导致服务中断或暂停。我们会尽可能提前通知你。 + 若你违反本协议或相关法律法规,我们有权终止你的服务使用权限。 +五、知识产权 + 本服务的所有内容,包括但不限于软件、文字、图片、音频、视频等,均受知识产权法律法规的保护。未经我们书面许可,你不得擅自复制、传播、修改或用于其他商业用途。 +六、责任限制 + 我们会尽力确保服务的稳定性和可靠性,但不保证服务不会出现故障、中断或错误。对于因不可抗力、技术故障等原因导致的服务问题,我们不承担责任。 + 你在使用本服务过程中应自行判断信息的真实性和可靠性,对于因你使用不当或依赖错误信息而导致的损失,我们不承担责任。 +七、免责声明 + 1. 本项目是作为一款开源项目提供的,开发者在法律允许的范围内不对软件的功能性、安全性或适用性提供任何形式的明示或暗示的保证 + 2. 用户明确理解并同意,使用本软件的风险完全由用户自己承担,软件以"现状"和"现有"基础提供。开发者不提供任何形式的担保,无论是明示还是暗示的,包括但不限于适销性、特定用途的适用性和非侵权的担保 + 3. 在任何情况下,开发者或其供应商都不对任何直接的、间接的、偶然的、特殊的、惩罚性的或后果性的损害承担责任,包括但不限于使用本软件产生的利润损失、业务中断、个人信息泄露或其他商业损害或损失 + 4. 所有在本项目上进行二次开发的用户,都需承诺将本软件用于合法目的,并自行负责遵守当地的法律和法规 + 5. 开发者有权在任何时间修改软件的功能或特性,以及本免责声明的任何部分,并且这些修改可能会以软件更新的形式体现 +` diff --git a/src/assets/fonts/AlimamaFangYuanTiVF-Thin.woff2 b/src/assets/fonts/AlimamaFangYuanTiVF-Thin.woff2 new file mode 100644 index 0000000..0285f8a Binary files /dev/null and b/src/assets/fonts/AlimamaFangYuanTiVF-Thin.woff2 differ diff --git a/src/assets/fonts/PingFang-Medium.woff2 b/src/assets/fonts/PingFang-Medium.woff2 new file mode 100644 index 0000000..0f2f69d Binary files /dev/null and b/src/assets/fonts/PingFang-Medium.woff2 differ diff --git a/src/assets/img/loading-bright.svg b/src/assets/img/loading-bright.svg new file mode 100644 index 0000000..86e3103 --- /dev/null +++ b/src/assets/img/loading-bright.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/img/loading-one.svg b/src/assets/img/loading-one.svg new file mode 100644 index 0000000..1890389 --- /dev/null +++ b/src/assets/img/loading-one.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/img/loading.svg b/src/assets/img/loading.svg new file mode 100644 index 0000000..91d8b62 --- /dev/null +++ b/src/assets/img/loading.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/img/win.png b/src/assets/img/win.png new file mode 100644 index 0000000..04c7091 Binary files /dev/null and b/src/assets/img/win.png differ diff --git a/src/assets/mobile/1.webp b/src/assets/mobile/1.webp new file mode 100644 index 0000000..8a3801d Binary files /dev/null and b/src/assets/mobile/1.webp differ diff --git a/src/assets/mobile/2.svg b/src/assets/mobile/2.svg new file mode 100644 index 0000000..a4f3fa1 --- /dev/null +++ b/src/assets/mobile/2.svg @@ -0,0 +1,14 @@ + + + 注册页面-步骤一-hula + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/mobile/chat-home/add-friend.webp b/src/assets/mobile/chat-home/add-friend.webp new file mode 100644 index 0000000..6f7de40 Binary files /dev/null and b/src/assets/mobile/chat-home/add-friend.webp differ diff --git a/src/assets/mobile/chat-home/background.webp b/src/assets/mobile/chat-home/background.webp new file mode 100644 index 0000000..f61d4ff Binary files /dev/null and b/src/assets/mobile/chat-home/background.webp differ diff --git a/src/assets/mobile/chat-home/group-chat.webp b/src/assets/mobile/chat-home/group-chat.webp new file mode 100644 index 0000000..abe2664 Binary files /dev/null and b/src/assets/mobile/chat-home/group-chat.webp differ diff --git a/src/assets/mobile/community/scanner.webp b/src/assets/mobile/community/scanner.webp new file mode 100644 index 0000000..0bca033 Binary files /dev/null and b/src/assets/mobile/community/scanner.webp differ diff --git a/src/assets/mobile/friend/right-arrow.webp b/src/assets/mobile/friend/right-arrow.webp new file mode 100644 index 0000000..a036360 Binary files /dev/null and b/src/assets/mobile/friend/right-arrow.webp differ diff --git a/src/assets/mobile/my/my-medal.webp b/src/assets/mobile/my/my-medal.webp new file mode 100644 index 0000000..0301892 Binary files /dev/null and b/src/assets/mobile/my/my-medal.webp differ diff --git a/src/assets/mobile/my/qr-code.webp b/src/assets/mobile/my/qr-code.webp new file mode 100644 index 0000000..1bf54ac Binary files /dev/null and b/src/assets/mobile/my/qr-code.webp differ diff --git a/src/assets/video/issue.mp4 b/src/assets/video/issue.mp4 new file mode 100644 index 0000000..eb894d7 Binary files /dev/null and b/src/assets/video/issue.mp4 differ diff --git a/src/assets/video/star.mp4 b/src/assets/video/star.mp4 new file mode 100644 index 0000000..e46ef14 Binary files /dev/null and b/src/assets/video/star.mp4 differ diff --git a/src/common/constants.ts b/src/common/constants.ts new file mode 100644 index 0000000..bf8684d --- /dev/null +++ b/src/common/constants.ts @@ -0,0 +1,8 @@ +/** 底部选项栏高度 */ +export const FOOTER_HEIGHT = 260 +/** 底部选项栏最大高度 */ +export const MAX_FOOTER_HEIGHT = 390 +/** 底部选项栏最小高度 */ +export const MIN_FOOTER_HEIGHT = 200 +/** 顶部选项栏高度 */ +export const TOOLBAR_HEIGHT = 40 diff --git a/src/common/exception.ts b/src/common/exception.ts new file mode 100644 index 0000000..6bb1a60 --- /dev/null +++ b/src/common/exception.ts @@ -0,0 +1,60 @@ +export enum ErrorType { + Network = 'Network', + Server = 'Server', + Client = 'Client', + Validation = 'Validation', + Authentication = 'Authentication', + Unknown = 'Unknown', + TokenExpired = 'TokenExpired', + TokenInvalid = 'TokenInvalid' +} + +export interface ErrorDetails { + type: ErrorType + code?: number + details?: Record + showError?: boolean + isRetryError?: boolean +} + +export class AppException extends Error { + public readonly type: ErrorType + public readonly code?: number + public readonly details?: Record + // 使用静态标志位来追踪是否已经显示过错误消息 + private static hasShownError = false + + constructor(message: string, errorDetails?: Partial) { + super(message) + this.name = 'AppException' + this.type = errorDetails?.type || ErrorType.Unknown + this.code = errorDetails?.code + this.details = errorDetails?.details + + // 只有在明确指定显示错误时才显示 + if (errorDetails?.showError && !AppException.hasShownError) { + // 如果是重试相关的错误,使用console.log打印而不是弹窗提示 + if (errorDetails?.isRetryError) { + console.log('重试错误:', message, this.details) + } else { + window.$message.error(message) + AppException.hasShownError = true + + // 只有在 2 秒内没有显示过错误消息时才会显示 + setTimeout(() => { + AppException.hasShownError = false + }, 2000) + } + } + } + + public toJSON() { + return { + name: this.name, + message: this.message, + type: this.type, + code: this.code, + details: this.details + } + } +} diff --git a/src/common/message.ts b/src/common/message.ts new file mode 100644 index 0000000..4d6b6e2 --- /dev/null +++ b/src/common/message.ts @@ -0,0 +1,18 @@ +import { MsgEnum } from '@/enums' + +// 消息回复映射表 +export const MSG_REPLY_TEXT_MAP: Record = { + [MsgEnum.UNKNOWN]: '[未知]', + [MsgEnum.RECALL]: '[撤回消息]', + [MsgEnum.IMAGE]: '[图片]', + [MsgEnum.FILE]: '[文件]', + [MsgEnum.VOICE]: '[语音]', + [MsgEnum.VIDEO]: '[视频]', + [MsgEnum.EMOJI]: '[动画表情]', + [MsgEnum.MERGE]: '[聊天记录]', + [MsgEnum.NOTICE]: '[公告]', + [MsgEnum.VIDEO_CALL]: '[视频通话]', + [MsgEnum.AUDIO_CALL]: '[语音通话]', + [MsgEnum.BOT]: '[小管家]', + [MsgEnum.LOCATION]: '[位置]' +} diff --git a/src/components/chat-inner-drawer.vue b/src/components/chat-inner-drawer.vue new file mode 100644 index 0000000..66f03f9 --- /dev/null +++ b/src/components/chat-inner-drawer.vue @@ -0,0 +1,51 @@ + + + + + + diff --git a/src/components/common/AreaDrawer.vue b/src/components/common/AreaDrawer.vue new file mode 100644 index 0000000..48f0965 --- /dev/null +++ b/src/components/common/AreaDrawer.vue @@ -0,0 +1,63 @@ + + + diff --git a/src/components/common/AvatarCropper.vue b/src/components/common/AvatarCropper.vue new file mode 100644 index 0000000..4ebf641 --- /dev/null +++ b/src/components/common/AvatarCropper.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/src/components/common/ContextMenu.vue b/src/components/common/ContextMenu.vue new file mode 100644 index 0000000..697cedc --- /dev/null +++ b/src/components/common/ContextMenu.vue @@ -0,0 +1,660 @@ + + + + + diff --git a/src/components/common/DynamicDetail.vue b/src/components/common/DynamicDetail.vue new file mode 100644 index 0000000..6a96b5d --- /dev/null +++ b/src/components/common/DynamicDetail.vue @@ -0,0 +1,578 @@ + + + + + diff --git a/src/components/common/DynamicList.vue b/src/components/common/DynamicList.vue new file mode 100644 index 0000000..f437a11 --- /dev/null +++ b/src/components/common/DynamicList.vue @@ -0,0 +1,452 @@ + + + + + diff --git a/src/components/common/FeedNotificationPopup.vue b/src/components/common/FeedNotificationPopup.vue new file mode 100644 index 0000000..48bc621 --- /dev/null +++ b/src/components/common/FeedNotificationPopup.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/src/components/common/FloatBlockList.vue b/src/components/common/FloatBlockList.vue new file mode 100644 index 0000000..9d2bb08 --- /dev/null +++ b/src/components/common/FloatBlockList.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/src/components/common/InfoPopover.vue b/src/components/common/InfoPopover.vue new file mode 100644 index 0000000..dfa9d14 --- /dev/null +++ b/src/components/common/InfoPopover.vue @@ -0,0 +1,390 @@ + + + + + diff --git a/src/components/common/LoadingSpinner.vue b/src/components/common/LoadingSpinner.vue new file mode 100644 index 0000000..6c704e5 --- /dev/null +++ b/src/components/common/LoadingSpinner.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/src/components/common/MemoryMonitor.vue b/src/components/common/MemoryMonitor.vue new file mode 100644 index 0000000..8b20fb8 --- /dev/null +++ b/src/components/common/MemoryMonitor.vue @@ -0,0 +1,337 @@ + + + diff --git a/src/components/common/NaiveProvider.vue b/src/components/common/NaiveProvider.vue new file mode 100644 index 0000000..7438016 --- /dev/null +++ b/src/components/common/NaiveProvider.vue @@ -0,0 +1,341 @@ + + + + diff --git a/src/components/common/PinInput.vue b/src/components/common/PinInput.vue new file mode 100644 index 0000000..8d79e15 --- /dev/null +++ b/src/components/common/PinInput.vue @@ -0,0 +1,167 @@ + + + diff --git a/src/components/common/Screenshot.vue b/src/components/common/Screenshot.vue new file mode 100644 index 0000000..8289027 --- /dev/null +++ b/src/components/common/Screenshot.vue @@ -0,0 +1,1664 @@ + + + + + diff --git a/src/components/common/SystemNotification.tsx b/src/components/common/SystemNotification.tsx new file mode 100644 index 0000000..2c5e2e2 --- /dev/null +++ b/src/components/common/SystemNotification.tsx @@ -0,0 +1,28 @@ +import { NAvatar, NButton } from 'naive-ui' +import { useNoticeStore } from '@/stores/notice.ts' +import { handRelativeTime } from '@/utils/ComputedTime' + +const { systemNotice } = storeToRefs(useNoticeStore()) +const SysNTF = null +if (!systemNotice.value) { + const SysNTF = window.$notification.create({ + title: () =>

系统提示

, + content: () =>

当前系统尚未完善,请不要把重要信息保存在这里

, + meta: () =>

{handRelativeTime('2024/11/28 16:48:32')}

, + closable: false, + avatar: () => , + action: () => ( + { + systemNotice.value = true + SysNTF.destroy() + }}> +

已读

+
+ ) + }) +} + +export default SysNTF diff --git a/src/components/common/Validation.vue b/src/components/common/Validation.vue new file mode 100644 index 0000000..a328233 --- /dev/null +++ b/src/components/common/Validation.vue @@ -0,0 +1,57 @@ + + + diff --git a/src/components/common/VirtualList.vue b/src/components/common/VirtualList.vue new file mode 100644 index 0000000..6129ebe --- /dev/null +++ b/src/components/common/VirtualList.vue @@ -0,0 +1,727 @@ + + + + + diff --git a/src/components/fileManager/EmptyState.vue b/src/components/fileManager/EmptyState.vue new file mode 100644 index 0000000..a7b860d --- /dev/null +++ b/src/components/fileManager/EmptyState.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/src/components/fileManager/FileContent.vue b/src/components/fileManager/FileContent.vue new file mode 100644 index 0000000..97dd2e4 --- /dev/null +++ b/src/components/fileManager/FileContent.vue @@ -0,0 +1,370 @@ + + + + + diff --git a/src/components/fileManager/SideNavigation.vue b/src/components/fileManager/SideNavigation.vue new file mode 100644 index 0000000..6526d11 --- /dev/null +++ b/src/components/fileManager/SideNavigation.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/src/components/fileManager/UserItem.vue b/src/components/fileManager/UserItem.vue new file mode 100644 index 0000000..a2b6256 --- /dev/null +++ b/src/components/fileManager/UserItem.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/src/components/fileManager/UserList.vue b/src/components/fileManager/UserList.vue new file mode 100644 index 0000000..5fde63b --- /dev/null +++ b/src/components/fileManager/UserList.vue @@ -0,0 +1,336 @@ + + + + + diff --git a/src/components/rightBox/ApplyList.vue b/src/components/rightBox/ApplyList.vue new file mode 100644 index 0000000..e9f2385 --- /dev/null +++ b/src/components/rightBox/ApplyList.vue @@ -0,0 +1,392 @@ + + + + diff --git a/src/components/rightBox/Details.vue b/src/components/rightBox/Details.vue new file mode 100644 index 0000000..9f31ad0 --- /dev/null +++ b/src/components/rightBox/Details.vue @@ -0,0 +1,529 @@ + + + + diff --git a/src/components/rightBox/FileUploadModal.vue b/src/components/rightBox/FileUploadModal.vue new file mode 100644 index 0000000..d6a3b3d --- /dev/null +++ b/src/components/rightBox/FileUploadModal.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/src/components/rightBox/FileUploadProgress.vue b/src/components/rightBox/FileUploadProgress.vue new file mode 100644 index 0000000..6d5fcce --- /dev/null +++ b/src/components/rightBox/FileUploadProgress.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/src/components/rightBox/MsgInput.vue b/src/components/rightBox/MsgInput.vue new file mode 100644 index 0000000..724eb5f --- /dev/null +++ b/src/components/rightBox/MsgInput.vue @@ -0,0 +1,785 @@ + + + + diff --git a/src/components/rightBox/VoiceRecorder.vue b/src/components/rightBox/VoiceRecorder.vue new file mode 100644 index 0000000..daa2247 --- /dev/null +++ b/src/components/rightBox/VoiceRecorder.vue @@ -0,0 +1,402 @@ + + + + + diff --git a/src/components/rightBox/chatBox/Bot.vue b/src/components/rightBox/chatBox/Bot.vue new file mode 100644 index 0000000..0edfa43 --- /dev/null +++ b/src/components/rightBox/chatBox/Bot.vue @@ -0,0 +1,1131 @@ + + + + + diff --git a/src/components/rightBox/chatBox/ChatFooter.vue b/src/components/rightBox/chatBox/ChatFooter.vue new file mode 100644 index 0000000..2a0d70a --- /dev/null +++ b/src/components/rightBox/chatBox/ChatFooter.vue @@ -0,0 +1,859 @@ + + + + + diff --git a/src/components/rightBox/chatBox/ChatHeader.vue b/src/components/rightBox/chatBox/ChatHeader.vue new file mode 100644 index 0000000..250b945 --- /dev/null +++ b/src/components/rightBox/chatBox/ChatHeader.vue @@ -0,0 +1,862 @@ + + + + + diff --git a/src/components/rightBox/chatBox/ChatMain.vue b/src/components/rightBox/chatBox/ChatMain.vue new file mode 100644 index 0000000..2243479 --- /dev/null +++ b/src/components/rightBox/chatBox/ChatMain.vue @@ -0,0 +1,940 @@ + + + + + diff --git a/src/components/rightBox/chatBox/ChatMsgMultiChoose.vue b/src/components/rightBox/chatBox/ChatMsgMultiChoose.vue new file mode 100644 index 0000000..9032288 --- /dev/null +++ b/src/components/rightBox/chatBox/ChatMsgMultiChoose.vue @@ -0,0 +1,483 @@ + + + + diff --git a/src/components/rightBox/chatBox/ChatMultiMsg.vue b/src/components/rightBox/chatBox/ChatMultiMsg.vue new file mode 100644 index 0000000..a2d612f --- /dev/null +++ b/src/components/rightBox/chatBox/ChatMultiMsg.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/src/components/rightBox/chatBox/ChatSidebar.vue b/src/components/rightBox/chatBox/ChatSidebar.vue new file mode 100644 index 0000000..5b49529 --- /dev/null +++ b/src/components/rightBox/chatBox/ChatSidebar.vue @@ -0,0 +1,448 @@ + + + + diff --git a/src/components/rightBox/chatBox/GroupChatSidebar.vue b/src/components/rightBox/chatBox/GroupChatSidebar.vue new file mode 100644 index 0000000..511eb98 --- /dev/null +++ b/src/components/rightBox/chatBox/GroupChatSidebar.vue @@ -0,0 +1,290 @@ + + + + + diff --git a/src/components/rightBox/chatBox/GroupQrCodeModal.vue b/src/components/rightBox/chatBox/GroupQrCodeModal.vue new file mode 100644 index 0000000..9bd245d --- /dev/null +++ b/src/components/rightBox/chatBox/GroupQrCodeModal.vue @@ -0,0 +1,539 @@ + + + + + diff --git a/src/components/rightBox/chatBox/HuLaAssistant.vue b/src/components/rightBox/chatBox/HuLaAssistant.vue new file mode 100644 index 0000000..99aac16 --- /dev/null +++ b/src/components/rightBox/chatBox/HuLaAssistant.vue @@ -0,0 +1,560 @@ + + + + + diff --git a/src/components/rightBox/chatBox/SingleChatSidebar.vue b/src/components/rightBox/chatBox/SingleChatSidebar.vue new file mode 100644 index 0000000..3474a10 --- /dev/null +++ b/src/components/rightBox/chatBox/SingleChatSidebar.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/src/components/rightBox/chatBox/index.vue b/src/components/rightBox/chatBox/index.vue new file mode 100644 index 0000000..d57bed2 --- /dev/null +++ b/src/components/rightBox/chatBox/index.vue @@ -0,0 +1,117 @@ + + + diff --git a/src/components/rightBox/emoticon/index.vue b/src/components/rightBox/emoticon/index.vue new file mode 100644 index 0000000..ef40114 --- /dev/null +++ b/src/components/rightBox/emoticon/index.vue @@ -0,0 +1,1027 @@ + + + + + diff --git a/src/components/rightBox/emoticon/material.ts b/src/components/rightBox/emoticon/material.ts new file mode 100644 index 0000000..a9b9fde --- /dev/null +++ b/src/components/rightBox/emoticon/material.ts @@ -0,0 +1,15 @@ +/** + * 这里存放所以Emoji物料 + */ +// 表情 +const expressionEmojis = + '😀😄😁😆😅🤣😂🙂🙃😉😊😇🫡🫥😶‍🥰😍🤩😘😗😚😙🥲😋😛😜🤪😝🤑🤗🤭🤫🤔🤐🤨😐😑😶😏😒🙄😬🤥😌😔😪🤤😴😷🤒🤕🤢🤮🤧🥵🥶🥴😵🤯🤠🥳🥸😎🤓🧐😕😟🙁😮😯😲😳🥺😦😧😨😰😥😢😭😱😖😣😞😓😩😫🥱😤😡😠🤬😈👿💀💩🤡👹👺👻' + +// 小动物 +const animalEmojis = + '🙊💥💫💦💨🐵🐒🦍🦧🐶🐕🦮🐕‍🦺🐩🐺🦊🦝🐱🐈🦁🐯🐅🐆🐴🐎🦄🦓🦌🦬🐮🐂🐃🐄🐷🐖🐗🐽🐏🐑🐐🐪🐫🦙🦒🐘🦣🦏🦛🐭🐁🐀🐹🐰🐇🐿️🦫🦔🦇🐻🐻‍❄️🐨🐼🦥🦦🦨🦘🦡🐾🦃🐔🐓🐣🐤🐥🐦🐧🦅🦆🦢🦉🦤🪶🦩🦚🦜🐸🐊🐢🦎🐍🐲🐉🦕🦖🐳🐋🐬🦭🐟🐠🐡🦈🐙🐚🐌🦋🐛🐜🐝🪲🐞🦗🪳🕷️🕸️🦂🦟🪰🪱🦠💐🌸💮🌹🥀🌺🌻🌼🌷🌱🪴🌲🌳🌴🌵🌾🌿☘️🍀🍁🍂🍃🍄🌰🦀🦞🦐🦑' + +// 手势 +const gestureEmojis = '💪👈👉👆👇✋👌👍👎✊👊👋👏👐' + +export { expressionEmojis, animalEmojis, gestureEmojis } diff --git a/src/components/rightBox/location/LocationMap.vue b/src/components/rightBox/location/LocationMap.vue new file mode 100644 index 0000000..bb3ace8 --- /dev/null +++ b/src/components/rightBox/location/LocationMap.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/src/components/rightBox/location/LocationModal.vue b/src/components/rightBox/location/LocationModal.vue new file mode 100644 index 0000000..ca8292b --- /dev/null +++ b/src/components/rightBox/location/LocationModal.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/src/components/rightBox/location/StaticProxyMap.vue b/src/components/rightBox/location/StaticProxyMap.vue new file mode 100644 index 0000000..bd97972 --- /dev/null +++ b/src/components/rightBox/location/StaticProxyMap.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/src/components/rightBox/renderMessage/Announcement.vue b/src/components/rightBox/renderMessage/Announcement.vue new file mode 100644 index 0000000..b3eb251 --- /dev/null +++ b/src/components/rightBox/renderMessage/Announcement.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/components/rightBox/renderMessage/AudioCall.vue b/src/components/rightBox/renderMessage/AudioCall.vue new file mode 100644 index 0000000..7f612ff --- /dev/null +++ b/src/components/rightBox/renderMessage/AudioCall.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/components/rightBox/renderMessage/Emoji.vue b/src/components/rightBox/renderMessage/Emoji.vue new file mode 100644 index 0000000..db7be32 --- /dev/null +++ b/src/components/rightBox/renderMessage/Emoji.vue @@ -0,0 +1,161 @@ + + + diff --git a/src/components/rightBox/renderMessage/File.vue b/src/components/rightBox/renderMessage/File.vue new file mode 100644 index 0000000..b469a2f --- /dev/null +++ b/src/components/rightBox/renderMessage/File.vue @@ -0,0 +1,594 @@ + + + + + diff --git a/src/components/rightBox/renderMessage/Image.vue b/src/components/rightBox/renderMessage/Image.vue new file mode 100644 index 0000000..4fbdca5 --- /dev/null +++ b/src/components/rightBox/renderMessage/Image.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/src/components/rightBox/renderMessage/Location.vue b/src/components/rightBox/renderMessage/Location.vue new file mode 100644 index 0000000..3d699be --- /dev/null +++ b/src/components/rightBox/renderMessage/Location.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/src/components/rightBox/renderMessage/MergeMessage.vue b/src/components/rightBox/renderMessage/MergeMessage.vue new file mode 100644 index 0000000..004b42c --- /dev/null +++ b/src/components/rightBox/renderMessage/MergeMessage.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/src/components/rightBox/renderMessage/Text.vue b/src/components/rightBox/renderMessage/Text.vue new file mode 100644 index 0000000..ab53adc --- /dev/null +++ b/src/components/rightBox/renderMessage/Text.vue @@ -0,0 +1,301 @@ + + + + diff --git a/src/components/rightBox/renderMessage/Video.vue b/src/components/rightBox/renderMessage/Video.vue new file mode 100644 index 0000000..f65fc22 --- /dev/null +++ b/src/components/rightBox/renderMessage/Video.vue @@ -0,0 +1,495 @@ + + + + + diff --git a/src/components/rightBox/renderMessage/VideoCall.vue b/src/components/rightBox/renderMessage/VideoCall.vue new file mode 100644 index 0000000..75da245 --- /dev/null +++ b/src/components/rightBox/renderMessage/VideoCall.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/components/rightBox/renderMessage/Voice.vue b/src/components/rightBox/renderMessage/Voice.vue new file mode 100644 index 0000000..4cb8bf2 --- /dev/null +++ b/src/components/rightBox/renderMessage/Voice.vue @@ -0,0 +1,337 @@ + + + + + diff --git a/src/components/rightBox/renderMessage/index.vue b/src/components/rightBox/renderMessage/index.vue new file mode 100644 index 0000000..a127832 --- /dev/null +++ b/src/components/rightBox/renderMessage/index.vue @@ -0,0 +1,690 @@ + + + diff --git a/src/components/rightBox/renderMessage/special/BotMessage.vue b/src/components/rightBox/renderMessage/special/BotMessage.vue new file mode 100644 index 0000000..c61ab20 --- /dev/null +++ b/src/components/rightBox/renderMessage/special/BotMessage.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/components/rightBox/renderMessage/special/RecallMessage.vue b/src/components/rightBox/renderMessage/special/RecallMessage.vue new file mode 100644 index 0000000..928d8d9 --- /dev/null +++ b/src/components/rightBox/renderMessage/special/RecallMessage.vue @@ -0,0 +1,76 @@ + + + diff --git a/src/components/rightBox/renderMessage/special/SystemMessage.vue b/src/components/rightBox/renderMessage/special/SystemMessage.vue new file mode 100644 index 0000000..bf671f6 --- /dev/null +++ b/src/components/rightBox/renderMessage/special/SystemMessage.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/windows/ActionBar.vue b/src/components/windows/ActionBar.vue new file mode 100644 index 0000000..fe62d21 --- /dev/null +++ b/src/components/windows/ActionBar.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/src/directives/v-resize.ts b/src/directives/v-resize.ts new file mode 100644 index 0000000..30de227 --- /dev/null +++ b/src/directives/v-resize.ts @@ -0,0 +1,33 @@ +const map = new WeakMap() + +// 创建一个ResizeObserver实例 +const ob = new ResizeObserver((entries: any[]) => { + // 遍历所有监测到的元素 + for (const entry of entries) { + // 获取该元素的处理器 + const handler = map.get(entry.target) + // 如果存在处理器 + if (handler) { + // 调用处理器函数并传入元素的宽度和高度 + handler({ + width: entry.borderBoxSize[0].inlineSize, + height: entry.borderBoxSize[0].blockSize + }) + } + } +}) + +/** + * 调整元素尺寸指令 + */ +export default { + mounted(el: any, binding: any) { + //监听el元素尺寸的变化 + map.set(el, binding.value) + ob.observe(el) + }, + unmounted(el: any) { + //取消监听 + ob.unobserve(el) + } +} diff --git a/src/directives/v-slide.ts b/src/directives/v-slide.ts new file mode 100644 index 0000000..c95369d --- /dev/null +++ b/src/directives/v-slide.ts @@ -0,0 +1,50 @@ +const DISTANCE = 35 // 距离 +const DURATION = 300 // 持续时间 + +const map = new WeakMap() // 弱引用映射 + +/** 创建观察器 */ +const ob = new IntersectionObserver((entries: any) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const animation = map.get(entry.target) // 获取映射中的动画 + if (animation) { + animation.play() // 播放动画 + ob.unobserve(entry.target) // 停止观察该元素 + } + } + } +}) + +function isBelowViewport(el: any) { + return el.getBoundingClientRect().top - DISTANCE > window.innerHeight // 判断元素是否在视口下方 +} + +export default { + mounted(el: any) { + if (!isBelowViewport(el)) return // 如果元素在视口内,则不执行下面的代码 + const animation = el.animate( + [ + { + transform: `translateY(${DISTANCE}px)`, + opacity: 0.5 + }, + { + transform: 'translateY(0)', + opacity: 1 + } + ], + { + duration: DURATION, // 设置动画持续时间 + easing: 'ease-in-out', // 设置动画缓动效果 + fill: 'forwards' // 设置动画填充模式 + } + ) + animation.pause() // 暂停动画 + ob.observe(el) // 开始观察该元素 + map.set(el, animation) // 将动画对象存入映射中 + }, + unmounted(el: any) { + ob.unobserve(el) // 停止观察该元素 + } +} diff --git a/src/enums/index.ts b/src/enums/index.ts new file mode 100644 index 0000000..a5b9b3c --- /dev/null +++ b/src/enums/index.ts @@ -0,0 +1,937 @@ +/** + * 全局枚举文件 + * 如果枚举值需要在全局使用,那么请在此文件中定义。其他枚举值请在对应的文件中定义。 + * 定义规则: + * 枚举名:XxxEnum + * 枚举值:全部大写,单词间用下划线分割 + */ + +/**请求响应码类型*/ +export enum RCodeEnum { + /**成功请求*/ + OK = '200', + /**请求错误*/ + FAIL = '400', + /**服务器出现问题*/ + SERVE_EXCEPTION = '500', + /**业务出现问题*/ + BUSINESS_EXCEPTION = '600' +} + +/**URL*/ +export enum URLEnum { + /**用户*/ + USER = '/im/user', + /**Token*/ + TOKEN = '/oauth', + /**聊天*/ + CHAT = '/im/chat', + /**房间*/ + ROOM = '/im/room', + /**系统*/ + SYSTEM = '/system', + /**验证码*/ + CAPTCHA = '/im/captcha', + /**消息推送服务前缀*/ + WEBSOCKET = '/ws' +} + +/** tauri原生跨窗口通信时传输的类型 */ +export enum EventEnum { + /** 窗口关闭 */ + WIN_CLOSE = 'winClose', + /** 窗口显示 */ + WIN_SHOW = 'winShow', + /** 退出程序 */ + EXIT = 'exit', + /** 退出账号 */ + LOGOUT = 'logout', + /** 独立窗口 */ + ALONE = 'alone', + /** 共享屏幕 */ + SHARE_SCREEN = 'shareScreen', + /** 锁屏 */ + LOCK_SCREEN = 'lockScreen', + /** 多窗口 */ + MULTI_MSG = 'multiMsg' +} + +/** Mitt兄弟组件通信 */ +export enum MittEnum { + /** 更新消息数量 */ + UPDATE_MSG_TOTAL = 'updateMsgTotal', + /** 显示消息框 */ + MSG_BOX_SHOW = 'msgBoxShow', + /** 跳到发送信息 */ + TO_SEND_MSG = 'toSendMsg', + /** 缩小窗口 */ + SHRINK_WINDOW = 'windowShrink', + /** 详情页面显示 */ + DETAILS_SHOW = 'detailsShow', + /** 好友申请页面显示 */ + APPLY_SHOW = 'applyShow', + /** 回复消息 */ + REPLY_MEG = 'replyMsg', + /** 手动触发InfoPopover */ + INFO_POPOVER = 'infoPopover', + /** 打开个人信息编辑窗口 */ + OPEN_EDIT_INFO = 'openEditInfo', + /** 关闭个人信息浮窗 */ + CLOSE_INFO_SHOW = 'closeInfoShow', + /** 打开修改群昵称弹窗 */ + OPEN_GROUP_NICKNAME_MODAL = 'openGroupNicknameModal', + /** 左边菜单弹窗 */ + LEFT_MODAL_SHOW = 'leftModalShow', + /** 登录窗口异地登录弹窗 */ + LOGIN_REMOTE_MODAL = 'loginRemoteModal', + /** 触发home窗口事件 */ + HOME_WINDOW_RESIZE = 'homeWindowResize', + /** @ AT */ + AT = 'at', + /** 重新编辑 */ + RE_EDIT = 'reEdit', + /** 删除会话 */ + DELETE_SESSION = 'deleteSession', + /** 隐藏会话 */ + HIDE_SESSION = 'hideSession', + /** 定位会话 */ + LOCATE_SESSION = 'locateSession', + /** 聊天框滚动到底部 */ + CHAT_SCROLL_BOTTOM = 'chatScrollBottom', + /** 创建群聊 */ + CREATE_GROUP = 'createGroup', + /** 更新提示 */ + CHECK_UPDATE = 'checkUpdate', + /** 强制更新 */ + DO_UPDATE = 'doUpdate', + /** 视频下载状态更新 */ + VIDEO_DOWNLOAD_STATUS_UPDATED = 'videoDownloadStatusUpdated', + /** 切换语言页面 */ + VOICE_RECORD_TOGGLE = 'voiceRecordToggle', + /** 消息多选 */ + MSG_MULTI_CHOOSE = 'msgMultiChoose', + /** 扫码事件 */ + QR_SCAN_EVENT = 'qrScanEvent', + /** 移动端通话浮层请求 */ + MOBILE_RTC_CALL_REQUEST = 'mobileRtcCallRequest', + /** 移动端关闭输入框面板 */ + MOBILE_CLOSE_PANEL = 'mobileClosePanel', + /** 全局文件拖拽 */ + GLOBAL_FILES_DROP = 'globalFilesDrop', + /** 切换会话 */ + MSG_INIT = 'msg_init', + /** 会话切换完成*/ + SESSION_CHANGED = 'sessionChanged', + /** 更新会话最后一条消息 */ + UPDATE_SESSION_LAST_MSG = 'updateSessionLastMsg' +} + +/** 主题类型 */ +export enum ThemeEnum { + /** 亮色 */ + LIGHT = 'light', + /** 暗色 */ + DARK = 'dark', + /** 跟随系统 */ + OS = 'os' +} + +/** pinia存储的名称 */ +export enum StoresEnum { + /** 置顶 */ + ALWAYS_ON_TOP = 'alwaysOnTop', + /** 设置 */ + SETTING = 'setting', + /** 历史内容 */ + HISTORY = 'history', + /** 聊天列表 */ + CHAT_LIST = 'chatList', + /** 插件列表 */ + PLUGINS = 'plugins', + /** 侧边栏头部菜单栏 */ + MENUTOP = 'menuTop', + /** 账号账号历史记录列表 */ + LOGIN_HISTORY = 'loginHistory', + /** 好友列表 */ + NOTICE = 'notice', + /** 图片查看器数据 */ + IMAGEVIEWER = 'imageViewer', + /** 用户状态 */ + USER_STATE = 'userState', + /** 用户 */ + USER = 'user', + /** 群组 */ + GROUP = 'group', + /** 公告 */ + ANNOUNCEMENT = 'announcement', + /** 全局 */ + GLOBAL = 'global', + /** 表情 */ + EMOJI = 'emoji', + /** 联系人 */ + CONTACTS = 'contacts', + /** 聊天 */ + CHAT = 'chat', + /** 会话未读缓存 */ + SESSION_UNREAD = 'sessionUnread', + /** 缓存 */ + CACHED = 'cached', + /** 配置 */ + CONFIG = 'config', + /** 视频查看器数据 */ + VIDEOVIEWER = 'videoViewer', + /** 文件下载管理 */ + FILE_DOWNLOAD = 'fileDownload', + /** 移动端状态 */ + MOBILE = 'mobile', + /** 目录扫描器 */ + SCANNER = 'scanner', + /** 引导状态 */ + GUIDE = 'guide', + /** 动态/朋友圈 */ + FEED = 'feed', + /** 朋友圈通知 */ + FEED_NOTIFICATION = 'feedNotification', + /** Bot 视图状态 */ + BOT = 'bot', + /** 文件管理 */ + FILE = 'file', + /** 缩略图缓存 */ + THUMBNAIL_CACHE = 'thumbnailCache', + /** 初始化同步状态 */ + INITIAL_SYNC = 'initialSync' +} + +/** + * 消息类型 + * todo: 后续需要补充 + */ +export enum MsgEnum { + /** 未知 0*/ + UNKNOWN, + /** 文本 1*/ + TEXT, + /** 撤回 2*/ + RECALL, + /** 图片 3*/ + IMAGE, + /** 文件 4*/ + FILE, + /** 语音 5*/ + VOICE, + /** 视频 6*/ + VIDEO, + /** 表情包 7*/ + EMOJI, + /** 系统消息 8*/ + SYSTEM, + /** 聊天记录 9*/ + MERGE, + /** 公告 10*/ + NOTICE, + /** 机器人 11*/ + BOT, + /** 视频通话 12*/ + VIDEO_CALL, + /** 语音通话 13*/ + AUDIO_CALL, + /** 混合 14*/ + MIXED, + /** 艾特 15*/ + AIT, + /** 回复 16*/ + REPLY, + /** AI 17*/ + AI, + /** 位置 18*/ + LOCATION +} + +/** + * AI 消息内容类型枚举 + * 用于标识 AI 生成的消息内容类型(文本、图片、视频、音频) + */ +export enum AiMsgContentTypeEnum { + /** 文本 1 */ + TEXT = 1, + /** 图片 2 */ + IMAGE = 2, + /** 视频 3 */ + VIDEO = 3, + /** 音频 4 */ + AUDIO = 4 +} + +/** + * 在线状态 + */ +export enum OnlineEnum { + /** 在线 */ + ONLINE = 1, + /** 离线 */ + OFFLINE +} + +/** + * 操作类型 + */ +export enum ActEnum { + /** 确认 */ + Confirm = 1, + /** 取消 */ + Cancel +} + +/** 性别 */ +export enum SexEnum { + /** 男 */ + MAN = 1, + /** 女 */ + REMALE +} + +/** 权限状态 */ +export enum PowerEnum { + /** 用户 */ + USER, + /** 管理员 */ + ADMIN +} + +/** 是否状态 */ +export enum IsYesEnum { + /** 否 */ + NO, + /** 是 */ + YES +} + +export enum MarkEnum { + /** 点赞 */ + LIKE = 1, + /** 不满 */ + DISLIKE, + /** 爱心 */ + HEART, + /** 愤怒 */ + ANGRY, + /** 礼炮 */ + CELEBRATE, + /** 火箭 */ + ROCKET, + /** 笑哭 */ + LOL, + /** 鼓掌 */ + APPLAUSE, + /** 鲜花 */ + FLOWER, + /** 炸弹 */ + BOMB, + /** 疑问 */ + CONFUSED, + /** 胜利 */ + VICTORY, + /** 灯光 */ + LIGHT, + /** 红包 */ + MONEY +} + +// 成员角色 1群主 2管理员 3普通成员 4踢出群聊 +export enum RoleEnum { + /** 1群主 */ + LORD = 1, + /** 2管理员 */ + ADMIN, + /** 3普通成员 */ + NORMAL, + /** 4踢出群聊 */ + REMOVED +} + +/** 房间类型 1群聊 2单聊 */ +export enum RoomTypeEnum { + /** 1群聊 */ + GROUP = 1, + /** 2单聊 */ + SINGLE = 2 +} + +/** 房间操作 */ +export enum RoomActEnum { + /** 退出群聊 */ + EXIT_GROUP, + /** 解散群聊 */ + DISSOLUTION_GROUP, + /** 删除好友 */ + DELETE_FRIEND, + /** 删除记录 */ + DELETE_RECORD, + /** 屏蔽好友 */ + BLOCK_FRIEND, + /** 修改群名称 */ + UPDATE_GROUP_NAME, + /** 修改群信息 */ + UPDATE_GROUP_INFO +} + +/** 变更类型 1 加入群组,2: 移除群组 */ +export enum ChangeTypeEnum { + /** 1 加入群组 */ + JOIN = 1, + /** 2 移除群组 */ + REMOVE, + /** 3 退出群组 */ + EXIT_GROUP +} + +/** 关闭窗口的行为 */ +export enum CloseBxEnum { + /** 隐藏 */ + HIDE = 'hide', + /** 关闭 */ + CLOSE = 'close' +} + +/** 限制上传 */ +export enum LimitEnum { + /** 通用限制数量 */ + COM_COUNT = 5 +} + +/** ws响应类型 */ +export enum WorkerMsgEnum { + /** open */ + OPEN = 'open', + /** message */ + MESSAGE = 'message', + /** close */ + CLOSE = 'close', + /** error */ + ERROR = 'error', + /** ws_error */ + WS_ERROR = 'wsError' +} + +/** 左边菜单弹出框类型 */ +export enum ModalEnum { + /** 锁屏弹窗 */ + LOCK_SCREEN, + /** 检查更新弹窗 */ + CHECK_UPDATE +} + +/** MacOS键盘映射 */ +export enum MacOsKeyEnum { + '⌘' = '⌘', + '⌥' = '⌥', + '⇧' = '⇧', + '^' = '^' +} + +/** Windows键盘映射 */ +export enum WinKeyEnum { + CTRL = 'Ctrl', + WIN = 'Win', + ALT = 'Alt', + SHIFT = 'Shift' +} + +/** 插件状态 */ +export enum PluginEnum { + /** 已内置 */ + BUILTIN, + /** 已安装 */ + INSTALLED, + /** 下载中 */ + DOWNLOADING, + /** 未安装 */ + NOT_INSTALLED, + /** 卸载中 */ + UNINSTALLING, + /** 可更新 */ + CAN_UPDATE +} + +/** 菜单显示模式 */ +export enum ShowModeEnum { + /** 图标方式 */ + ICON, + /** 文字方式 */ + TEXT +} + +/** + * 消息发送状态 + */ +export enum MessageStatusEnum { + PENDING = 'pending', + SENDING = 'sending', + SUCCESS = 'success', + FAILED = 'failed' +} + +/** 触发类型枚举 */ +export enum TriggerEnum { + MENTION = '@', + AI = '/', + TOPIC = '#' +} + +/** 连接状态枚举 */ +export enum ConnectionState { + CONNECTING = 'connecting', + CONNECTED = 'connected', + DISCONNECTED = 'disconnected', + RECONNECTING = 'reconnecting' +} + +/** 上传scene值状态 */ +export enum UploadSceneEnum { + /** 聊天 */ + CHAT = 'chat', + /** 表情 */ + EMOJI = 'emoji', + /** 头像 */ + AVATAR = 'avatar' +} + +/** 移动端面板状态枚举 */ +export enum MobilePanelStateEnum { + /** 无面板 */ + NONE = 'none', + /** 表情面板 */ + EMOJI = 'emoji', + /** 语音面板 */ + VOICE = 'voice', + /** 更多面板 */ + MORE = 'more', + /** 输入框聚焦 */ + FOCUS = 'focus' +} + +/** 会话操作 */ +export enum SessionOperateEnum { + /** 删除好友 */ + DELETE_FRIEND = 0, + /** 解散群聊 */ + DISSOLUTION_GROUP = 1, + /** 退出群聊 */ + EXIT_GROUP = 2 | 3 +} + +/** + * 通知类型 0 -> 允许接受消息 1 -> 接收但不提醒[免打扰] 2 -> 屏蔽消息 + */ +export enum NotificationTypeEnum { + /** 允许接受消息 */ + RECEPTION = 0, + /** 接收但不提醒[免打扰] */ + NOT_DISTURB = 1 +} + +/** Tauri 命令 */ +export enum TauriCommand { + /** 更新我的群聊信息 */ + UPDATE_MY_ROOM_INFO = 'update_my_room_info', + /** 获取房间成员 */ + GET_ROOM_MEMBERS = 'get_room_members', + /** 分页查询所有房间 */ + PAGE_ROOM = 'page_room', + /** 分页查询房间成员 */ + CURSOR_PAGE_ROOM_MEMBERS = 'cursor_page_room_members', + /** 列出所有会话列表 */ + LIST_CONTACTS = 'list_contacts_command', + /** 分页查询会话消息 */ + PAGE_MSG = 'page_msg', + /** 保存用户信息 */ + SAVE_USER_INFO = 'save_user_info', + /** 更新用户最后操作时间 */ + UPDATE_USER_LAST_OPT_TIME = 'update_user_last_opt_time', + /** 发送消息 */ + SEND_MSG = 'send_msg', + /** 保存消息 */ + SAVE_MSG = 'save_msg', + /** 保存消息标记 */ + SAVE_MESSAGE_MARK = 'save_message_mark', + /** 删除单条聊天消息 */ + DELETE_MESSAGE = 'delete_message', + /** 删除房间内的所有聊天记录 */ + DELETE_ROOM_MESSAGES = 'delete_room_messages', + /** 更新消息撤回状态 */ + UPDATE_MESSAGE_RECALL_STATUS = 'update_message_recall_status', + /** 获取用户 tokens */ + GET_USER_TOKENS = 'get_user_tokens', + /** 更新 token */ + UPDATE_TOKEN = 'update_token', + /** 移除 token */ + REMOVE_TOKENS = 'remove_tokens', + /** 查询聊天历史记录 */ + QUERY_CHAT_HISTORY = 'query_chat_history', + /** AI 消息流式发送 */ + AI_MESSAGE_SEND_STREAM = 'ai_message_send_stream', + /** 生成 MinIO 预签名 URL */ + GENERATE_MINIO_PRESIGNED_URL = 'generate_minio_presigned_url', + /** 通过 Rust 端 PUT 上传本地文件 */ + UPLOAD_FILE_PUT = 'upload_file_put', + /** 通过 Rust 端七牛分片上传本地文件 */ + QINIU_UPLOAD_RESUMABLE = 'qiniu_upload_resumable' +} + +// 通话状态枚举 +export enum RTCCallStatus { + CALLING = 1, // 呼叫 + ACCEPT = 2, // 接听 + END = 3, // 结束 + REJECT = 4, // 拒绝 + ERROR = 5, // 错误中断 + BUSY = 6, // 忙线中 + CANCEL = 7 // 取消 +} + +// 通话类型枚举 +export enum CallTypeEnum { + AUDIO = 1, // 语音通话 + VIDEO = 2 // 视频通话 +} + +export enum ImUrlEnum { + // Token 相关 + /** 登录 */ + LOGIN = 'login', + /** 刷新token */ + REFRESH_TOKEN = 'refreshToken', + /** 忘记密码 */ + FORGET_PASSWORD = 'forgetPassword', + /** 检查token */ + CHECK_TOKEN = 'checkToken', + /** 退出登录 */ + LOGOUT = 'logout', + /** 注册 */ + REGISTER = 'register', + + // 系统相关 + /** 获取七牛云token */ + GET_QINIU_TOKEN = 'getQiniuToken', + /** 初始化配置 */ + INIT_CONFIG = 'initConfig', + /** 获取默认存储提供者 */ + STORAGE_PROVIDER = 'storageProvider', + /** 获取模型列表 */ + GET_ASSISTANT_MODEL_LIST = 'getAssistantModelList', + /** 坐标转换 */ + MAP_COORD_TRANSLATE = 'mapCoordTranslate', + /** 逆地理编码 */ + MAP_REVERSE_GEOCODE = 'mapReverseGeocode', + /** 地址静态图片 */ + MAP_STATIC = 'mapStatic', + + // 验证码相关 + /** 发送验证码 */ + SEND_CAPTCHA = 'sendCaptcha', + /** 获取验证码 */ + GET_CAPTCHA = 'getCaptcha', + + // 群公告相关 + /** 查看群公告 */ + ANNOUNCEMENT = 'announcement', + /** 编辑群公告 */ + EDIT_ANNOUNCEMENT = 'editAnnouncement', + /** 删除群公告 */ + DELETE_ANNOUNCEMENT = 'deleteAnnouncement', + /** 发布群公告 */ + PUSH_ANNOUNCEMENT = 'pushAnnouncement', + /** 获取群公告列表 */ + GET_ANNOUNCEMENT_LIST = 'getAnnouncementList', + + // 群聊申请相关 + /** 申请加群 */ + APPLY_GROUP = 'applyGroup', + + // 群聊搜索和管理 + /** 搜索群聊 */ + SEARCH_GROUP = 'searchGroup', + /** 修改我的群聊信息 */ + UPDATE_MY_ROOM_INFO = 'updateMyRoomInfo', + /** 修改群聊信息 */ + UPDATE_ROOM_INFO = 'updateRoomInfo', + /** 群聊列表 */ + GROUP_LIST = 'groupList', + /** 群聊详情 */ + GROUP_DETAIL = 'groupDetail', + /** 群聊信息 */ + GROUP_INFO = 'groupInfo', + + // 群聊管理员 + /** 撤销管理员 */ + REVOKE_ADMIN = 'revokeAdmin', + /** 添加管理员 */ + ADD_ADMIN = 'addAdmin', + + // 群聊成员管理 + /** 退出群聊 */ + EXIT_GROUP = 'exitGroup', + /** 接受邀请 */ + ACCEPT_INVITE = 'acceptInvite', + /** 邀请列表 */ + INVITE_LIST = 'inviteList', + /** 邀请群成员 */ + INVITE_GROUP_MEMBER = 'inviteGroupMember', + /** 创建群聊 */ + CREATE_GROUP = 'createGroup', + + // 聊天会话相关 + /** 屏蔽消息 */ + SHIELD = 'shield', + /** 通知设置 */ + NOTIFICATION = 'notification', + /** 删除会话 */ + DELETE_SESSION = 'deleteSession', + /** 设置会话置顶 */ + SET_SESSION_TOP = 'setSessionTop', + /** 会话详情(联系人) */ + SESSION_DETAIL_WITH_FRIENDS = 'sessionDetailWithFriends', + /** 会话详情 */ + SESSION_DETAIL = 'sessionDetail', + + // 消息已读未读 + /** 获取消息已读数 */ + GET_MSG_READ_COUNT = 'getMsgReadCount', + /** 获取消息已读列表 */ + GET_MSG_READ_LIST = 'getMsgReadList', + + // 好友相关 + /** 修改好友备注 */ + MODIFY_FRIEND_REMARK = 'modifyFriendRemark', + /** 删除好友 */ + DELETE_FRIEND = 'deleteFriend', + /** 发送添加好友请求 */ + SEND_ADD_FRIEND_REQUEST = 'sendAddFriendRequest', + /** 处理邀请 */ + HANDLE_INVITE = 'handleInvite', + /** 通知未读数 */ + NOTICE_UN_READ_COUNT = 'noticeUnReadCount', + /** 请求通知页面 */ + REQUEST_NOTICE_PAGE = 'requestNoticePage', + /** 通知已读 */ + REQUEST_NOTICE_READ = 'RequestNoticeRead', + /** 获取联系人列表 */ + GET_CONTACT_LIST = 'getContactList', + /** 搜索好友 */ + SEARCH_FRIEND = 'searchFriend', + + // 用户状态相关 + /** 改变用户状态 */ + CHANGE_USER_STATE = 'changeUserState', + /** 获取所有用户状态 */ + GET_ALL_USER_STATE = 'getAllUserState', + + // 二维码相关 + /** 生成二维码 */ + GENERATE_QR_CODE = 'generateQRCode', + /** 检查二维码状态 */ + CHECK_QR_STATUS = 'checkQRStatus', + /** 扫描二维码 */ + SCAN_QR_CODE = 'scanQRCode', + /** 确认登录 */ + CONFIRM_QR_CODE = 'confirmQRCode', + + // 用户信息相关 + /** 上传头像 */ + UPLOAD_AVATAR = 'uploadAvatar', + /** 获取表情 */ + GET_EMOJI = 'getEmoji', + /** 删除表情 */ + DELETE_EMOJI = 'deleteEmoji', + /** 添加表情 */ + ADD_EMOJI = 'addEmoji', + /** 设置用户徽章 */ + SET_USER_BADGE = 'setUserBadge', + /** 修改用户基础信息 */ + MODIFY_USER_INFO = 'ModifyUserInfo', + /** 获取用户信息详情 */ + GET_USER_INFO_DETAIL = 'getUserInfoDetail', + /** 批量获取徽章 */ + GET_BADGES_BATCH = 'getBadgesBatch', + /** 获取徽章列表 */ + GET_BADGE_LIST = 'getBadgeList', + /** 拉黑用户 */ + BLOCK_USER = 'blockUser', + + // 消息相关 + /** 撤回消息 */ + RECALL_MSG = 'recallMsg', + /** 标记消息 */ + MARK_MSG = 'markMsg', + /** 获取消息列表 */ + GET_MSG_LIST = 'getMsgList', + /** 获取成员统计 */ + GET_MEMBER_STATISTIC = 'getMemberStatistic', + + /** 获取朋友圈详情 */ + FEED_DETAIL = 'feedDetail', + /** 获取朋友圈列表 */ + FEED_LIST = 'feedList', + /** 发布朋友圈 */ + PUSH_FEED = 'pushFeed', + /** 删除朋友圈 */ + DEL_FEED = 'delFeed', + /** 编辑朋友圈 */ + EDIT_FEED = 'editFeed', + /** 获取朋友圈权限 */ + GET_FEED_PERMISSION = 'getFeedPermission', + + // 朋友圈点赞相关 + /** 点赞或取消点赞 */ + FEED_LIKE_TOGGLE = 'feedLikeToggle', + /** 获取点赞列表 */ + FEED_LIKE_LIST = 'feedLikeList', + /** 获取点赞数量 */ + FEED_LIKE_COUNT = 'feedLikeCount', + /** 判断是否已点赞 */ + FEED_LIKE_HAS_LIKED = 'feedLikeHasLiked', + + // 朋友圈评论相关 + /** 发表评论 */ + FEED_COMMENT_ADD = 'feedCommentAdd', + /** 删除评论 */ + FEED_COMMENT_DELETE = 'feedCommentDelete', + /** 获取评论列表 */ + FEED_COMMENT_LIST = 'feedCommentList', + /** 获取所有评论列表(不分页) */ + FEED_COMMENT_ALL = 'feedCommentAll', + /** 获取评论数量 */ + FEED_COMMENT_COUNT = 'feedCommentCount', + + // 群成员信息 + /** 获取所有用户基础信息 */ + GET_ALL_USER_BASE_INFO = 'getAllUserBaseInfo', + + GROUP_LIST_MEMBER = 'groupListMember', + SEND_MSG = 'sendMsg', + SET_HIDE = 'setHide', + GET_FRIEND_PAGE = 'getFriendPage', + MARK_MSG_READ = 'markMsgRead', + /** 移出群成员 */ + REMOVE_GROUP_MEMBER = 'removeGroupMember', + CHECK_EMAIL = 'checkEmail', + + MERGE_MSG = 'mergeMsg', + GET_USER_BY_IDS = 'getUserByIds', + + /** 发送 AI 消息 */ + MESSAGE_SEND_STREAM = 'messageSendStream', + /** 保存生成内容消息(用于音频、图片、视频等生成功能) */ + MESSAGE_SAVE_GENERATED_CONTENT = 'messageSaveGeneratedContent', + /** 获取指定会话消息列表 */ + MESSAGE_LIST_BY_CONVERSATION_ID = 'messageListByConversationId', + /** 删除单条消息 */ + MESSAGE_DELETE = 'messageDelete', + /** 删除指定对话的消息 */ + MESSAGE_DELETE_BY_CONVERSATION_ID = 'messageDeleteByConversationId', + /** 获取会话消息列表 */ + CONVERSATION_PAGE = 'conversationPage', + /** 获得【我的】聊天对话 */ + CONVERSATION_GET_MY = 'conversationGetMy', + /** 创建会话 */ + CONVERSATION_CREATE_MY = 'conversationCreateMy', + /** 更新会话 */ + CONVERSATION_UPDATE_MY = 'conversationUpdateMy', + /** 删除会话 */ + CONVERSATION_DELETE_MY = 'conversationDeleteMy', + /** 获得模型 */ + MODEL_GET = 'modelGet', + /** 获得模型剩余使用次数 */ + MODEL_REMAINING_USAGE = 'modelRemainingUsage', + /** 获得模型分页 */ + MODEL_PAGE = 'modelPage', + /** 创建模型 */ + MODEL_CREATE = 'modelCreate', + /** 更新模型 */ + MODEL_UPDATE = 'modelUpdate', + /** 删除模型 */ + MODEL_DELETE = 'modelDelete', + + // ==================== AI 图片生成 ==================== + /** 获取【我的】图片生成分页 */ + IMAGE_MY_PAGE = 'imageMyPage', + /** 获取【我的】图片生成记录 */ + IMAGE_GET = 'imageGet', + /** 根据ID列表获取【我的】图片记录 */ + IMAGE_MY_LIST_BY_IDS = 'imageMyListByIds', + /** 生成图片 */ + IMAGE_DRAW = 'imageDraw', + /** 删除【我的】图片记录 */ + IMAGE_DELETE_MY = 'imageDeleteMy', + + // ==================== AI 视频生成 ==================== + /** 获取【我的】视频生成分页 */ + VIDEO_MY_PAGE = 'videoMyPage', + /** 获取【我的】视频生成记录 */ + VIDEO_GET = 'videoGet', + /** 根据ID列表获取【我的】视频记录 */ + VIDEO_MY_LIST_BY_IDS = 'videoMyListByIds', + /** 生成视频 */ + VIDEO_GENERATE = 'videoGenerate', + /** 删除【我的】视频记录 */ + VIDEO_DELETE_MY = 'videoDeleteMy', + + // ==================== AI 音频生成 ==================== + /** 获取【我的】音频生成分页 */ + AUDIO_MY_PAGE = 'audioMyPage', + /** 获取【我的】音频生成记录 */ + AUDIO_GET_MY = 'audioGetMy', + /** 根据ID列表获取【我的】音频记录 */ + AUDIO_MY_LIST_BY_IDS = 'audioMyListByIds', + /** 生成音频 */ + AUDIO_GENERATE = 'audioGenerate', + /** 删除【我的】音频记录 */ + AUDIO_DELETE_MY = 'audioDeleteMy', + /** 获取指定模型支持的声音列表 */ + AUDIO_VOICES = 'audioVoices', + + /** API 密钥分页 */ + API_KEY_PAGE = 'apiKeyPage', + /** API 密钥简单列表 */ + API_KEY_SIMPLE_LIST = 'apiKeySimpleList', + /** 创建 API 密钥 */ + API_KEY_CREATE = 'apiKeyCreate', + /** 更新 API 密钥 */ + API_KEY_UPDATE = 'apiKeyUpdate', + /** 删除 API 密钥 */ + API_KEY_DELETE = 'apiKeyDelete', + /** 查询 API 密钥余额 */ + API_KEY_BALANCE = 'apiKeyBalance', + /** 获取平台列表 */ + PLATFORM_LIST = 'platformList', + /** 添加平台模型 */ + PLATFORM_ADD_MODEL = 'platformAddModel', + /** 聊天角色分页 */ + CHAT_ROLE_PAGE = 'chatRolePage', + /** 聊天角色类别列表 */ + CHAT_ROLE_CATEGORY_LIST = 'chatRoleCategoryList', + /** 创建聊天角色 */ + CHAT_ROLE_CREATE = 'chatRoleCreate', + /** 更新聊天角色 */ + CHAT_ROLE_UPDATE = 'chatRoleUpdate', + /** 删除聊天角色 */ + CHAT_ROLE_DELETE = 'chatRoleDelete' +} + +// 滚动意图管理枚举 +export enum ScrollIntentEnum { + NONE = 'none', + INITIAL = 'initial', // 初始化或切换房间 + NEW_MESSAGE = 'new_message', // 新消息到达 + LOAD_MORE = 'load_more' // 加载更多历史消息 +} + +export enum MergeMessageType { + SINGLE = 1, + MERGE = 2 +} + +// 用户类型 +export enum UserType { + BOT = 'bot' +} diff --git a/src/hooks/useAssistantModelPresets.ts b/src/hooks/useAssistantModelPresets.ts new file mode 100644 index 0000000..74ec652 --- /dev/null +++ b/src/hooks/useAssistantModelPresets.ts @@ -0,0 +1,67 @@ +import { ref } from 'vue' +import { ImUrlEnum } from '@/enums' +import { imRequest } from '@/utils/ImRequestUtils' + +export type AssistantModelPreset = { + id: string + modelKey: string + modelName: string + modelUrl: string + description: string + status: boolean + version: string +} + +const assistantModelPresets = ref([]) +const assistantModelLoaded = ref(false) +const assistantModelLoading = ref(false) +const assistantModelError = ref(null) +const assistantModelMeta = ref>({}) + +const appendVersionQuery = (url: string, version: string) => { + const encoded = encodeURIComponent(version) + const separator = url.includes('?') ? '&' : '?' + return `${url}${separator}v=${encoded}` +} + +const fetchAssistantModelPresets = async (force = false) => { + if (assistantModelLoading.value || (assistantModelLoaded.value && !force)) return + assistantModelLoading.value = true + assistantModelError.value = null + try { + const response = await imRequest({ + url: ImUrlEnum.GET_ASSISTANT_MODEL_LIST + }) + const normalized = (response ?? []).map((preset) => ({ + ...preset, + modelUrl: appendVersionQuery(preset.modelUrl, preset.version) + })) + const sorted = normalized.slice().sort((a, b) => Number(a.id) - Number(b.id)) + const metaMap: Record = {} + for (const preset of sorted) { + metaMap[preset.modelUrl] = { + name: preset.modelName, + version: preset.version + } + } + assistantModelMeta.value = metaMap + assistantModelPresets.value = sorted + } catch (error) { + console.error('获取 AI 模型列表失败:', error) + assistantModelError.value = error + assistantModelPresets.value = [] + assistantModelMeta.value = {} + } finally { + assistantModelLoading.value = false + assistantModelLoaded.value = true + } +} + +export const useAssistantModelPresets = () => ({ + presets: assistantModelPresets, + loaded: assistantModelLoaded, + loading: assistantModelLoading, + error: assistantModelError, + fetchAssistantModelPresets, + metaMap: assistantModelMeta +}) diff --git a/src/hooks/useAudioFileManager.ts b/src/hooks/useAudioFileManager.ts new file mode 100644 index 0000000..80e9264 --- /dev/null +++ b/src/hooks/useAudioFileManager.ts @@ -0,0 +1,268 @@ +import { join } from '@tauri-apps/api/path' +import { BaseDirectory, create, exists, mkdir, readFile } from '@tauri-apps/plugin-fs' +import type { Ref } from 'vue' +import type { FilesMeta } from '@/services/types' +import { getFilesMeta, getImageCache } from '@/utils/PathUtil' +import { isMac, isMobile } from '@/utils/PlatformConstants' + +/** + * 单个文件元数据接口 + */ +export type FileMetaItem = { + name: string + path: string + file_type: string + mime_type: string + exists: boolean +} + +/** + * 本地音频文件信息接口 + */ +export type LocalAudioFile = { + fileBuffer: ArrayBuffer + cachePath: string + fullPath: string + fileExists: boolean +} + +/** + * 音频存在性检查结果接口 + */ +export type AudioExistsResult = { + exists: boolean + fullPath: string + fileMeta: FileMetaItem +} + +/** + * 音频文件管理器返回接口 + */ +export type AudioFileManagerReturn = { + // 状态 + isFileReady: Ref + audioBuffer: Ref + + // 方法 + getAudioUrl: (originalUrl: string) => Promise + checkAudioSupport: (mimeType: string) => boolean + downloadAndCache: (url: string, fileName: string) => Promise + loadAudioWaveform: (url: string) => Promise + existsAudioFile: (url: string) => Promise + + // 清理 + cleanup: () => void +} + +/** + * 音频文件管理Hook + * @param userId 用户ID,用于构建缓存路径 + * @returns 文件管理接口 + */ +export const useAudioFileManager = (userId: string): AudioFileManagerReturn => { + const isFileReady = ref(false) + const audioBuffer = ref(null) + const isMacOS = isMac() + + /** + * 检查音频格式支持 + * @param mimeType MIME类型 + * @returns 支持级别字符串 + */ + const checkAudioSupport = (mimeType: string): boolean => { + const audio = document.createElement('audio') + const support = audio.canPlayType(mimeType) + return support === 'probably' || support === 'maybe' + } + + /** + * 尝试从本地缓存中读取音频文件 + * @param fileName 音频文件名(如 voice_1234.mp3) + * @returns 包含文件 buffer、完整路径、缓存路径和是否存在标志的对象 + */ + const getLocalAudioFile = async (fileName: string): Promise => { + const audioFolder = 'audio' + // 拼接缓存路径(如 cache\46022457888256\audio) + const cachePath = getImageCache(audioFolder, userId) + const fullPath = await join(cachePath, fileName) + + // 检查文件是否存在于本地缓存文件夹中 + const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache + const fileExists = await exists(fullPath, { baseDir }) + if (!fileExists) { + return { + cachePath, + fullPath, + fileBuffer: new ArrayBuffer(0), + fileExists + } + } + + // 读取音频文件内容 + const fileBuffer = await readFile(fullPath, { baseDir }) + + // 如果是 Uint8Array,手动转成ArrayBuffer + const arrayBuffer = + fileBuffer instanceof Uint8Array + ? fileBuffer.buffer.slice(fileBuffer.byteOffset, fileBuffer.byteOffset + fileBuffer.byteLength) + : fileBuffer + + return { + fileBuffer: arrayBuffer, + cachePath, + fullPath, + fileExists + } + } + + /** + * 从远程下载音频文件并保存到本地缓存目录 + * @param cachePath 要保存的目录路径 + * @param fileName 要保存的文件名 + * @param url 远程URL + * @returns 下载的 ArrayBuffer 数据 + */ + const fetchAndDownloadAudioFile = async (cachePath: string, fileName: string, url: string): Promise => { + const response = await fetch(url) + const arrayBuffer = await response.arrayBuffer() + const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache + const dirExists = await exists(cachePath, { baseDir }) + + // 若目录不存在,则创建缓存目录 + if (!dirExists) { + await mkdir(cachePath, { baseDir, recursive: true }) + } + + // 拼接完整路径并保存文件 + const fullPath = await join(cachePath, fileName) + const file = await create(fullPath, { baseDir }) + await file.write(new Uint8Array(arrayBuffer)) + await file.close() + + return arrayBuffer + } + + /** + * 检查音频文件是否存在于本地缓存 + * @param url 音频文件URL + * @returns 存在性检查结果 + */ + const existsAudioFile = async (url: string): Promise => { + const [fileMeta] = await getFilesMeta([url]) + const audioFolder = 'audio' + const cachePath = getImageCache(audioFolder, userId) + const fullPath = await join(cachePath, fileMeta.name) + + const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache + const fileExists = await exists(fullPath, { baseDir }) + + return { + exists: fileExists, + fullPath: fullPath, + fileMeta: fileMeta // 这里fileMeta是从解构出来的,类型是正确的 + } + } + + /** + * 获取可播放的音频URL + * @param originalUrl 原始音频URL + * @returns 可播放的URL(本地或远程) + */ + const getAudioUrl = async (originalUrl: string): Promise => { + const existsData = await existsAudioFile(originalUrl) + + if (existsData.exists) { + const fileData = await getLocalAudioFile(existsData.fileMeta.name) + + // Mac系统优化:设置正确的MIME类型 + const mimeType = existsData.fileMeta.mime_type || 'audio/mpeg' + + // 检查音频格式支持(mac) + const support = checkAudioSupport(mimeType) + if (!support && isMacOS) { + console.warn(`Mac系统可能不支持此音频格式: ${mimeType}`) + // 降级到远程URL + return originalUrl + } else { + // 确保 fileBuffer 是 ArrayBuffer 类型 + let arrayBuffer: ArrayBuffer + if (fileData.fileBuffer instanceof ArrayBuffer) { + arrayBuffer = fileData.fileBuffer + } else { + arrayBuffer = new ArrayBuffer((fileData.fileBuffer as any).byteLength) + new Uint8Array(arrayBuffer).set(new Uint8Array(fileData.fileBuffer as any)) + } + return URL.createObjectURL(new Blob([new Uint8Array(arrayBuffer)], { type: mimeType })) + } + } + + return originalUrl + } + + /** + * 下载并缓存音频文件 + * @param url 音频URL + * @param fileName 文件名 + * @returns ArrayBuffer数据 + */ + const downloadAndCache = async (url: string, fileName: string): Promise => { + const audioFolder = 'audio' + const cachePath = getImageCache(audioFolder, userId) + + return await fetchAndDownloadAudioFile(cachePath, fileName, url) + } + + /** + * 加载音频波形数据 + * 优先尝试从本地缓存中读取音频文件,若不存在则从远程 URL 下载, + * 并保存到本地缓存中以供后续使用。支持错误回退生成默认波形。 + * @param url 音频URL + * @returns 音频数据buffer + */ + const loadAudioWaveform = async (url: string): Promise => { + try { + // 从url中提取文件基本信息 + const [fileMeta] = await getFilesMeta([url]) + + // 尝试获取本地音频文件 + const localAudioFile = await getLocalAudioFile(fileMeta.name) + + // 判断本地音频文件是否存在 + if (localAudioFile.fileExists) { + // 本地音频存在,则读取它的Buffer格式为Uint8Array + audioBuffer.value = localAudioFile.fileBuffer + isFileReady.value = true + return localAudioFile.fileBuffer + } else { + // 本地音频不存在,读取在线资源文件,格式为Uint8Array + const arrayBuffer = await fetchAndDownloadAudioFile(localAudioFile.cachePath, fileMeta.name, url) + audioBuffer.value = arrayBuffer + isFileReady.value = true + return arrayBuffer + } + } catch (error) { + console.error('加载音频波形失败:', error) + isFileReady.value = false + throw error + } + } + + /** + * 清理资源 + */ + const cleanup = () => { + audioBuffer.value = null + isFileReady.value = false + } + + return { + isFileReady, + audioBuffer, + getAudioUrl, + checkAudioSupport, + downloadAndCache, + loadAudioWaveform, + existsAudioFile, + cleanup + } +} diff --git a/src/hooks/useAudioPlayback.ts b/src/hooks/useAudioPlayback.ts new file mode 100644 index 0000000..6b23c81 --- /dev/null +++ b/src/hooks/useAudioPlayback.ts @@ -0,0 +1,264 @@ +import type { Ref } from 'vue' +import { audioManager } from '@/utils/AudioManager' + +export type AudioPlaybackReturn = { + // 状态 + isPlaying: Ref + loading: Ref + currentTime: Ref + playbackProgress: Ref + audioElement: Ref + + // 播放位置记忆 + lastPlayPosition: Ref + hasBeenPlayed: Ref + shouldResumeFromPosition: Ref + + // 方法 + togglePlayback: () => Promise + createAudioElement: (audioUrl: string, audioId: string, duration: number) => Promise + seekToTime: (time: number) => void + cleanup: () => void +} + +/** + * 音频播放控制Hook + * @param audioId 音频唯一标识 + * @param onTimeUpdate 时间更新回调 + * @returns 播放控制接口 + */ +export const useAudioPlayback = ( + audioId: string, + onTimeUpdate?: (currentTime: number, progress: number) => void +): AudioPlaybackReturn => { + const isPlaying = ref(false) + const loading = ref(false) + const currentTime = ref(0) + const playbackProgress = ref(0) + const audioElement = ref(null) + + const lastPlayPosition = ref(0) // 最后播放位置(秒) + const hasBeenPlayed = ref(false) // 是否曾经播放过 + const shouldResumeFromPosition = ref(false) // 是否应从记忆位置恢复 + + /** + * 增强的边界处理 + */ + const enhancedClampTime = (time: number, maxTime: number = 0) => { + if (!Number.isFinite(time) || isNaN(time)) { + return 0 + } + + const max = Math.max(0, maxTime) + return Math.max(0, Math.min(max, time)) + } + + /** + * 音频状态变化监听器 + */ + const handleAudioStateChange = () => { + const currentId = audioManager.getCurrentAudioId() + // 如果当前播放的不是本组件的音频,则重置播放状态 + if (currentId !== audioId && isPlaying.value) { + // 在切换前记忆当前位置 + if (audioElement.value && audioElement.value.currentTime > 0) { + lastPlayPosition.value = audioElement.value.currentTime + shouldResumeFromPosition.value = true + } + + isPlaying.value = false + } + } + + /** + * 创建音频元素并设置事件监听 + * @param audioUrl 音频URL + * @param id 音频ID + * @param duration 音频时长 + */ + const createAudioElement = async (audioUrl: string, _id: string, duration: number) => { + if (audioElement.value) return + + audioElement.value = new Audio(audioUrl) + + // 设置事件监听 + audioElement.value.addEventListener('loadstart', () => { + loading.value = true + }) + + audioElement.value.addEventListener('canplay', () => { + loading.value = false + + // 音频准备就绪后,检查是否需要恢复播放位置 + if (shouldResumeFromPosition.value && lastPlayPosition.value > 0) { + try { + const clampedPosition = enhancedClampTime(lastPlayPosition.value, duration) + audioElement.value!.currentTime = clampedPosition + currentTime.value = clampedPosition + const progress = (clampedPosition / audioElement.value!.duration) * 100 + playbackProgress.value = progress || 0 + shouldResumeFromPosition.value = false // 重置标志 + } catch (error) { + console.warn('恢复播放位置失败:', error) + // 恢复失败时清理状态 + lastPlayPosition.value = 0 + shouldResumeFromPosition.value = false + } + } + }) + + audioElement.value.addEventListener('timeupdate', () => { + if (audioElement.value) { + currentTime.value = audioElement.value.currentTime + const progress = (audioElement.value.currentTime / audioElement.value.duration) * 100 + playbackProgress.value = progress || 0 + + // 实时更新播放位置记忆(仅在正常播放时) + if (isPlaying.value) { + lastPlayPosition.value = audioElement.value.currentTime + hasBeenPlayed.value = true + } + + // 调用时间更新回调 + onTimeUpdate?.(currentTime.value, playbackProgress.value) + } + }) + + audioElement.value.addEventListener('ended', () => { + isPlaying.value = false + currentTime.value = 0 + playbackProgress.value = 0 + + // 播放结束时清理位置记忆 + lastPlayPosition.value = 0 + hasBeenPlayed.value = false + shouldResumeFromPosition.value = false + + // 通知外部播放结束 + onTimeUpdate?.(0, 0) + }) + + audioElement.value.addEventListener('error', () => { + loading.value = false + isPlaying.value = false + }) + + // 添加音频管理器监听器 + audioManager.addListener(handleAudioStateChange) + } + + /** + * 切换播放状态 + */ + const togglePlayback = async () => { + if (loading.value || !audioElement.value) return + + try { + // 如果当前是播放状态,则暂停并记忆位置 + if (isPlaying.value) { + // 记忆当前播放位置 + if (audioElement.value) { + lastPlayPosition.value = audioElement.value.currentTime + shouldResumeFromPosition.value = true + } + + audioManager.pause() + isPlaying.value = false + return + } + + // 检查是否需要恢复播放位置 + if (shouldResumeFromPosition.value && lastPlayPosition.value > 0) { + const clampedPosition = enhancedClampTime(lastPlayPosition.value, audioElement.value.duration) + audioElement.value.currentTime = clampedPosition + currentTime.value = clampedPosition + const progress = (clampedPosition / audioElement.value.duration) * 100 + playbackProgress.value = progress || 0 + shouldResumeFromPosition.value = false + } + + // 开始播放 + await audioManager.play(audioElement.value, audioId) + isPlaying.value = true + hasBeenPlayed.value = true + } catch (error) { + console.error('播放控制错误:', error) + isPlaying.value = false + loading.value = false + + // 错误时清理状态 + lastPlayPosition.value = 0 + shouldResumeFromPosition.value = false + } + } + + /** + * 跳转到指定时间 + * @param time 目标时间(秒) + */ + const seekToTime = (time: number) => { + if (!audioElement.value) return + + const clampedTime = enhancedClampTime(time, audioElement.value.duration) + + if (Number.isFinite(clampedTime) && !isNaN(clampedTime)) { + audioElement.value.currentTime = clampedTime + currentTime.value = clampedTime + const progress = (clampedTime / audioElement.value.duration) * 100 + playbackProgress.value = progress || 0 + + // 更新播放位置记忆 + lastPlayPosition.value = clampedTime + } + } + + /** + * 清理资源 + */ + const cleanup = () => { + // 移除音频管理器监听器 + audioManager.removeListener(handleAudioStateChange) + + // 清理音频元素 + if (audioElement.value) { + try { + audioElement.value.pause() + audioElement.value.src = '' + audioElement.value.load() // 重置音频元素 + } catch (error) { + console.warn('清理音频元素时出现错误:', error) + } finally { + audioElement.value = null + } + } + + // 重置状态 + isPlaying.value = false + loading.value = false + currentTime.value = 0 + playbackProgress.value = 0 + lastPlayPosition.value = 0 + hasBeenPlayed.value = false + shouldResumeFromPosition.value = false + } + + return { + // 状态 + isPlaying, + loading, + currentTime, + playbackProgress, + audioElement, + + // 播放位置记忆 + lastPlayPosition, + hasBeenPlayed, + shouldResumeFromPosition, + + // 方法 + togglePlayback, + createAudioElement, + seekToTime, + cleanup + } +} diff --git a/src/hooks/useAutoScrollGuard.ts b/src/hooks/useAutoScrollGuard.ts new file mode 100644 index 0000000..d58d373 --- /dev/null +++ b/src/hooks/useAutoScrollGuard.ts @@ -0,0 +1,48 @@ +import { onUnmounted, ref } from 'vue' + +/** + * 管理自动滚动保护窗口,避免在程序滚动期间被误判为用户滚动。 + * 使用 requestAnimationFrame 控制时长,不依赖 setTimeout。 + */ +export const useAutoScrollGuard = () => { + const isAutoScrolling = ref(false) + let autoScrollRafId: number | null = null + let autoScrollUntil = 0 + + const stopGuard = () => { + if (autoScrollRafId) { + cancelAnimationFrame(autoScrollRafId) + autoScrollRafId = null + } + autoScrollUntil = 0 + isAutoScrolling.value = false + } + + const enableAutoScroll = (duration = 500) => { + const now = performance.now() + autoScrollUntil = Math.max(autoScrollUntil, now + duration) + if (!isAutoScrolling.value) { + isAutoScrolling.value = true + } + + const step = (timestamp: number) => { + if (timestamp >= autoScrollUntil) { + stopGuard() + return + } + autoScrollRafId = requestAnimationFrame(step) + } + + if (!autoScrollRafId) { + autoScrollRafId = requestAnimationFrame(step) + } + } + + onUnmounted(stopGuard) + + return { + isAutoScrolling, + enableAutoScroll, + stopAutoScrollGuard: stopGuard + } +} diff --git a/src/hooks/useAvatarUpload.ts b/src/hooks/useAvatarUpload.ts new file mode 100644 index 0000000..f44dde0 --- /dev/null +++ b/src/hooks/useAvatarUpload.ts @@ -0,0 +1,127 @@ +import { UploadSceneEnum } from '@/enums' +import { UploadProviderEnum, useUpload } from './useUpload' + +export interface AvatarUploadOptions { + // 上传成功后的回调函数,参数为下载URL + onSuccess?: (downloadUrl: string) => void + // 上传场景,默认为头像 + scene?: UploadSceneEnum + // 文件大小限制(KB),默认为150KB + sizeLimit?: number +} + +/** + * 上传头像的hook + * @param options 上传配置 + */ +export const useAvatarUpload = (options: AvatarUploadOptions = {}) => { + const { onSuccess, scene = UploadSceneEnum.AVATAR, sizeLimit = 150 } = options + + const fileInput = ref() + const localImageUrl = ref('') + const showCropper = ref(false) + const cropperRef = ref() + + // 打开文件选择器 + const openFileSelector = () => { + fileInput.value?.click() + } + + // 处理文件选择 + const handleFileChange = (e: Event) => { + const file = (e.target as HTMLInputElement).files?.[0] + if (file) { + const img = new Image() + const url = URL.createObjectURL(file) + img.onload = () => { + localImageUrl.value = url + nextTick(() => { + showCropper.value = true + }) + } + img.onerror = () => { + window.$message.error('图片加载失败') + URL.revokeObjectURL(url) + } + img.src = url + } + } + + // 校验头像更改条件 + const openAvatarCropper = () => { + fileInput.value?.click() + } + + // 处理裁剪 + const handleCrop = async (cropBlob: Blob) => { + try { + const fileName = `avatar_${Date.now()}.webp` + const file = new File([cropBlob], fileName, { type: 'image/webp' }) + + // 检查裁剪后的文件大小 + if (file.size > sizeLimit * 1024) { + window.$message.error(`图片大小不能超过${sizeLimit}KB,当前图片裁剪后大小为${Math.round(file.size / 1024)}KB`) + // 结束加载状态 + cropperRef.value?.finishLoading() + return + } + + // 先设置图片URL,等待图片加载完成后再显示裁剪窗口 + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'] + if (!allowedTypes.includes(file.type)) { + window.$message.error('只支持 JPG、PNG、WebP 格式的图片') + // 结束加载状态 + cropperRef.value?.finishLoading() + return + } + + // 使用useUpload中的七牛云上传功能 + const { uploadFile, fileInfo } = useUpload() + + // 执行上传,使用七牛云上传方式 + await uploadFile(file, { + provider: UploadProviderEnum.QINIU, + enableDeduplication: true, + scene: scene + }) + + // 获取下载URL + const downloadUrl = fileInfo.value?.downloadUrl || '' + + // 调用成功回调 + if (onSuccess) { + onSuccess(downloadUrl) + } + + // 清理资源 + if (localImageUrl.value) { + URL.revokeObjectURL(localImageUrl.value) + } + localImageUrl.value = '' + if (fileInput.value) { + fileInput.value.value = '' + } + + // 结束加载状态 + cropperRef.value?.finishLoading() + // 关闭裁剪窗口 + showCropper.value = false + } catch (error) { + console.error('上传头像失败:', error) + window.$message.error('上传头像失败') + // 发生错误时也需要结束加载状态 + cropperRef.value?.finishLoading() + } + } + + return { + fileInput, + localImageUrl, + showCropper, + cropperRef, + openFileSelector, + handleFileChange, + handleCrop, + openAvatarCropper + } +} diff --git a/src/hooks/useCanvasTool.ts b/src/hooks/useCanvasTool.ts new file mode 100644 index 0000000..f003934 --- /dev/null +++ b/src/hooks/useCanvasTool.ts @@ -0,0 +1,369 @@ +export function useCanvasTool(drawCanvas: any, drawCtx: any, imgCtx: any, screenConfig: any) { + const drawConfig = ref({ + startX: 0, + startY: 0, + endX: 0, + endY: 0, + scaleX: 1, + scaleY: 1, + lineWidth: 2, // 线宽 + color: 'red', // 颜色 + isDrawing: false, // 正在绘制 + brushSize: 10, // 马赛克大小 + actions: [], // 存储绘制动作 + undoStack: [] + }) + + const currentTool = ref('') + // 标记当前一次绘制过程中是否实际产生了绘制 + const hasDrawn = ref(false) + // 是否可以撤回(当存在已保存的绘制动作时) + const canUndo = computed(() => drawConfig.value.actions.length > 0) + + const draw = (type: string) => { + const { clientWidth: containerWidth, clientHeight: containerHeight } = drawCanvas.value + drawConfig.value.scaleX = (screen.width * window.devicePixelRatio) / containerWidth + drawConfig.value.scaleY = (screen.height * window.devicePixelRatio) / containerHeight + currentTool.value = type + startListen() + } + + onUnmounted(() => { + closeListen() + }) + + const handleMouseDown = (event: MouseEvent) => { + const { offsetX, offsetY } = event + drawConfig.value.isDrawing = true + hasDrawn.value = false + + // 限制起点坐标在框选矩形区域内 + drawConfig.value.startX = Math.min( + Math.max(offsetX * drawConfig.value.scaleX, screenConfig.value.startX), + screenConfig.value.endX + ) + drawConfig.value.startY = Math.min( + Math.max(offsetY * drawConfig.value.scaleY, screenConfig.value.startY), + screenConfig.value.endY + ) + } + + const handleMouseMove = (event: MouseEvent) => { + const { offsetX, offsetY } = event + if (!drawConfig.value.isDrawing || !drawCtx.value) return + + // 限制绘制区域在框选矩形区域内 + let limitedX = Math.min( + Math.max(offsetX * drawConfig.value.scaleX, screenConfig.value.startX), + screenConfig.value.endX + ) + let limitedY = Math.min( + Math.max(offsetY * drawConfig.value.scaleY, screenConfig.value.startY), + screenConfig.value.endY + ) + + // 对于马赛克工具,需要考虑边框宽度和画笔半径偏移,避免涂抹到选区边框 + if (currentTool.value === 'mosaic') { + const borderWidth = 2 + const halfBrushSize = drawConfig.value.brushSize / 2 + const safeMargin = borderWidth + halfBrushSize + + limitedX = Math.min( + Math.max(offsetX * drawConfig.value.scaleX, screenConfig.value.startX + safeMargin), + screenConfig.value.endX - safeMargin + ) + limitedY = Math.min( + Math.max(offsetY * drawConfig.value.scaleY, screenConfig.value.startY + safeMargin), + screenConfig.value.endY - safeMargin + ) + } + + drawConfig.value.endX = limitedX + drawConfig.value.endY = limitedY + + // 清除非马赛克的情况下重新绘制 + if (currentTool.value !== 'mosaic') { + drawCtx.value.clearRect(0, 0, drawCanvas.value.width, drawCanvas.value.height) + drawConfig.value.actions.forEach((action) => { + drawCtx.value.putImageData(action, 0, 0) + }) + } + + const x = Math.min(drawConfig.value.startX, drawConfig.value.endX) + const y = Math.min(drawConfig.value.startY, drawConfig.value.endY) + + const width = Math.abs(drawConfig.value.startX - drawConfig.value.endX) + const height = Math.abs(drawConfig.value.startY - drawConfig.value.endY) + + switch (currentTool.value) { + case 'rect': + drawRectangle(drawCtx.value, x, y, width, height) + hasDrawn.value = true + break + case 'circle': + drawCircle( + drawCtx.value, + drawConfig.value.startX, + drawConfig.value.startY, + drawConfig.value.endX, + drawConfig.value.endY + ) + hasDrawn.value = true + break + case 'arrow': + drawArrow( + drawCtx.value, + drawConfig.value.startX, + drawConfig.value.startY, + drawConfig.value.endX, + drawConfig.value.endY + ) + hasDrawn.value = true + break + case 'mosaic': + drawMosaic(drawCtx.value, limitedX, limitedY, drawConfig.value.brushSize) + hasDrawn.value = true + break + default: + break + } + } + + const handleMouseUp = () => { + // const { offsetX, offsetY } = event; + drawConfig.value.isDrawing = false + + // 没有实际绘制时不保存动作,避免误触(例如点击工具栏按钮时) + if (!hasDrawn.value) { + return + } + + drawCtx.value.drawImage(drawCanvas.value!, 0, 0, drawCanvas.value.width, drawCanvas.value.height) + + saveAction() + } + + const drawRectangle = (context: any, x: any, y: any, width: any, height: any) => { + context.strokeStyle = drawConfig.value.color + context.lineWidth = drawConfig.value.lineWidth + context.strokeRect(x, y, width, height) + } + + const drawCircle = (context: any, startX: any, startY: any, endX: any, endY: any) => { + // 限制圆形的绘制范围在框选矩形区域内 + const limitedEndX = Math.min(Math.max(endX, screenConfig.value.startX), screenConfig.value.endX) + const limitedEndY = Math.min(Math.max(endY, screenConfig.value.startY), screenConfig.value.endY) + + // 计算半径,保证半径不会超过限定矩形的边界 + const deltaX = limitedEndX - startX + const deltaY = limitedEndY - startY + + // 检查圆形是否会超出矩形区域的边界 + const maxRadiusX = Math.min(startX - screenConfig.value.startX, screenConfig.value.endX - startX) + const maxRadiusY = Math.min(startY - screenConfig.value.startY, screenConfig.value.endY - startY) + const maxRadius = Math.min(maxRadiusX, maxRadiusY) + + // 使用 min 函数确保半径不会超过限定范围 + const radius = Math.min(Math.sqrt(deltaX * deltaX + deltaY * deltaY), maxRadius) + + // 绘制圆形 + context.strokeStyle = drawConfig.value.color + context.lineWidth = drawConfig.value.lineWidth + context.beginPath() + context.arc(startX, startY, radius, 0, Math.PI * 2) + context.stroke() + } + + const drawArrow = (context: any, fromX: any, fromY: any, toX: any, toY: any) => { + const headLength = 15 // 箭头的长度 + const angle = Math.atan2(toY - fromY, toX - fromX) // 算出箭头的角度 + + context.strokeStyle = drawConfig.value.color + context.lineWidth = drawConfig.value.lineWidth + + // 绘制箭头的主线 + context.beginPath() + context.moveTo(fromX, fromY) + context.lineTo(toX, toY) + context.stroke() + + // 计算箭头三角形的三个角 + context.beginPath() + context.moveTo(toX, toY) + context.lineTo(toX - headLength * Math.cos(angle - Math.PI / 6), toY - headLength * Math.sin(angle - Math.PI / 6)) + context.lineTo(toX - headLength * Math.cos(angle + Math.PI / 6), toY - headLength * Math.sin(angle + Math.PI / 6)) + context.closePath() + + // 填充箭头三角形 + context.fillStyle = drawConfig.value.color + context.fill() + } + + // 设置马赛克画笔大小 + const drawMosaicBrushSize = (size: any) => { + drawConfig.value.brushSize = size + } + + // 实时马赛克涂抹 + const drawMosaic = (context: any, x: any, y: any, size: any) => { + // 确保马赛克绘制区域不会超出选区边界(考虑边框和画笔半径) + const borderWidth = 2 + const halfSize = size / 2 + + // 考虑画笔半径的安全边距,确保画笔边缘不会涂抹到边框 + const safeMargin = borderWidth + halfSize + + // 计算实际绘制区域,确保完全在选区内容区域内 + const drawX = Math.max(x - halfSize, screenConfig.value.startX + safeMargin) + const drawY = Math.max(y - halfSize, screenConfig.value.startY + safeMargin) + const maxDrawX = Math.min(x + halfSize, screenConfig.value.endX - safeMargin) + const maxDrawY = Math.min(y + halfSize, screenConfig.value.endY - safeMargin) + + // 计算实际绘制尺寸 + const drawWidth = Math.max(0, maxDrawX - drawX) + const drawHeight = Math.max(0, maxDrawY - drawY) + + if (drawWidth > 0 && drawHeight > 0) { + const imageData = imgCtx.value.getImageData(drawX, drawY, drawWidth, drawHeight) + const blurredData = blurImageData(imageData, Math.min(drawWidth, drawHeight)) + context.putImageData(blurredData, drawX, drawY) + } + } + + const blurImageData = (imageData: ImageData, size: any): ImageData => { + const data = imageData.data + const width = imageData.width + const height = imageData.height + + const radius = size / 2 // 模糊半径 + const tempData = new Uint8ClampedArray(data) // 用于保存原始图像数据 + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = (y * width + x) * 4 + + let r = 0, + g = 0, + b = 0, + a = 0 + let count = 0 + + for (let ky = -radius; ky <= radius; ky++) { + for (let kx = -radius; kx <= radius; kx++) { + const newX = x + kx + const newY = y + ky + + if (newX >= 0 && newX < width && newY >= 0 && newY < height) { + const newIndex = (newY * width + newX) * 4 + r += tempData[newIndex] + g += tempData[newIndex + 1] + b += tempData[newIndex + 2] + a += tempData[newIndex + 3] + count++ + } + } + } + + data[index] = r / count + data[index + 1] = g / count + data[index + 2] = b / count + data[index + 3] = a / count + } + } + + return imageData + } + + const saveAction = () => { + const imageData = drawCtx.value.getImageData(0, 0, drawCanvas.value.width, drawCanvas.value.height) + drawConfig.value.actions.push(imageData as never) + drawConfig.value.undoStack = [] // 清空撤销堆栈 + } + + const undo = () => { + closeListen() + if (drawConfig.value.actions.length > 0) { + drawConfig.value.undoStack.push(drawConfig.value.actions.pop() as never) + drawCtx.value.clearRect(0, 0, drawCanvas.value.width, drawCanvas.value.height) + if (drawConfig.value.actions.length > 0) { + drawCtx.value.putImageData(drawConfig.value.actions[drawConfig.value.actions.length - 1], 0, 0) + } + } + } + + const redo = () => { + closeListen() + if (drawConfig.value.undoStack.length > 0) { + const imageData = drawConfig.value.undoStack.pop() + drawConfig.value.actions.push(imageData as never) + drawCtx.value.putImageData(imageData, 0, 0) + } + } + + // 一键清空所有绘制内容 + const clearAll = () => { + closeListen() + drawConfig.value.actions = [] + drawConfig.value.undoStack = [] + if (drawCtx.value && drawCanvas.value) { + drawCtx.value.clearRect(0, 0, drawCanvas.value.width, drawCanvas.value.height) + } + } + + // 重置绘图状态,清除所有绘制历史 + const resetState = () => { + drawConfig.value.actions = [] + drawConfig.value.undoStack = [] + drawConfig.value.isDrawing = false + currentTool.value = '' + console.log('绘图状态已重置,历史记录已清除') + } + + // 停止当前绘图操作 + const stopDrawing = () => { + drawConfig.value.isDrawing = false + currentTool.value = '' + closeListen() + console.log('绘图操作已停止') + } + + // 清除事件监听 + const clearEvents = () => { + closeListen() + console.log('绘图事件监听已清除') + } + + const startListen = () => { + const el = drawCanvas.value + if (!el) return + // 仅在绘图画布上监听按下与移动,避免点击工具栏也触发绘图流程 + el.addEventListener('mousedown', handleMouseDown) + el.addEventListener('mousemove', handleMouseMove) + // mouseup 放在 document 上,确保拖出画布后仍能结束一次绘制 + document.addEventListener('mouseup', handleMouseUp) + } + + const closeListen = () => { + const el = drawCanvas.value + if (el) { + el.removeEventListener('mousedown', handleMouseDown) + el.removeEventListener('mousemove', handleMouseMove) + } + document.removeEventListener('mouseup', handleMouseUp) + } + + return { + draw, + drawMosaicBrushSize, + drawRectangle, + drawCircle, + drawArrow, + undo, + redo, + clearAll, + resetState, + stopDrawing, + clearEvents, + canUndo + } +} diff --git a/src/hooks/useChatLayout.ts b/src/hooks/useChatLayout.ts new file mode 100644 index 0000000..93aacdc --- /dev/null +++ b/src/hooks/useChatLayout.ts @@ -0,0 +1,23 @@ +import { FOOTER_HEIGHT } from '@/common/constants' + +/** + * 聊天页面布局管理 + */ +export const useChatLayout = () => { + const footerHeight = ref(FOOTER_HEIGHT) + + const setFooterHeight = (height: number) => { + footerHeight.value = height + } + + return { + footerHeight: readonly(footerHeight), + setFooterHeight + } +} + +// 创建全局实例 +const chatLayoutInstance = useChatLayout() + +// 导出全局实例方法 +export const useChatLayoutGlobal = () => chatLayoutInstance diff --git a/src/hooks/useChatMain.ts b/src/hooks/useChatMain.ts new file mode 100644 index 0000000..76b9af2 --- /dev/null +++ b/src/hooks/useChatMain.ts @@ -0,0 +1,1358 @@ +import { appDataDir, join, resourceDir } from '@tauri-apps/api/path' +import { writeImage, writeText } from '@tauri-apps/plugin-clipboard-manager' +import { save } from '@tauri-apps/plugin-dialog' +import { BaseDirectory } from '@tauri-apps/plugin-fs' +import { revealItemInDir } from '@tauri-apps/plugin-opener' +import type { FileTypeResult } from 'file-type' +import { computed, onUnmounted, type InjectionKey } from 'vue' +import { ErrorType } from '@/common/exception' +import { + MergeMessageType, + MittEnum, + MsgEnum, + PowerEnum, + CallTypeEnum, + RoleEnum, + RoomTypeEnum, + TauriCommand +} from '@/enums' +import { useCommon } from '@/hooks/useCommon.ts' +import { useDownload } from '@/hooks/useDownload' +import { useMitt } from '@/hooks/useMitt.ts' +import { useVideoViewer } from '@/hooks/useVideoViewer' +import { translateTextStream } from '@/services/translate' +import type { FilesMeta, MessageType, RightMouseMessageItem } from '@/services/types.ts' +import { useCachedStore } from '@/stores/cached' +import { useChatStore } from '@/stores/chat.ts' +import { useContactStore } from '@/stores/contacts' +import { useEmojiStore } from '@/stores/emoji' +import { type FileDownloadStatus, useFileDownloadStore } from '@/stores/fileDownload' +import { useGlobalStore } from '@/stores/global.ts' +import { useGroupStore } from '@/stores/group' +import { useSettingStore } from '@/stores/setting.ts' +import { useUserStore } from '@/stores/user' +import { saveFileAttachmentAs, saveVideoAttachmentAs } from '@/utils/AttachmentSaver' +import { isDiffNow } from '@/utils/ComputedTime.ts' +import { extractFileName, removeTag } from '@/utils/Formatting' +import { detectImageFormat, imageUrlToUint8Array, isImageUrl } from '@/utils/ImageUtils' +import { recallMsg, removeGroupMember, updateMyRoomInfo } from '@/utils/ImRequestUtils' +import { detectRemoteFileType, getFilesMeta } from '@/utils/PathUtil' +import { isMac, isMobile } from '@/utils/PlatformConstants' +import { invokeWithErrorHandler } from '@/utils/TauriInvokeHandler' +import { useWindow } from './useWindow' +import { useI18n } from 'vue-i18n' + +type UseChatMainOptions = { + enableGroupNicknameModal?: boolean + disableHistoryActions?: boolean +} + +type GroupNicknameModalPayload = { + roomId: string + currentUid: string + originalNickname: string +} + +export const useChatMain = (isHistoryMode = false, options: UseChatMainOptions = {}) => { + const { t } = useI18n() + const { openMsgSession, userUid } = useCommon() + const { createWebviewWindow, sendWindowPayload, startRtcCall } = useWindow() + const { getLocalVideoPath, checkVideoDownloaded } = useVideoViewer() + const fileDownloadStore = useFileDownloadStore() + const settingStore = useSettingStore() + const { chat } = storeToRefs(settingStore) + const globalStore = useGlobalStore() + const groupStore = useGroupStore() + const chatStore = useChatStore() + const cachedStore = useCachedStore() + const emojiStore = useEmojiStore() + const userStore = useUserStore() + const { downloadFile } = useDownload() + const enableGroupNicknameModal = options.enableGroupNicknameModal ?? false + const disableHistoryActions = options.disableHistoryActions ?? false + /** 滚动条位置 */ + const scrollTop = ref(-1) + /** 提醒框标题 */ + const tips = ref() + /** 是否显示删除信息的弹窗 */ + const modalShow = ref(false) + /** 需要删除信息的下标 */ + const delIndex = ref('') + const delRoomId = ref('') + /** 选中的气泡消息 */ + const activeBubble = ref('') + /** 记录历史消息下标 */ + const historyIndex = ref(0) + /** 当前点击的用户的key */ + const selectKey = ref() + + /** 修改群昵称的模态框是否显示 */ + const groupNicknameModalVisible = ref(false) + /** 修改群昵称输入的值 */ + const groupNicknameValue = ref('') + /** 修改群昵称错误提示 */ + const groupNicknameError = ref('') + /** 修改群昵称提交状态 */ + const groupNicknameSubmitting = ref(false) + /** 修改群昵称上下文信息 */ + const groupNicknameContext = ref<{ roomId: string; currentUid: string; originalNickname: string } | null>(null) + + const handleGroupNicknameConfirm = async () => { + if (!groupNicknameContext.value) { + return + } + + const trimmedName = groupNicknameValue.value.trim() + if (!trimmedName) { + groupNicknameError.value = t('home.chat_main.group_nickname.error.empty') + return + } + + if (trimmedName === groupNicknameContext.value.originalNickname) { + groupNicknameModalVisible.value = false + return + } + + const { roomId, currentUid } = groupNicknameContext.value + if (!roomId) { + window.$message?.error(t('home.chat_main.group_nickname.error.invalid_room')) + return + } + + try { + groupNicknameSubmitting.value = true + const remark = groupStore.countInfo?.remark || '' + const payload = { + id: roomId, + myName: trimmedName, + remark + } + await cachedStore.updateMyRoomInfo(payload) + await updateMyRoomInfo(payload) + groupStore.updateUserItem(currentUid, { myName: trimmedName }, roomId) + await groupStore.updateGroupDetail(roomId, { myName: trimmedName }) + if (currentUid === userUid.value) { + groupStore.myNameInCurrentGroup = trimmedName + } + groupNicknameModalVisible.value = false + } catch (error) { + console.error('修改群昵称失败', error) + groupNicknameSubmitting.value = false + } + } + + if (enableGroupNicknameModal) { + useMitt.on(MittEnum.OPEN_GROUP_NICKNAME_MODAL, (payload: GroupNicknameModalPayload) => { + groupNicknameContext.value = payload + groupNicknameValue.value = payload.originalNickname || '' + groupNicknameError.value = '' + groupNicknameSubmitting.value = false + groupNicknameModalVisible.value = true + }) + } + + /** 通用右键菜单 */ + const handleForward = async (item: MessageType) => { + if (!item?.message?.id) return + const target = chatStore.chatMessageList.find((msg) => msg.message.id === item.message.id) + if (!target) { + return + } + chatStore.clearMsgCheck() + target.isCheck = true + chatStore.setMsgMultiChoose(true, 'forward') + await nextTick() + useMitt.emit(MittEnum.MSG_MULTI_CHOOSE, { + action: 'open-forward', + mergeType: MergeMessageType.SINGLE + }) + } + + // 复制禁用类型 + const copyDisabledTypes: MsgEnum[] = [MsgEnum.NOTICE, MsgEnum.MERGE, MsgEnum.LOCATION, MsgEnum.VOICE] + const shouldHideCopy = (item: MessageType) => copyDisabledTypes.includes(item.message.type) + const isNoticeMessage = (item: MessageType) => item.message.type === MsgEnum.NOTICE + const revealInDirSafely = async (targetPath?: string | null) => { + if (!targetPath) { + window.$message?.error(t('home.chat_main.file.missing_local')) + return + } + try { + await revealItemInDir(targetPath) + } catch (error) { + console.error('在文件夹中显示文件失败:', error) + window.$message?.error(t('home.chat_main.file.show_failed')) + } + } + + const commonMenuList = ref([ + { + label: () => t('menu.select'), + icon: 'list-checkbox', + click: () => { + chatStore.setMsgMultiChoose(true) + }, + visible: (item: MessageType) => !isNoticeMessage(item) + }, + { + label: () => t('menu.add_sticker'), + icon: 'add-expression', + click: async (item: MessageType) => { + const imageUrl = item.message.body.url || item.message.body.content + if (!imageUrl) { + window.$message.error(t('home.chat_main.image.fetch_failed')) + return + } + await emojiStore.addEmoji(imageUrl) + }, + visible: (item: MessageType) => { + return item.message.type === MsgEnum.IMAGE || item.message.type === MsgEnum.EMOJI + } + }, + { + label: () => t('menu.forward'), + icon: 'share', + click: (item: MessageType) => { + if (isMobile()) { + window.$message.warning(t('home.chat_main.feature.coming_soon')) + return + } + handleForward(item) + }, + visible: (item: MessageType) => !isNoticeMessage(item) + }, + // { + // label: '收藏', + // icon: 'collection-files', + // click: () => { + // window.$message.warning('暂未实现') + // } + // }, + { + label: () => t('menu.reply'), + icon: 'reply', + click: (item: any) => { + useMitt.emit(MittEnum.REPLY_MEG, item) + } + }, + { + label: () => t('menu.recall'), + icon: 'corner-down-left', + click: async (item: MessageType) => { + const msg = { ...item } + // 在调用 API 前先保存原始类型,避免 WebSocket 消息先到达导致 type 被修改 + const originalType = item.message.type + const originalContent = item.message.body.content + const res = await recallMsg({ roomId: globalStore.currentSessionRoomId, msgId: item.message.id }) + if (res) { + window.$message.error(res) + return + } + chatStore.recordRecallMsg({ + recallUid: userStore.userInfo!.uid, + msg, + originalType, + originalContent + }) + await chatStore.updateRecallMsg({ + recallUid: userStore.userInfo!.uid, + roomId: msg.message.roomId, + msgId: msg.message.id + }) + }, + visible: (item: MessageType) => { + const isSystemAdmin = userStore.userInfo?.power === PowerEnum.ADMIN + if (isSystemAdmin) { + return true + } + + const isGroupSession = globalStore.currentSession?.type === RoomTypeEnum.GROUP + const groupMembers = groupStore.userList + const currentMember = isGroupSession ? groupMembers.find((member) => member.uid === userUid.value) : undefined + const isGroupManager = + isGroupSession && + (currentMember?.roleId === RoleEnum.LORD || + currentMember?.roleId === RoleEnum.ADMIN || + groupStore.currentLordId === userUid.value || + groupStore.adminUidList.includes(userUid.value)) + + if (isGroupManager) { + return true + } + + const isCurrentUser = item.fromUser.uid === userUid.value + if (!isCurrentUser) { + return false + } + + return !isDiffNow({ time: item.message.sendTime, unit: 'minute', diff: 2 }) + } + } + ]) + const videoMenuList = ref([ + { + label: () => t('menu.copy'), + icon: 'copy', + click: (item: MessageType) => { + if (isMobile()) { + window.$message.warning(t('home.chat_main.feature.coming_soon')) + return + } + handleCopy(item.message.body.url, true, item.message.id) + } + }, + ...commonMenuList.value, + { + label: () => t('menu.save_as'), + icon: 'Importing', + click: async (item: MessageType) => { + if (isMobile()) { + window.$message.warning(t('home.chat_main.feature.coming_soon')) + return + } + await saveVideoAttachmentAs({ + url: item.message.body.url, + downloadFile, + defaultFileName: item.message.body.fileName + }) + } + }, + + { + label: () => (isMac() ? t('menu.show_in_finder') : t('menu.show_in_folder')), + icon: 'file2', + click: async (item: MessageType) => { + try { + const localPath = await getLocalVideoPath(item.message.body.url) + + // 检查视频是否已下载 + const isDownloaded = await checkVideoDownloaded(item.message.body.url) + + if (!isDownloaded) { + // 如果未下载,先下载视频 + const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.Resource + await downloadFile(item.message.body.url, localPath, baseDir) + // 通知相关组件更新视频下载状态 + useMitt.emit(MittEnum.VIDEO_DOWNLOAD_STATUS_UPDATED, { url: item.message.body.url, downloaded: true }) + } + + // 获取视频的绝对路径 + const baseDirPath = isMobile() ? await appDataDir() : await resourceDir() + const absolutePath = await join(baseDirPath, localPath) + await revealInDirSafely(absolutePath) + } catch (error) { + console.error('Failed to show video in folder:', error) + } + } + } + ]) + /** 右键消息菜单列表 */ + const menuList = ref([ + { + label: () => t('menu.copy'), + icon: 'copy', + click: (item: MessageType) => { + handleCopy(item.message.body.content, true, item.message.id) + }, + visible: (item: MessageType) => !shouldHideCopy(item) + }, + { + label: () => t('menu.translate'), + icon: 'translate', + click: async (item: MessageType) => { + const selectedText = getSelectedText(item.message.id) + if (!selectedText && item.message.body.translatedText) { + delete item.message.body.translatedText + return + } + + const content = selectedText || item.message.body.content + if (!content) { + window.$message?.warning(t('home.chat_main.translate.empty')) + return + } + + item.message.body.translatedText = { provider: chat.value.translate || 'tencent', text: '' } + await translateTextStream(content, chat.value.translate || 'tencent', (seg) => { + const prev = item.message.body.translatedText?.text || '' + item.message.body.translatedText = { provider: chat.value.translate || 'tencent', text: prev + seg } + }) + }, + visible: (item: MessageType) => item.message.type === MsgEnum.TEXT + }, + + ...commonMenuList.value + ]) + const specialMenuList = computed(() => { + return (messageType?: MsgEnum): OPT.RightMenu[] => { + if (isHistoryMode) { + // 历史记录模式:基础菜单(复制、转发) + const baseMenus: OPT.RightMenu[] = [ + { + label: () => t('menu.copy'), + icon: 'copy', + click: (item: MessageType) => { + const content = item.message.body.url || item.message.body.content + handleCopy(content, true, item.message.id) + } + } + ] + + if (!disableHistoryActions) { + baseMenus.push( + { + label: () => t('menu.select'), + icon: 'list-checkbox', + click: () => { + chatStore.setMsgMultiChoose(true) + } + }, + { + label: () => t('menu.forward'), + icon: 'share', + click: (item: MessageType) => { + handleForward(item) + } + } + ) + } + + // 媒体文件额外菜单(收藏、另存为、在文件中打开) + if ( + messageType === MsgEnum.IMAGE || + messageType === MsgEnum.EMOJI || + messageType === MsgEnum.VIDEO || + messageType === MsgEnum.FILE + ) { + const mediaMenus: OPT.RightMenu[] = [ + // { + // label: '收藏', + // icon: 'collection-files', + // click: () => { + // window.$message.warning('暂未实现') + // } + // }, + { + label: () => t('menu.save_as'), + icon: 'Importing', + click: async (item: MessageType) => { + if (isMobile()) { + window.$message.warning(t('home.chat_main.feature.coming_soon')) + return + } + const fileUrl = item.message.body.url + const fileName = item.message.body.fileName + if (item.message.type === MsgEnum.VIDEO) { + await saveVideoAttachmentAs({ + url: fileUrl, + downloadFile, + defaultFileName: fileName + }) + } else { + await saveFileAttachmentAs({ + url: fileUrl, + downloadFile, + defaultFileName: fileName + }) + } + } + }, + + { + label: () => (isMac() ? t('menu.show_in_finder') : t('menu.show_in_folder')), + icon: 'file2', + click: async (item: RightMouseMessageItem) => { + console.log('打开文件夹的item项:', item) + + const fileUrl = item.message.body.url + const fileName = item.message.body.fileName || extractFileName(fileUrl) + + // 检查文件是否已下载 + const fileStatus = fileDownloadStore.getFileStatus(fileUrl) + + console.log('找到的文件状态:', fileStatus) + const currentChatRoomId = globalStore.currentSessionRoomId // 这个id可能为群id可能为用户uid,所以不能只用用户uid + const currentUserUid = userStore.userInfo!.uid as string + + const resourceDirPath = await userStore.getUserRoomAbsoluteDir() + let absolutePath = await join(resourceDirPath, fileName) + + const [fileMeta] = await getFilesMeta([fileStatus?.absolutePath || absolutePath || fileUrl]) + + // 最后判断文件不存在本地,那就下载它 + if (!fileMeta.exists) { + // 文件不存在本地 + const downloadMessage = window.$message.info(t('home.chat_main.file.download_prompt')) + const _absolutePath = await fileDownloadStore.downloadFile(fileUrl, fileName) + + if (_absolutePath) { + absolutePath = _absolutePath + downloadMessage.destroy() + window.$message.success(t('home.chat_main.file.download_success')) + await revealInDirSafely(_absolutePath) + await fileDownloadStore.refreshFileDownloadStatus({ + fileUrl: item.message.body.url, + roomId: currentChatRoomId, + userId: currentUserUid, + fileName: item.message.body.fileName, + exists: true + }) + return + } else { + absolutePath = '' + window.$message.error(t('home.chat_main.file.download_failed')) + return + } + } + + await revealInDirSafely(absolutePath) + } + } + ] + return [...baseMenus, ...mediaMenus] + } + + return baseMenus + } else { + // 正常聊天模式:只显示删除 + return [ + { + label: () => t('menu.del'), + icon: 'delete', + click: (item: any) => { + tips.value = t('home.chat_main.delete.confirm') + modalShow.value = true + delIndex.value = item.message.id + delRoomId.value = item.message.roomId + } + } + ] + } + } + }) + /** 文件类型右键菜单 */ + const fileMenuList = ref([ + { + label: () => t('menu.preview'), + icon: 'preview-open', + click: (item: RightMouseMessageItem) => { + console.log('预览文件的参数:', item) + nextTick(async () => { + const path = 'previewFile' + const LABEL = 'previewFile' + + const fileStatus: FileDownloadStatus = fileDownloadStore.getFileStatus(item.message.body.url) + + const currentChatRoomId = globalStore.currentSessionRoomId // 这个id可能为群id可能为用户uid,所以不能只用用户uid + const currentUserUid = userStore.userInfo!.uid as string + + /** + * 构建窗口所需的 payload 数据,用于传递文件预览相关的信息。 + * + * 包括用户 ID、房间 ID、消息 ID、文件路径、类型、是否存在本地等。 + * 若本地存在文件,则 url 使用本地路径,否则使用远程 URL。 + * + * @param item - 右键点击的消息项,包含文件的消息结构和用户信息。 + * @param type - 文件类型信息(扩展名和 MIME 类型),可为空。 + * @param localExists - 文件是否存在于本地,用于决定路径选择。 + * @returns 构建后的 payload 对象。 + */ + const buildPayload = ( + item: RightMouseMessageItem, + type: FileTypeResult | undefined, + localExists: boolean + ) => { + const payload = { + userId: currentUserUid, + roomId: currentChatRoomId, + messageId: item.message.id, + resourceFile: { + fileName: item.message.body.fileName, + absolutePath: fileStatus?.absolutePath, + nativePath: fileStatus?.nativePath, + url: item.message.body.url, + type, + localExists + } + } + return payload + } + + /** + * 当本地文件不存在或获取元数据失败时,执行远程文件类型检测,并构建 fallback payload。 + * + * 构建完成后通过窗口通信接口发送该 payload,供目标窗口使用。 + * + * @returns Promise + */ + const fallbackToRemotePayload = async () => { + const remoteType = await detectRemoteFileType({ + url: item.message.body.url, + fileSize: Number(item.message.body.size) + }) + const fallbackPayload = buildPayload(item, remoteType, false) + await sendWindowPayload(LABEL, fallbackPayload) + } + + // 这里不用状态中的absolute,是因为不能完全相信状态的绝对路径是否存在,有时不存在 + const resourceDirPath = await userStore.getUserRoomAbsoluteDir() + const absolutePath = await join(resourceDirPath, item.message.body.fileName) + + // 获取文件元信息(判断文件是否已下载/存在) + const result = await getFilesMeta([ + fileStatus?.absolutePath || absolutePath || item.message.body.url + ]) + const fileMeta = result[0] + + try { + // 如果本地不存在该文件,清空旧的下载状态,准备读取远程链接作为兜底 + if (!fileMeta.exists) { + await fallbackToRemotePayload() + } else { + // 本地存在文件,构造 payload 使用本地路径和已知类型 + const payload = buildPayload( + item, + { + ext: fileMeta.file_type, + mime: fileMeta.mime_type + }, + fileMeta.exists + ) + + await sendWindowPayload(LABEL, payload) + } + } catch (error) { + // 本地信息获取失败,可能是路径非法或 RPC 异常,兜底走远程解析 + await fallbackToRemotePayload() + console.error('检查文件出错:', error) + } + + console.log('预览时刷新下载状态') + await fileDownloadStore.refreshFileDownloadStatus({ + fileUrl: item.message.body.url, + roomId: currentChatRoomId, + userId: currentUserUid, + fileName: item.message.body.fileName, + exists: fileMeta.exists + }) + + // 最后创建用于预览文件的 WebView 窗口 + await createWebviewWindow('预览文件', path, 860, 720, '', true) + }) + } + }, + ...commonMenuList.value, + { + label: () => t('menu.save_as'), + icon: 'Importing', + click: async (item: RightMouseMessageItem) => { + if (isMobile()) { + window.$message.warning(t('home.chat_main.feature.coming_soon')) + return + } + await saveFileAttachmentAs({ + url: item.message.body.url, + downloadFile, + defaultFileName: item.message.body.fileName + }) + } + }, + + { + label: () => (isMac() ? t('menu.show_in_finder') : t('menu.show_in_folder')), + icon: 'file2', + click: async (item: RightMouseMessageItem) => { + console.log('打开文件夹的item项:', item) + + const fileUrl = item.message.body.url + const fileName = item.message.body.fileName || extractFileName(fileUrl) + + // 检查文件是否已下载 + const fileStatus = fileDownloadStore.getFileStatus(fileUrl) + + console.log('找到的文件状态:', fileStatus) + const currentChatRoomId = globalStore.currentSessionRoomId // 这个id可能为群id可能为用户uid,所以不能只用用户uid + const currentUserUid = userStore.userInfo!.uid as string + + const resourceDirPath = await userStore.getUserRoomAbsoluteDir() + let absolutePath = await join(resourceDirPath, fileName) + + const [fileMeta] = await getFilesMeta([fileStatus?.absolutePath || absolutePath || fileUrl]) + + // 最后判断文件不存在本地,那就下载它 + if (!fileMeta.exists) { + // 文件不存在本地 + const downloadMessage = window.$message.info(t('home.chat_main.file.download_prompt')) + const _absolutePath = await fileDownloadStore.downloadFile(fileUrl, fileName) + + if (_absolutePath) { + absolutePath = _absolutePath + downloadMessage.destroy() + window.$message.success(t('home.chat_main.file.save_success')) + await revealInDirSafely(_absolutePath) + await fileDownloadStore.refreshFileDownloadStatus({ + fileUrl: item.message.body.url, + roomId: currentChatRoomId, + userId: currentUserUid, + fileName: item.message.body.fileName, + exists: true + }) + return + } else { + absolutePath = '' + window.$message.error(t('home.chat_main.file.download_failed')) + return + } + } + + await revealInDirSafely(absolutePath) + } + } + ]) + /** 图片类型右键菜单 */ + const imageMenuList = ref([ + { + label: () => t('menu.copy'), + icon: 'copy', + click: async (item: MessageType) => { + // 对于图片消息,优先使用 url 字段,回退到 content 字段 + const imageUrl = item.message.body.url || item.message.body.content + await handleCopy(imageUrl, true, item.message.id) + } + }, + ...commonMenuList.value, + { + label: () => t('menu.save_as'), + icon: 'Importing', + click: async (item: MessageType) => { + if (isMobile()) { + window.$message.warning(t('home.chat_main.feature.coming_soon')) + return + } + try { + const imageUrl = item.message.body.url + const suggestedName = imageUrl || 'image.png' + + // 这里会自动截取url后的文件名,可以尝试打印一下 + const savePath = await save({ + filters: [ + { + name: '图片', + extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] + } + ], + defaultPath: suggestedName + }) + + if (savePath) { + await downloadFile(imageUrl, savePath) + } + } catch (error) { + console.error('保存图片失败:', error) + window.$message.error(t('home.chat_main.image.save_failed')) + } + } + }, + { + label: () => (isMac() ? t('menu.show_in_finder') : t('menu.show_in_folder')), + icon: 'file2', + click: async (item: MessageType) => { + const fileUrl = item.message.body.url || item.message.body.content + const fileName = item.message.body.fileName || extractFileName(fileUrl) + if (!fileUrl || !fileName) { + window.$message.warning(t('home.chat_main.image.locate_failed')) + return + } + + const fileStatus = fileDownloadStore.getFileStatus(fileUrl) + const currentChatRoomId = globalStore.currentSessionRoomId + const currentUserUid = userStore.userInfo!.uid as string + + const resourceDirPath = await userStore.getUserRoomAbsoluteDir() + let absolutePath = await join(resourceDirPath, fileName) + + const [fileMeta] = await getFilesMeta([fileStatus?.absolutePath || absolutePath || fileUrl]) + + if (!fileMeta.exists) { + const downloadMessage = window.$message.info(t('home.chat_main.image.download_prompt')) + const _absolutePath = await fileDownloadStore.downloadFile(fileUrl, fileName) + + if (_absolutePath) { + absolutePath = _absolutePath + downloadMessage.destroy() + window.$message.success(t('home.chat_main.image.save_success')) + await revealInDirSafely(_absolutePath) + await fileDownloadStore.refreshFileDownloadStatus({ + fileUrl, + roomId: currentChatRoomId, + userId: currentUserUid, + fileName, + exists: true + }) + return + } else { + absolutePath = '' + window.$message.error(t('home.chat_main.image.download_failed')) + return + } + } + + await revealInDirSafely(absolutePath) + } + } + ]) + /** 右键用户信息菜单(群聊的时候显示) */ + const optionsList = ref([ + { + label: () => t('menu.send_message'), + icon: 'message-action', + click: (item: any) => { + openMsgSession(item.uid || item.fromUser.uid) + }, + visible: (item: any) => checkFriendRelation(item.uid || item.fromUser.uid, 'friend') + }, + { + label: 'TA', + icon: 'aite', + click: (item: any) => { + useMitt.emit(MittEnum.AT, item.uid || item.fromUser.uid) + }, + visible: (item: any) => (item.uid ? item.uid !== userUid.value : item.fromUser.uid !== userUid.value) + }, + { + label: () => t('menu.get_user_info'), + icon: 'notes', + click: (item: any, type: string) => { + // 如果是聊天框内的资料就使用的是消息的key,如果是群聊成员的资料就使用的是uid + const uid = item.uid || item.message.id + useMitt.emit(`${MittEnum.INFO_POPOVER}-${type}`, { uid: uid, type: type }) + } + }, + { + label: () => t('menu.modify_group_nickname'), + icon: 'edit', + click: (item: any) => { + const targetUid = item.uid || item.fromUser?.uid + const currentUid = userUid.value + const roomId = globalStore.currentSessionRoomId + const isGroup = globalStore.currentSession?.type === RoomTypeEnum.GROUP + + if (!isGroup || targetUid !== currentUid) { + return + } + + const currentUserInfo = groupStore.getUserInfo(currentUid, roomId) + const currentNickname = currentUserInfo?.myName || '' + + useMitt.emit(MittEnum.OPEN_GROUP_NICKNAME_MODAL, { + roomId, + currentUid, + originalNickname: currentNickname + } as GroupNicknameModalPayload) + }, + visible: (item: any) => (item.uid ? item.uid === userUid.value : item.fromUser.uid === userUid.value) + }, + { + label: () => t('menu.add_friend'), + icon: 'people-plus', + click: async (item: any) => { + await createWebviewWindow('申请加好友', 'addFriendVerify', 380, 300, '', false, 380, 300) + globalStore.addFriendModalInfo.show = true + globalStore.addFriendModalInfo.uid = item.uid || item.fromUser.uid + }, + visible: (item: any) => !checkFriendRelation(item.uid || item.fromUser.uid, 'all') + }, + { + label: () => t('menu.set_admin'), + icon: 'people-safe', + click: async (item: any) => { + const targetUid = item.uid || item.fromUser.uid + const roomId = globalStore.currentSessionRoomId + if (!roomId) return + + try { + await groupStore.addAdmin([targetUid]) + window.$message.success(t('menu.set_admin_success')) + } catch (_error) { + window.$message.error(t('menu.set_admin_fail')) + } + }, + visible: (item: any) => { + // 1. 检查是否在群聊中 + const isInGroup = globalStore.currentSession?.type === RoomTypeEnum.GROUP + if (!isInGroup) return false + + // 2. 检查房间号是否为1(频道) + const roomId = globalStore.currentSessionRoomId + if (!roomId || roomId === '1') return false + + // 3. 获取目标用户ID + const targetUid = item.uid || item.fromUser?.uid + if (!targetUid) return false + + // 4. 检查目标用户角色 + let targetRoleId = item.roleId + + // 如果item中没有roleId,则通过uid从群成员列表中查找 + if (targetRoleId === void 0) { + const targetUser = groupStore.userList.find((user) => user.uid === targetUid) + targetRoleId = targetUser?.roleId + } + + // 检查目标用户是否已经是管理员或群主 + if (targetRoleId === RoleEnum.ADMIN || targetRoleId === RoleEnum.LORD) return false + + // 5. 检查当前用户是否是群主 + const currentUser = groupStore.userList.find((user) => user.uid === userUid.value) + return currentUser?.roleId === RoleEnum.LORD + } + }, + { + label: () => t('menu.revoke_admin'), + icon: 'reduce-user', + click: async (item: any) => { + const targetUid = item.uid || item.fromUser.uid + const roomId = globalStore.currentSessionRoomId + if (!roomId) return + + try { + await groupStore.revokeAdmin([targetUid]) + window.$message.success(t('menu.revoke_admin_success')) + } catch (_error) { + window.$message.error(t('menu.revoke_admin_fail')) + } + }, + visible: (item: any) => { + // 1. 检查是否在群聊中 + const isInGroup = globalStore.currentSession?.type === RoomTypeEnum.GROUP + if (!isInGroup) return false + + // 2. 检查房间号是否为1(频道) + const roomId = globalStore.currentSessionRoomId + if (!roomId || roomId === '1') return false + + // 3. 获取目标用户ID + const targetUid = item.uid || item.fromUser?.uid + if (!targetUid) return false + + // 4. 检查目标用户角色 + let targetRoleId = item.roleId + + // 如果item中没有roleId,则通过uid从群成员列表中查找 + if (targetRoleId === void 0) { + const targetUser = groupStore.userList.find((user) => user.uid === targetUid) + targetRoleId = targetUser?.roleId + } + + // 检查目标用户是否是管理员(只能撤销管理员,不能撤销群主) + if (targetRoleId !== RoleEnum.ADMIN) return false + + // 5. 检查当前用户是否是群主 + const currentUser = groupStore.userList.find((user) => user.uid === userUid.value) + return currentUser?.roleId === RoleEnum.LORD + } + } + ]) + /** 举报选项 */ + const report = ref([ + { + label: () => t('menu.remove_from_group'), + icon: 'people-delete-one', + click: async (item: any) => { + const targetUid = item.uid || item.fromUser.uid + const roomId = globalStore.currentSessionRoomId + if (!roomId) return + + try { + await removeGroupMember({ roomId, uidList: [targetUid] }) + // 从群成员列表中移除该用户 + groupStore.removeUserItem(targetUid, roomId) + window.$message.success(t('menu.remove_from_group_success')) + } catch (_error) { + window.$message.error(t('menu.remove_from_group_fail')) + } + }, + visible: (item: any) => { + // 1. 检查是否在群聊中 + const isInGroup = globalStore.currentSession?.type === RoomTypeEnum.GROUP + if (!isInGroup) return false + + // 2. 检查房间号是否为1(频道) + const roomId = globalStore.currentSessionRoomId + if (!roomId || roomId === '1') return false + + // 3. 获取目标用户ID + const targetUid = item.uid || item.fromUser?.uid + if (!targetUid) return false + + // 4. 检查目标用户角色 + let targetRoleId = item.roleId + + // 如果item中没有roleId,则通过uid从群成员列表中查找 + if (targetRoleId === void 0) { + const targetUser = groupStore.userList.find((user) => user.uid === targetUid) + targetRoleId = targetUser?.roleId + } + + // 检查目标用户是否是群主(群主不能被移出) + if (targetRoleId === RoleEnum.LORD) return false + + // 5. 检查当前用户是否有权限(群主或管理员) + const currentUser = groupStore.userList.find((user) => user.uid === userUid.value) + const isLord = currentUser?.roleId === RoleEnum.LORD + const isAdmin = currentUser?.roleId === RoleEnum.ADMIN + + // 6. 如果当前用户是管理员,则不能移出其他管理员 + if (isAdmin && targetRoleId === RoleEnum.ADMIN) return false + + return isLord || isAdmin + } + }, + { + label: () => t('menu.report'), + icon: 'caution', + click: () => {} + } + ]) + /** emoji表情菜单 */ + const emojiList = computed(() => [ + { + url: '/msgAction/like.png', + value: 1, + title: t('home.chat_reaction.like') + }, + { + url: '/msgAction/slightly-frowning-face.png', + value: 2, + title: t('home.chat_reaction.unsatisfied') + }, + { + url: '/msgAction/heart-on-fire.png', + value: 3, + title: t('home.chat_reaction.heart') + }, + { + url: '/msgAction/enraged-face.png', + value: 4, + title: t('home.chat_reaction.angry') + }, + { + url: '/emoji/party-popper.webp', + value: 5, + title: t('home.chat_reaction.party') + }, + { + url: '/emoji/rocket.webp', + value: 6, + title: t('home.chat_reaction.rocket') + }, + { + url: '/msgAction/face-with-tears-of-joy.png', + value: 7, + title: t('home.chat_reaction.lol') + }, + { + url: '/msgAction/clapping.png', + value: 8, + title: t('home.chat_reaction.clap') + }, + { + url: '/msgAction/rose.png', + value: 9, + title: t('home.chat_reaction.flower') + }, + { + url: '/msgAction/bomb.png', + value: 10, + title: t('home.chat_reaction.bomb') + }, + { + url: '/msgAction/exploding-head.png', + value: 11, + title: t('home.chat_reaction.question') + }, + { + url: '/msgAction/victory-hand.png', + value: 12, + title: t('home.chat_reaction.victory') + }, + { + url: '/msgAction/flashlight.png', + value: 13, + title: t('home.chat_reaction.light') + }, + { + url: '/msgAction/pocket-money.png', + value: 14, + title: t('home.chat_reaction.red_envelope') + } + ]) + + /** + * 检查用户关系 + * @param uid 用户ID + * @param type 检查类型: 'friend' - 仅好友, 'all' - 好友或自己 + */ + const checkFriendRelation = (uid: string, type: 'friend' | 'all' = 'all') => { + const contactStore = useContactStore() + const userStore = useUserStore() + const myUid = userStore.userInfo!.uid + const isFriend = contactStore.contactsList.some((item) => item.uid === uid) + return type === 'friend' ? isFriend && uid !== myUid : isFriend || uid === myUid + } + + const extractMsgIdFromDataKey = (dataKey?: string | null) => { + if (!dataKey) return '' + return dataKey.replace(/^[A-Za-z]/, '') + } + + const resolveSelectionMessageId = (selection: Selection): string => { + const resolveElement = (node: Node | null) => { + if (!node) return null + return node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement + } + + const anchorElement = resolveElement(selection.anchorNode) + const focusElement = resolveElement(selection.focusNode) + + if (!anchorElement || !focusElement) return '' + + const anchorKey = anchorElement.closest('[data-key]')?.getAttribute('data-key') + const focusKey = focusElement.closest('[data-key]')?.getAttribute('data-key') + + if (!anchorKey || !focusKey || anchorKey !== focusKey) { + return '' + } + + const chatMainElement = document.getElementById('image-chat-main') + if (chatMainElement && (!chatMainElement.contains(anchorElement) || !chatMainElement.contains(focusElement))) { + return '' + } + + return extractMsgIdFromDataKey(anchorKey) + } + + /** + * 获取用户选中的文本(仅返回聊天气泡内的选择,并可校验消息ID) + */ + const getSelectedText = (messageId?: string): string => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) { + return '' + } + + const text = selection.toString().trim() + if (!text) { + return '' + } + + const selectedMessageId = resolveSelectionMessageId(selection) + if (!selectedMessageId) { + return '' + } + + if (messageId && selectedMessageId !== messageId) { + return '' + } + + return text + } + + /** + * 检查是否有文本被选中 + */ + const hasSelectedText = (messageId?: string): boolean => { + return getSelectedText(messageId).length > 0 + } + + /** + * 清除文本选择 + */ + const clearSelection = (): void => { + const selection = window.getSelection() + if (selection) { + selection.removeAllRanges() + } + } + + /** + * 处理复制事件 + * @param content 复制的内容(作为回退) + * @param prioritizeSelection 是否优先复制选中的文本 + */ + const handleCopy = async (content: string | undefined, prioritizeSelection: boolean = true, messageId?: string) => { + try { + let textToCopy = content || '' + let isSelectedText = false + + // 如果启用了优先选择模式,检查是否有选中的文本 + if (prioritizeSelection) { + const selectedText = getSelectedText(messageId) + if (selectedText) { + textToCopy = selectedText + isSelectedText = true + } + } + + // 检查内容是否为空 + if (!textToCopy) { + window.$message?.warning('没有可复制的内容') + return + } + + // 如果是图片 + if (isImageUrl(textToCopy)) { + try { + const imageFormat = detectImageFormat(textToCopy) + + // 提示用户正在处理不同格式的图片 + if (imageFormat === 'GIF' || imageFormat === 'WEBP') { + window.$message?.info(`正在将 ${imageFormat} 格式图片转换为 PNG 并复制...`) + } + + // 使用 Tauri 的 clipboard API 复制图片(自动转换为 PNG 格式) + const imageBytes = await imageUrlToUint8Array(textToCopy) + await writeImage(imageBytes) + + const successMessage = imageFormat === 'PNG' ? '图片已复制到剪贴板' : '图片已转换为 PNG 格式并复制到剪贴板' + window.$message?.success(successMessage) + } catch (imageError) { + console.error('图片复制失败:', imageError) + } + } else { + // 如果是纯文本 + await writeText(removeTag(textToCopy)) + const message = isSelectedText ? '选中文本已复制' : '消息内容已复制' + window.$message?.success(message) + } + } catch (error) { + console.error('复制失败:', error) + } + } + + /** + * 根据消息类型获取右键菜单列表 + * @param type 消息类型 + */ + const handleItemType = (type: MsgEnum) => { + return type === MsgEnum.IMAGE || type === MsgEnum.EMOJI + ? imageMenuList.value + : type === MsgEnum.FILE + ? fileMenuList.value + : type === MsgEnum.VIDEO + ? videoMenuList.value + : menuList.value + } + + /** 删除信息事件 */ + const handleConfirm = async () => { + if (!delIndex.value) return + const targetRoomId = delRoomId.value || globalStore.currentSessionRoomId + if (!targetRoomId) { + window.$message?.error('无法确定消息所属的会话') + return + } + try { + await invokeWithErrorHandler( + TauriCommand.DELETE_MESSAGE, + { + messageId: delIndex.value, + roomId: targetRoomId + }, + { + customErrorMessage: '删除消息失败', + errorType: ErrorType.Client + } + ) + chatStore.deleteMsg(delIndex.value) + useMitt.emit(MittEnum.UPDATE_SESSION_LAST_MSG, { roomId: targetRoomId }) + delIndex.value = '' + delRoomId.value = '' + modalShow.value = false + window.$message?.success('消息已删除') + } catch (error) { + console.error('删除消息失败:', error) + } + } + + let activeKeyPressListener: ((e: KeyboardEvent) => void) | null = null + + const removeKeyPressListener = () => { + if (activeKeyPressListener) { + document.removeEventListener('keydown', activeKeyPressListener) + activeKeyPressListener = null + } + } + + /** 点击气泡消息时候监听用户是否按下ctrl+c来复制内容 */ + const handleMsgClick = (item: MessageType) => { + if (item.message.type === MsgEnum.VIDEO_CALL) { + startRtcCall(CallTypeEnum.VIDEO) + return + } else if (item.message.type === MsgEnum.AUDIO_CALL) { + startRtcCall(CallTypeEnum.AUDIO) + return + } + + // 移动端不触发 active 效果 + if (!isMobile()) { + if (chatStore.msgMultiChooseMode === 'forward') { + activeBubble.value = '' + } else { + activeBubble.value = item.message.id + } + } + + // 先移除可能残留的监听,避免重复绑定 + removeKeyPressListener() + + // 启用键盘监听 + const handleKeyPress = (e: KeyboardEvent) => { + if ((e.ctrlKey && e.key === 'c') || (e.metaKey && e.key === 'c')) { + // 优先复制用户选中的文本,如果没有选中则复制整个消息内容 + // 对于图片或其他类型的消息,优先使用 url 字段 + const contentToCopy = item.message.body.url || item.message.body.content + handleCopy(contentToCopy, true, item.message.id) + // 取消监听键盘事件,以免多次绑定 + removeKeyPressListener() + } + } + activeKeyPressListener = handleKeyPress + // 绑定键盘事件到 document + document.addEventListener('keydown', handleKeyPress) + } + + onUnmounted(() => { + removeKeyPressListener() + }) + + return { + handleMsgClick, + handleConfirm, + handleItemType, + handleCopy, + videoMenuList, + getSelectedText, + hasSelectedText, + clearSelection, + historyIndex, + tips, + modalShow, + specialMenuList, + optionsList, + report, + selectKey, + emojiList, + commonMenuList, + scrollTop, + groupNicknameModalVisible, + groupNicknameValue, + groupNicknameError, + groupNicknameSubmitting, + handleGroupNicknameConfirm, + activeBubble + } +} + +export type UseChatMainContext = ReturnType +export const chatMainInjectionKey = Symbol('chatMainInjectionKey') as InjectionKey diff --git a/src/hooks/useCheckUpdate.ts b/src/hooks/useCheckUpdate.ts new file mode 100644 index 0000000..6df7e92 --- /dev/null +++ b/src/hooks/useCheckUpdate.ts @@ -0,0 +1,73 @@ +import { getVersion } from '@tauri-apps/api/app' +import { check } from '@tauri-apps/plugin-updater' +import { useSettingStore } from '@/stores/setting.ts' +import { MittEnum } from '../enums' +import { useMitt } from './useMitt' +import { isMobile } from '@/utils/PlatformConstants' + +/** + * 检查更新 + */ +export const useCheckUpdate = () => { + const settingStore = useSettingStore() + // 检查更新周期 + const CHECK_UPDATE_TIME = 30 * 60 * 1000 + // 在未登录情况下缩短检查周期 + const CHECK_UPDATE_LOGIN_TIME = 5 * 60 * 1000 + const isProduction = import.meta.env.PROD && !isMobile() + + /** + * 检查更新 + * @param closeWin 需要关闭的窗口 + * @param initialCheck 是否是初始检查,默认为false。初始检查时只显示强制更新提示,不显示普通更新提示 + */ + const checkUpdate = async (closeWin: string, initialCheck: boolean = false) => { + if (import.meta.env.DEV) { + return + } + + await check({ + timeout: 5000 /* 接口请求时长 5秒 */, + headers: { + 'X-AccessKey': 'geShj8UB7zd1DyrM_YFNdg' // UpgradeLink的AccessKey + } + }) + .then(async (e) => { + if (!e?.available) { + return + } + + const newVersion = e.version + const newMajorVersion = newVersion.substring(0, newVersion.indexOf('.')) + const newMiddleVersion = newVersion.substring( + newVersion.indexOf('.') + 1, + newVersion.lastIndexOf('.') === -1 ? newVersion.length : newVersion.lastIndexOf('.') + ) + const currenVersion = await getVersion() + const currentMajorVersion = currenVersion.substring(0, currenVersion.indexOf('.')) + const currentMiddleVersion = currenVersion.substring( + currenVersion.indexOf('.') + 1, + currenVersion.lastIndexOf('.') === -1 ? currenVersion.length : currenVersion.lastIndexOf('.') + ) + const requireForceUpdate = + isProduction && + (newMajorVersion > currentMajorVersion || + (newMajorVersion === currentMajorVersion && newMiddleVersion > currentMiddleVersion)) + if (requireForceUpdate) { + useMitt.emit(MittEnum.DO_UPDATE, { close: closeWin }) + } else if (newVersion !== currenVersion && settingStore.update.dismiss !== newVersion && !initialCheck) { + // 只在非初始检查时显示普通更新提示 + useMitt.emit(MittEnum.CHECK_UPDATE) + } + }) + .catch((e) => { + console.log(e) + }) + } + + return { + checkUpdate, + CHECK_UPDATE_TIME, + CHECK_UPDATE_LOGIN_TIME + } +} diff --git a/src/hooks/useCommon.ts b/src/hooks/useCommon.ts new file mode 100644 index 0000000..40fff63 --- /dev/null +++ b/src/hooks/useCommon.ts @@ -0,0 +1,939 @@ +import { WebviewWindow } from '@tauri-apps/api/webviewWindow' +import { BaseDirectory, create, exists, mkdir, readFile } from '@tauri-apps/plugin-fs' +import { info } from '@tauri-apps/plugin-log' +import GraphemeSplitter from 'grapheme-splitter' +import type { Ref } from 'vue' +import { LimitEnum, MittEnum, MsgEnum } from '@/enums' +import { useMessage } from '@/hooks/useMessage.ts' +import { useMitt } from '@/hooks/useMitt.ts' +import router from '@/router' +import { useChatStore } from '@/stores/chat.ts' +import { useGlobalStore } from '@/stores/global.ts' +import { useUserStore } from '@/stores/user.ts' +import { AvatarUtils } from '@/utils/AvatarUtils' +import { removeTag } from '@/utils/Formatting' +import { SUPPORTED_IMAGE_EXTENSIONS, getFileExtension } from '@/utils/FileType' +import { getSessionDetailWithFriends } from '@/utils/ImRequestUtils' +import { getImageCache } from '@/utils/PathUtil.ts' +import { isPathUploadFile, type UploadFile } from '@/utils/FileType' +import { isMobile } from '@/utils/PlatformConstants' +import { invokeWithErrorHandler } from '../utils/TauriInvokeHandler' + +export interface SelectionRange { + range: Range + selection: Selection +} +const domParser = new DOMParser() + +const REPLY_NODE_ID = 'replyDiv' + +/** + * 返回dom指定id的文本 + * @param dom 指定dom + * @param id 元素id + */ +export const parseInnerText = (dom: string, id: string): string | undefined => { + const doc = domParser.parseFromString(dom, 'text/html') + return doc.getElementById(id)?.innerText +} + +/** 常用工具类 */ +export const useCommon = () => { + const globalStore = useGlobalStore() + const chatStore = useChatStore() + const userStore = useUserStore() + const { handleMsgClick } = useMessage() + /** 当前登录用户的uid */ + const userUid = computed(() => userStore.userInfo!.uid) + /** 回复消息 */ + const reply = ref({ + avatar: '', + accountName: '', + content: '', + key: 0, + imgCount: 0 + }) + + const saveCacheFile = async (file: File, subFolder: string): Promise => { + const fileName = file.name ?? 'test.png' + const tempPath = getImageCache(subFolder, userUid.value!) + const fullPath = `${tempPath}${fileName}` + + console.log(`cache file start: ${fullPath}, size: ${file.size} bytes`) + + return new Promise((resolve, reject) => { + const cacheReader = new FileReader() + cacheReader.onload = async (e: any) => { + try { + const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache + const isExists = await exists(tempPath, { baseDir }) + if (!isExists) { + await mkdir(tempPath, { baseDir, recursive: true }) + } + const tempFile = await create(fullPath, { baseDir }) + await tempFile.write(e.target.result) + await tempFile.close() + + console.log(`cache file saved: ${fullPath}, written: ${e.target.result.byteLength} bytes`) + resolve(fullPath) + } catch (error) { + reject(error) + } + } + + cacheReader.onerror = (error) => { + reject(error) + } + + cacheReader.readAsArrayBuffer(file) + }) + } + + /** + * 判断 URL 是否安全 + * @param url URL 字符串 + * @returns 是否安全 + */ + const isSafeUrl = (url: string) => { + // 只允许 http/https 协议,且不能包含 javascript: 或 data: + return /^(https?:\/\/|\/)/.test(url) && !/^javascript:/i.test(url) && !/^data:/i.test(url) + } + + /** + * 获取当前光标选取的信息(需要判断是否为空) + */ + const getEditorRange = () => { + if (window.getSelection) { + const selection = window.getSelection() + // 如果没有 rangeCount,尝试获取输入框的最后一个子节点 + if (!selection || selection.rangeCount === 0) { + const inputElement = document.getElementById('message-input') + if (inputElement) { + inputElement.focus() + const range = document.createRange() + // 将光标移动到输入框的最后 + range.selectNodeContents(inputElement) + range.collapse(false) // 折叠到末尾 + selection?.removeAllRanges() + selection?.addRange(range) + } + } + + // 重新检查 + if (selection && selection.rangeCount) { + const range = selection.getRangeAt(0) + return { range, selection } + } + } + return null + } + + /** + * 获取messageInputDom输入框中的内容类型 + * @param messageInputDom 输入框dom + */ + const getMessageContentType = (messageInputDom: Ref): MsgEnum => { + let hasText = false + let hasImage = false + let hasVideo = false + let hasFile = false + let hasEmoji = false + let hasVoice = false + + const elements = messageInputDom.value.childNodes + for (const element of elements) { + if (element.nodeType === Node.TEXT_NODE && element.nodeValue.trim() !== '') { + hasText = true + } else if (element.tagName === 'IMG') { + if (element.dataset.type === 'file-canvas') { + hasFile = true + } else if (element.dataset.type === 'emoji') { + // 检查是否是表情包图片 + hasEmoji = true + } else { + hasImage = true + } + } else if (element.tagName === 'VIDEO' || (element.tagName === 'A' && element.href.match(/\.(mp4|webm)$/i))) { + hasVideo = true + } else if (element.tagName === 'DIV' && element.className === 'voice-message-placeholder') { + hasVoice = true + } + } + + if (hasVoice) { + return MsgEnum.VOICE + } else if (hasFile) { + return MsgEnum.FILE + } else if (hasVideo) { + return MsgEnum.VIDEO + } else if (hasEmoji && !hasText && !hasImage) { + // 如果只有表情包,没有其他文本或图片,则返回EMOJI类型 + return MsgEnum.EMOJI + } else if ((hasText && hasImage) || (hasText && hasEmoji)) { + return MsgEnum.MIXED + } else if (hasImage) { + return MsgEnum.IMAGE + } else { + return MsgEnum.TEXT + } + } + + /** + * 触发输入框事件(粘贴的时候需要重新触发这个方法) + * @param element 输入框dom + */ + const triggerInputEvent = (element: HTMLElement) => { + if (element) { + const event = new Event('input', { + bubbles: true, + cancelable: true + }) + element.dispatchEvent(event) + } + } + + /** + * 将指定节点插入到光标位置 + * @param { MsgEnum } type 插入的类型 + * @param dom dom节点 + * @param target 目标节点 + */ + const insertNode = (type: MsgEnum, dom: any, target: HTMLElement) => { + const sr = getEditorRange()! + if (!sr) return + + insertNodeAtRange(type, dom, target, sr) + } + + /** + * 将指定节点插入到光标位置 + * @param { MsgEnum } type 插入的类型 + * @param dom dom节点 + * @param target 目标节点 + * @param sr 选区 + */ + const insertNodeAtRange = (type: MsgEnum, dom: any, _target: HTMLElement, sr: SelectionRange) => { + const { range, selection } = sr + + // 删除选中的内容 + range?.deleteContents() + + // 将节点插入范围最前面添加节点 + if (type === MsgEnum.AIT) { + // 为@节点保存展示名字和uid,后续提取atUidList时直接依赖结构化数据,避免误判 + const mentionText = typeof dom === 'object' && dom !== null ? dom.name || dom.text || dom.label || '' : dom || '' + const mentionUid = typeof dom === 'object' && dom !== null ? dom.uid : undefined + // 创建一个span标签节点 + const spanNode = document.createElement('span') + spanNode.id = 'aitSpan' // 设置id为aitSpan + spanNode.contentEditable = 'false' // 设置为不可编辑 + spanNode.classList.add('text-#13987f') + spanNode.classList.add('select-none') + spanNode.classList.add('cursor-default') + spanNode.style.userSelect = 'text' // 允许全选选中 + if (mentionUid) { + spanNode.dataset.aitUid = String(mentionUid) + } + spanNode.appendChild(document.createTextNode(`@${mentionText}`)) + // 将span标签插入到光标位置 + range?.insertNode(spanNode) + // 将光标折叠到Range的末尾(true表示折叠到Range的开始位置,false表示折叠到Range的末尾) + range?.collapse(false) + // 创建一个空格文本节点 + const spaceNode = document.createTextNode('\u00A0') + // 将空格文本节点插入到光标位置 + range?.insertNode(spaceNode) + } else if (type === MsgEnum.TEXT) { + range?.insertNode(document.createTextNode(dom)) + } else if (type === MsgEnum.REPLY) { + // 获取消息输入框元素 + const inputElement = document.getElementById('message-input') + if (!inputElement) return + + // 确保输入框获得焦点 + inputElement.focus() + + // 创建回复节点 + const replyNode = createReplyDom(dom) + + // 如果已经存在回复框,则替换它 + const preReplyNode = document.getElementById('replyDiv') + if (preReplyNode) { + preReplyNode.replaceWith(replyNode) + } else { + // 检查输入框是否有内容 + const hasChildNodes = + inputElement.childNodes.length > 0 && + !( + inputElement.childNodes.length === 1 && + inputElement.childNodes[0].nodeType === Node.TEXT_NODE && + !inputElement.childNodes[0].textContent?.trim() + ) + // 插入回复节点 + inputElement.insertBefore(replyNode, inputElement.firstChild) + + // 如果输入框已有内容,需要确保光标位置正确 + if (hasChildNodes) { + // 获取回复框后的第一个文本节点 + let nextNode = replyNode.nextSibling + let position = 0 + + // 将选择范围设置在回复框之后 + if (nextNode) { + if (nextNode.nodeType === Node.TEXT_NODE) { + position = 0 // 文本节点的开始位置 + } else { + // 如果不是文本节点,找到下一个文本节点 + while (nextNode && nextNode.nodeType !== Node.TEXT_NODE) { + nextNode = nextNode.nextSibling + } + if (!nextNode) { + // 如果没有找到文本节点,创建一个 + nextNode = document.createTextNode(' ') + inputElement.appendChild(nextNode) + } + position = 0 + } + + // 设置选区在回复框之后 + selection.removeAllRanges() + const rangeAfter = document.createRange() + rangeAfter.setStart(nextNode, position) + rangeAfter.setEnd(nextNode, position) + selection.addRange(rangeAfter) + } else { + // 没有后续节点,创建一个 + nextNode = document.createTextNode(' ') + inputElement.appendChild(nextNode) + + // 设置选区在新创建的节点 + selection.removeAllRanges() + const rangeAfter = document.createRange() + rangeAfter.setStart(nextNode, 0) + rangeAfter.setEnd(nextNode, 0) + selection.addRange(rangeAfter) + } + } else { + // 如果输入框没有内容,添加一个空格节点以便于后续编辑 + const spaceNode = document.createElement('span') + spaceNode.textContent = '\u00A0' + spaceNode.contentEditable = 'false' + spaceNode.style.userSelect = 'none' + + // 在回复框后插入空格节点 + const afterRange = document.createRange() + afterRange.selectNode(replyNode) + afterRange.collapse(false) // 折叠到结束位置 + afterRange.insertNode(spaceNode) + + // 在空格节点后插入一个空的文本节点,便于光标定位 + const textNode = document.createTextNode('') + afterRange.selectNode(spaceNode) + afterRange.collapse(false) + afterRange.insertNode(textNode) + + // 设置光标位置在文本节点处 + selection.removeAllRanges() + const newRangeAfter = document.createRange() + newRangeAfter.setStart(textNode, 0) + newRangeAfter.setEnd(textNode, 0) + selection.addRange(newRangeAfter) + } + } + + // 触发输入事件,确保UI更新 + triggerInputEvent(inputElement) + return + } else if (type === MsgEnum.AI) { + // 删除触发字符 "/" + const startContainer = range.startContainer + if (startContainer.nodeType === Node.TEXT_NODE) { + const text = startContainer.textContent || '' + const lastIndex = text.lastIndexOf('/') + if (lastIndex !== -1) { + startContainer.textContent = text.substring(0, lastIndex) + } + } + + // 创建一个div标签节点 + const divNode = document.createElement('div') + divNode.id = 'AIDiv' // 设置id为replyDiv + divNode.contentEditable = 'false' // 设置为不可编辑 + divNode.tabIndex = -1 // 防止被focus + divNode.style.cssText = ` + background-color: var(--reply-bg); + font-size: 12px; + padding: 4px 6px; + width: fit-content; + max-height: 86px; + border-radius: 8px; + margin-bottom: 2px; + user-select: none; + pointer-events: none; /* 防止鼠标事件 */ + cursor: default; + outline: none; /* 移除focus时的轮廓 */ + ` + // 把dom中的value值作为回复信息的作者,dom中的content作为回复信息的内容 + const author = dom.name + // 创建一个img标签节点作为头像 + const imgNode = document.createElement('img') + const avatarUrl = AvatarUtils.getAvatarUrl(dom.avatar) + if (isSafeUrl(avatarUrl)) { + imgNode.src = avatarUrl + } else { + // 设置为默认头像或空 + imgNode.src = '/avatar/001.png' + } + imgNode.style.cssText = ` + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: contain; + ` + // 创建一个div标签节点作为回复信息的头部 + const headerNode = document.createElement('div') + headerNode.style.cssText = ` + line-height: 1.5; + font-size: 12px; + padding: 0 4px; + color: rgba(19, 152, 127); + cursor: default; + user-select: none; + pointer-events: none; + ` + headerNode.appendChild(document.createTextNode(author)) + // 在回复信息的右边添加一个关闭信息的按钮 + const closeBtn = document.createElement('span') + closeBtn.id = 'closeBtn' + closeBtn.style.cssText = ` + display: flex; + align-items: center; + font-size: 12px; + color: #999; + cursor: pointer; + margin-left: 10px; + flex-shrink: 0; + user-select: none; + pointer-events: auto; /* 确保关闭按钮可以点击 */ + ` + closeBtn.textContent = '关闭' + closeBtn.addEventListener('click', () => { + // 首先移除回复节点 + divNode.remove() + + // 获取消息输入框 + const messageInput = document.getElementById('message-input') as HTMLElement + if (!messageInput) return + + // 确保输入框获得焦点 + messageInput.focus() + + // 完全清空reply状态 + reply.value = { avatar: '', imgCount: 0, accountName: '', content: '', key: 0 } + + // 优化光标处理 + const selection = window.getSelection() + if (selection) { + const range = document.createRange() + + // 处理输入框内容,如果只有空格,则清空它 + if (messageInput.textContent && messageInput.textContent.trim() === '') { + messageInput.textContent = '' + } + + // 将光标移动到输入框的末尾 + range.selectNodeContents(messageInput) + range.collapse(false) // 折叠到末尾 + + selection.removeAllRanges() + selection.addRange(range) + + // 触发输入事件以更新UI状态 + triggerInputEvent(messageInput) + } + }) + // 为头像和标题创建容器 + const headerContainer = document.createElement('div') + headerContainer.style.cssText = ` + display: flex; + align-items: center; + gap: 2px; + ` + // 在容器中添加头像和标题 + headerContainer.appendChild(imgNode) + headerContainer.appendChild(headerNode) + headerContainer.appendChild(closeBtn) + + // 将容器添加到主div中 + divNode.appendChild(headerContainer) + // 将div标签节点插入到光标位置 + range?.insertNode(divNode) + // 将光标折叠到Range的末尾(true表示折叠到Range的开始位置,false表示折叠到Range的末尾) + range?.collapse(false) + // 创建一个span节点作为空格 + const spaceNode = document.createElement('span') + spaceNode.textContent = '\u00A0' + // 设置不可编辑 + spaceNode.contentEditable = 'false' + // 不可以选中 + spaceNode.style.userSelect = 'none' + // 插入一个br标签节点作为换行 + const brNode = document.createElement('br') + // 将br标签节点插入到光标位置 + range?.insertNode(brNode) + // 将空格节点插入到光标位置 + range?.insertNode(spaceNode) + range?.collapse(false) + } else { + range?.insertNode(dom) + range?.collapse(false) + } + // 将光标移到选中范围的最后面 + selection?.collapseToEnd() + } + + /** + * create a reply element + */ + function createReplyDom(dom: { accountName: string; content: string; avatar: string }) { + // 创建一个div标签节点 + const replyNode = document.createElement('div') + replyNode.id = REPLY_NODE_ID // 设置id为replyDiv + replyNode.contentEditable = 'false' // 设置为不可编辑 + replyNode.tabIndex = -1 // 防止被focus + replyNode.style.cssText = ` + background-color: var(--reply-bg); + font-size: 12px; + padding: 4px 6px; + width: fit-content; + max-height: 86px; + border-radius: 8px; + margin-bottom: 2px; + user-select: none; + pointer-events: none; /* 防止鼠标事件 */ + cursor: default; + outline: none; /* 移除focus时的轮廓 */ + ` + if (isMobile()) { + replyNode.style.cssText += `max-width: 170px;` + } + // 把dom中的value值作为回复信息的作者,dom中的content作为回复信息的内容 + const author = dom.accountName + ':' + let content = dom.content + // 创建一个img标签节点作为头像 + const imgNode = document.createElement('img') + const avatarUrl = AvatarUtils.getAvatarUrl(dom.avatar) + if (isSafeUrl(avatarUrl)) { + imgNode.src = avatarUrl + } else { + // 设置为默认头像或空 + imgNode.src = 'avatar/001.png' + } + imgNode.style.cssText = ` + width: 20px; + height: 20px; + border-radius: 50%; + ` + // 创建一个div标签节点作为回复信息的头部 + const headerNode = document.createElement('div') + headerNode.style.cssText = ` + line-height: 1.5; + font-size: 12px; + padding: 0 4px; + color: rgba(19, 152, 127); + cursor: default; + user-select: none; + pointer-events: none; + ` + headerNode.appendChild(document.createTextNode(author)) + // 创建一个div标签节点包裹正文内容 + const contentNode = document.createElement('div') + contentNode.style.cssText = ` + display: flex; + justify-content: space-between; + border-radius: 8px; + padding: 2px; + margin-top: 4px; + min-width: 0; + ` + let contentBox + // 判断content内容是否是data:image/开头的数组 + if (Array.isArray(content)) { + // 获取总共有多少张图片 + const imageCount = content.length + // 获取第一个data:image/开头的图片 + content = content.find((item: string) => item.startsWith('data:image/')) + reply.value.imgCount = imageCount + } + + // todo: 暂时用http开头的图片判断,后续需要优化 + if (content.startsWith('http')) { + // 再创建一个img标签节点,并设置src属性为base64编码的图片 + contentBox = document.createElement('img') + contentBox.src = content + contentBox.style.cssText = ` + max-width: 55px; + max-height: 55px; + border-radius: 4px; + cursor: default; + user-select: none; + pointer-events: none; + ` + // 将img标签节点插入到div标签节点中 + replyNode.appendChild(contentBox) + // 把图片传入到reply的content属性中 + reply.value.content = content + } else { + // 判断是否有@标签 + if (content.includes('id="aitSpan"')) { + // 去掉content中的标签 + content = removeTag(content) + } + // 把正文放到span标签中,并设置span标签的样式 + contentBox = document.createElement('span') + contentBox.style.cssText = ` + font-size: 12px; + color: var(--text-color); + cursor: default; + width: fit-content; + max-width: 350px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + pointer-events: none; + ` + contentBox.appendChild(document.createTextNode(content)) + } + // 在回复信息的右边添加一个关闭信息的按钮 + const closeBtn = document.createElement('span') + closeBtn.id = 'closeBtn' + closeBtn.style.cssText = ` + display: flex; + align-items: center; + font-size: 12px; + color: #999; + cursor: pointer; + margin-left: 10px; + flex-shrink: 0; + user-select: none; + pointer-events: auto; /* 确保关闭按钮可以点击 */ + ` + closeBtn.textContent = '关闭' + closeBtn.addEventListener('click', () => { + // 首先移除回复节点 + replyNode.remove() + + // 获取消息输入框 + const messageInput = document.getElementById('message-input') as HTMLElement + if (!messageInput) return + + // 确保输入框获得焦点 + messageInput.focus() + + // 完全清空reply状态 + reply.value = { avatar: '', imgCount: 0, accountName: '', content: '', key: 0 } + + // 优化光标处理 + const selection = window.getSelection() + if (selection) { + const range = document.createRange() + + // 处理输入框内容,如果只有空格,则清空它 + if (messageInput.textContent && messageInput.textContent.trim() === '') { + messageInput.textContent = '' + } + + // 将光标移动到输入框的末尾 + range.selectNodeContents(messageInput) + range.collapse(false) // 折叠到末尾 + + selection.removeAllRanges() + selection.addRange(range) + + // 触发输入事件以更新UI状态 + triggerInputEvent(messageInput) + } + }) + // 为头像和标题创建容器 + const headerContainer = document.createElement('div') + headerContainer.style.cssText = ` + display: flex; + align-items: center; + gap: 2px; + ` + // 在容器中添加头像和标题 + headerContainer.appendChild(imgNode) + headerContainer.appendChild(headerNode) + + // 将容器添加到主div中 + replyNode.appendChild(headerContainer) + replyNode.appendChild(contentNode) + contentNode.appendChild(contentBox) + contentNode.appendChild(closeBtn) + return replyNode + } + + /** + * 处理图片粘贴事件 + * @param file 图片文件 + * @param dom 输入框dom + */ + const imgPaste = async (file: any, dom: HTMLElement) => { + // 如果file是blob URL格式 + if (typeof file === 'string' && file.startsWith('blob:')) { + const url = file.replace('blob:', '') // 移除blob:前缀 + console.log(url) + + const img = document.createElement('img') + img.src = url + img.style.maxHeight = '88px' + img.style.maxWidth = '140px' + img.style.marginRight = '6px' + + // 获取MsgInput组件暴露的lastEditRange + const lastEditRange = (dom as any).getLastEditRange?.() + + // 确保dom获得焦点 + dom.focus() + + let range: Range + if (!lastEditRange) { + range = document.createRange() + range.selectNodeContents(dom) + range.collapse(false) + } else { + range = lastEditRange + } + + const selection = window.getSelection() + if (selection) { + range.deleteContents() + range.insertNode(img) + range.setStartAfter(img) + range.setEndAfter(img) + selection.removeAllRanges() + selection.addRange(range) + } + + triggerInputEvent(dom) + return + } + + //缓存文件 + const cachePath = await saveCacheFile(file, 'img') + + // 原有的File对象处理逻辑 + const reader = new FileReader() + reader.onload = (e: any) => { + const img = document.createElement('img') + img.src = e.target.result + img.style.maxHeight = '88px' + img.style.maxWidth = '140px' + img.style.marginRight = '6px' + // 设置ID,使用缓存路径作为ID,这样parseInnerText可以找到它 + img.id = 'temp-image' + img.setAttribute('data-path', cachePath) + + // 获取MsgInput组件暴露的lastEditRange + const lastEditRange = (dom as any).getLastEditRange?.() + + // 确保dom获得焦点 + dom.focus() + + let range: Range + if (!lastEditRange) { + range = document.createRange() + range.selectNodeContents(dom) + range.collapse(false) + } else { + range = lastEditRange + } + + const selection = window.getSelection() + if (selection) { + range.deleteContents() + range.insertNode(img) + range.setStartAfter(img) + range.setEndAfter(img) + selection.removeAllRanges() + selection.addRange(range) + } + + triggerInputEvent(dom) + } + // 读取文件 + reader.readAsDataURL(file) + } + + /** + * 处理视频或者文件粘贴事件 + * @param file 文件 + * @param type 类型 + * @param dom 输入框dom + */ + const FileOrVideoPaste = async (file: File) => { + const reader = new FileReader() + if (file.size > 1024 * 1024 * 50) { + window.$message.warning('文件大小不能超过50M,请重新选择') + return + } + await saveCacheFile(file, 'video') + reader.readAsDataURL(file) + } + + /** + * 处理确认的文件列表(来自弹窗) + * @param files 文件列表 + * @param dom 输入框dom + */ + const handleConfirmFiles = async (files: File[]) => { + for (const file of files) { + await FileOrVideoPaste(file) + } + } + + /** + * 处理粘贴事件 + * @param e 事件对象 + * @param dom 输入框dom + * @param showFileModal 显示文件弹窗的回调函数 + */ + const handlePaste = async (e: any, dom: HTMLElement, showFileModal?: (files: UploadFile[]) => void) => { + e.preventDefault() + if (e.clipboardData.files.length > 0) { + // 使用通用文件处理函数 + await processFiles(Array.from(e.clipboardData.files), dom, showFileModal) + } else { + // 如果没有文件,而是文本,处理纯文本粘贴 + const plainText = e.clipboardData.getData('text/plain') + insertNode(MsgEnum.TEXT, plainText, dom) + triggerInputEvent(dom) + } + } + + /** 计算字符长度 */ + const countGraphemes = (value: string) => { + const splitter = new GraphemeSplitter() + return splitter.countGraphemes(value) + } + + /** + * 打开消息会话(右键发送消息功能) + * @param uid 用户id + * @param type + */ + const openMsgSession = async (uid: string, type: number = 2) => { + // 获取home窗口实例 + const label = WebviewWindow.getCurrent().label + if (router.currentRoute.value.name !== '/message' && label === 'home') { + router.push('/message') + } + + info('打开消息会话') + const res = await getSessionDetailWithFriends({ id: uid, roomType: type }) + // 把隐藏的会话先显示 + try { + await invokeWithErrorHandler('hide_contact_command', { data: { roomId: res.roomId, hide: false } }) + } catch (_error) { + window.$message.error('显示会话失败') + } + + // 先检查会话是否已存在 + const existingSession = chatStore.getSession(res.roomId) + if (!existingSession) { + // 只有当会话不存在时才更新会话列表顺序 + chatStore.updateSessionLastActiveTime(res.roomId) + // 如果会话不存在,需要重新获取会话列表,但保持当前选中的会话 + await chatStore.getSessionList(true) + } + globalStore.updateCurrentSessionRoomId(res.roomId) + + // 发送消息定位 + useMitt.emit(MittEnum.LOCATE_SESSION, { roomId: res.roomId }) + handleMsgClick(res as any) + useMitt.emit(MittEnum.TO_SEND_MSG, { url: 'message' }) + } + + /** + * 通用文件处理函数 + * @param files 文件列表 + * @param dom 输入框DOM元素 + * @param showFileModal 显示文件弹窗的回调函数 + * @param resetCallback 重置回调函数(可选) + */ + const processFiles = async ( + files: UploadFile[], + dom: HTMLElement, + showFileModal?: (files: UploadFile[]) => void, + resetCallback?: () => void + ) => { + if (!files) return + + // 检查文件数量 + if (files.length > LimitEnum.COM_COUNT) { + window.$message.warning(`一次性只能上传${LimitEnum.COM_COUNT}个文件或图片`) + return + } + + // 分类文件:图片 or 其他文件 + const imageFiles: UploadFile[] = [] + const otherFiles: UploadFile[] = [] + + for (const file of files) { + // 检查文件大小 + const fileSizeInMB = file.size / 1024 / 1024 + if (fileSizeInMB > 500) { + window.$message.warning(`文件 ${file.name} 超过500MB`) + continue + } + + const mimeType = file.type || '' + const extension = getFileExtension(file.name) + const isImage = + (mimeType.startsWith('image/') || SUPPORTED_IMAGE_EXTENSIONS.includes(extension as any)) && + extension !== 'svg' && + !mimeType.includes('svg') + + if (isImage) imageFiles.push(file) + else otherFiles.push(file) + } + + // 处理图片文件(直接插入输入框) + for (const file of imageFiles) { + if (isPathUploadFile(file)) { + const fileData = await readFile(file.path) + const fileObj = new File([fileData], file.name, { type: file.type }) + await imgPaste(fileObj, dom) + } else { + await imgPaste(file, dom) + } + } + + // 处理其他文件(显示弹窗) + if (otherFiles.length > 0 && showFileModal) { + showFileModal(otherFiles) + } + + // 执行重置回调 + resetCallback?.() + } + + return { + imgPaste, + getEditorRange, + getMessageContentType, + insertNode, + triggerInputEvent, + handlePaste, + FileOrVideoPaste, + handleConfirmFiles, + countGraphemes, + openMsgSession, + insertNodeAtRange, + reply, + userUid, + processFiles, + saveCacheFile + } +} diff --git a/src/hooks/useContextMenu.ts b/src/hooks/useContextMenu.ts new file mode 100644 index 0000000..458f816 --- /dev/null +++ b/src/hooks/useContextMenu.ts @@ -0,0 +1,153 @@ +import type { Ref } from 'vue' + +/** + * 右键菜单的状态管理 + * @param ContextMenuRef 右键菜单的容器 + * @param isNull 传入的容器是否为空 + */ + +export const useContextMenu = (ContextMenuRef: Ref, isNull?: Ref) => { + const showMenu = ref(false) + const x = ref(0) + const y = ref(0) + + // 禁止滚动的默认行为 + const preventDefault = (e: Event) => e.preventDefault() + + /** 阻止 window 上的默认右键菜单,避免与关闭逻辑冲突;必须保存引用以便 onUnmounted 移除,否则会只增不减导致 DOM/引用无法回收 */ + const preventWindowContextMenu = (e: Event) => { + e.preventDefault() + e.stopPropagation() + } + // 禁止选中文本的默认行为 + const preventTextSelection = (e: Event) => e.preventDefault() + + // 禁用文本选择 + const disableTextSelection = () => { + // 清除当前选择 + window.getSelection()?.removeAllRanges() + // 添加禁止选择事件 + document.body.classList.add('no-select') + window.addEventListener('selectstart', preventTextSelection) + } + + // 启用文本选择 + const enableTextSelection = () => { + document.body.classList.remove('no-select') + window.removeEventListener('selectstart', preventTextSelection) + } + + /**! 解决使用n-virtual-list时,右键菜单出现还可以滚动的问题 */ + const handleVirtualListScroll = (isBan: boolean) => { + const scrollbar_main = document.querySelector('#image-chat-main') as HTMLElement + const scrollbar_sidebar = document.querySelector('#image-chat-sidebar') as HTMLElement + + scrollbar_main && (scrollbar_main.style.pointerEvents = isBan ? 'none' : '') + scrollbar_sidebar && (scrollbar_sidebar.style.pointerEvents = isBan ? 'none' : '') + } + + const isSelectionInsideContext = () => { + const selection = window.getSelection() + if (!selection?.anchorNode || !selection?.focusNode) return false + + const contextEl = ContextMenuRef.value as HTMLElement | null + if (!contextEl) return false + + const resolveElement = (node: Node | null) => { + if (!node) return null + return node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement + } + + const anchorElement = resolveElement(selection.anchorNode) + const focusElement = resolveElement(selection.focusNode) + if (!anchorElement || !focusElement) return false + + return contextEl.contains(anchorElement) && contextEl.contains(focusElement) + } + + const handleContextMenu = (e: MouseEvent) => { + e.preventDefault() + e.stopPropagation() + if (isNull?.value) return + + // 如果当前右键目标包含了已有的文本选择,则保留用户选择,避免影响复制/翻译 + if (!isSelectionInsideContext()) { + // 在显示菜单前清除选择 + disableTextSelection() + } + + handleVirtualListScroll(true) + showMenu.value = true + x.value = e.clientX + y.value = e.clientY + window.addEventListener('wheel', preventDefault, { passive: false }) // 禁止使用滚轮滚动页面 + } + + const closeMenu = (event: any) => { + /** 需要判断点击如果不是.context-menu类的元素的时候,menu才会关闭 */ + if (!event.target.matches('.context-menu, .context-menu *')) { + handleVirtualListScroll(false) + showMenu.value = false + enableTextSelection() // 恢复文本选择功能 + } + window.removeEventListener('wheel', preventDefault) // 移除禁止滚轮滚动 + } + + // 监听showMenu状态变化 + watch( + () => showMenu.value, + (newValue) => { + if (!newValue) { + // 当菜单关闭时,恢复文本选择功能 + enableTextSelection() + } + } + ) + + onMounted(() => { + // 添加全局样式 + if (!document.querySelector('#no-select-style')) { + const style = document.createElement('style') + style.id = 'no-select-style' + style.textContent = `.no-select { + user-select: none !important; + -webkit-user-select: none !important; + -moz-user-select: none !important; + -ms-user-select: none !important; + }` + document.head.appendChild(style) + } + + const div = ContextMenuRef.value + //这里只监听了div的右键,如果需要监听其他元素的右键,需要在其他元素上监听 + div.addEventListener('contextmenu', handleContextMenu) + // 这里需要监听window的右键,否则右键会触发div的右键事件,导致menu无法关闭,并且阻止默认右键菜单 + window.addEventListener('contextmenu', preventWindowContextMenu, false) + window.addEventListener('click', closeMenu, true) + window.addEventListener('contextmenu', closeMenu, true) + }) + + onUnmounted(() => { + const div = ContextMenuRef.value + div?.removeEventListener('contextmenu', handleContextMenu) + window.removeEventListener('contextmenu', preventWindowContextMenu) + window.removeEventListener('contextmenu', preventDefault) + window.removeEventListener('wheel', preventDefault) + window.removeEventListener('selectstart', preventTextSelection) + window.removeEventListener('click', closeMenu, true) + window.removeEventListener('contextmenu', closeMenu, true) + + // 确保恢复选择功能 + enableTextSelection() + + // 移除样式 + const style = document.querySelector('#no-select-style') + if (style) style.remove() + }) + + return { + showMenu, + x, + y + } +} diff --git a/src/hooks/useCustomForwardTask.ts b/src/hooks/useCustomForwardTask.ts new file mode 100644 index 0000000..70457b5 --- /dev/null +++ b/src/hooks/useCustomForwardTask.ts @@ -0,0 +1,118 @@ +import { MsgEnum } from '@/enums' +import { UploadProviderEnum } from '@/hooks/useUpload' +import { useChatStore } from '@/stores/chat' +import { messageStrategyMap } from '@/strategy/MessageStrategy' +import { removeTempFile } from '@/utils/TempFileManager' + +type ReplyContext = { + value: { + content: string + key: number + accountName: string + } +} + +/** + * 针对“自定义转发任务”(目前用于群二维码)的统一 Hook。 + * 负责 blob 资源释放、临时消息清理、多选状态复位以及重新上传临时图片。 + * 后续如果增加“名片/机器人卡片”等其它临时转发形态,只需要在这里扩展即可。 + */ +export const useCustomForwardTask = () => { + const chatStore = useChatStore() + + /** + * 统一释放 blob URL,避免内存泄漏 + */ + const releaseBlobUrl = (url?: string) => { + if (url && url.startsWith('blob:')) { + URL.revokeObjectURL(url) + } + } + + /** + * 清理当前的自定义转发任务: + * 释放预览图的 blob URL + * 删除挂在消息列表里的临时消息 + * 将 store 中的 customForwardTask 重置为 null + */ + const clearCustomForwardTask = () => { + const customTask = chatStore.customForwardTask + if (!customTask) return + + releaseBlobUrl(customTask.previewUrl) + chatStore.deleteMsg(customTask.id) + chatStore.setCustomForwardTask(null) + } + + /** + * 恢复多选状态: + * 统一封装可以防止遗漏某一步导致 UI 状态异常。 + */ + const resetMultiChooseState = () => { + chatStore.clearMsgCheck() + chatStore.setMsgMultiChoose(false) + chatStore.resetSessionSelection() + } + + /** + * 根据 customForwardTask 中的字节数据重新构造图片消息体,并上传到服务端。 + * 还原 File 对象并复用图片消息策略 + * 走图片上传流程,得到最终可转发的 url + * 回填 width/height/size,保证消息体完整 + */ + const buildCustomTaskImageBody = async () => { + const task = chatStore.customForwardTask + if (!task) { + throw new Error('customForwardTask is missing') + } + + const messageStrategy = messageStrategyMap[MsgEnum.IMAGE] + const replyContext: ReplyContext = { + value: { + content: '', + key: 0, + accountName: '' + } + } + + const fileBuffer = task.bytes.slice().buffer + const file = new File([fileBuffer], task.fileName, { type: task.mimeType }) + + let msg: Awaited> | null = null + + try { + msg = await messageStrategy.getMsg('', replyContext.value, [file]) + const messageBody = messageStrategy.buildMessageBody(msg, replyContext) + + const { uploadUrl, downloadUrl, config } = await messageStrategy.uploadFile(msg.path, { + provider: UploadProviderEnum.QINIU + }) + const doUploadResult = await messageStrategy.doUpload(msg.path, uploadUrl, config) + + messageBody.url = + config?.provider && config?.provider === UploadProviderEnum.QINIU ? doUploadResult?.qiniuUrl : downloadUrl + delete messageBody.path + + if (!messageBody.width) { + messageBody.width = task.width + } + if (!messageBody.height) { + messageBody.height = task.height + } + if (!messageBody.size) { + messageBody.size = task.size + } + + return messageBody + } finally { + releaseBlobUrl(msg?.url) + await removeTempFile(msg?.path, { reason: '删除临时二维码文件失败' }) + } + } + + return { + clearCustomForwardTask, + resetMultiChooseState, + buildCustomTaskImageBody + } +} diff --git a/src/hooks/useDevicePermission.ts b/src/hooks/useDevicePermission.ts new file mode 100644 index 0000000..d3f7619 --- /dev/null +++ b/src/hooks/useDevicePermission.ts @@ -0,0 +1,108 @@ +import { openAppSettings } from '@tauri-apps/plugin-barcode-scanner' +import { openUrl } from '@tauri-apps/plugin-opener' +import { isAndroid, isIOS, isMac, isWindows } from '@/utils/PlatformConstants' + +export type DeviceKind = 'microphone' | 'camera' + +/** + * 设备权限相关 + * + * 仅做权限预检和引导用户打开系统设置,不做原生权限请求。 + * macOS/iOS 的系统弹窗由 getUserMedia 首次调用时自动触发; + * Android 的运行时权限由 MainActivity.kt 在启动时请求。 + */ +export const useDevicePermission = () => { + /** + * 预检设备权限状态 + * @param kind 设备类型 microphone | camera + * @returns PermissionState,不支持查询时回退 'prompt' + */ + const checkDevicePermission = async (kind: DeviceKind): Promise => { + if (!('permissions' in navigator)) return 'prompt' + try { + const permission = await navigator.permissions.query({ name: kind as PermissionName }) + return permission.state + } catch (err) { + console.warn(`检查 ${kind} 权限失败`, err) + return 'prompt' + } + } + + /** + * 判断错误是否为权限拒绝 + */ + const isPermissionDenied = (err: unknown): boolean => { + const name = (err as { name?: string })?.name + return name === 'NotAllowedError' || name === 'SecurityError' + } + + /** + * 多个设备同时请求失败时,尽量识别是哪个权限被拒绝。 + */ + const resolveDeniedDeviceKind = async (kinds: DeviceKind[]): Promise => { + if (kinds.length === 1) return kinds[0] + for (const kind of kinds) { + if ((await checkDevicePermission(kind)) === 'denied') return kind + } + return kinds.includes('camera') ? 'camera' : 'microphone' + } + + /** + * 获取当前平台的隐私设置入口 + * @param kind 设备类型 + */ + const getSettingsUrl = (kind: DeviceKind): string => { + const isCamera = kind === 'camera' + if (isMac()) { + return isCamera + ? 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera' + : 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone' + } + if (isWindows()) { + return isCamera ? 'ms-settings:privacy-webcam' : 'ms-settings:privacy-microphone' + } + // iOS/Android 暂无通用 DeepLink,返回应用设置页占位 + if (isIOS()) return 'app-settings:' + if (isAndroid()) return 'package:com.android.settings' + return '' + } + + /** + * 打开系统隐私设置页 + * 优先用 opener 的 openUrl(走系统默认处理器),失败时降级兜底。 + */ + const openSystemSettings = async (kind: DeviceKind): Promise => { + if (isIOS() || isAndroid()) { + try { + await openAppSettings() + return + } catch (err) { + console.error('打开应用设置失败:', err) + } + } + + const url = getSettingsUrl(kind) + if (!url) { + console.warn('当前平台暂不支持自动打开系统设置') + return + } + try { + await openUrl(url) + } catch (err) { + console.error('打开系统设置失败:', err) + // 降级:尝试用 window.open + try { + window.open(url, '_blank', 'noreferrer') + } catch { + // 忽略降级失败 + } + } + } + + return { + checkDevicePermission, + isPermissionDenied, + resolveDeniedDeviceKind, + openSystemSettings + } +} diff --git a/src/hooks/useDownload.ts b/src/hooks/useDownload.ts new file mode 100644 index 0000000..881e0d7 --- /dev/null +++ b/src/hooks/useDownload.ts @@ -0,0 +1,80 @@ +import { BaseDirectory, exists, mkdir, writeFile } from '@tauri-apps/plugin-fs' +import { createEventHook } from '@vueuse/core' +import { isMobile } from '@/utils/PlatformConstants' + +export const useDownload = () => { + const process = ref(0) + const isDownloading = ref(false) + const { on: onLoaded, trigger } = createEventHook() + + const downloadFile = async ( + url: string, + savePath: string, + baseDir: BaseDirectory = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache + ) => { + try { + isDownloading.value = true + process.value = 0 + + // 确保目录存在 + const dirPath = savePath.substring(0, savePath.lastIndexOf('/')) + if (dirPath) { + const dirExists = await exists(dirPath, { baseDir }) + if (!dirExists) { + await mkdir(dirPath, { baseDir, recursive: true }) + } + } + + const response = await fetch(url) + if (!response.ok) { + return window.$message.error('下载失败') + } + + const reader = response.body?.getReader() + if (!reader) { + return window.$message.error('无法读取响应内容') + } + + const contentLength = Number(response.headers.get('Content-Length')) || 0 + const chunks: Uint8Array[] = [] + let receivedLength = 0 + + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read() + if (done) break + + chunks.push(value) + receivedLength += value.length + + if (contentLength) { + process.value = Math.floor((receivedLength / contentLength) * 100) + } + } + + const allChunks = new Uint8Array(receivedLength) + let position = 0 + for (const chunk of chunks) { + allChunks.set(chunk, position) + position += chunk.length + } + + await writeFile(savePath, allChunks, { baseDir }) + trigger('success') + } catch (error) { + console.error('下载失败:', error) + trigger('fail') + throw error + } finally { + isDownloading.value = false + process.value = 0 + } + } + + return { + onLoaded, + downloadFile, + process, + isDownloading + } +} diff --git a/src/hooks/useDriver.ts b/src/hooks/useDriver.ts new file mode 100644 index 0000000..346cdd2 --- /dev/null +++ b/src/hooks/useDriver.ts @@ -0,0 +1,228 @@ +import { type Config, type Driver, type DriveStep, driver } from 'driver.js' +import { useGuideStore } from '@/stores/guide' +import 'driver.js/dist/driver.css' +import '@/styles/scss/global/driver.scss' + +/** + * Driver.js 步骤配置接口 + */ +export interface DriverStepConfig extends Omit { + element: string + /** 是否禁用被聚焦元素的交互(步骤级别配置,会覆盖全局配置) */ + disableActiveInteraction?: boolean + popover?: { + title?: string + description?: string + side?: 'top' | 'right' | 'bottom' | 'left' + align?: 'start' | 'center' | 'end' + onNextClick?: () => void + onPrevClick?: () => void + onCloseClick?: () => void + onDestroyed?: () => void + } +} + +/** + * Driver.js 配置选项接口 + */ +export interface DriverConfig extends Omit { + nextBtnText?: string + prevBtnText?: string + doneBtnText?: string + showButtons?: Array<'next' | 'previous' | 'close'> + showProgress?: boolean + allowClose?: boolean + popoverClass?: string + progressText?: string + /** 是否禁用被聚焦元素的交互(点击事件等) */ + disableActiveInteraction?: boolean +} + +/** + * useDriver hooks 返回值接口 + */ +export interface UseDriverReturn { + /** Driver 实例 */ + driverInstance: Driver | null + /** 开始引导 */ + startTour: () => void + /** 停止引导 */ + stopTour: () => void + /** 移动到下一步 */ + moveNext: () => void + /** 移动到上一步 */ + movePrevious: () => void + /** 移动到指定步骤 */ + moveTo: (stepIndex: number) => void + /** 重新初始化引导 */ + reinitialize: (newSteps: DriverStepConfig[], newConfig?: Partial) => void +} + +/** + * Driver.js 引导功能 hooks + * @param steps 引导步骤配置数组 + * @param config 可选的 Driver 配置 + * @returns useDriver 返回值对象 + */ +export const useDriver = (steps: DriverStepConfig[], config: DriverConfig = {}): UseDriverReturn => { + let driverInstance: Driver | null = null + const guideStore = useGuideStore() + + // 默认配置 + const defaultConfig: DriverConfig = { + progressText: '{{current}}/{{total}}', + nextBtnText: '下一步', + prevBtnText: '上一步', + doneBtnText: '完成', + showButtons: ['next', 'previous'], + showProgress: true, + allowClose: false, + popoverClass: 'driverjs-theme', + disableActiveInteraction: true // 默认禁用被聚焦元素的点击事件 + } + + // 合并配置 + const mergedConfig = { + ...defaultConfig, + ...config, + onDestroyed: () => { + guideStore.markGuideCompleted() + } + } + + /** + * 处理步骤中的自定义点击事件 + * @param step 步骤配置 + * @returns 处理后的步骤配置 + */ + const processStep = (step: DriverStepConfig): DriveStep => { + const processedStep: DriveStep = { + element: step.element, + disableActiveInteraction: step.disableActiveInteraction, + popover: step.popover + ? { + title: step.popover.title, + description: step.popover.description, + side: step.popover.side, + align: step.popover.align + } + : undefined + } + + // 处理自定义点击事件 + if (step.popover?.onNextClick) { + processedStep.popover = { + ...processedStep.popover, + onNextClick: () => { + step.popover!.onNextClick!() + // 如果有自定义的 onNextClick,需要在 nextTick 后手动移动到下一步 + nextTick(() => { + if (driverInstance) { + driverInstance.moveNext() + } + }) + } + } + } + + if (step.popover?.onPrevClick) { + processedStep.popover = { + ...processedStep.popover, + onPrevClick: step.popover.onPrevClick + } + } + + if (step.popover?.onCloseClick) { + processedStep.popover = { + ...processedStep.popover, + onCloseClick: step.popover.onCloseClick + } + } + + return processedStep + } + + /** + * 初始化 Driver 实例 + */ + const initializeDriver = () => { + const processedSteps = steps.map(processStep) + + driverInstance = driver({ + ...mergedConfig, + steps: processedSteps + }) + } + + /** + * 开始引导 + */ + const startTour = () => { + if (!driverInstance) { + initializeDriver() + } + driverInstance?.drive() + } + + /** + * 停止引导 + */ + const stopTour = () => { + driverInstance?.destroy() + driverInstance = null + } + + /** + * 移动到下一步 + */ + const moveNext = () => { + driverInstance?.moveNext() + } + + /** + * 移动到上一步 + */ + const movePrevious = () => { + driverInstance?.movePrevious() + } + + /** + * 移动到指定步骤 + * @param stepIndex 步骤索引(从0开始) + */ + const moveTo = (stepIndex: number) => { + driverInstance?.moveTo(stepIndex) + } + + /** + * 重新初始化引导 + * @param newSteps 新的步骤配置 + * @param newConfig 新的配置(可选) + */ + const reinitialize = (newSteps: DriverStepConfig[], newConfig?: Partial) => { + // 销毁现有实例 + stopTour() + + // 更新步骤和配置 + steps.splice(0, steps.length, ...newSteps) + if (newConfig) { + Object.assign(mergedConfig, newConfig) + } + + // 重新初始化 + initializeDriver() + } + + // 初始化 Driver 实例 + initializeDriver() + + return { + driverInstance, + startTour, + stopTour, + moveNext, + movePrevious, + moveTo, + reinitialize + } +} diff --git a/src/hooks/useFileUploadQueue.ts b/src/hooks/useFileUploadQueue.ts new file mode 100644 index 0000000..77a8e5d --- /dev/null +++ b/src/hooks/useFileUploadQueue.ts @@ -0,0 +1,154 @@ +import { computed, reactive, readonly } from 'vue' +import type { UploadFile } from '@/utils/FileType' + +export type FileUploadItem = { + id: string + name: string + size: number + type: string + status: 'pending' | 'uploading' | 'completed' | 'failed' + progress: number + startTime?: number + endTime?: number +} + +export type FileUploadQueue = { + items: FileUploadItem[] + totalFiles: number + completedFiles: number + failedFiles: number + isActive: boolean + startTime?: number + endTime?: number +} + +/** + * 文件上传队列状态管理 + */ +export const useFileUploadQueue = () => { + // 队列状态 + const queue = reactive({ + items: [], + totalFiles: 0, + completedFiles: 0, + failedFiles: 0, + isActive: false, + startTime: undefined, + endTime: undefined + }) + + // 计算属性 + const progress = computed(() => { + if (queue.totalFiles === 0) return 0 + return Math.round((queue.completedFiles / queue.totalFiles) * 100) + }) + + const isUploading = computed(() => { + return queue.isActive && queue.items.some((item) => item.status === 'uploading') + }) + + /** + * 初始化队列 + */ + const initQueue = (files: UploadFile[]) => { + queue.items = files.map((file, index) => ({ + id: `${Date.now()}_${index}`, + name: file.name, + size: file.size, + type: file.type, + status: 'pending', + progress: 0 + })) + queue.totalFiles = files.length + queue.completedFiles = 0 + queue.failedFiles = 0 + queue.isActive = true + queue.startTime = Date.now() + queue.endTime = undefined + } + + /** + * 更新文件状态 + */ + const updateFileStatus = (fileId: string, status: FileUploadItem['status'], progress?: number) => { + const item = queue.items.find((item) => item.id === fileId) + if (!item) return + + const oldStatus = item.status + item.status = status + if (progress !== undefined) { + item.progress = Math.min(100, Math.max(0, progress)) + } + + // 更新时间戳 + if (status === 'uploading' && oldStatus === 'pending') { + item.startTime = Date.now() + } else if ((status === 'completed' || status === 'failed') && oldStatus === 'uploading') { + item.endTime = Date.now() + } + + // 更新计数器 + if (status === 'completed' && oldStatus !== 'completed') { + queue.completedFiles++ + if (oldStatus === 'failed') queue.failedFiles-- + } else if (status === 'failed' && oldStatus !== 'failed') { + queue.failedFiles++ + if (oldStatus === 'completed') queue.completedFiles-- + } + + // 检查是否完成 + if (queue.completedFiles + queue.failedFiles >= queue.totalFiles) { + finishQueue() + } + } + + /** + * 更新文件上传进度 + */ + const updateFileProgress = (fileId: string, progress: number) => { + const item = queue.items.find((item) => item.id === fileId) + if (item && item.status === 'uploading') { + item.progress = Math.min(100, Math.max(0, progress)) + } + } + + /** + * 完成队列 + */ + const finishQueue = () => { + queue.isActive = false + queue.endTime = Date.now() + + // 2秒后清理队列 + setTimeout(() => { + clearQueue() + }, 2000) + } + + /** + * 清空队列 + */ + const clearQueue = () => { + queue.items = [] + queue.totalFiles = 0 + queue.completedFiles = 0 + queue.failedFiles = 0 + queue.isActive = false + queue.startTime = undefined + queue.endTime = undefined + } + + return { + queue: readonly(queue), + progress, + isUploading, + initQueue, + updateFileStatus, + updateFileProgress, + finishQueue, + clearQueue + } +} + +// 全局单例 +export const globalFileUploadQueue = useFileUploadQueue() diff --git a/src/hooks/useFixedScale.ts b/src/hooks/useFixedScale.ts new file mode 100644 index 0000000..c244836 --- /dev/null +++ b/src/hooks/useFixedScale.ts @@ -0,0 +1,415 @@ +import { invoke } from '@tauri-apps/api/core' +import { useDebounceFn } from '@vueuse/core' +export type FixedScaleMode = 'zoom' | 'transform' + +export type UseFixedScaleOptions = { + /** 目标容器:CSS 选择器或元素,默认 '#app' */ + target?: string | HTMLElement + /** 默认 'zoom' */ + mode?: FixedScaleMode + /** 自定义计算缩放比例方法,默认返回 1 / devicePixelRatio */ + getScale?: () => number + /** 限制最小缩放 */ + minScale?: number + /** 限制最大缩放 */ + maxScale?: number + /** 是否启用Windows文本缩放检测 */ + enableWindowsTextScaleDetection?: boolean +} + +type FixedScaleController = { + enable: () => void + disable: () => void + getCurrentScale: () => number + forceUpdate: () => void + /** 当前是否启用 */ + readonly isEnabled: ComputedRef + /** 当前缩放比例 */ + readonly currentScale: ComputedRef + /** 当前 DPR */ + readonly devicePixelRatio: ComputedRef +} + +const clamp = (n: number, min?: number, max?: number) => { + let x = n + if (typeof min === 'number') x = Math.max(min, x) + if (typeof max === 'number') x = Math.min(max, x) + return x +} + +const resolveElement = (target?: string | HTMLElement): HTMLElement => { + if (!target) return (document.getElementById('app') || document.body || document.documentElement) as HTMLElement + if (typeof target === 'string') { + const el = document.querySelector(target) + return (el as HTMLElement) || (document.getElementById('app') as HTMLElement) || document.body + } + return target +} + +// 检测是否支持 zoom 样式 +const supportsZoom = (() => { + const testEl = document.createElement('div') + testEl.style.zoom = '1' + return testEl.style.zoom === '1' +})() + +/** + * 保持页面在不同系统显示缩放(DPI)下视觉尺寸一致的组合式函数 + * - 当系统显示缩放为 125%、150%、200% 等导致 window.devicePixelRatio(DPR) 改变时,自动反向缩放页面,保持 UI 视觉尺寸一致 + * - 默认基于 zoom 方案(桌面端 Chromium/Edge/Tauri 环境表现较稳定) + * - 提供 transform 方案作为兜底(当某些环境不支持 zoom 或有兼容问题时可切换) + * - 监听窗口 resize / visualViewport 变化与 DPR 媒体查询变化,动态应用 + * - 提供启用/禁用接口,确保卸载时恢复样式与事件 + * @param options 配置选项 + */ +// TODO:在win10多屏幕高分辨率下还有问题,暂时不使用该hooks解决系统文字放大导致内容不显示问题 +export const useFixedScale = (options: UseFixedScaleOptions = {}): FixedScaleController => { + const { + target = '#app', + mode = 'zoom', + getScale, + minScale = 0.1, + maxScale = 3.0, + enableWindowsTextScaleDetection = false + } = options + + const isEnabled = ref(false) + const currentDPR = ref(window.devicePixelRatio || 1) + const targetElement = ref(null) + + // Windows缩放信息 + const windowsScaleInfo = ref<{ + system_dpi: number + system_scale: number + text_scale: number + has_text_scaling: boolean + } | null>(null) + + // 保存进入前的样式,便于恢复 + const originalStyles: Partial = {} + + // 事件监听器管理 - 使用 Map 来跟踪监听器 + const eventListeners = new Map void>() + const mediaQueryListeners = new Set() + + // Windows缩放检测函数 + const checkWindowsScale = async () => { + if (!enableWindowsTextScaleDetection) return + + try { + const scaleInfo = (await invoke('get_windows_scale_info')) as { + system_dpi: number + system_scale: number + text_scale: number + has_text_scaling: boolean + } + + // 检查是否有变化 + const oldTextScale = windowsScaleInfo.value?.text_scale + const newTextScale = scaleInfo.text_scale + + windowsScaleInfo.value = scaleInfo + + // 如果text_scale发生变化,触发resize-needed事件 + if (oldTextScale && Math.abs(newTextScale - oldTextScale) > 0.001) { + window.dispatchEvent( + new CustomEvent('resize-needed', { + detail: { + type: 'text-scale-change', + oldScale: oldTextScale, + newScale: newTextScale, + scaleInfo + } + }) + ) + } + } catch (error) { + console.warn('Failed to get Windows scale info:', error) + } + } + + // 改进的缩放计算逻辑 - 针对不同缩放比例的优化 + const calculateOptimalScale = (): number => { + const dpr = currentDPR.value + + if (getScale) { + return getScale() + } + + // 如果启用了Windows文本缩放检测且有缩放信息 + if (enableWindowsTextScaleDetection && windowsScaleInfo.value && windowsScaleInfo.value.has_text_scaling) { + const textScaleCompensation = 1 / windowsScaleInfo.value.text_scale + return textScaleCompensation + } + + // 针对常见系统缩放的优化计算 + // 使用更精确的数值来处理浮点数精度问题 + if (Math.abs(dpr - 2.0) < 0.01) { + // 200% 缩放:精确的 0.5 + return 0.5 + } else if (Math.abs(dpr - 1.5) < 0.01) { + // 150% 缩放:精确的 2/3 + return 2 / 3 + } else if (Math.abs(dpr - 1.25) < 0.01) { + // 125% 缩放:精确的 0.8 + return 0.8 + } + + // 默认反向缩放,但使用更安全的计算 + const scale = 1 / dpr + return scale + } + + // 计算属性 - Vue 响应式系统的优势 + const currentScale = computed(() => clamp(calculateOptimalScale(), minScale, maxScale)) + const devicePixelRatio = computed(() => currentDPR.value) + + const applyZoom = (scale: number) => { + if (!targetElement.value) return + const el = targetElement.value + ;(el.style as any).zoom = String(scale) + el.style.transformOrigin = '' + el.style.transform = '' + el.style.width = '' + el.style.height = '' + } + + const applyTransform = (scale: number) => { + if (!targetElement.value) return + const el = targetElement.value + el.style.transformOrigin = '0 0' + el.style.transform = `scale(${scale})` + // 为保持可视区域充满,需要反向扩大容器尺寸 + el.style.width = `${100 / scale}%` + el.style.height = `${100 / scale}%` + // 清理 zoom 以避免叠加 + ;(el.style as any).zoom = '' + } + + const apply = () => { + if (!targetElement.value) return + + const scale = currentScale.value + // 设置 CSS 自定义属性供其他组件使用 + document.documentElement.style.setProperty('--page-scale', String(scale)) + document.documentElement.style.setProperty('--device-pixel-ratio', String(currentDPR.value)) + + // 检查模式并应用相应的缩放方法 + const effectiveMode = mode === 'zoom' && !supportsZoom ? 'transform' : mode + + if (effectiveMode === 'zoom') { + applyZoom(scale) + } else { + applyTransform(scale) + } + + // 触发窗口尺寸调整事件 + window.dispatchEvent( + new CustomEvent('resize-needed', { + detail: { scale, devicePixelRatio: currentDPR.value } + }) + ) + } + + // 改进的 DPR 更新函数 + const updateDPR = () => { + const newDPR = window.devicePixelRatio || 1 + if (Math.abs(newDPR - currentDPR.value) > 0.001) { + // 避免浮点数比较问题 + currentDPR.value = newDPR + if (isEnabled.value) { + // 使用 nextTick 确保响应式更新完成后再应用 + nextTick(() => { + apply() + }) + } + } + } + + const setupListeners = () => { + // 防抖函数,避免频繁触发 + const debounceApply = useDebounceFn(() => { + updateDPR() + }, 100) + + const debounceCheckWindowsScale = useDebounceFn(() => { + checkWindowsScale() + }, 200) + + // 监听自定义的resize-needed事件 + const customResizeHandler = (e: CustomEvent) => { + if (e.detail?.type === 'text-scale-change') { + // 文本缩放变化时强制更新 + nextTick(() => { + // 无论之前是否有文本缩放,现在都要应用新的缩放 + apply() + }) + } + } + eventListeners.set('resize-needed', () => { + window.removeEventListener('resize-needed', customResizeHandler as EventListener) + }) + window.addEventListener('resize-needed', customResizeHandler as EventListener) + + // window.resize 监听器 + const resizeHandler = () => { + debounceApply() + if (enableWindowsTextScaleDetection) { + debounceCheckWindowsScale() + } + } + eventListeners.set('resize', resizeHandler) + window.addEventListener('resize', resizeHandler, { passive: true }) + + // visualViewport 监听器 + if (window.visualViewport) { + const viewportHandler = () => { + debounceApply() + if (enableWindowsTextScaleDetection) { + debounceCheckWindowsScale() + } + } + + window.visualViewport.addEventListener('resize', viewportHandler, { passive: true }) + window.visualViewport.addEventListener('scroll', viewportHandler, { passive: true }) + + eventListeners.set('visualViewport', () => { + window.visualViewport?.removeEventListener('resize', viewportHandler) + window.visualViewport?.removeEventListener('scroll', viewportHandler) + }) + } + + // 更精确的 DPR 监听 - 使用更全面的范围 + const dprValues = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3, 3.5, 4] + + dprValues.forEach((dpr) => { + try { + const mql = matchMedia(`(resolution: ${dpr}dppx)`) + if (mql) { + const handler = (e: MediaQueryListEvent) => { + if (e.matches) { + debounceApply() + if (enableWindowsTextScaleDetection) { + debounceCheckWindowsScale() + } + } + } + + mql.addEventListener('change', handler) + mediaQueryListeners.add(mql) + + // 存储清理函数 + eventListeners.set(`mql-${dpr}`, () => { + mql.removeEventListener('change', handler) + }) + } + } catch (error) { + console.log(`Failed to create media query for ${dpr}dppx:`, error) + } + }) + } + + const removeListeners = () => { + // 移除所有事件监听器 + eventListeners.forEach((cleanup, key) => { + try { + if (key === 'resize') { + window.removeEventListener('resize', cleanup as EventListener) + } else if (key === 'resize-needed') { + window.removeEventListener('resize-needed', cleanup as EventListener) + } else { + cleanup() + } + } catch (error) { + console.log(`Error removing listener ${key}:`, error) + } + }) + + eventListeners.clear() + mediaQueryListeners.clear() + } + + const saveOriginal = () => { + if (!targetElement.value) return + const el = targetElement.value + originalStyles.zoom = (el.style as any).zoom + originalStyles.transform = el.style.transform + originalStyles.transformOrigin = el.style.transformOrigin + originalStyles.width = el.style.width + originalStyles.height = el.style.height + } + + const restoreOriginal = () => { + if (!targetElement.value) return + const el = targetElement.value + + // 移除 CSS 自定义属性 + document.documentElement.style.removeProperty('--page-scale') + document.documentElement.style.removeProperty('--device-pixel-ratio') + + // 恢复原始样式 + if (originalStyles.zoom !== undefined) (el.style as any).zoom = originalStyles.zoom + if (originalStyles.transform !== undefined) el.style.transform = originalStyles.transform + if (originalStyles.transformOrigin !== undefined) el.style.transformOrigin = originalStyles.transformOrigin + if (originalStyles.width !== undefined) el.style.width = originalStyles.width + if (originalStyles.height !== undefined) el.style.height = originalStyles.height + } + + const enable = async () => { + if (isEnabled.value) { + return + } + + const el = resolveElement(target) + if (!el) { + return + } + + targetElement.value = el + currentDPR.value = window.devicePixelRatio || 1 + + // 如果启用Windows文本缩放检测,先获取缩放信息 + if (enableWindowsTextScaleDetection) { + await checkWindowsScale() + } + + saveOriginal() + setupListeners() + + // 只有当检测到文本缩放时才应用缩放,但监听器始终设置 + if (!enableWindowsTextScaleDetection || windowsScaleInfo.value?.has_text_scaling) { + apply() + } + + isEnabled.value = true + } + + const disable = () => { + if (!isEnabled.value) { + return + } + + removeListeners() + restoreOriginal() + isEnabled.value = false + targetElement.value = null + } + + // Vue 生命周期管理 + onBeforeUnmount(() => { + disable() + }) + + return { + enable, + disable, + getCurrentScale: () => currentScale.value, + forceUpdate: () => { + updateDPR() + apply() + }, + isEnabled: computed(() => isEnabled.value), + currentScale, + devicePixelRatio + } +} diff --git a/src/hooks/useGeolocation.ts b/src/hooks/useGeolocation.ts new file mode 100644 index 0000000..5f4a0b0 --- /dev/null +++ b/src/hooks/useGeolocation.ts @@ -0,0 +1,135 @@ +import { transformCoordinates } from '@/services/mapApi' +import { useI18n } from 'vue-i18n' + +type GeolocationState = { + loading: boolean + error: string | null + position: GeolocationPosition | null + permission: PermissionState | null + precision: 'high' | 'low' +} + +type GeolocationOptions = { + enableHighAccuracy?: boolean + timeout?: number + maximumAge?: number +} + +export const useGeolocation = () => { + const { t } = useI18n() + const state = ref({ + loading: false, + error: null, + position: null, + permission: null, + precision: 'high' + }) + + const isSupported = computed(() => 'geolocation' in navigator) + const hasPermission = computed(() => state.value.permission === 'granted') + const isLoading = computed(() => state.value.loading) + const error = computed(() => state.value.error) + const currentPosition = computed(() => state.value.position) + + // 检查权限状态 + const checkPermission = async (): Promise => { + if ('permissions' in navigator) { + try { + const permission = await navigator.permissions.query({ name: 'geolocation' }) + state.value.permission = permission.state + return permission.state + } catch (error) { + console.warn(t('message.location.hook.permission_check_failed'), error) + } + } + return 'prompt' + } + + // 获取当前位置 + const getCurrentPosition = async (options?: GeolocationOptions): Promise => { + return new Promise((resolve, reject) => { + if (!navigator.geolocation) { + const unsupportedError = t('message.location.hook.unsupported') + reject(new Error(unsupportedError)) + return + } + + const defaultOptions: PositionOptions = { + enableHighAccuracy: state.value.precision === 'high', + timeout: 10000, + maximumAge: 300000, // 5分钟缓存 + ...options + } + + state.value.loading = true + state.value.error = null + + navigator.geolocation.getCurrentPosition( + (position) => { + state.value.loading = false + state.value.position = position + resolve(position) + }, + (error) => { + state.value.loading = false + let errorMessage = t('message.location.hook.error_generic') + + switch (error.code) { + case error.PERMISSION_DENIED: + errorMessage = t('message.location.hook.permission_denied') + break + case error.POSITION_UNAVAILABLE: + errorMessage = t('message.location.hook.position_unavailable') + break + case error.TIMEOUT: + errorMessage = t('message.location.hook.timeout') + break + } + + state.value.error = errorMessage + reject(new Error(errorMessage)) + }, + defaultOptions + ) + }) + } + + // 获取位置并转换坐标 + const getLocationWithTransform = async (options?: GeolocationOptions) => { + const position = await getCurrentPosition(options) + const { latitude, longitude } = position.coords + + // 转换坐标 + const transformed = await transformCoordinates(latitude, longitude) + + return { + original: { lat: latitude, lng: longitude }, + transformed, + position, + address: '', // 后续可以通过逆地理编码获取 + precision: state.value.precision, + timestamp: Date.now() + } + } + + // 清除错误状态 + const clearError = () => { + state.value.error = null + } + + return { + // 状态 + state: state.value, + isSupported, + hasPermission, + isLoading, + error, + currentPosition, + + // 方法 + checkPermission, + getCurrentPosition, + getLocationWithTransform, + clearError + } +} diff --git a/src/hooks/useGlobalShortcut.ts b/src/hooks/useGlobalShortcut.ts new file mode 100644 index 0000000..b32c0e8 --- /dev/null +++ b/src/hooks/useGlobalShortcut.ts @@ -0,0 +1,399 @@ +import { invoke } from '@tauri-apps/api/core' +import { LogicalPosition, LogicalSize } from '@tauri-apps/api/dpi' +import { emitTo, listen } from '@tauri-apps/api/event' +import { WebviewWindow } from '@tauri-apps/api/webviewWindow' +import { register, unregister } from '@tauri-apps/plugin-global-shortcut' +import { useSettingStore } from '@/stores/setting.ts' +import { isMac } from '@/utils/PlatformConstants' + +// 快捷键配置接口 +type ShortcutConfig = { + /** 配置键名,用于从 store 中读取设置 */ + key: keyof NonNullable['shortcuts']> + /** 默认快捷键值 */ + defaultValue: string + /** 快捷键处理函数 */ + handler: () => Promise + /** 监听的更新事件名 */ + updateEventName: string + /** 发送注册状态的事件名 */ + registrationEventName: string +} + +// 全局快捷键状态管理 +const globalShortcutStates = new Map() + +// 防抖状态管理 +let togglePanelTimeout: ReturnType | null = null +let lastToggleTime = 0 +const isMacPlatform = isMac() + +/** + * 全局快捷键管理 Hook + * 负责注册、取消注册和管理全局快捷键 + * 使用配置驱动的方式,方便扩展新快捷键 + */ +export const useGlobalShortcut = () => { + const settingStore = useSettingStore() + // 获取平台对应的默认快捷键 + const getDefaultShortcuts = () => { + return { + screenshot: isMac() ? 'Cmd+Ctrl+H' : 'Ctrl+Alt+H', + openMainPanel: isMac() ? 'Cmd+Ctrl+P' : 'Ctrl+Alt+P' + } + } + + /** + * 确保capture窗口存在 + * 如果不存在则创建,如果存在则确保设置了关闭拦截 + */ + const ensureCaptureWindow = async () => { + const captureWindow = await WebviewWindow.getByLabel('capture') + + if (captureWindow) { + // 设置关闭拦截 - 将关闭转为隐藏 + captureWindow.onCloseRequested(async (event) => { + event.preventDefault() + await captureWindow.hide() + // 触发重置事件,让Screenshot组件重新初始化 + await captureWindow.emit('capture-reset', {}) + }) + // 初始状态为隐藏 + await captureWindow.hide() + } + + return captureWindow + } + + /** + * 截图处理函数 + */ + const handleScreenshot = async () => { + try { + const homeWindow = await WebviewWindow.getByLabel('home') + if (!homeWindow) return + + const captureWindow = await WebviewWindow.getByLabel('capture') + if (!captureWindow) return + + // 检查是否需要隐藏home窗口 + if (settingStore.screenshot.isConceal) { + await homeWindow.hide() + // 等待窗口隐藏完成 + await new Promise((resolve) => setTimeout(resolve, 100)) + } + + // 设置窗口覆盖整个屏幕(包括菜单栏) + const screenWidth = window.screen.width * window.devicePixelRatio + const screenHeight = window.screen.height * window.devicePixelRatio + + // 依靠窗口级别设置来确保覆盖菜单栏 + await captureWindow.setSize(new LogicalSize(screenWidth, screenHeight)) + await captureWindow.setPosition(new LogicalPosition(0, 0)) + + // 在 macOS 上设置窗口级别以覆盖菜单栏 + if (isMacPlatform) { + await invoke('set_window_level_above_menubar', { windowLabel: 'capture' }) + } + + await captureWindow.show() + await captureWindow.setFocus() + await captureWindow.emit('capture', true) + + console.log('截图窗口已启动') + } catch (error) { + console.error('Failed to open screenshot window:', error) + } + } + + /** + * 切换主面板显示状态 + * - 如果窗口已显示,则隐藏 + * - 如果窗口隐藏或最小化,则显示并聚焦 + */ + const handleOpenMainPanel = async () => { + const currentTime = Date.now() + + // 防抖:如果距离上次操作少于500ms,则忽略 + if (currentTime - lastToggleTime < 500) { + return + } + + // 清除之前的延时操作 + if (togglePanelTimeout) { + clearTimeout(togglePanelTimeout) + togglePanelTimeout = null + } + + lastToggleTime = currentTime + + try { + const homeWindow = await WebviewWindow.getByLabel('home') + if (!homeWindow) { + console.warn('Home window not found') + return + } + + // 获取当前窗口状态 + const isVisible = await homeWindow.isVisible() + const isMinimized = await homeWindow.isMinimized() + + console.log(`快捷键触发 - 窗口状态: 可见=${isVisible}, 最小化=${isMinimized}`) + + if (isVisible && !isMinimized) { + // 窗口当前可见且未最小化,直接隐藏 + await homeWindow.hide() + } else { + // 处理最小化状态 + if (isMinimized) { + await homeWindow.unminimize() + } + + // 显示窗口 + await homeWindow.show() + + // 延迟设置焦点,确保窗口已完全显示 + togglePanelTimeout = setTimeout(async () => { + await homeWindow.setFocus() + }, 50) + } + } catch (error) { + console.error('Failed to toggle main panel:', error) + } + } + + // 快捷键配置数组 - 新增快捷键只需在此处添加配置即可 + const shortcutConfigs: ShortcutConfig[] = [ + { + key: 'screenshot', + defaultValue: getDefaultShortcuts().screenshot, + handler: handleScreenshot, + updateEventName: 'shortcut-updated', + registrationEventName: 'shortcut-registration-updated' + }, + { + key: 'openMainPanel', + defaultValue: getDefaultShortcuts().openMainPanel, + handler: handleOpenMainPanel, + updateEventName: 'open-main-panel-shortcut-updated', + registrationEventName: 'open-main-panel-shortcut-registration-updated' + } + ] + + /** + * 通用快捷键注册函数 + * @param config 快捷键配置 + * @param shortcut 快捷键字符串 + */ + const registerShortcut = async (config: ShortcutConfig, shortcut: string): Promise => { + try { + const currentShortcut = globalShortcutStates.get(config.key) + + // 清理当前快捷键 + if (currentShortcut) { + await unregister(currentShortcut) + console.log(`清理快捷键 [${config.key}]: ${currentShortcut}`) + } + + // 预防性清理目标快捷键 + if (!currentShortcut) { + try { + await unregister(shortcut) + console.log(`预清理快捷键 [${config.key}]: ${shortcut}`) + } catch (_e) { + console.log(`快捷键 [${config.key}] 未注册: ${shortcut}`) + } + } + + // 注册新快捷键 + await register(shortcut, config.handler) + globalShortcutStates.set(config.key, shortcut) + console.log(`快捷键已注册 [${config.key}]: ${shortcut}`) + return true + } catch (error) { + console.error(`注册快捷键失败 [${config.key}]:`, error) + return false + } + } + + /** + * 取消注册快捷键 + * @param shortcut 要取消注册的快捷键字符串 + */ + const unregisterShortcut = async (shortcut: string) => { + try { + await unregister(shortcut) + console.log(`成功取消注册快捷键: ${shortcut}`) + } catch (error) { + console.error(`取消注册快捷键失败: ${shortcut}`, error) + } + } + + /** + * 强制清理快捷键残留 + */ + const forceCleanupShortcuts = async (shortcuts: string[]) => { + for (const shortcut of shortcuts) { + try { + await unregister(shortcut) + } catch (_e) { + console.log(`强制清理 ${shortcut} (可能未注册)`) + } + } + } + + /** + * 通用快捷键更新处理函数 + * @param config 快捷键配置 + * @param newShortcut 新快捷键 + */ + const handleShortcutUpdate = async (config: ShortcutConfig, newShortcut: string) => { + const oldShortcut = globalShortcutStates.get(config.key) + + // 强制清理旧快捷键 + const shortcutsToClean = [oldShortcut, newShortcut].filter(Boolean) as string[] + await forceCleanupShortcuts(shortcutsToClean) + + // 清除状态,准备重新注册 + globalShortcutStates.delete(config.key) + + // 尝试注册新快捷键 + console.log(`[Home] 开始注册新快捷键 [${config.key}]: ${newShortcut}`) + const success = await registerShortcut(config, newShortcut) + + // 如果注册失败且有旧快捷键,尝试回滚 + if (!success && oldShortcut) { + globalShortcutStates.delete(config.key) + const rollbackSuccess = await registerShortcut(config, oldShortcut) + console.log(`[Home] 快捷键回滚结果 [${config.key}]: ${rollbackSuccess ? '成功' : '失败'}`) + } + + // 通知设置页面注册状态更新 + await emitTo('settings', config.registrationEventName, { + shortcut: newShortcut, + registered: success + }) + console.log(`[Home] 已通知 settings 窗口快捷键状态更新 [${config.key}]: ${success ? '已注册' : '未注册'}`) + } + + /** + * 处理全局快捷键开关状态变化 + * @param enabled 是否启用全局快捷键 + */ + const handleGlobalShortcutToggle = async (enabled: boolean) => { + if (enabled) { + // 开启时重新注册所有快捷键并通知设置页面 + for (const config of shortcutConfigs) { + const savedShortcut = settingStore.shortcuts?.[config.key] || config.defaultValue + const success = await registerShortcut(config, savedShortcut as string) + + // 通知设置页面注册状态更新 + await emitTo('settings', config.registrationEventName, { + shortcut: savedShortcut, + registered: success + }) + } + } else { + // 关闭时取消注册所有快捷键并通知设置页面状态为未绑定 + for (const config of shortcutConfigs) { + const savedShortcut = settingStore.shortcuts?.[config.key] || config.defaultValue + + // 通知设置页面注册状态更新为未绑定 + await emitTo('settings', config.registrationEventName, { + shortcut: savedShortcut, + registered: false + }) + } + + // 取消注册所有快捷键 + await cleanupGlobalShortcut() + } + } + + /** + * 初始化全局快捷键 + * 根据配置自动注册所有快捷键并监听更新事件 + */ + const initializeGlobalShortcut = async () => { + // 确保capture窗口存在 + await ensureCaptureWindow() + + // 检查全局快捷键是否开启,默认为关闭 + const globalEnabled = settingStore.shortcuts?.globalEnabled ?? false + + // 只有开启时才注册快捷键 + if (globalEnabled) { + // 批量注册所有配置的快捷键 + for (const config of shortcutConfigs) { + const savedShortcut = settingStore.shortcuts?.[config.key] || config.defaultValue + await registerShortcut(config, savedShortcut as string) + } + } + + // 监听全局快捷键开关变化 + listen('global-shortcut-enabled-changed', (event) => { + const enabled = (event.payload as any)?.enabled + if (typeof enabled === 'boolean') { + handleGlobalShortcutToggle(enabled) + } else { + console.warn(`[Home] 收到无效的全局快捷键开关事件:`, event.payload) + } + }) + + // 监听每个快捷键的更新事件 + for (const config of shortcutConfigs) { + listen(config.updateEventName, (event) => { + const newShortcut = (event.payload as any)?.shortcut + if (newShortcut) { + // 只有全局快捷键开启时才处理更新 + const globalEnabled = settingStore.shortcuts?.globalEnabled ?? false + if (globalEnabled) { + handleShortcutUpdate(config, newShortcut) + } else { + console.log(`[Home] 全局快捷键已关闭,跳过快捷键更新 [${config.key}]`) + } + } else { + console.warn(`[Home] 收到无效的快捷键更新事件 [${config.key}]:`, event.payload) + } + }) + } + } + + /** + * 清理全局快捷键 + * 取消注册所有快捷键并清理状态 + */ + const cleanupGlobalShortcut = async () => { + // 清理防抖定时器 + if (togglePanelTimeout) { + clearTimeout(togglePanelTimeout) + togglePanelTimeout = null + } + + // 取消注册所有已注册的快捷键 + for (const shortcut of globalShortcutStates.values()) { + await unregisterShortcut(shortcut) + } + // 清理状态 + globalShortcutStates.clear() + } + + return { + // 处理函数 + handleScreenshot, + handleOpenMainPanel, + + // 核心功能 + initializeGlobalShortcut, + cleanupGlobalShortcut, + ensureCaptureWindow, + + // 工具函数 + registerShortcut: (config: ShortcutConfig, shortcut: string) => registerShortcut(config, shortcut), + unregisterShortcut, + getDefaultShortcuts, + + // 配置信息(用于外部访问) + shortcutConfigs + } +} diff --git a/src/hooks/useImageViewer.ts b/src/hooks/useImageViewer.ts new file mode 100644 index 0000000..b835573 --- /dev/null +++ b/src/hooks/useImageViewer.ts @@ -0,0 +1,378 @@ +import { convertFileSrc } from '@tauri-apps/api/core' +import { WebviewWindow } from '@tauri-apps/api/webviewWindow' +import { MsgEnum } from '@/enums' +import { useWindow } from '@/hooks/useWindow' +import { useChatStore } from '@/stores/chat' +import { useFileDownloadStore } from '@/stores/fileDownload' +import { useImageViewer as useImageViewerStore } from '@/stores/imageViewer' +import type { FilesMeta } from '@/services/types' +import { extractFileName } from '@/utils/Formatting' +import { getFilesMeta } from '@/utils/PathUtil' + +type WorkerResponse = { + success: boolean + url: string + buffer?: ArrayBuffer + error?: string +} + +type WorkerRequest = { + resolve: (value: string | null) => void + reject: (reason?: unknown) => void + fileName: string +} + +const workerRequests = new Map() +let imageDownloadWorker: Worker | null = null +const imageDownloaderWorkerUrl = new URL('../workers/imageDownloader.ts', import.meta.url) + +const ensureWorker = () => { + if (imageDownloadWorker || typeof window === 'undefined') return + imageDownloadWorker = new Worker(imageDownloaderWorkerUrl, { type: 'module' }) + imageDownloadWorker.onmessage = async (event: MessageEvent) => { + const { success, url, buffer, error } = event.data + const request = workerRequests.get(url) + if (!request) { + return + } + workerRequests.delete(url) + + if (!success || !buffer) { + request.reject(new Error(error || '下载失败')) + return + } + + try { + const fileDownloadStore = useFileDownloadStore() + const absolutePath = await fileDownloadStore.saveFileFromBytes(url, request.fileName, new Uint8Array(buffer)) + request.resolve(absolutePath) + } catch (err) { + request.reject(err) + } + } +} + +const downloadImageWithWorker = (url: string, fileName: string) => { + ensureWorker() + if (!imageDownloadWorker) { + return Promise.reject(new Error('Web Worker 不可用')) + } + + const existing = workerRequests.get(url) + if (existing) { + return new Promise((resolve, reject) => { + const prevResolve = existing.resolve + const prevReject = existing.reject + existing.resolve = (value) => { + prevResolve(value) + resolve(value) + } + existing.reject = (reason) => { + prevReject(reason) + reject(reason) + } + }) + } + + const promise = new Promise((resolve, reject) => { + workerRequests.set(url, { resolve, reject, fileName }) + imageDownloadWorker!.postMessage({ url }) + }) + + return promise +} + +const deduplicateList = (list: string[]) => { + const uniqueList: string[] = [] + const seen = new Set() + list.forEach((url) => { + if (url && !seen.has(url)) { + seen.add(url) + uniqueList.push(url) + } + }) + return uniqueList +} + +/** + * 图片查看器Hook,用于处理图片和表情包的查看功能 + */ +export const useImageViewer = () => { + const chatStore = useChatStore() + const { createWebviewWindow } = useWindow() + const imageViewerStore = useImageViewerStore() + const fileDownloadStore = useFileDownloadStore() + + const ensureLocalFileExists = async (url: string) => { + if (!url) return null + const status = fileDownloadStore.getFileStatus(url) + const validatePath = async (absolutePath: string | undefined | null) => { + if (!absolutePath) { + return null + } + try { + const [meta] = await getFilesMeta([absolutePath]) + if (meta?.exists) { + return absolutePath + } + return null + } catch (error) { + console.error('检查本地图片失败:', error) + return null + } + } + + if (status?.isDownloaded) { + const validPath = await validatePath(status.absolutePath) + if (validPath) { + return validPath + } + + fileDownloadStore.updateFileStatus(url, { + isDownloaded: false, + absolutePath: '', + localPath: '', + nativePath: '', + displayPath: '', + status: 'pending', + progress: 0 + }) + } + + const fileName = extractFileName(url) + if (!fileName) { + return null + } + + try { + const exists = await fileDownloadStore.checkFileExists(url, fileName) + if (!exists) { + return null + } + + const refreshedStatus = fileDownloadStore.getFileStatus(url) + return await validatePath(refreshedStatus.absolutePath) + } catch (error) { + console.error('重新检查本地图片失败:', error) + return null + } + } + + const getDisplayUrl = async (url: string) => { + const localPath = await ensureLocalFileExists(url) + if (localPath) { + try { + return convertFileSrc(localPath) + } catch (error) { + console.error('转换本地图片路径失败:', error) + } + } + return url + } + + const getLocalMediaPathFromChat = (url: string, includeTypes: MsgEnum[]) => { + const messages = Object.values(chatStore.currentMessageMap || {}) + for (const msg of messages) { + if (!includeTypes.includes(msg.message?.type)) continue + if (msg.message.body?.url !== url) continue + if (msg.message.body?.localPath) { + return msg.message.body.localPath as string + } + } + return null + } + + const resolveDisplayUrl = async (url: string, includeTypes: MsgEnum[]) => { + const localPath = getLocalMediaPathFromChat(url, includeTypes) + if (localPath) { + try { + return convertFileSrc(localPath) + } catch (error) { + console.error('转换本地媒体路径失败:', error) + } + } + return await getDisplayUrl(url) + } + + const replaceImageWithLocalPath = (originalUrl: string, absolutePath: string) => { + const index = imageViewerStore.originalImageList.indexOf(originalUrl) + if (index === -1) { + return + } + try { + const displayUrl = convertFileSrc(absolutePath) + imageViewerStore.updateImageAt(index, displayUrl) + imageViewerStore.updateSingleImageSource(displayUrl) + } catch (error) { + console.error('替换本地图片路径失败:', error) + } + } + + const scheduleDownload = (originalUrl: string) => { + const fileName = extractFileName(originalUrl) || `image-${Date.now()}.png` + downloadImageWithWorker(originalUrl, fileName) + .then((absolutePath) => { + if (absolutePath) { + replaceImageWithLocalPath(originalUrl, absolutePath) + } + }) + .catch((error) => { + console.error('图片下载失败:', error) + }) + } + + const downloadOriginalByIndex = (index: number) => { + if (index < 0) { + return + } + const originalUrl = imageViewerStore.originalImageList[index] + if (!originalUrl) { + return + } + const displayUrl = imageViewerStore.imageList[index] + if (!displayUrl || displayUrl !== originalUrl) { + return + } + scheduleDownload(originalUrl) + } + + /** + * 获取当前聊天中的所有图片和表情包URL + * @param currentUrl 当前查看的URL + * @param includeTypes 要包含的消息类型数组 + */ + const getAllMediaFromChat = (currentUrl: string, includeTypes: MsgEnum[] = [MsgEnum.IMAGE, MsgEnum.EMOJI]) => { + const messages = [...Object.values(chatStore.currentMessageMap || {})] + const mediaUrls: string[] = [] + let currentIndex = 0 + + messages.forEach((msg) => { + // 收集指定类型的媒体URL + if (includeTypes.includes(msg.message?.type) && msg.message.body?.url) { + mediaUrls.push(msg.message.body.url) + // 找到当前媒体的索引 + if (msg.message.body.url === currentUrl) { + currentIndex = mediaUrls.length - 1 + } + } + }) + + return { + list: mediaUrls, + index: currentIndex + } + } + + /** + * 打开图片查看器 + * @param url 要查看的URL + * @param includeTypes 要包含在查看器中的消息类型 + * @param customImageList 自定义图片列表,用于聊天历史等场景 + */ + const openImageViewer = async ( + url: string, + includeTypes: MsgEnum[] = [MsgEnum.IMAGE, MsgEnum.EMOJI], + customImageList?: string[] + ) => { + if (!url) return + + try { + let list: string[] + let index: number + + if (customImageList && customImageList.length > 0) { + // 使用自定义图片列表 + list = customImageList + index = customImageList.indexOf(url) + if (index === -1) { + // 如果当前图片不在列表中,将其添加到列表开头 + list = [url, ...customImageList] + index = 0 + } + } else { + // 使用默认逻辑从聊天中获取 + const result = getAllMediaFromChat(url, includeTypes) + list = result.list + index = result.index + } + + const dedupedList = deduplicateList(list) + const resolvedList = await Promise.all(dedupedList.map((item) => resolveDisplayUrl(item, includeTypes))) + + const targetIndex = dedupedList.indexOf(url) + const resolvedIndex = targetIndex === -1 ? (index >= 0 ? index : 0) : targetIndex + + imageViewerStore.resetImageList(resolvedList, resolvedIndex, dedupedList) + + // 检查图片查看器窗口是否已存在 + const existingWindow = await WebviewWindow.getByLabel('imageViewer') + + if (existingWindow) { + // 如果窗口已存在,更新图片内容并显示窗口 + await existingWindow.emit('update-image', { list: resolvedList, index: resolvedIndex }) + await existingWindow.show() + await existingWindow.setFocus() + return + } + + const img = new Image() + img.src = resolvedList[resolvedIndex] || url + + await new Promise((resolve, reject) => { + img.onload = resolve + img.onerror = reject + }) + + // 默认窗口尺寸(最小尺寸) + const MIN_WINDOW_WIDTH = 630 + const MIN_WINDOW_HEIGHT = 660 + // 计算实际窗口尺寸(保留一定边距) + const MARGIN = 100 // 窗口边距 + let windowWidth = MIN_WINDOW_WIDTH + let windowHeight = MIN_WINDOW_HEIGHT + + // 获取屏幕尺寸 + const { width: screenWidth, height: screenHeight } = window.screen + + // 计算最大可用尺寸(考虑边距) + const maxWidth = screenWidth - MARGIN * 2 + const maxHeight = screenHeight - MARGIN * 2 + + // 保持图片比例计算窗口尺寸 + const imageRatio = img.width / img.height + + // 计算实际窗口尺寸 + if (img.width > MIN_WINDOW_WIDTH || img.height > MIN_WINDOW_HEIGHT) { + if (imageRatio > maxWidth / maxHeight) { + // 以宽度为基准 + windowWidth = Math.min(img.width + MARGIN, maxWidth) + windowHeight = Math.max(windowWidth / imageRatio + MARGIN, MIN_WINDOW_HEIGHT) + } else { + // 以高度为基准 + windowHeight = Math.min(img.height + MARGIN, maxHeight) + windowWidth = Math.max(windowHeight * imageRatio + MARGIN, MIN_WINDOW_WIDTH) + } + } + + // 创建窗口,使用计算后的尺寸 + await createWebviewWindow( + '图片查看', + 'imageViewer', + Math.round(windowWidth), + Math.round(windowHeight), + '', + true, + Math.round(windowWidth), + Math.round(windowHeight) + ) + } catch (error) { + console.error('打开图片查看器失败:', error) + } + } + + return { + getAllMediaFromChat, + openImageViewer, + downloadOriginalByIndex + } +} diff --git a/src/hooks/useIntersectionTaskQueue.ts b/src/hooks/useIntersectionTaskQueue.ts new file mode 100644 index 0000000..f3193b6 --- /dev/null +++ b/src/hooks/useIntersectionTaskQueue.ts @@ -0,0 +1,84 @@ +import { onBeforeUnmount } from 'vue' + +type VisibilityTask = () => void | Promise + +type ObserverEntry = { + task: VisibilityTask + once: boolean +} + +const isClient = typeof window !== 'undefined' +const isIntersectionObserverSupported = isClient && 'IntersectionObserver' in window + +/** + * 轻量封装 IntersectionObserver,便于在元素可见时触发任务 + * @param options IntersectionObserver 配置 + */ +export const useIntersectionTaskQueue = (options?: IntersectionObserverInit) => { + let observer: IntersectionObserver | null = null + const entryTaskMap = new Map() + + const ensureObserver = () => { + if (!isIntersectionObserverSupported) { + return null + } + if (observer) { + return observer + } + observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) { + return + } + const target = entry.target as Element + const meta = entryTaskMap.get(target) + if (!meta) { + return + } + void meta.task() + if (meta.once !== false) { + observer?.unobserve(target) + entryTaskMap.delete(target) + } + }) + }, options) + return observer + } + + const observe = (el: Element | null, task: VisibilityTask, once = true) => { + if (!el) { + return + } + const inst = ensureObserver() + if (!inst) { + void task() + return + } + entryTaskMap.set(el, { task, once }) + inst.observe(el) + } + + const unobserve = (el: Element | null) => { + if (!el) { + return + } + observer?.unobserve(el) + entryTaskMap.delete(el) + } + + const disconnect = () => { + observer?.disconnect() + observer = null + entryTaskMap.clear() + } + + onBeforeUnmount(() => { + disconnect() + }) + + return { + observe, + unobserve, + disconnect + } +} diff --git a/src/hooks/useLinkSegments.ts b/src/hooks/useLinkSegments.ts new file mode 100644 index 0000000..d590d09 --- /dev/null +++ b/src/hooks/useLinkSegments.ts @@ -0,0 +1,78 @@ +import { computed, type ComputedRef, type Ref, unref } from 'vue' +import { open } from '@tauri-apps/plugin-shell' + +export type LinkSegment = { + text: string + isLink: boolean +} + +type MaybeRef = T | Ref | ComputedRef + +const LINK_URL_PATTERN = /https?:\/\/[^\s<]+[^<.,:;"')\]\s]/gi + +export const extractLinkSegments = (text: string): LinkSegment[] => { + const content = (text || '').replace(/ /g, '\u00a0') + if (!content) return [] + + const segments: LinkSegment[] = [] + const regex = new RegExp(LINK_URL_PATTERN.source, 'gi') + let lastIndex = 0 + let match: RegExpExecArray | null + + while ((match = regex.exec(content)) !== null) { + const matchText = match[0] + const startIndex = match.index + + if (startIndex > lastIndex) { + segments.push({ + text: content.slice(lastIndex, startIndex), + isLink: false + }) + } + + segments.push({ + text: matchText, + isLink: true + }) + + lastIndex = startIndex + matchText.length + } + + if (lastIndex < content.length) { + segments.push({ + text: content.slice(lastIndex), + isLink: false + }) + } + + return segments +} + +export const normalizeExternalUrl = (url: string) => { + const trimmed = url?.trim() ?? '' + if (!trimmed) return '' + return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}` +} + +export const openExternalUrl = async (url: string) => { + const normalizedUrl = normalizeExternalUrl(url) + if (!normalizedUrl) return + + try { + await open(normalizedUrl) + } catch (error) { + console.error('打开链接失败:', error) + if (typeof window !== 'undefined') { + window.open(normalizedUrl, '_blank', 'noreferrer') + } + } +} + +export const useLinkSegments = (source: MaybeRef) => { + const segments = computed(() => extractLinkSegments(unref(source) ?? '')) + + return { + segments, + openLink: openExternalUrl + } +} diff --git a/src/hooks/useLogin.ts b/src/hooks/useLogin.ts new file mode 100644 index 0000000..bb82822 --- /dev/null +++ b/src/hooks/useLogin.ts @@ -0,0 +1,774 @@ +import { emit, listen } from '@tauri-apps/api/event' +import { WebviewWindow } from '@tauri-apps/api/webviewWindow' +import { useRouter } from 'vue-router' +import { EventEnum, MittEnum, TauriCommand } from '@/enums' +import { useWindow } from '@/hooks/useWindow.ts' +import { useChatStore } from '@/stores/chat' +import { useGlobalStore } from '@/stores/global.ts' +import { LoginStatus, useWsLoginStore } from '@/stores/ws' +import { isDesktop, isMac, isMobile } from '@/utils/PlatformConstants' +import { clearListener } from '@/utils/ReadCountQueue' +import { ErrorType, invokeSilently, invokeWithErrorHandler } from '@/utils/TauriInvokeHandler.ts' +import { useSettingStore } from '../stores/setting' +import { useGroupStore } from '../stores/group' +import { useCachedStore } from '../stores/cached' +import { useConfigStore } from '../stores/config' +import { useUserStatusStore } from '../stores/userStatus' +import { useUserStore } from '../stores/user' +import { useLoginHistoriesStore } from '../stores/loginHistory' +import rustWebSocketClient from '@/services/webSocketRust' +import { useEmojiStore } from '@/stores/emoji' +import { getAllUserState, getUserDetail } from '../utils/ImRequestUtils' +import { useNetwork } from '@vueuse/core' +import { UserInfoType } from '../services/types' +import { getEnhancedFingerprint } from '../services/fingerprint' +import { invoke } from '@tauri-apps/api/core' +import { useMitt } from './useMitt' +import { info as logInfo } from '@tauri-apps/plugin-log' +import { ensureAppStateReady } from '@/utils/AppStateReady' +import { useI18nGlobal } from '../services/i18n' +import { useInitialSyncStore } from '@/stores/initialSync' +import { openExternalUrl } from './useLinkSegments' +import { TokenManager } from '@/utils/TokenManager' + +export const useLogin = () => { + const { resizeWindow } = useWindow() + const globalStore = useGlobalStore() + const loginStore = useWsLoginStore() + const chatStore = useChatStore() + const settingStore = useSettingStore() + const { isTrayMenuShow } = storeToRefs(globalStore) + const groupStore = useGroupStore() + const cachedStore = useCachedStore() + const configStore = useConfigStore() + const userStatusStore = useUserStatusStore() + const userStore = useUserStore() + const loginHistoriesStore = useLoginHistoriesStore() + const initialSyncStore = useInitialSyncStore() + const { createWebviewWindow } = useWindow() + + const { t } = useI18nGlobal() + + /** + * 清空 localStorage 中用户相关的持久化数据 + * 防止 Pinia 在页面刷新时自动恢复旧账号数据 + */ + const clearUserLocalStorage = () => { + const userScopedStoreKeys = ['chat', 'group', 'contacts', 'feed', 'cached', 'sessionUnread'] + userScopedStoreKeys.forEach((key) => { + localStorage.removeItem(key) + }) + console.log('[useLogin] User localStorage has been cleared') + } + + /** + * 清空消息缓存和群组数据 + * 在新数据加载完成后调用,避免旧消息混入 + */ + const clearMessageCache = () => { + // 清空消息缓存(messageMap 是 reactive Record,需要逐个删除键) + for (const key of Object.keys(chatStore.messageMap)) { + delete chatStore.messageMap[key] + } + // 清空群组成员数据 + for (const key of Object.keys(groupStore.userListMap)) { + delete groupStore.userListMap[key] + } + console.log('[useLogin] Message cache has been cleared') + } + + /** + * 在 composable 初始化时获取 router 实例 + * 注意: useRouter() 必须在组件 setup 上下文中调用 + * 不能在异步回调中调用 useRouter(),因为那时已经失去了 Vue 组件上下文 + * 所以在这里提前获取并保存 router 实例,供后续异步操作使用 + */ + let router: ReturnType | null = null + try { + router = useRouter() + } catch (_e) { + void logInfo('[useLogin] 无法获取 router 实例,可能不在组件上下文中') + } + + /** 网络连接是否正常 */ + const { isOnline } = useNetwork() + const loading = ref(false) + /** 登录按钮的文本内容 */ + const loginText = ref(isOnline.value ? t('login.button.login.default') : t('login.button.login.network_error')) + const loginDisabled = ref(!isOnline.value) + /** 账号信息 */ + const info = ref({ + account: '', + password: '', + avatar: '', + name: '', + uid: '' + }) + const uiState = ref<'manual' | 'auto'>('manual') + /** + * 设置登录状态(系统托盘图标,系统托盘菜单选项) + */ + const setLoginState = async () => { + // 登录成功后删除本地存储的wsLogin,防止用户在二维码页面刷新出二维码但是不使用二维码登录,导致二维码过期或者登录失败 + if (localStorage.getItem('wsLogin')) { + localStorage.removeItem('wsLogin') + } + isTrayMenuShow.value = true + if (!isMobile()) { + await resizeWindow('tray', 130, 356) + } + } + + /** + * 登出账号 + */ + const logout = async () => { + globalStore.updateCurrentSessionRoomId('') + + const sendLogoutEvent = async () => { + // ws 退出连接 + await invokeSilently('ws_disconnect') + await invokeSilently(TauriCommand.REMOVE_TOKENS) + await invokeSilently(TauriCommand.UPDATE_USER_LAST_OPT_TIME) + } + + if (isDesktop()) { + const { createWebviewWindow } = useWindow() + isTrayMenuShow.value = false + try { + await sendLogoutEvent() + // 创建登录窗口 + await createWebviewWindow('登录', 'login', 320, 448, undefined, false, 320, 448) + // 发送登出事件 + await emit(EventEnum.LOGOUT) + + // 调整托盘大小 + await resizeWindow('tray', 130, 44) + } catch (_error) { + void logInfo('创建登录窗口失败') + } + } else { + try { + await sendLogoutEvent() + // 发送登出事件 + await emit(EventEnum.LOGOUT) + } catch (_error) { + void logInfo('登出失败') + window.$message.error('登出失败') + } + } + } + + // const { openExternalUrl } = useLinkSegments() + + /** 重置登录的状态 */ + const resetLoginState = async (isAutoLogin = false) => { + // 清理消息已读计数监听器 + clearListener() + // 1. 清理本地存储 + if (!isAutoLogin) { + // TODO 未来这里需要区分账号,切换不同的account;用不同的REFRESH_TOKEN调用 + localStorage.removeItem('user') + localStorage.removeItem('TOKEN') + localStorage.removeItem('REFRESH_TOKEN') + } + settingStore.closeAutoLogin() + loginStore.loginStatus = LoginStatus.Init + globalStore.updateCurrentSessionRoomId('') + // 2. 清除系统托盘图标上的未读数 + if (isMac()) { + const homeWindow = await WebviewWindow.getByLabel('home') + if (homeWindow) { + await homeWindow.setBadgeCount(undefined) + } + } + } + + // 全量同步 + const runFullSync = async (preserveSession?: string) => { + await chatStore.getSessionList(true) + // 如果有需要保留的会话且该会话仍存在于列表中,则恢复选中状态 + if (preserveSession) { + const sessionExists = chatStore.sessionList.some((s) => s.roomId === preserveSession) + if (sessionExists) { + // 会话存在,保持选中状态不变 + } else { + // 会话不存在了,清空选中 + globalStore.updateCurrentSessionRoomId('') + } + } else { + // 没有需要保留的会话,重置 + globalStore.updateCurrentSessionRoomId('') + } + + // 加载所有群的成员数据 + const groupSessions = chatStore.getGroupSessions() + await Promise.all([ + ...groupSessions.map((session) => groupStore.getGroupUserList(session.roomId, true)), + groupStore.setGroupDetails(), + chatStore.setAllSessionMsgList(20), + cachedStore.getAllBadgeList() + ]) + } + + // 增量同步 + const runIncrementalSync = async (preserveSession?: string) => { + // 优先保证会话列表最新消息和未读数:拉会话即可让未读/最新一条消息就绪 + await chatStore.getSessionList(true) + // 如果有需要保留的会话且该会话仍存在于列表中,则保持选中状态 + if (preserveSession) { + const sessionExists = chatStore.sessionList.some((s) => s.roomId === preserveSession) + if (!sessionExists) { + // 会话不存在了,清空选中 + globalStore.updateCurrentSessionRoomId('') + } + // 会话存在则保持当前状态不变 + } + // 没有需要保留的会话时也保持当前状态(增量同步不重置) + + // 加载所有群的成员数据和群公告,确保切换会话时数据已就绪 + const groupSessions = chatStore.getGroupSessions() + await Promise.allSettled([ + ...groupSessions.map((session) => groupStore.getGroupUserList(session.roomId, true)), + groupStore.setGroupDetails(), + chatStore.setAllSessionMsgList(20), + cachedStore.getAllBadgeList() + ]).catch(() => { + void logInfo('[useLogin] 增量预热任务失败') + }) + } + + const init = async (options?: { isInitialSync?: boolean }) => { + const emojiStore = useEmojiStore() + + // 保存当前选中的会话,同步后如果该会话仍存在则恢复选中状态 + const previousSessionRoomId = globalStore.currentSessionRoomId + + // 清空 localStorage,防止页面刷新时恢复旧账号数据 + clearUserLocalStorage() + + // 清空消息缓存,避免旧消息混入新账号 + clearMessageCache() + + // 立即清空旧账号的会话列表,并立即获取新账号数据 + // 这样用户看到的是短暂加载而不是错误的旧数据 + chatStore.sessionList.length = 0 + groupStore.groupDetails.length = 0 + + // 连接 ws + await rustWebSocketClient.initConnect() + + // 立即获取新账号的会话列表(优先加载,减少空白时间) + chatStore.getSessionList(true).catch(() => { + void logInfo('[useLogin] 获取会话列表失败') + }) + + // 用户相关数据初始化 + userStatusStore.stateList = await getAllUserState() + const userDetail: any = await getUserDetail() + userStatusStore.stateId = userDetail.userStateId + const account = { + ...userDetail, + client: isDesktop() ? 'PC' : 'MOBILE' + } + userStore.userInfo = account + loginHistoriesStore.addLoginHistory(account) + // 初始化表情列表并在后台预取本地缓存(使用 worker + 并发限制) + void emojiStore.initEmojis().catch(() => { + void logInfo('[login] 初始化表情失败') + }) + + // 在 sqlite 中存储用户信息 + await invokeWithErrorHandler( + TauriCommand.SAVE_USER_INFO, + { + userInfo: userDetail + }, + { + customErrorMessage: '保存用户信息失败', + errorType: ErrorType.Client + } + ) + + // 数据初始化 + const cachedConfig = localStorage.getItem('config') + if (cachedConfig) { + configStore.config = JSON.parse(cachedConfig).config + } else { + await configStore.initConfig() + } + const isInitialSync = options?.isInitialSync ?? !initialSyncStore.isSynced(account.uid) + + // 登录后立即预热表情本地缓存(异步,不阻塞后续流程) + void emojiStore.prefetchEmojiToLocal().catch(() => { + void logInfo('[login] 预热表情缓存失败') + }) + + if (isInitialSync) { + chatStore.syncLoading = true + try { + await runFullSync(previousSessionRoomId) + } finally { + chatStore.syncLoading = false + } + } else { + chatStore.syncLoading = true + try { + await runIncrementalSync(previousSessionRoomId) + } finally { + // 增量登录仅等待会话准备好就关闭提示,后台同步继续进行 + chatStore.syncLoading = false + } + } + // 强制持久化 + chatStore.$persist?.() + cachedStore.$persist?.() + globalStore.$persist?.() + + await setLoginState() + } + + /** + * 根据平台类型执行不同的跳转逻辑 + * 桌面端: 创建主窗口 + * 移动端: 路由跳转到主页 + */ + const routerOrOpenHomeWindow = async () => { + if (isDesktop()) { + const registerWindow = await WebviewWindow.getByLabel('register') + if (registerWindow) { + await registerWindow.close().catch(() => { + void logInfo('关闭注册窗口失败') + }) + } + await createWebviewWindow('HuLa', 'home', 960, 720, 'login', true, 330, 480, undefined, false) + // 只有在成功创建home窗口并且已登录的情况下才显示托盘菜单 + globalStore.isTrayMenuShow = true + } else { + // 移动端使用路由跳转 + router?.push('/mobile/home') + } + } + + const normalLogin = async ( + deviceType: 'PC' | 'MOBILE', + syncRecentMessages: boolean, + auto: boolean = settingStore.login.autoLogin + ) => { + loading.value = true + loginText.value = t('login.status.logging_in') + loginDisabled.value = true + const hasStoredUserInfo = !!userStore.userInfo && !!userStore.userInfo.account + if (auto && !hasStoredUserInfo) { + loading.value = false + loginDisabled.value = false + loginText.value = isOnline.value ? t('login.button.login.default') : t('login.button.login.network_error') + uiState.value = 'manual' + settingStore.setAutoLogin(false) + logInfo('自动登录信息已失效,请手动登录') + return + } + + // 根据auto参数决定从哪里获取登录信息 + const loginInfo = auto && userStore.userInfo ? (userStore.userInfo as UserInfoType) : info.value + const account = loginInfo?.account + const password = loginInfo?.password ?? info.value.password + if (!account) { + loading.value = false + loginDisabled.value = false + loginText.value = isOnline.value ? '登录' : '网络异常' + if (auto) { + uiState.value = 'manual' + settingStore.setAutoLogin(false) + } + logInfo('账号信息缺失,请重新输入') + return + } + + // 存储此次登陆设备指纹 + const clientId = await getEnhancedFingerprint() + localStorage.setItem('clientId', clientId) + + await ensureAppStateReady() + + invoke('login_command', { + data: { + account: account, + password: password, + deviceType: deviceType, + systemType: '2', + clientId: clientId, + grantType: 'PASSWORD', + isAutoLogin: auto, + asyncData: syncRecentMessages, + uid: auto ? userStore.userInfo!.uid : null + } + }) + .then(async (_: any) => { + // 数据库切换已在后端 login_command 中完成 + loginDisabled.value = true + loading.value = false + loginText.value = t('login.status.success_redirect') + + // 仅在移动端的首次手动登录时,才默认打开自动登录开关 + if (!auto && isMobile()) { + settingStore.setAutoLogin(true) + } + + // 移动端登录之后,初始化数据 + if (isMobile()) { + await init() + await invoke('hide_splash_screen') // 初始化完再关闭启动页 + } + useMitt.emit(MittEnum.MSG_INIT) + + await routerOrOpenHomeWindow() + }) + .catch((e: any) => { + window.$message.error(e) + loading.value = false + loginDisabled.value = false + loginText.value = t('login.button.login.default') + // 如果是自动登录失败,切换到手动登录界面并重置按钮状态 + if (auto) { + uiState.value = 'manual' + loginDisabled.value = false + loginText.value = t('login.button.login.default') + // 取消自动登录 + settingStore.setAutoLogin(false) + // 自动填充之前尝试登录的账号信息到手动登录表单 + if (userStore.userInfo) { + info.value.account = userStore.userInfo.account || userStore.userInfo.email || '' + info.value.avatar = userStore.userInfo.avatar + info.value.name = userStore.userInfo.name + info.value.uid = userStore.userInfo.uid + } + // Token 过期时,移动端跳转到登录页 + if (isMobile()) { + router?.replace('/mobile/login') + } + } + }) + } + + const giteeLogin = async () => { + try { + loading.value = true + loginDisabled.value = true + loginText.value = t('login.status.logging_in') + + const clientId = await getEnhancedFingerprint() + localStorage.setItem('clientId', clientId) + + await ensureAppStateReady() + + const port: number = await invoke('start_oauth_server') + const redirectUri = `http://127.0.0.1:${port}/` + + // 监听 OAuth 回调 + let isProcessing = false + const unlisten = await listen('oauth-token', async (event) => { + if (isProcessing) return + isProcessing = true + + try { + const payload = event.payload || '' + const params = new URLSearchParams(payload) + const token = params.get('token') || '' + const refreshToken = params.get('refreshToken') || '' + const uid = params.get('uid') || '' + if (!token || !refreshToken) { + throw new Error('授权回调缺少 token 或 refreshToken') + } + const targetUid = uid || undefined + // 先切换到用户专属数据库 + if (targetUid) { + await invoke('switch_user_database', { uid: targetUid }) + } + await TokenManager.updateToken(token, refreshToken, targetUid) + await invoke('sync_messages', { + param: { + asyncData: true, + fullSync: false, + uid: targetUid + } + }) + loginDisabled.value = true + loading.value = false + loginText.value = t('login.status.success_redirect') + useMitt.emit(MittEnum.MSG_INIT) + await routerOrOpenHomeWindow() + } catch { + window.$message.error('Gitee 登录失败') + loading.value = false + loginDisabled.value = false + loginText.value = t('login.button.login.default') + } finally { + if (typeof unlisten === 'function') { + unlisten() + } + } + }) + + let baseUrl = '' + + // 1. 优先尝试从 Tauri 后端获取配置 (local.yaml) + try { + const backendSettings = (await invoke('get_settings')) as Partial + if (backendSettings && backendSettings.backend) { + // 兼容 snake_case (Rust默认) 和 camelCase (可能的序列化配置) + // @ts-expect-error + baseUrl = backendSettings.backend.base_url || backendSettings.backend.baseUrl || '' + } + } catch (_e) { + void logInfo('Failed to get settings from backend') + } + + // 简化:仅从后端配置读取 base_url(来源 base.yaml) + + if (!baseUrl) { + window.$message.error('请先在设置中配置服务器地址') + loading.value = false + loginDisabled.value = false + return + } + + // 移除末尾斜杠 + baseUrl = baseUrl.replace(/\/$/, '') + + console.log('baseUrl', baseUrl) + + // 后端已配置固定回调地址 http://127.0.0.1:36677/ + const authorizeUrlEndpoint = `${baseUrl}/oauth/anyTenant/gitee/authorize-url?redirect=${encodeURIComponent(redirectUri)}` + + // 先请求后端获取真正的授权地址 + // 注意:这里需要根据项目使用的 HTTP 客户端来调用 + // 假设 invoke 无法直接调用后端 HTTP 接口,需要用 fetch 或 axios + // 这里暂时使用 fetch,如果项目有封装好的 http client 应该使用它 + const response = await fetch(authorizeUrlEndpoint, { + method: 'GET', + headers: { + Accept: 'application/json' + } + }) + + const resText = await response.text() + + let resJson + try { + resJson = JSON.parse(resText) + } catch (_e) { + throw new Error(`解析响应失败: ${resText.substring(0, 100)}...`) + } + + if (resJson.code === 200 || resJson.code === 0) { + const giteeAuthUrl = resJson.data + await openExternalUrl(giteeAuthUrl) + } else { + throw new Error(resJson.msg || '获取授权地址失败') + } + } catch (_e) { + window.$message.error('Gitee 登录失败') + loading.value = false + loginDisabled.value = false + loginText.value = t('login.button.login.default') + } + } + + const githubLogin = async () => { + try { + loading.value = true + loginDisabled.value = true + loginText.value = t('login.status.logging_in') + const clientId = await getEnhancedFingerprint() + localStorage.setItem('clientId', clientId) + await ensureAppStateReady() + const port: number = await invoke('start_oauth_server') + const redirectUri = `http://127.0.0.1:${port}/` + let isProcessing = false + const unlisten = await listen('oauth-token', async (event) => { + if (isProcessing) return + isProcessing = true + try { + const payload = event.payload || '' + const params = new URLSearchParams(payload) + const token = params.get('token') || '' + const refreshToken = params.get('refreshToken') || '' + const uid = params.get('uid') || '' + if (!token || !refreshToken) { + throw new Error('授权回调缺少 token 或 refreshToken') + } + const targetUid = uid || undefined + // 先切换到用户专属数据库 + if (targetUid) { + await invoke('switch_user_database', { uid: targetUid }) + } + await TokenManager.updateToken(token, refreshToken, targetUid) + await invoke('sync_messages', { + param: { + asyncData: true, + fullSync: false, + uid: targetUid + } + }) + loginDisabled.value = true + loading.value = false + loginText.value = t('login.status.success_redirect') + useMitt.emit(MittEnum.MSG_INIT) + await routerOrOpenHomeWindow() + } catch { + window.$message.error('GitHub 登录失败') + loading.value = false + loginDisabled.value = false + loginText.value = t('login.button.login.default') + } finally { + if (typeof unlisten === 'function') { + unlisten() + } + } + }) + let baseUrl = '' + try { + const backendSettings = (await invoke('get_settings')) as Partial + if (backendSettings && backendSettings.backend) { + // @ts-expect-error + baseUrl = backendSettings.backend.base_url || backendSettings.backend.baseUrl || '' + } + } catch (_e) {} + if (!baseUrl) { + window.$message.error('请先在设置中配置服务器地址') + loading.value = false + loginDisabled.value = false + return + } + baseUrl = baseUrl.replace(/\/$/, '') + const authorizeUrlEndpoint = `${baseUrl}/oauth/anyTenant/github/authorize-url?redirect=${encodeURIComponent(redirectUri)}` + const response = await fetch(authorizeUrlEndpoint, { + method: 'GET', + headers: { Accept: 'application/json' } + }) + const resText = await response.text() + let resJson + try { + resJson = JSON.parse(resText) + } catch { + throw new Error(`解析响应失败: ${resText.substring(0, 100)}...`) + } + if (resJson.code === 200 || resJson.code === 0) { + const githubAuthUrl = resJson.data + await openExternalUrl(githubAuthUrl) + } else { + throw new Error(resJson.msg || '获取授权地址失败') + } + } catch (_e) { + window.$message.error('GitHub 登录失败') + loading.value = false + loginDisabled.value = false + loginText.value = t('login.button.login.default') + } + } + + const gitcodeLogin = async () => { + try { + loading.value = true + loginDisabled.value = true + loginText.value = t('login.status.logging_in') + const clientId = await getEnhancedFingerprint() + localStorage.setItem('clientId', clientId) + await ensureAppStateReady() + const port: number = await invoke('start_oauth_server') + const redirectUri = `http://127.0.0.1:${port}/` + let isProcessing = false + const unlisten = await listen('oauth-token', async (event) => { + if (isProcessing) return + isProcessing = true + try { + const payload = event.payload || '' + const params = new URLSearchParams(payload) + const token = params.get('token') || '' + const refreshToken = params.get('refreshToken') || '' + const uid = params.get('uid') || '' + if (!token || !refreshToken) { + throw new Error('授权回调缺少 token 或 refreshToken') + } + const targetUid = uid || undefined + // 先切换到用户专属数据库 + if (targetUid) { + await invoke('switch_user_database', { uid: targetUid }) + } + await TokenManager.updateToken(token, refreshToken, targetUid) + await invoke('sync_messages', { + param: { + asyncData: true, + fullSync: false, + uid: targetUid + } + }) + loginDisabled.value = true + loading.value = false + loginText.value = t('login.status.success_redirect') + useMitt.emit(MittEnum.MSG_INIT) + await routerOrOpenHomeWindow() + } finally { + if (typeof unlisten === 'function') { + unlisten() + } + } + }) + let baseUrl = '' + try { + const backendSettings = (await invoke('get_settings')) as Partial + if (backendSettings && backendSettings.backend) { + // @ts-expect-error + baseUrl = backendSettings.backend.base_url || backendSettings.backend.baseUrl || '' + } + } catch (_e) {} + if (!baseUrl) { + window.$message.error('请先在设置中配置服务器地址') + loading.value = false + loginDisabled.value = false + return + } + baseUrl = baseUrl.replace(/\/$/, '') + const authorizeUrlEndpoint = `${baseUrl}/oauth/anyTenant/gitcode/authorize-url?redirect=${encodeURIComponent(redirectUri)}` + const response = await fetch(authorizeUrlEndpoint, { + method: 'GET', + headers: { Accept: 'application/json' } + }) + const resText = await response.text() + let resJson + try { + resJson = JSON.parse(resText) + } catch { + throw new Error(`解析响应失败: ${resText.substring(0, 100)}...`) + } + if (resJson.code === 200 || resJson.code === 0) { + const gitcodeAuthUrl = resJson.data + await openExternalUrl(gitcodeAuthUrl) + } else { + throw new Error(resJson.msg || '获取授权地址失败') + } + } catch (_e) { + window.$message.error('GitCode 登录失败') + loading.value = false + loginDisabled.value = false + loginText.value = t('login.button.login.default') + } + } + + return { + resetLoginState, + setLoginState, + logout, + normalLogin, + giteeLogin, + githubLogin, + gitcodeLogin, + loading, + loginText, + loginDisabled, + info, + uiState, + init + } +} diff --git a/src/hooks/useMessage.ts b/src/hooks/useMessage.ts new file mode 100644 index 0000000..5f8e891 --- /dev/null +++ b/src/hooks/useMessage.ts @@ -0,0 +1,379 @@ +import { MittEnum, NotificationTypeEnum, RoomTypeEnum, SessionOperateEnum, UserType } from '@/enums' +import { useMitt } from '@/hooks/useMitt.ts' +import type { SessionItem } from '@/services/types.ts' +import { useChatStore } from '@/stores/chat.ts' +import { useContactStore } from '@/stores/contacts.ts' +import { useGlobalStore } from '@/stores/global.ts' +import { useSettingStore } from '@/stores/setting.ts' +import { useGroupStore } from '@/stores/group' +import { useUserStore } from '@/stores/user' +import { exitGroup, notification, setSessionTop, shield } from '@/utils/ImRequestUtils' +import { invokeWithErrorHandler } from '../utils/TauriInvokeHandler' +import { useI18n } from 'vue-i18n' + +const msgBoxShow = ref(false) +const shrinkStatus = ref(false) + +// 模块级别注册事件监听,避免 hook 被多次调用时重复注册 +let isShrinkListenerRegistered = false +const registerShrinkListener = () => { + if (isShrinkListenerRegistered) return + isShrinkListenerRegistered = true + useMitt.on(MittEnum.SHRINK_WINDOW, async (event: any) => { + shrinkStatus.value = event as boolean + }) +} + +export const useMessage = () => { + const { t } = useI18n() + const globalStore = useGlobalStore() + const chatStore = useChatStore() + const settingStore = useSettingStore() + const { chat } = storeToRefs(settingStore) + const contactStore = useContactStore() + const groupStore = useGroupStore() + const userStore = useUserStore() + const BOT_ALLOWED_MENU_INDEXES = new Set([0, 1, 2, 3]) + + // 确保监听器只注册一次 + registerShrinkListener() + + /** + * 处理点击选中消息 + * 如果本地缓存中找不到自己,说明尚未同步服务端数据,此时强制刷新群成员信息。 + */ + const ensureGroupMembersSynced = async (roomId: string, sessionType: RoomTypeEnum) => { + if (sessionType !== RoomTypeEnum.GROUP) return + + const currentUid = userStore.userInfo?.uid + if (!currentUid) return + + const memberList = groupStore.getUserListByRoomId(roomId) + const alreadyHasCurrentUser = memberList.some((member) => member.uid === currentUid) + + if (!alreadyHasCurrentUser) { + await groupStore.getGroupUserList(roomId, true) + } + } + + const handleMsgClick = async (item: SessionItem) => { + msgBoxShow.value = true + // 更新当前会话信息 + const roomId = item.roomId + console.log('[handleMsgClick] 点击会话:', roomId, 'UI未读数:', item.unreadCount) + + globalStore.updateCurrentSessionRoomId(roomId) + + chatStore.getSession(roomId) + chatStore.markSessionRead(roomId) + + // 再根据是否存在自身成员做一次兜底刷新,防止批量切换账号后看到旧数据 + try { + await ensureGroupMembersSynced(roomId, item.type) + } catch (error) { + console.error('[useMessage] 同步群成员失败:', error) + } + } + + /** + * 预加载聊天室 + * @param roomId + */ + const preloadChatRoom = (roomId: string = '1') => { + globalStore.updateCurrentSessionRoomId(roomId) + } + + /** + * 删除会话 + * @param roomId 会话信息 + */ + const handleMsgDelete = async (roomId: string) => { + const currentSessions = chatStore.sessionList + const currentIndex = currentSessions.findIndex((session) => session.roomId === roomId) + + // 检查是否是当前选中的会话 + const isCurrentSession = roomId === globalStore.currentSessionRoomId + + chatStore.removeSession(roomId) + // TODO: 使用隐藏会话接口 + // const res = await apis.hideSession({ roomId, hide: true }) + await invokeWithErrorHandler('hide_contact_command', { data: { roomId, hide: true } }) + // console.log(res, roomId) + + // 如果不是当前选中的会话,直接返回 + if (!isCurrentSession) { + return + } + + const updatedSessions = chatStore.sessionList + + // 选择下一个或上一个会话 + const nextIndex = Math.min(currentIndex, updatedSessions.length - 1) + const nextSession = updatedSessions[nextIndex] + if (nextSession) { + await handleMsgClick(nextSession) + } + } + + /** 处理双击事件 */ + const handleMsgDblclick = (item: SessionItem) => { + if (!chat.value.isDouble) return + console.log(item) + } + + const menuList = ref([ + { + label: (item: SessionItem) => (item.top ? t('menu.unpin') : t('menu.pin')), + icon: (item: SessionItem) => (item.top ? 'to-bottom' : 'to-top'), + click: (item: SessionItem) => { + setSessionTop({ roomId: item.roomId, top: !item.top }) + .then(() => { + // 更新本地会话状态 + chatStore.updateSession(item.roomId, { top: !item.top }) + window.$message.success( + item.top ? t('message.message_menu.unpin_success') : t('message.message_menu.pin_success') + ) + }) + .catch(() => { + window.$message.error(item.top ? t('message.message_menu.unpin_fail') : t('message.message_menu.pin_fail')) + }) + } + }, + { + label: () => t('menu.copy_account'), + icon: 'copy', + click: (item: any) => { + navigator.clipboard.writeText(item.account) + window.$message.success(t('message.message_menu.copy_success', { account: item.account })) + } + }, + { + label: () => t('menu.mark_unread'), + icon: 'message-unread' + }, + { + label: (item: SessionItem) => { + if (item.type === RoomTypeEnum.GROUP) { + return t('menu.group_message_setting') + } + + return item.muteNotification === NotificationTypeEnum.RECEPTION + ? t('menu.set_do_not_disturb') + : t('menu.unset_do_not_disturb') + }, + icon: (item: SessionItem) => { + if (item.type === RoomTypeEnum.GROUP) { + return 'peoples-two' + } + return item.muteNotification === NotificationTypeEnum.RECEPTION ? 'close-remind' : 'remind' + }, + children: (item: SessionItem) => { + if (item.type === RoomTypeEnum.SINGLE) return null + + return [ + { + label: () => t('menu.allow_notifications'), + icon: !item.shield && item.muteNotification === NotificationTypeEnum.RECEPTION ? 'check-small' : '', + click: async () => { + // 如果当前是屏蔽状态,需要先取消屏蔽 + if (item.shield) { + await shield({ + roomId: item.roomId, + state: false + }) + chatStore.updateSession(item.roomId, { shield: false }) + } + await handleNotificationChange(item, NotificationTypeEnum.RECEPTION) + } + }, + { + label: () => t('menu.receive_silently'), + icon: !item.shield && item.muteNotification === NotificationTypeEnum.NOT_DISTURB ? 'check-small' : '', + click: async () => { + // 如果当前是屏蔽状态,需要先取消屏蔽 + if (item.shield) { + await shield({ + roomId: item.roomId, + state: false + }) + chatStore.updateSession(item.roomId, { shield: false }) + } + await handleNotificationChange(item, NotificationTypeEnum.NOT_DISTURB) + } + }, + { + label: () => t('menu.block_group_messages'), + icon: item.shield ? 'check-small' : '', + click: async () => { + await shield({ + roomId: item.roomId, + state: !item.shield + }) + + // 更新本地会话状态 + chatStore.updateSession(item.roomId, { + shield: !item.shield + }) + + window.$message.success( + item.shield ? t('message.message_menu.unshield_success') : t('message.message_menu.shield_success') + ) + } + } + ] + }, + click: async (item: SessionItem) => { + if (item.type === RoomTypeEnum.GROUP) return // 群聊不执行点击事件 + + const newType = + item.muteNotification === NotificationTypeEnum.RECEPTION + ? NotificationTypeEnum.NOT_DISTURB + : NotificationTypeEnum.RECEPTION + + await handleNotificationChange(item, newType) + } + } + ]) + + const specialMenuList = ref([ + { + label: (item: SessionItem) => (item.shield ? t('menu.unblock_user_messages') : t('menu.block_user_messages')), + icon: (item: SessionItem) => (item.shield ? 'message-success' : 'people-unknown'), + click: async (item: SessionItem) => { + await shield({ + roomId: item.roomId, + state: !item.shield + }) + + // 更新本地会话状态 + chatStore.updateSession(item.roomId, { + shield: !item.shield + }) + + window.$message.success( + item.shield ? t('message.message_menu.unshield_success') : t('message.message_menu.shield_success') + ) + }, + // 只在单聊时显示 + visible: (item: SessionItem) => item.type === RoomTypeEnum.SINGLE + }, + { + label: () => t('menu.remove_from_list'), + icon: 'delete', + click: async (item: SessionItem) => { + await handleMsgDelete(item.roomId) + } + }, + { + label: (item: SessionItem) => { + if (item.type === RoomTypeEnum.SINGLE) return t('menu.delete_friend') + if (item.operate === SessionOperateEnum.DISSOLUTION_GROUP) return t('menu.dissolve_group') + return t('menu.leave_group') + }, + icon: (item: SessionItem) => { + if (item.type === RoomTypeEnum.SINGLE) return 'forbid' + if (item.operate === SessionOperateEnum.DISSOLUTION_GROUP) return 'logout' + return 'logout' + }, + click: async (item: SessionItem) => { + console.log('删除好友或退出群聊执行') + // 单聊:删除好友 + if (item.type === RoomTypeEnum.SINGLE) { + await contactStore.onDeleteFriend(item.detailId) + await handleMsgDelete(item.roomId) + window.$message.success(t('message.message_menu.delete_friend_success')) + return + } + + // 群聊:检查是否是频道 + if (item.roomId === '1') { + window.$message.warning( + item.operate === SessionOperateEnum.DISSOLUTION_GROUP + ? t('message.message_menu.cannot_dissolve_channel') + : t('message.message_menu.cannot_quit_channel') + ) + return + } + + // 群聊:解散或退出 + await exitGroup({ roomId: item.roomId }) + await handleMsgDelete(item.roomId) + window.$message.success( + item.operate === SessionOperateEnum.DISSOLUTION_GROUP + ? t('message.message_menu.dissolve_group_success') + : t('message.message_menu.quit_group_success') + ) + }, + visible: (item: SessionItem) => { + // 单聊:只在operate为DELETE_FRIEND时显示 + if (item.type === RoomTypeEnum.SINGLE) { + return item.operate === SessionOperateEnum.DELETE_FRIEND + } + + // 群聊:不显示频道选项 + if (item.roomId === '1') return false + + // 群聊:始终显示退出选项,如果是群主则显示解散选项 + return true + } + } + ]) + + // 添加通知设置变更处理函数 + const handleNotificationChange = async (item: SessionItem, newType: NotificationTypeEnum) => { + await notification({ + roomId: item.roomId, + type: newType + }) + + // 更新本地会话状态 + chatStore.updateSession(item.roomId, { + muteNotification: newType + }) + + // 如果从免打扰切换到允许提醒,需要重新计算全局未读数 + if (item.muteNotification === NotificationTypeEnum.NOT_DISTURB && newType === NotificationTypeEnum.RECEPTION) { + chatStore.updateTotalUnreadCount() + } + + // 显示操作成功提示 + let message = '' + switch (newType) { + case NotificationTypeEnum.RECEPTION: + message = t('message.message_menu.notification_allowed') + break + case NotificationTypeEnum.NOT_DISTURB: + message = t('message.message_menu.notification_silent') + // 设置免打扰时也需要更新全局未读数,因为该会话的未读数将不再计入 + chatStore.updateTotalUnreadCount() + break + } + window.$message.success(message) + } + + const visibleMenu = (item: SessionItem) => { + if (item.account === UserType.BOT) { + return menuList.value.filter((_, index) => BOT_ALLOWED_MENU_INDEXES.has(index)) + } + return menuList.value + } + + const visibleSpecialMenu = (item: SessionItem) => { + if (item.account === UserType.BOT) { + return [] + } + return specialMenuList.value + } + + return { + msgBoxShow, + handleMsgClick, + handleMsgDelete, + handleMsgDblclick, + menuList, + specialMenuList, + visibleMenu, + visibleSpecialMenu, + preloadChatRoom + } +} diff --git a/src/hooks/useMessageSender.ts b/src/hooks/useMessageSender.ts new file mode 100644 index 0000000..b1a707d --- /dev/null +++ b/src/hooks/useMessageSender.ts @@ -0,0 +1,58 @@ +import { MessageStatusEnum, MittEnum } from '@/enums' +import { sendMessageWithChannel, type SendMessagePayload } from '@/utils/MessageSender' +import { useChatStore } from '@/stores/chat' +import { useMitt } from '@/hooks/useMitt' + +export type SendWithTrackingOptions = { + tempMsgId: string + payload: SendMessagePayload + /** 是否在发送成功后更新会话最近活跃时间,默认 true */ + updateSessionActive?: boolean + /** 是否在状态变更时滚动至底部,默认 true */ + scrollOnUpdate?: boolean + onSuccess?: (payload: any) => void + onError?: (msgId?: string) => void +} + +export const useMessageSender = () => { + const chatStore = useChatStore() + + const sendWithTracking = async (options: SendWithTrackingOptions) => { + const { tempMsgId, payload, updateSessionActive = true, scrollOnUpdate = true, onSuccess, onError } = options + + await sendMessageWithChannel({ + data: payload, + onSuccess: (response) => { + chatStore.updateMsg({ + msgId: response?.oldMsgId ?? tempMsgId, + status: MessageStatusEnum.SUCCESS, + newMsgId: response?.message?.id, + body: response?.message?.body, + timeBlock: response?.timeBlock + }) + if (scrollOnUpdate) { + useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM) + } + onSuccess?.(response) + }, + onError: (msgId) => { + chatStore.updateMsg({ + msgId: msgId || tempMsgId, + status: MessageStatusEnum.FAILED + }) + if (scrollOnUpdate) { + useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM) + } + onError?.(msgId) + } + }) + + if (updateSessionActive) { + chatStore.updateSessionLastActiveTime(payload.roomId) + } + } + + return { + sendWithTracking + } +} diff --git a/src/hooks/useMitt.ts b/src/hooks/useMitt.ts new file mode 100644 index 0000000..9a5247b --- /dev/null +++ b/src/hooks/useMitt.ts @@ -0,0 +1,23 @@ +import type { Emitter, Handler } from 'mitt' +import mitt from 'mitt' +import type { MittEnum } from '@/enums' + +const mittInstance: Emitter = mitt() + +export const useMitt = { + on: (event: MittEnum | string, handler: Handler) => { + mittInstance.on(event, handler) + // 仅当在有效的响应式作用域中时才注册清理 + if (getCurrentScope()) { + onUnmounted(() => { + mittInstance.off(event, handler) + }) + } + }, + emit: (event: MittEnum | string, data?: any) => { + mittInstance.emit(event, data) + }, + off: (event: MittEnum | string, handler: Handler) => { + mittInstance.off(event, handler) + } +} diff --git a/src/hooks/useMockMessage.ts b/src/hooks/useMockMessage.ts new file mode 100644 index 0000000..36427b4 --- /dev/null +++ b/src/hooks/useMockMessage.ts @@ -0,0 +1,60 @@ +import { computed } from 'vue' +import { MessageStatusEnum } from '@/enums' +import type { MessageType } from '@/services/types' +import { useGlobalStore } from '@/stores/global' +import { useGroupStore } from '@/stores/group' + +/** + * Mock 消息 Hook + */ +export const useMockMessage = () => { + const globalStore = useGlobalStore() + // 获取本地存储的用户信息 + const userInfo = computed(() => JSON.parse(localStorage.getItem('user') || '{}')) + + /** + * 模拟消息生成 + * @param type 消息类型 + * @param body 消息体 + * @param messageMarks 互动信息 + * @returns 服务器格式消息 + */ + const mockMessage = (type: number, body: any, messageMarks?: any): MessageType => { + const currentTimeStamp: number = Date.now() + const random: number = Math.floor(Math.random() * 15) + // 唯一id 后五位时间戳+随机数 + const uniqueId: string = String(currentTimeStamp + random).slice(-7) + const { uid = 0, name: username = '', avatar = '' } = userInfo.value || {} + const groupStore = useGroupStore() + + return { + fromUser: { + username, + uid, + avatar, + locPlace: groupStore.getUserInfo(uid)?.locPlace || 'xx' + }, + message: { + id: uniqueId, + roomId: globalStore.currentSessionRoomId, + sendTime: Number(currentTimeStamp), + type: type, + body, + messageMarks: { + likeCount: 0, + userLike: 0, + dislikeCount: 0, + userDislike: 0, + ...messageMarks + }, + status: MessageStatusEnum.PENDING + }, + sendTime: currentTimeStamp, + loading: true + } + } + + return { + mockMessage + } +} diff --git a/src/hooks/useMsgInput.ts b/src/hooks/useMsgInput.ts new file mode 100644 index 0000000..5439ed1 --- /dev/null +++ b/src/hooks/useMsgInput.ts @@ -0,0 +1,1373 @@ +import { readImage, readText } from '@tauri-apps/plugin-clipboard-manager' +import { useDebounceFn } from '@vueuse/core' +import pLimit from 'p-limit' +import { storeToRefs } from 'pinia' +import type { Ref } from 'vue' +import { nextTick } from 'vue' +import { LimitEnum, MessageStatusEnum, MittEnum, MsgEnum, UploadSceneEnum } from '@/enums' +import { useMitt } from '@/hooks/useMitt.ts' +import type { AIModel } from '@/services/types.ts' +import type { BaseUserItem } from '@/stores/cached.ts' +import { useChatStore } from '@/stores/chat.ts' +import { useGlobalStore } from '@/stores/global.ts' +import { useGroupStore } from '@/stores/group.ts' +import { useSettingStore } from '@/stores/setting.ts' +import { useMessageSender } from '@/hooks/useMessageSender' +import { messageStrategyMap } from '@/strategy/MessageStrategy.ts' +import { processClipboardImage } from '@/utils/ImageUtils.ts' +import { getReplyContent } from '@/utils/MessageReply.ts' +import { isPathUploadFile, type PathUploadFile, type UploadFile } from '@/utils/FileType' +import { isMac, isMobile, isWindows } from '@/utils/PlatformConstants' +import { type SelectionRange, useCommon } from './useCommon.ts' +import { globalFileUploadQueue } from './useFileUploadQueue.ts' +import { useTrigger } from './useTrigger' +import { UploadProviderEnum, useUpload } from './useUpload.ts' +import { useI18n } from 'vue-i18n' + +/** + * 光标管理器 + */ +export function useCursorManager() { + /** + * 记录当前光标范围 + */ + let cursorSelectionRange: SelectionRange | null = null + /** + * 记录当前编辑器的选取范围 + */ + const updateSelectionRange = (sr: SelectionRange | null) => { + cursorSelectionRange = sr + } + + const getCursorSelectionRange = () => { + return cursorSelectionRange + } + + /** + * 聚焦指定的编辑器元素 + * @param editor 可聚焦的编辑器元素 + */ + const focusOn = (editor: HTMLElement) => { + editor.focus() + + const selection = window.getSelection() + if (!selection) return + const selectionRange = getCursorSelectionRange() + if (!selectionRange) return + + const range = document.createRange() + range.selectNodeContents(editor) + range.collapse(false) + selection?.removeAllRanges() + selection?.addRange(selectionRange.range) + } + + return { getCursorSelectionRange, updateSelectionRange, focusOn } +} + +export const useMsgInput = (messageInputDom: Ref) => { + const { t } = useI18n() + const groupStore = useGroupStore() + const chatStore = useChatStore() + const globalStore = useGlobalStore() + const { getCursorSelectionRange, updateSelectionRange, focusOn } = useCursorManager() + const { triggerInputEvent, insertNode, getMessageContentType, getEditorRange, imgPaste, reply, userUid } = useCommon() + + const createRafProgressUpdater = (tempMsgId: string) => { + let scheduled = false + let latest = 0 + return (value: number) => { + latest = value + if (scheduled) return + scheduled = true + requestAnimationFrame(() => { + scheduled = false + chatStore.updateMsg({ + msgId: tempMsgId, + status: MessageStatusEnum.SENDING, + uploadProgress: latest + }) + }) + } + } + const settingStore = useSettingStore() + const { chat } = storeToRefs(settingStore) + /** 艾特选项的key */ + const chatKey = ref(chat.value.sendKey) + /** 输入框内容 */ + const msgInput = ref('') + /** 发送按钮是否禁用 */ + const disabledSend = computed(() => { + const plainText = stripHtml(msgInput.value) + return ( + plainText.length === 0 || + plainText + .replace(/ /g, ' ') + .replace(/\u00A0/g, ' ') + .trim().length === 0 + ) + }) + // @艾特弹出框 + const ait = ref(false) + const aitKey = ref('') + // AI弹出框 + const aiDialogVisible = ref(false) + const aiKeyword = ref('') + const aiModelList = ref([ + { + uid: '1', + type: 'Ollama', + name: 'DeepSeek-Chat', + value: 'deepseek-chat', + avatar: '/AI/deepseek.png' + }, + { + uid: '1b', + type: 'Ollama', + name: 'DeepSeek-Reasoner', + value: 'deepseek-reasoner', + avatar: '/AI/deepseek.png' + }, + { + uid: '2', + type: 'Ollama', + name: '通义千问-Plus', + value: 'qwen-plus', + avatar: '/AI/QW.png' + }, + { + uid: '3', + type: 'OpenAI', + name: 'ChatGPT-4', + value: 'ChatGPT-4', + avatar: '/AI/openai.svg' + } + ]) + // 使用计算属性获取分组后的数据 + const groupedAIModels = computed(() => { + if (aiKeyword.value && !isChinese.value) { + return aiModelList.value.filter((i) => i.name?.startsWith(aiKeyword.value)) + } else { + return aiModelList.value + } + }) + /** 记录当前选中的AI选项 key */ + // 允许为空是因为 / 触发面板关闭时需要清空当前选中项 + const selectedAIKey = ref(groupedAIModels.value[0]?.uid ?? null) + + // #话题弹出框 + const topicDialogVisible = ref(false) + const topicKeyword = ref('') + const topicList = ref([ + { + uid: '1', + label: '话题1', + value: '话题1' + }, + { + uid: '2', + label: '话题2', + value: '话题2' + } + ]) + + /** 是否正在输入拼音 */ + const isChinese = ref(false) + // 记录编辑器光标的位置 + const editorRange = ref<{ range: Range; selection: Selection } | null>(null) + /** @ 候选人列表 */ + const personList = computed(() => { + if (aitKey.value && !isChinese.value) { + return groupStore.userList.filter((user) => { + // 同时匹配群昵称(myName)和原名称(name) + const displayName = user.myName || user.name + return displayName?.startsWith(aitKey.value) && user.uid !== userUid.value + }) + } else { + // 过滤当前登录的用户 + return groupStore.userList.filter((user) => user.uid !== userUid.value) + } + }) + /** 记录当前选中的提及项 key */ + const selectedAitKey = ref(personList.value[0]?.uid ?? null) + /** 右键菜单列表 */ + const menuList = ref([ + { label: () => t('editor.menu.cut'), icon: 'screenshot', disabled: true }, + { label: () => t('editor.menu.copy'), icon: 'copy', disabled: true }, + { + label: () => t('editor.menu.paste'), + icon: 'intersection', + click: async () => { + try { + let imageProcessed = false + + // 使用Tauri的readImage API获取剪贴板图片 + const clipboardImage = await readImage().catch(() => null) + if (clipboardImage) { + try { + // 使用工具函数处理剪贴板图片数据 + const file = await processClipboardImage(clipboardImage) + + messageInputDom.value.focus() + nextTick(() => { + // 使用File对象触发缓存机制 + imgPaste(file, messageInputDom.value) + }) + + imageProcessed = true + } catch (error) { + console.error('Tauri处理图片数据失败:', error) + } + } + + // 如果没有图片,尝试读取文本 + if (!imageProcessed) { + const content = await readText().catch(() => null) + if (content) { + messageInputDom.value.focus() + nextTick(() => { + insertNode(MsgEnum.TEXT, content, {} as HTMLElement) + triggerInputEvent(messageInputDom.value) + }) + return + } else { + // 当既没有图片也没有文本时,显示提示信息 + alert('无法获取当前剪贴板中对于的类型的内容,请使用 ctrl/command + v') + } + } + } catch (error) { + console.error('粘贴失败:', error) + } + } + }, + { label: () => t('editor.menu.save_as'), icon: 'Importing', disabled: true }, + { label: () => t('editor.menu.select_all'), icon: 'check-one' } + ]) + + // 将 useTrigger 的初始化移到这里 + const { handleTrigger, resetAllStates } = useTrigger( + personList, + groupedAIModels, + topicList, + ait, + aitKey, + aiDialogVisible, + aiKeyword, + topicDialogVisible, + topicKeyword + ) + + watchEffect(() => { + chatKey.value = chat.value.sendKey + if (!ait.value && personList.value.length > 0) { + selectedAitKey.value = personList.value[0]?.uid + } + if (groupedAIModels.value.length === 0) { + // 没有可选模型时关闭弹层并清空游标,避免 Enter 键误触发 + selectedAIKey.value = null + aiDialogVisible.value = false + } else if (!aiDialogVisible.value) { + selectedAIKey.value = groupedAIModels.value[0]?.uid + } + // 如果输入框没有值就把回复内容清空 + if (msgInput.value === '') { + reply.value = { avatar: '', imgCount: 0, accountName: '', content: '', key: 0 } + } + }) + + watch(chatKey, (v) => { + chat.value.sendKey = v + }) + + /** + * 从HTML内容中提取 @ 用户的uid + * @param content HTML格式的消息内容 + * @param userList 用户列表 + * @returns 被 @ 用户的uid数组 + */ + const extractAtUserIds = (content: string, userList: (BaseUserItem & Partial<{ myName: string }>)[]): string[] => { + const atUserIds: string[] = [] + + const resolveUidByName = (rawName?: string | null) => { + const normalized = rawName?.trim() + if (!normalized) return undefined + + const matches = userList.filter((user) => { + const groupName = user.myName?.trim() + const originName = user.name?.trim() + return groupName === normalized || originName === normalized + }) + + if (matches.length === 1) { + return matches[0].uid + } + + return undefined + } + + // 创建临时DOM元素来解析HTML + const tempDiv = document.createElement('div') + tempDiv.innerHTML = content + + // 优先通过@标签节点提取 + // 优先读取带有uid的@节点,确保只统计真正选择过的成员 + const mentionNodes = tempDiv.querySelectorAll('#aitSpan, [data-ait-uid]') + mentionNodes.forEach((node) => { + const uid = node.dataset.aitUid + if (uid) { + atUserIds.push(uid) + return + } + const name = node.textContent?.replace(/^@/, '')?.trim() + if (!name) return + const resolvedUid = resolveUidByName(name) + if (resolvedUid) { + atUserIds.push(resolvedUid) + } + }) + + if (atUserIds.length > 0) { + return [...new Set(atUserIds)] + } + + // 获取纯文本内容 + const textContent = tempDiv.textContent || '' + + // 使用更精确的正则表达式匹配@用户 + // 匹配@后面的非空白字符,直到遇到空白字符或字符串结束 + const regex = /@([^\s]+)/g + const matches = textContent.match(regex) + + if (matches) { + matches.forEach((match) => { + const username = match.slice(1) // 移除@符号 + const resolvedUid = resolveUidByName(username) + if (resolvedUid) { + atUserIds.push(resolvedUid) + } + }) + } + + // 去重并返回 + return [...new Set(atUserIds)] + } + + // 在 HTML 字符串中安全解析为 Document 对象 + const parseHtmlSafely = (html: string) => { + if (!html) return null + + if (typeof DOMParser !== 'undefined') { + return new DOMParser().parseFromString(html, 'text/html') + } + + return null + } + + /** 去除html标签(用于鉴别回复时是否有输入内容) */ + const stripHtml = (html: string) => { + try { + // 检查是否是表情包 + if (html.includes('data-type="emoji"')) { + const doc = parseHtmlSafely(html) + const imgElement = doc?.querySelector('img[data-type]') + if (imgElement) { + const serverUrl = imgElement.dataset?.serverUrl + if (serverUrl) { + return (msgInput.value = serverUrl) + } + if (imgElement.src) { + return (msgInput.value = imgElement.src) + } + } + } + // 检查是否是视频 + if (html.includes('data-type="video"')) { + return html + } + + const doc = parseHtmlSafely(html) + if (!doc || !doc.body) { + let sanitized = html + let previous + do { + previous = sanitized + sanitized = sanitized.replace(/<[^>]*>/g, '') + } while (sanitized !== previous) + return sanitized.trim() + } + + const replyDiv = doc.querySelector('#replyDiv') + replyDiv?.remove() + + // 检查是否包含粘贴的图片(有temp-image id的图片元素) + const pastedImage = doc.querySelector('#temp-image') + if (pastedImage) { + return 'image' // 返回非空字符串,表示有内容 + } + + const textContent = doc.body.textContent?.trim() + if (textContent) return textContent + + const innerText = (doc.body as HTMLElement).innerText?.trim?.() + if (innerText) return innerText + + return '' + } catch (error) { + console.error('Error in stripHtml:', error) + return '' + } + } + + /** 重置输入框内容 */ + const resetInput = () => { + try { + msgInput.value = '' + messageInputDom.value.innerHTML = '' + // 确保完全清除所有空白字符 + messageInputDom.value.textContent = '' + reply.value = { avatar: '', imgCount: 0, accountName: '', content: '', key: 0 } + } catch (error) { + console.error('Error in resetInput:', error) + } + } + + const retainRawContent = (type: MsgEnum) => [MsgEnum.EMOJI, MsgEnum.IMAGE].includes(type) + + /** 处理发送信息事件 */ + // TODO 输入框中的内容当我切换消息的时候需要记录之前输入框的内容 (nyh -> 2024-03-01 07:03:43) + const { sendWithTracking } = useMessageSender() + + const send = async () => { + const targetRoomId = globalStore.currentSessionRoomId + // 判断输入框中的图片或者文件数量是否超过限制 + if (messageInputDom.value.querySelectorAll('img').length > LimitEnum.COM_COUNT) { + window.$message.warning(`一次性只能上传${LimitEnum.COM_COUNT}个文件或图片`) + return + } + const contentType = getMessageContentType(messageInputDom) + //根据消息类型获取消息处理策略 + const messageStrategy = messageStrategyMap[contentType] + if (!messageStrategy) { + window.$message.warning('暂不支持发送类型消息') + return + } + // 排除id="replyDiv"的元素的内容 + const replyDiv = messageInputDom.value.querySelector('#replyDiv') + if (replyDiv) { + replyDiv?.remove() + // 如果回复的内容是一个链接,那么需要保留链接数据 + if (!retainRawContent(contentType)) + msgInput.value = messageInputDom.value.innerHTML.replace(replyDiv.outerHTML, '') + } + const msg = await messageStrategy.getMsg(msgInput.value, reply.value) + const atUidList = extractAtUserIds(msgInput.value, groupStore.userList) + const tempMsgId = 'T' + Date.now().toString() + + // 根据消息类型创建消息体 + const messageBody = { + ...messageStrategy.buildMessageBody(msg, reply), + atUidList + } + + // 创建临时消息对象 + const tempMsg = await messageStrategy.buildMessageType(tempMsgId, messageBody, globalStore, userUid) + resetInput() + + tempMsg.message.status = MessageStatusEnum.SENDING + // 先添加到消息列表 + chatStore.pushMsg(tempMsg) + + // 设置发送状态的定时器 + chatStore.updateMsg({ + msgId: tempMsgId, + status: MessageStatusEnum.SENDING + }) + + // 移动端发送消息后重新聚焦输入框 + if (isMobile()) { + nextTick(() => { + focusOn(messageInputDom.value) + }) + } + + try { + // 如果是图片或表情消息,需要先上传文件 + if (msg.type === MsgEnum.IMAGE || msg.type === MsgEnum.EMOJI) { + // TODO: 如果使用的是默认上传方式,则uploadFile方法就会返回上传和下载链接了,但是使用七牛云上传方式则需要调用doUpload方法后才会返回对应的下载链接 + const { uploadUrl, downloadUrl, config } = await messageStrategy.uploadFile(msg.path, { + provider: UploadProviderEnum.QINIU + }) + const doUploadResult = await messageStrategy.doUpload(msg.path, uploadUrl, config) + // 更新消息体中的URL为服务器URL(判断使用的是七牛云还是默认上传方式),如果没有provider就默认赋值downloadUrl + messageBody.url = + config?.provider && config?.provider === UploadProviderEnum.QINIU ? doUploadResult?.qiniuUrl : downloadUrl + delete messageBody.path // 删除临时路径 + + // 更新临时消息的URL + chatStore.updateMsg({ + msgId: tempMsgId, + body: { + ...messageBody + }, + status: MessageStatusEnum.SENDING + }) + } else if (msg.type === MsgEnum.VIDEO) { + // 先上传缩略图(使用去重功能) + let uploadResult: string + if (messageStrategy.uploadThumbnail && messageStrategy.doUploadThumbnail) { + const thumbnailUploadInfo = await messageStrategy.uploadThumbnail(msg.thumbnail, { + provider: UploadProviderEnum.QINIU + }) + const thumbnailUploadResult = await messageStrategy.doUploadThumbnail( + msg.thumbnail, + thumbnailUploadInfo.uploadUrl, + thumbnailUploadInfo.config + ) + uploadResult = + thumbnailUploadInfo.config?.provider === UploadProviderEnum.QINIU + ? thumbnailUploadResult?.qiniuUrl || thumbnailUploadInfo.downloadUrl + : thumbnailUploadInfo.downloadUrl + } else { + uploadResult = await useUpload() + .uploadFile(msg.thumbnail, { + provider: UploadProviderEnum.QINIU, + scene: UploadSceneEnum.CHAT + }) + .then((UploadResult) => { + return UploadResult.downloadUrl + }) + } + + // 再上传视频文件 + const { uploadUrl, downloadUrl, config } = await messageStrategy.uploadFile(msg.path, { + provider: UploadProviderEnum.QINIU + }) + const doUploadResult = await messageStrategy.doUpload(msg.path, uploadUrl, config) + messageBody.url = + config?.provider && config?.provider === UploadProviderEnum.QINIU ? doUploadResult?.qiniuUrl : downloadUrl + delete messageBody.path // 删除临时路径 + messageBody.thumbUrl = uploadResult + messageBody.thumbSize = msg.thumbnail.size + messageBody.thumbWidth = 300 + messageBody.thumbHeight = 150 + + // 更新临时消息的URL + chatStore.updateMsg({ + msgId: tempMsgId, + body: { + ...messageBody + }, + status: MessageStatusEnum.SENDING + }) + } + await sendWithTracking({ + tempMsgId, + payload: { + id: tempMsgId, + roomId: targetRoomId, + msgType: msg.type, + body: messageBody + } + }) + + // 消息发送成功后释放预览URL + if ((msg.type === MsgEnum.IMAGE || msg.type === MsgEnum.EMOJI) && msg.url.startsWith('blob:')) { + URL.revokeObjectURL(msg.url) + } + + // 释放视频缩略图的本地预览URL + if (msg.type === MsgEnum.VIDEO && messageBody.thumbUrl && messageBody.thumbUrl.startsWith('blob:')) { + URL.revokeObjectURL(messageBody.thumbUrl) + } + } catch (error) { + console.error('消息发送失败:', error) + chatStore.updateMsg({ + msgId: tempMsgId, + status: MessageStatusEnum.FAILED + }) + + // 释放预览URL + if ((msg.type === MsgEnum.IMAGE || msg.type === MsgEnum.EMOJI) && msg.url.startsWith('blob:')) { + URL.revokeObjectURL(msg.url) + } + + // 释放视频缩略图的本地预览URL + if (msg.type === MsgEnum.VIDEO && messageBody.thumbUrl && messageBody.thumbUrl.startsWith('blob:')) { + URL.revokeObjectURL(messageBody.thumbUrl) + } + } + } + + /** 当输入框手动输入值的时候触发input事件(使用vueUse的防抖) */ + const handleInput = useDebounceFn(async (e: Event) => { + const inputElement = e.target as HTMLInputElement + + // 检查输入框内容,如果只有空白字符、br标签或空元素则清空 + const textContent = inputElement.textContent || '' + const innerHTML = inputElement.innerHTML || '' + + // 检查是否有实际内容(图片、视频、表情等) + const hasMediaContent = + innerHTML.includes('' || + innerHTML === '

' || + innerHTML.match(/^(
|

<\/div>|


<\/p>|\s)*$/) + + // 只有在没有媒体内容且没有有效文本时才清空 + if (!hasMediaContent && (cleanText === '' || hasOnlyEmptyElements)) { + inputElement.innerHTML = '' + inputElement.textContent = '' + msgInput.value = '' + // 输入框为空时重置所有状态 + resetAllStates() + return + } + msgInput.value = inputElement.innerHTML || '' + + /** 获取当前光标所在的节点和文本内容 */ + const { range, selection } = getEditorRange()! + if (!range || !selection) { + resetAllStates() + return + } + + /** 获取当前节点 */ + const curNode = range.endContainer + /** 判断当前节点是否是文本节点 */ + if (!curNode || !curNode.textContent || curNode.nodeName !== '#text') { + resetAllStates() + return + } + + /** 获取当前光标位置和文本内容 */ + const cursorPosition = selection.focusOffset + const text = curNode.textContent + + await handleTrigger(text, cursorPosition, { range, selection, keyword: '' }) + }, 0) + + /** input的keydown事件 */ + const inputKeyDown = async (e: KeyboardEvent) => { + if (disabledSend.value) { + e.preventDefault() + e.stopPropagation() + resetInput() + return + } + + // 当 ait 或 aiDialogVisible 为 true 时,阻止默认行为 + if (ait.value || aiDialogVisible.value) { + e?.preventDefault() + return + } + + // 正在输入拼音,并且是macos系统 + if (isChinese.value && isMac()) { + return + } + const isWindowsPlatform = isWindows() + const isEnterKey = e.key === 'Enter' + const isCtrlOrMetaKey = isWindowsPlatform ? e.ctrlKey : e.metaKey + + const sendKeyIsEnter = chat.value.sendKey === 'Enter' + const sendKeyIsCtrlEnter = chat.value.sendKey === `${isWindowsPlatform ? 'Ctrl' : '⌘'}+Enter` + + // 如果当前的系统是mac,我需要判断当前的chat.value.sendKey是否是Enter,再判断当前是否是按下⌘+Enter + if (!isWindowsPlatform && chat.value.sendKey === 'Enter' && e.metaKey && e.key === 'Enter') { + // 就进行换行操作 + e.preventDefault() + insertNode(MsgEnum.TEXT, '\n', {} as HTMLElement) + triggerInputEvent(messageInputDom.value) + } + if (msgInput.value === '' || msgInput.value.trim() === '' || ait.value) { + e?.preventDefault() + return + } + if (!isWindowsPlatform && e.ctrlKey && isEnterKey && sendKeyIsEnter) { + e?.preventDefault() + return + } + if ((sendKeyIsEnter && isEnterKey && !isCtrlOrMetaKey) || (sendKeyIsCtrlEnter && isCtrlOrMetaKey && isEnterKey)) { + e?.preventDefault() + // 触发form提交而不是直接调用send + const form = document.getElementById('message-form') as HTMLFormElement + if (form) { + form.requestSubmit() + } + resetAllStates() + } + } + + /** 处理点击 @ 提及框事件 */ + const handleAit = (item: BaseUserItem) => { + // 如果正在输入拼音,不发送消息 + if (isChinese.value) { + return + } + // 先确保输入框获得焦点 + focusOn(messageInputDom.value) + // 先获取并保存当前的编辑器范围 + const { range: currentRange, selection: currentSelection } = getEditorRange()! + editorRange.value = { range: currentRange, selection: currentSelection } + + const myEditorRange = editorRange?.value?.range + /** 获取光标所在位置的文本节点 */ + const textNode = myEditorRange?.endContainer + + // 如果有文本节点,说明是通过输入框@触发的 + if (textNode) { + /** 获取光标在所在文本节点中的偏移位置 */ + const endOffset = myEditorRange?.endOffset + /** 获取文本节点的值,并将其转换为字符串类型 */ + const textNodeValue = textNode?.nodeValue as string + /** 使用正则表达式匹配@符号之后获取到的文本节点的值 */ + const expRes = /@([^@]*)$/.exec(textNodeValue) + if (expRes) { + /** 设置范围的起始位置为文本节点中@符号的位置 */ + currentRange.setStart(textNode, expRes.index) + /** 设置范围的结束位置为光标的位置 */ + currentRange.setEnd(textNode, endOffset!) + } + } + + // 获取用户的完整信息,优先使用群昵称(myName),与渲染逻辑保持一致 + const userInfo = groupStore.getUserInfo(item.uid) + const displayName = userInfo?.myName || item.name + + // 无论是哪种情况,都在当前光标位置插入@提及 + insertNode( + MsgEnum.AIT, + { + name: displayName, + uid: item.uid + }, + {} as HTMLElement + ) + triggerInputEvent(messageInputDom.value) + ait.value = false + } + + /** 处理点击 / 提及框事件 */ + const handleAI = (_item: any) => { + // 如果正在输入拼音,不发送消息 + if (isChinese.value) { + return + } + + // TODO: (临时展示) 显示AI对接中的提示 + window.$message.info('当前ai正在对接,敬请期待') + // 关闭AI选择弹窗 + aiDialogVisible.value = false + + // 清理输入框中的/触发词 + // 先确保输入框获得焦点 + focusOn(messageInputDom.value) + // 先获取并保存当前的编辑器范围 + const { range: currentRange, selection: currentSelection } = getEditorRange()! + editorRange.value = { range: currentRange, selection: currentSelection } + + const myEditorRange = editorRange?.value?.range + /** 获取光标所在位置的文本节点 */ + const textNode = myEditorRange?.endContainer + + // 如果有文本节点,说明是通过输入框 / 触发的 + if (textNode) { + /** 获取光标在所在文本节点中的偏移位置 */ + const endOffset = myEditorRange?.endOffset + /** 获取文本节点的值,并将其转换为字符串类型 */ + const textNodeValue = textNode?.nodeValue as string + /** 使用正则表达式匹配 / 符号之后获取到的文本节点的值 */ + const expRes = /([^/]*)$/.exec(textNodeValue) + if (expRes) { + /** 设置范围的起始位置为文本节点中 / 符号的位置 */ + currentRange.setStart(textNode, expRes.index) + /** 设置范围的结束位置为光标的位置 */ + currentRange.setEnd(textNode, endOffset!) + //TODO: (临时删除) 删除/触发词 + currentRange.deleteContents() + triggerInputEvent(messageInputDom.value) + } + } + } + + // ==================== 通用文件处理函数 ==================== + const processGenericFile = async ( + file: File, + tempMsgId: string, + messageStrategy: any, + targetRoomId: string + ): Promise => { + const msg = await messageStrategy.getMsg('', reply, [file]) + const messageBody = messageStrategy.buildMessageBody(msg, reply) + + const tempMsg = messageStrategy.buildMessageType(tempMsgId, { ...messageBody, url: '' }, globalStore, userUid) + tempMsg.message.roomId = targetRoomId + tempMsg.message.status = MessageStatusEnum.SENDING + chatStore.pushMsg(tempMsg) + + let isProgressActive = true + const cleanup = () => { + isProgressActive = false + } + + try { + const updateProgress = createRafProgressUpdater(tempMsgId) + const progressCallback = (pct: number) => { + if (!isProgressActive) return + updateProgress(pct) + } + + const { uploadUrl, downloadUrl, config } = await messageStrategy.uploadFile(msg.path, { + provider: UploadProviderEnum.QINIU + }) + const doUploadResult = await messageStrategy.doUpload(msg.path, uploadUrl, { ...config, progressCallback }) + + cleanup() + + messageBody.url = config?.provider === UploadProviderEnum.QINIU ? doUploadResult?.qiniuUrl : downloadUrl + delete messageBody.path + + chatStore.updateMsg({ + msgId: tempMsgId, + body: messageBody, + status: MessageStatusEnum.SENDING + }) + + await sendWithTracking({ + tempMsgId, + payload: { + id: tempMsgId, + roomId: targetRoomId, + msgType: MsgEnum.FILE, + body: messageBody + } + }) + } catch (error) { + cleanup() + throw error + } + } + + const processGenericPathFile = async ( + file: PathUploadFile, + tempMsgId: string, + messageStrategy: any, + targetRoomId: string + ): Promise => { + const MAX_UPLOAD_SIZE = 500 * 1024 * 1024 + if (file.size > MAX_UPLOAD_SIZE) { + throw new Error('文件大小不能超过500MB') + } + + const msg = { + type: MsgEnum.FILE, + path: file.path, + fileName: file.name, + size: file.size, + mimeType: file.type, + reply: reply.value.content + ? { + content: reply.value.content, + key: reply.value.key + } + : undefined + } + + const messageBody = messageStrategy.buildMessageBody(msg, reply) + + const tempMsg = messageStrategy.buildMessageType(tempMsgId, { ...messageBody, url: '' }, globalStore, userUid) + tempMsg.message.roomId = targetRoomId + tempMsg.message.status = MessageStatusEnum.SENDING + chatStore.pushMsg(tempMsg) + + let isProgressActive = true + const cleanup = () => { + isProgressActive = false + } + + try { + const updateProgress = createRafProgressUpdater(tempMsgId) + const progressCallback = (pct: number) => { + if (!isProgressActive) return + updateProgress(pct) + } + + const { uploadUrl, downloadUrl, config } = await messageStrategy.uploadFile(msg.path, { + provider: UploadProviderEnum.QINIU + }) + const doUploadResult = await messageStrategy.doUpload(msg.path, uploadUrl, { ...config, progressCallback }) + + cleanup() + + messageBody.url = config?.provider === UploadProviderEnum.QINIU ? doUploadResult?.qiniuUrl : downloadUrl + delete messageBody.path + + chatStore.updateMsg({ + msgId: tempMsgId, + body: messageBody, + status: MessageStatusEnum.SENDING + }) + + await sendWithTracking({ + tempMsgId, + payload: { + id: tempMsgId, + roomId: targetRoomId, + msgType: MsgEnum.FILE, + body: messageBody + } + }) + } catch (error) { + cleanup() + throw error + } + } + + onMounted(async () => { + useMitt.on(MittEnum.RE_EDIT, async (event: string) => { + messageInputDom.value.focus() + await nextTick(() => { + messageInputDom.value.innerHTML = event + msgInput.value = event + // 将光标设置到内容末尾 + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(messageInputDom.value) + range.collapse(false) + selection?.removeAllRanges() + selection?.addRange(range) + }) + }) + + if (messageInputDom.value) { + /** 正在输入拼音时触发 */ + messageInputDom.value.addEventListener('compositionstart', () => { + isChinese.value = true + }) + /** 结束输入拼音时触发 */ + messageInputDom.value.addEventListener('compositionend', (e: CompositionEvent) => { + setTimeout(() => { + isChinese.value = false + aitKey.value = e.data + aiKeyword.value = e.data + }, 10) + }) + } + /** 监听回复信息的传递 */ + useMitt.on(MittEnum.REPLY_MEG, (event: any) => { + // 如果输入框不存在,直接返回 + if (!messageInputDom.value) return + + try { + const userInfo = groupStore.getUserInfo(event.fromUser.uid)! + const accountName = userInfo.name + const avatar = userInfo.avatar + + // 步骤1: 确保输入框先获得焦点 + focusOn(messageInputDom.value) + + // 步骤2: 完全清理现有的回复状态 + // 如果已经有回复消息,需要先移除现有的回复框 + const existingReplyDiv = document.getElementById('replyDiv') + if (existingReplyDiv) { + existingReplyDiv.remove() + } + + // 始终重置reply状态,确保完全清除之前的回复状态 + reply.value = { avatar: '', imgCount: 0, accountName: '', content: '', key: 0 } + + // 步骤3: 处理回复内容 + const content = getReplyContent(event.message) + + // 步骤4: 设置新的回复内容 + reply.value = { + imgCount: 0, + avatar: avatar, + accountName: accountName, + content: content, + key: event.message.id + } + + // 步骤5: 在DOM更新后插入回复框 + nextTick().then(() => { + try { + // 再次确保输入框获得焦点 + focusOn(messageInputDom.value) + + // 插入回复框 + insertNode( + MsgEnum.REPLY, + { avatar: avatar, accountName: accountName, content: reply.value.content }, + {} as HTMLElement + ) + + // 确保光标位置在正确的位置 + updateSelectionRange(getEditorRange()) + focusOn(messageInputDom.value) + + // 触发input事件以更新UI + triggerInputEvent(messageInputDom.value) + } catch (err) { + console.error('插入回复框时错误:', err) + } + }) + } catch (err) { + console.error('回复_meg处理程序错误:', err) + } + }) + }) + + /** + * 发送文件的函数(优化版 - 并发处理,逐个显示) + * @param files 要发送的文件数组 + */ + const sendFilesDirect = async (files: UploadFile[]) => { + const targetRoomId = globalStore.currentSessionRoomId + + // 初始化文件上传队列 + globalFileUploadQueue.initQueue(files) + + const baseTempId = Date.now() + const jobs = files.map((file, index) => { + const fileId = globalFileUploadQueue.queue.items[index]?.id + const tempMsgId = String(baseTempId * 1000 + index) + + if (isPathUploadFile(file)) { + return { file, fileId, tempMsgId } + } + return { file, fileId, tempMsgId } + }) + + // 先把「文件消息」占位插入消息列表,避免大文件准备/上传前的空窗期 + const fileStrategy = messageStrategyMap[MsgEnum.FILE] + const replyPayload = reply.value.content + ? { + body: reply.value.content, + id: reply.value.key, + username: reply.value.accountName, + type: MsgEnum.FILE + } + : undefined + + for (const job of jobs) { + const tempMsg = fileStrategy.buildMessageType( + job.tempMsgId, + { + url: '', + fileName: job.file.name, + size: job.file.size, + mimeType: job.file.type, + replyMsgId: reply.value.content ? reply.value.key : undefined, + reply: replyPayload + }, + globalStore, + userUid + ) + tempMsg.message.roomId = targetRoomId + tempMsg.message.status = MessageStatusEnum.SENDING + tempMsg.uploadProgress = 0 + void chatStore.pushMsg(tempMsg) + } + useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM) + + // 让 UI 先渲染占位消息,再开始耗时上传/分片逻辑 + await nextTick() + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()) + }) + + // 控制并发数,避免同时上传过多文件拖慢渲染与交互 + const limit = pLimit(3) + + // 并发处理所有文件 + const tasks = jobs.map((job) => { + return limit(async () => { + const tempMsgId = job.tempMsgId + + try { + // 更新队列状态 + if (job.fileId) { + globalFileUploadQueue.updateFileStatus(job.fileId, 'uploading', 0) + } + + if (isPathUploadFile(job.file)) { + const messageStrategy = messageStrategyMap[MsgEnum.FILE] + await processGenericPathFile(job.file, tempMsgId, messageStrategy, targetRoomId) + } else { + const messageStrategy = messageStrategyMap[MsgEnum.FILE] + await processGenericFile(job.file, tempMsgId, messageStrategy, targetRoomId) + } + + // 成功 - 更新队列状态 + if (job.fileId) { + globalFileUploadQueue.updateFileStatus(job.fileId, 'completed', 100) + } + } catch (error) { + console.error(`${job.file.name} 发送失败:`, error) + + // 失败 - 更新队列和消息状态 + if (job.fileId) { + globalFileUploadQueue.updateFileStatus(job.fileId, 'failed', 0) + } + + if (tempMsgId) { + chatStore.updateMsg({ + msgId: tempMsgId, + status: MessageStatusEnum.FAILED + }) + } + + window.$message.error(`${job.file.name} 发送失败`) + } + }) + }) + + // 等待所有文件完成(不阻塞UI,文件会逐个显示成功) + await Promise.allSettled(tasks) + + // 检查输入框中是否有图片需要自动发送 + try { + await nextTick() + if ( + messageInputDom.value?.querySelectorAll('img').length > 0 && + globalStore.currentSessionRoomId === targetRoomId + ) { + const contentType = getMessageContentType(messageInputDom) + if (contentType === MsgEnum.IMAGE || contentType === MsgEnum.EMOJI) { + await send() + } + } + } catch (error) { + console.error('自动发送输入框图片失败:', error) + } + } + + const sendVoiceDirect = async (voiceData: any) => { + const targetRoomId = globalStore.currentSessionRoomId + try { + // 创建语音消息数据 + const msg = { + type: MsgEnum.VOICE, + path: voiceData.localPath, // 本地路径用于上传 + url: `asset://${voiceData.localPath}`, // 本地预览URL + size: voiceData.size, + duration: voiceData.duration, + filename: voiceData.filename + } + const tempMsgId = 'T' + Date.now().toString() + + // 创建消息体(初始使用本地路径) + const messageBody = { + url: msg.url, + path: msg.path, + size: msg.size, + second: Math.round(msg.duration) + } + + // 创建临时消息对象 + const userInfo = groupStore.getUserInfo(userUid.value) + const tempMsg = { + fromUser: { + uid: String(userUid.value || 0), + username: userInfo?.name || '', + avatar: userInfo?.avatar || '', + locPlace: userInfo?.locPlace || '' + }, + message: { + id: tempMsgId, + roomId: targetRoomId, + sendTime: Date.now(), + status: MessageStatusEnum.PENDING, + type: MsgEnum.VOICE, + body: messageBody, + messageMarks: {} + }, + sendTime: Date.now(), + loading: false + } + + // 添加到消息列表(显示本地预览) + chatStore.pushMsg(tempMsg) + + // 设置发送状态的定时器 + chatStore.updateMsg({ + msgId: tempMsgId, + status: MessageStatusEnum.SENDING + }) + + try { + // 获取语音消息策略 + const messageStrategy = messageStrategyMap[MsgEnum.VOICE] + // 上传语音文件到七牛云 + const { uploadUrl, downloadUrl, config } = await messageStrategy.uploadFile(msg.path, { + provider: UploadProviderEnum.QINIU + }) + const doUploadResult = await messageStrategy.doUpload(msg.path, uploadUrl, config) + + // 更新消息体中的URL为服务器URL + const finalUrl = + config?.provider && config?.provider === UploadProviderEnum.QINIU ? doUploadResult?.qiniuUrl : downloadUrl + messageBody.url = finalUrl || '' + delete messageBody.path // 删除临时路径 + + // 更新临时消息的URL + chatStore.updateMsg({ + msgId: tempMsgId, + body: { + ...messageBody + }, + status: MessageStatusEnum.SENDING + }) + + await sendWithTracking({ + tempMsgId, + payload: { + id: tempMsgId, + roomId: targetRoomId, + msgType: MsgEnum.VOICE, + body: messageBody + } + }) + + // 释放本地预览URL + if (msg.url.startsWith('asset://')) { + // asset:// 协议不需要手动释放 + } + } catch (uploadError) { + chatStore.updateMsg({ + msgId: tempMsgId, + status: MessageStatusEnum.FAILED + }) + throw uploadError + } + } catch (error) { + console.error('语音消息发送失败:', error) + } + } + + /** + * 发送地图的函数 + * @param locationData 地图数据 + */ + const sendLocationDirect = async (locationData: any) => { + const targetRoomId = globalStore.currentSessionRoomId + try { + const tempMsgId = 'T' + Date.now().toString() + const messageStrategy = messageStrategyMap[MsgEnum.LOCATION] + + // 将位置数据转换为JSON字符串作为消息内容 + const content = JSON.stringify(locationData) + + // 构建位置消息 + const msg = messageStrategy.getMsg(content, reply.value) + const messageBody = messageStrategy.buildMessageBody(msg, reply) + + // 创建临时消息对象 + const tempMsg = messageStrategy.buildMessageType(tempMsgId, messageBody, globalStore, userUid) + tempMsg.message.status = MessageStatusEnum.SENDING + + // 添加到消息列表 + chatStore.pushMsg(tempMsg) + + // 设置发送状态 + chatStore.updateMsg({ + msgId: tempMsgId, + status: MessageStatusEnum.SENDING + }) + + await sendWithTracking({ + tempMsgId, + payload: { + id: tempMsgId, + roomId: targetRoomId, + msgType: MsgEnum.LOCATION, + body: messageBody + } + }) + } catch (error) { + console.error('位置消息发送失败:', error) + } + } + + /** + * 直接发送表情包的函数(移动端专用) + * @param emojiUrl 表情包URL + */ + const sendEmojiDirect = async (emojiUrl: string) => { + const targetRoomId = globalStore.currentSessionRoomId + + try { + const tempMsgId = 'T' + Date.now().toString() + + const messageStrategy = messageStrategyMap[MsgEnum.EMOJI] + + // 构建表情包消息 + const msg = messageStrategy.getMsg(emojiUrl, reply.value) + const messageBody = messageStrategy.buildMessageBody(msg, reply) + + // 创建临时消息对象 + const tempMsg = messageStrategy.buildMessageType(tempMsgId, messageBody, globalStore, userUid) + tempMsg.message.status = MessageStatusEnum.SENDING + + // 添加到消息列表 + chatStore.pushMsg(tempMsg) + + // 设置发送状态 + chatStore.updateMsg({ + msgId: tempMsgId, + status: MessageStatusEnum.SENDING + }) + + await sendWithTracking({ + tempMsgId, + payload: { + id: tempMsgId, + roomId: targetRoomId, + msgType: MsgEnum.EMOJI, + body: messageBody + } + }) + } catch (error) { + console.error('[useMsgInput] 表情包消息发送失败:', error) + throw error + } + } + + return { + imgPaste, + inputKeyDown, + handleAit, + handleAI, + handleInput, + send, + stripHtml, + sendLocationDirect, + sendFilesDirect, + sendVoiceDirect, + sendEmojiDirect, + personList, + ait, + aitKey, + msgInput, + chatKey, + menuList, + selectedAitKey, + reply, + disabledSend, + aiDialogVisible, + aiKeyword, + aiModelList, + selectedAIKey, + topicDialogVisible, + topicKeyword, + topicList, + groupedAIModels, + getCursorSelectionRange, + updateSelectionRange: () => updateSelectionRange(getEditorRange()), + focusOn + } +} diff --git a/src/hooks/useMyRoomInfoUpdater.ts b/src/hooks/useMyRoomInfoUpdater.ts new file mode 100644 index 0000000..dede8b1 --- /dev/null +++ b/src/hooks/useMyRoomInfoUpdater.ts @@ -0,0 +1,75 @@ +import { useCachedStore } from '@/stores/cached' +import { useChatStore } from '@/stores/chat' +import { useGroupStore } from '@/stores/group' +import { useUserStore } from '@/stores/user.ts' +import { updateMyRoomInfo } from '@/utils/ImRequestUtils' + +type UpdatePayload = { + roomId: string + myName: string + remark: string +} + +// 记录已经同步过成员列表的房间ID,避免在同一次会话中重复触发同步 +const syncedRoomMembers = new Set() + +export const useMyRoomInfoUpdater = () => { + const cacheStore = useCachedStore() + const chatStore = useChatStore() + const groupStore = useGroupStore() + const userStore = useUserStore() + + const persistMyRoomInfo = async ({ roomId, myName, remark }: UpdatePayload) => { + const payload = { + id: roomId, + myName, + remark + } + + // 第一次编辑某个房间的昵称/备注时,先尝试同步一次成员列表,保证本地存在对应记录 + if (!syncedRoomMembers.has(roomId)) { + const synced = await cacheStore.syncRoomMembersToLocal(roomId) + if (synced) { + syncedRoomMembers.add(roomId) + } + } + + let updated = await cacheStore.updateMyRoomInfo(payload) + if (!updated) { + // 如果仍然失败,说明本地缓存可能过期,清除标记并再次强制同步后重试 + syncedRoomMembers.delete(roomId) + const synced = await cacheStore.syncRoomMembersToLocal(roomId) + if (synced) { + syncedRoomMembers.add(roomId) + updated = await cacheStore.updateMyRoomInfo(payload) + } + } + await updateMyRoomInfo(payload) + + groupStore.myNameInCurrentGroup = myName + if (groupStore.countInfo) { + groupStore.countInfo.remark = remark + } + chatStore.updateSession(roomId, { remark }) + } + + const resolveMyRoomNickname = ({ roomId, myName }: { roomId?: string; myName?: string }) => { + if (myName) { + return myName + } + if (!roomId) { + return '' + } + const currentUid = userStore.userInfo?.uid + if (!currentUid) { + return '' + } + const currentUser = groupStore.getUser(roomId, currentUid) ?? groupStore.getUserInfo(currentUid, roomId) + return currentUser?.name || userStore.userInfo?.name || '' + } + + return { + persistMyRoomInfo, + resolveMyRoomNickname + } +} diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts new file mode 100644 index 0000000..588cf84 --- /dev/null +++ b/src/hooks/useNetworkStatus.ts @@ -0,0 +1,145 @@ +/** + * 网络状态监测钩子 + */ +import { createSharedComposable, tryOnScopeDispose } from '@vueuse/core' +import { listen, type UnlistenFn } from '@tauri-apps/api/event' +import rustWebSocketClient, { ConnectionState } from '@/services/webSocketRust' + +const useSharedNetworkStatus = createSharedComposable(() => { + // 网络状态 - 基于浏览器navigator.onLine + const browserOnline = ref(typeof navigator !== 'undefined' ? navigator.onLine : true) + const wsState = ref(null) + const wsStatus = computed<'unknown' | 'connected' | 'connecting' | 'disconnected' | 'error'>(() => { + switch (wsState.value) { + case ConnectionState.CONNECTED: + return 'connected' + case ConnectionState.CONNECTING: + case ConnectionState.RECONNECTING: + return 'connecting' + case ConnectionState.DISCONNECTED: + return 'disconnected' + case ConnectionState.ERROR: + return 'error' + default: + return 'unknown' + } + }) + const wsOnline = computed(() => { + if (wsState.value === null) return null + return wsState.value === ConnectionState.CONNECTED + }) + const isWsConnecting = computed(() => wsStatus.value === 'connecting') + const isOnline = computed(() => { + if (!browserOnline.value) return false + if (wsOnline.value === null) return true + return wsOnline.value + }) + const isListening = ref(false) + let wsUnlisten: UnlistenFn | null = null + + // 监听浏览器网络状态变化 + const handleOnline = () => { + browserOnline.value = true + } + + const handleOffline = () => { + browserOnline.value = false + } + + const isTauriContext = () => { + if (typeof window === 'undefined') return false + return Boolean((window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__) + } + + const resolveWsState = (state: unknown): ConnectionState | null => { + if (!state) return null + const normalized = String(state).toUpperCase() + switch (normalized) { + case ConnectionState.CONNECTED: + return ConnectionState.CONNECTED + case ConnectionState.CONNECTING: + return ConnectionState.CONNECTING + case ConnectionState.RECONNECTING: + return ConnectionState.RECONNECTING + case ConnectionState.DISCONNECTED: + return ConnectionState.DISCONNECTED + case ConnectionState.ERROR: + return ConnectionState.ERROR + default: + return null + } + } + + const updateWsState = (state: unknown) => { + const resolved = resolveWsState(state) + if (!resolved) return + wsState.value = resolved + } + + const initWsListener = async () => { + if (!isTauriContext() || wsUnlisten) return + try { + wsUnlisten = await listen('websocket-event', (event) => { + const payload = event.payload as { type?: string; state?: string } + if (!payload) return + if (payload.type !== 'connectionStateChanged') return + updateWsState(payload.state) + }) + } catch (error) { + console.warn('[Network] websocket-event 监听失败:', error) + } + } + + const initWsState = async () => { + if (!isTauriContext()) return + try { + const state = await rustWebSocketClient.getState() + updateWsState(state) + } catch (error) { + console.warn('[Network] WebSocket 状态获取失败:', error) + } + } + + // 初始化网络状态监听 + const initNetworkListener = () => { + if (isListening.value || typeof window === 'undefined') return + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + isListening.value = true + void initWsListener() + void initWsState() + } + + // 清理网络状态监听 + const cleanupNetworkListener = () => { + if (!isListening.value || typeof window === 'undefined') return + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + isListening.value = false + if (wsUnlisten) { + wsUnlisten() + wsUnlisten = null + } + } + + // 自动初始化监听器 + initNetworkListener() + + // 共享实例在最后一个订阅销毁时清理 + tryOnScopeDispose(() => { + cleanupNetworkListener() + }) + + return { + isOnline, + browserOnline, + wsOnline, + wsStatus, + wsState, + isWsConnecting, + initNetworkListener, + cleanupNetworkListener + } +}) + +export const useNetworkStatus = () => useSharedNetworkStatus() diff --git a/src/hooks/useOnlineStatus.ts b/src/hooks/useOnlineStatus.ts new file mode 100644 index 0000000..2d86307 --- /dev/null +++ b/src/hooks/useOnlineStatus.ts @@ -0,0 +1,79 @@ +import { storeToRefs } from 'pinia' +import { useI18n } from 'vue-i18n' +import { OnlineEnum } from '@/enums' +import { useGroupStore } from '@/stores/group' +import { useUserStore } from '@/stores/user' +import { useUserStatusStore } from '@/stores/userStatus' + +// 在线状态管理(仅是在线和离线) +export const useOnlineStatus = (uid?: ComputedRef | Ref) => { + const { t } = useI18n() + const userStore = useUserStore() + const groupStore = useGroupStore() + const userStatusStore = useUserStatusStore() + const { currentState } = storeToRefs(userStatusStore) + + // 如果传入了uid参数,使用传入的uid对应的用户信息;否则使用当前登录用户的信息 + const currentUser = uid + ? computed(() => (uid.value ? groupStore.getUserInfo(uid.value) : undefined)) + : computed(() => { + // 没有传入uid时,从groupStore获取当前用户信息以获得activeStatus + const currentUid = userStore.userInfo?.uid + return currentUid ? groupStore.getUserInfo(currentUid) : undefined + }) + + // userStateId优先从userStore获取(保证响应式更新),如果没有则从currentUser获取 + const userStateId = uid + ? computed(() => currentUser.value?.userStateId) + : computed(() => userStore.userInfo?.userStateId) + + const activeStatus = computed(() => currentUser.value?.activeStatus ?? OnlineEnum.OFFLINE) + + const hasCustomState = computed(() => { + const stateId = userStateId.value + // 只有 '0' 表示清空状态(无自定义状态),其他都是自定义状态 + return !!stateId && stateId !== '0' + }) + + // 获取用户的状态信息 + const userStatus = computed(() => { + if (!userStateId.value) return null + return userStatusStore.stateList.find((state: { id: string }) => state.id === userStateId.value) + }) + + const isOnline = computed(() => activeStatus.value === OnlineEnum.ONLINE) + + const statusIcon = computed(() => { + if (hasCustomState.value && userStatus.value?.url) { + return userStatus.value.url + } + return isOnline.value ? '/status/online.png' : '/status/offline.png' + }) + + const statusTitle = computed(() => { + if (hasCustomState.value && userStatus.value?.title) { + const key = `auth.onlineStatus.states.${userStatus.value.title}` + const translated = t(key) + return translated === key ? userStatus.value.title : translated + } + return isOnline.value ? t('home.profile_card.status.online') : t('home.profile_card.status.offline') + }) + + const statusBgColor = computed(() => { + if (hasCustomState.value && userStatus.value?.bgColor) { + return userStatus.value.bgColor + } + return isOnline.value ? 'rgba(26, 178, 146, 0.4)' : 'rgba(144, 144, 144, 0.4)' + }) + + return { + currentState, + activeStatus, + statusIcon, + statusTitle, + statusBgColor, + isOnline, + hasCustomState, + userStatus + } +} diff --git a/src/hooks/useOverlayController.ts b/src/hooks/useOverlayController.ts new file mode 100644 index 0000000..e98a0d6 --- /dev/null +++ b/src/hooks/useOverlayController.ts @@ -0,0 +1,103 @@ +import type { ComputedRef, Ref } from 'vue' + +type OverlayControllerOptions = { + /** 是否为首次登录(需要阻塞首屏) */ + isInitialSync: ComputedRef + /** 进度条数值(会被本工具在就绪时自动置为 100) */ + progress: Ref + /** 异步子模块总数,默认 3 */ + asyncTotal?: number + /** 最小展示时长,默认 600ms */ + minDisplayMs?: number +} + +/** + * 控制首次登录的遮罩与 LoadingSpinner: + * - 只有 isInitialSync 为 true 时才显示遮罩 + * - 等待异步子模块全部加载完毕后,将进度置为 100%,再等待 minDisplayMs 后隐藏 + * - 非首次登录直接不显示遮罩 + */ +export const useOverlayController = (options: OverlayControllerOptions) => { + const asyncTarget = options.asyncTotal ?? 3 + const minDisplayMs = options.minDisplayMs ?? 600 + + // 是否已经触发过“首次登录遮罩”逻辑。一旦触发,直到流程结束才会隐藏。 + const activated = ref(options.isInitialSync.value) + const overlayVisible = ref(activated.value) + const asyncLoadedCount = ref(0) + const asyncComponentsReady = computed(() => asyncLoadedCount.value >= asyncTarget) + + let hideTimer: number | null = null + + const clearHideTimer = () => { + if (hideTimer) { + clearTimeout(hideTimer) + hideTimer = null + } + } + + const scheduleHide = () => { + clearHideTimer() + hideTimer = window.setTimeout(() => { + overlayVisible.value = false + hideTimer = null + }, minDisplayMs) + } + + const evaluateOverlay = () => { + // 未触发首次登录遮罩:直接隐藏 + if (!activated.value) { + overlayVisible.value = false + clearHideTimer() + return + } + + // 首次登录:保持显示,等待异步全部就绪 + overlayVisible.value = true + if (!asyncComponentsReady.value) { + clearHideTimer() + return + } + + // 异步全部就绪,将进度置为 100%,再等待最小展示时长后隐藏 + if (options.progress.value < 100) { + options.progress.value = 100 + } + scheduleHide() + } + + // 仅当 isInitialSync 首次为 true 时激活遮罩,之后不再因 isInitialSync 变 false 而提前隐藏 + watch( + options.isInitialSync, + (val) => { + if (val) { + activated.value = true + overlayVisible.value = true + clearHideTimer() + } + evaluateOverlay() + }, + { immediate: true } + ) + + watch(asyncComponentsReady, evaluateOverlay) + + const markAsyncLoaded = () => { + asyncLoadedCount.value = Math.min(asyncLoadedCount.value + 1, asyncTarget) + evaluateOverlay() + } + + const resetOverlay = () => { + asyncLoadedCount.value = 0 + activated.value = options.isInitialSync.value + overlayVisible.value = activated.value + clearHideTimer() + } + + return { + overlayVisible, + asyncComponentsReady, + markAsyncLoaded, + resetOverlay + } +} diff --git a/src/hooks/usePopover.ts b/src/hooks/usePopover.ts new file mode 100644 index 0000000..4adf8f6 --- /dev/null +++ b/src/hooks/usePopover.ts @@ -0,0 +1,53 @@ +import type { Ref } from 'vue' + +/**! 这个是暂时用来解决在n-scrollbar中使用n-virtual-list使用n-popover时候滚动出现原生滚动条的方法 */ +export const usePopover = (selectKey: Ref, id: string) => { + /**! 暂时使用这些方法来阻止popover显示时候的滚动行为 */ + // 禁止滚动的默认行为 + const preventDefault = (e: Event) => e.preventDefault() + + // 恢复滚动行为 + const enableScroll = () => { + const scrollbar = document.querySelector(`#${id}`) as HTMLElement + if (!scrollbar) return + scrollbar.style.pointerEvents = '' + window.removeEventListener('wheel', preventDefault) + } + + const close = (event: any) => { + if (!event.target.matches('.n-popover, .n-popover *')) { + enableScroll() + } + } + + const handlePopoverUpdate = (key: string, show?: boolean) => { + const scrollbar = document.querySelector(`#${id}`) as HTMLElement + if (!scrollbar) return + + if (selectKey.value === key) { + if (show) { + // popover 显示时禁止滚动 + scrollbar.style.pointerEvents = 'none' + window.addEventListener('wheel', preventDefault, { passive: false }) + } else { + // popover 关闭时恢复滚动 + enableScroll() + } + return true + } + } + + onMounted(() => { + window.addEventListener('click', close, true) + }) + + onUnmounted(() => { + window.removeEventListener('click', close, true) + enableScroll() // 确保组件卸载时恢复滚动 + }) + + return { + handlePopoverUpdate, + enableScroll + } +} diff --git a/src/hooks/useReplaceMsg.ts b/src/hooks/useReplaceMsg.ts new file mode 100644 index 0000000..e106ac0 --- /dev/null +++ b/src/hooks/useReplaceMsg.ts @@ -0,0 +1,149 @@ +import { MsgEnum, RoomTypeEnum } from '@/enums' +import type { MessageType } from '@/services/types' +import { useChatStore } from '@/stores/chat' +import { useGroupStore } from '@/stores/group' +import { useUserStore } from '@/stores/user' +import { renderReplyContent } from '@/utils/RenderReplyContent.ts' + +/** + * 用于处理消息内容展示的hook,包括@提醒和撤回消息处理 + */ +export const useReplaceMsg = () => { + const userStore = useUserStore() + const chatStore = useChatStore() + const groupStore = useGroupStore() + + /** + * 检查单条消息是否@当前用户 + * @param message 消息对象 + * @returns 是否@当前用户 + */ + const checkMessageAtMe = (message: MessageType) => { + const currentUid = userStore.userInfo?.uid + if (!message?.message?.body?.atUidList || !currentUid) { + return false + } + + // 确保类型一致的比较 + return message.message.body.atUidList.some((atUid: string) => String(atUid) === String(currentUid)) + } + + /** + * 检查消息是否@当前用户及是否在未读范围内 + * @param roomId 房间ID + * @param roomType 房间类型 + * @param currentRoomId 当前激活的房间ID + * @param messages 消息列表 + * @param unreadCount 未读消息数量 + * @returns 是否有人@当前用户 + */ + const checkRoomAtMe = ( + roomId: string, + roomType: RoomTypeEnum, + currentRoomId: string, + messages: MessageType[], + unreadCount: number = 0 + ) => { + // 仅在群聊且不是当前会话时才考虑@提醒 + if (roomType !== RoomTypeEnum.GROUP || roomId === currentRoomId) { + return false + } + + // 过滤出@当前用户的消息 + const messagesWithAt = messages.filter(checkMessageAtMe) + + // 检查是否有@我的消息以及是否在未读范围内 + return messagesWithAt.some((msg) => messages.indexOf(msg) >= messages.length - (unreadCount || 0)) + } + + /** + * 处理撤回消息显示逻辑 + * @param message 消息对象 + * @param roomType 房间类型 + * @param userName 发送消息用户的名称 + * @returns 格式化后的撤回消息文本 + */ + const formatRecallMessage = (message: MessageType, roomType: RoomTypeEnum, userName: string) => { + const currentUid = userStore.userInfo?.uid + const content = message.message?.body?.content + if (typeof content === 'string' && content.trim().length > 0) { + return content + } + + if (roomType === RoomTypeEnum.GROUP) { + return `${userName}:撤回了一条消息` + } else { + return message.fromUser.uid === currentUid ? '你撤回了一条消息' : '对方撤回了一条消息' + } + } + + /** + * 获取消息发送者的用户名 + * @param message 消息对象 + * @param defaultName 默认名称(可选) + * @returns 发送者用户名 + */ + const getMessageSenderName = ( + message: MessageType, + defaultName: string = '', + roomId?: string, + roomTypeHint?: RoomTypeEnum + ) => { + if (!message?.fromUser?.uid) { + return defaultName + } + + const resolvedRoomId = roomId || message.message?.roomId || '' + const fallbackName = message.fromUser?.username || defaultName || message.fromUser?.uid || '' + const session = chatStore.getSession(resolvedRoomId) + const resolvedRoomType = roomTypeHint ?? session?.type + + const globalUser = groupStore.getUserInfo(message.fromUser.uid, resolvedRoomId) + + if (resolvedRoomType === RoomTypeEnum.GROUP) { + const user = groupStore.getUser(resolvedRoomId, message.fromUser.uid) + const resolvedName = user?.myName || user?.name || globalUser?.name || fallbackName + return resolvedName + } + + const resolvedName = globalUser?.name || fallbackName + return resolvedName + } + + /** + * 处理消息内容,包括撤回消息 + * @param message 消息对象 + * @param roomType 房间类型 + * @param userName 发送消息用户的名称(可选,如果不提供会自动从消息中提取) + * @returns 格式化后的消息内容 + */ + const formatMessageContent = ( + message: MessageType, + roomType: RoomTypeEnum, + userName: string = '', + roomId?: string + ) => { + const resolvedRoomId = roomId ?? message.message?.roomId ?? '' + const senderName = userName || getMessageSenderName(message, '', resolvedRoomId, roomType) + // 判断是否是撤回消息 + if (message.message?.type === MsgEnum.RECALL) { + return formatRecallMessage(message, roomType, senderName) + } + + // 正常消息,处理内容 + return renderReplyContent( + senderName, + message.message?.type, + message.message?.body?.content || message.message?.body, + roomType + ) as string + } + + return { + checkMessageAtMe, + checkRoomAtMe, + formatRecallMessage, + formatMessageContent, + getMessageSenderName + } +} diff --git a/src/hooks/useTauriListener.ts b/src/hooks/useTauriListener.ts new file mode 100644 index 0000000..e988a25 --- /dev/null +++ b/src/hooks/useTauriListener.ts @@ -0,0 +1,189 @@ +import type { UnlistenFn } from '@tauri-apps/api/event' +import { WebviewWindow } from '@tauri-apps/api/webviewWindow' +import { error, info } from '@tauri-apps/plugin-log' +import { getCurrentInstance, onUnmounted } from 'vue' + +// 全局监听器管理 +const globalListeners = new Map[]>() +const windowCloseListenerSetup = new Map() +const listenerIdMap = new Map>() +// 防止对同一个 unlisten 函数重复调用导致底层 listeners[eventId] 不存在 +const calledUnlisteners = new WeakSet() + +const safeUnlisten = (unlisten: UnlistenFn) => { + try { + if (calledUnlisteners.has(unlisten)) return + unlisten() + calledUnlisteners.add(unlisten) + } catch (e) { + console.warn('safeUnlisten error:', e) + } +} + +/** 自动管理tauri Listener事件监听器的hooks */ +export const useTauriListener = () => { + const listeners: Promise[] = [] + const listenerIds: string[] = [] + const instance = getCurrentInstance() + const windowLabel = WebviewWindow.getCurrent().label + let isComponentMounted = true + + /** + * 添加事件监听器 + * @param listener Promise + */ + const addListener = async (listener: Promise, id?: string) => { + const listenerId = id || `listener_${Date.now()}_${Math.random().toString(36).substring(2, 9)}` + if (listenerIdMap.has(listenerId)) { + try { + const unlisten = await listener + safeUnlisten(unlisten) + } catch (e) { + error(`[跟踪] 取消新监听器失败:${listenerId}, 错误:${e}`) + } + } else { + // 添加新的监听器 + listenerIdMap.set(listenerId, listener) + listeners.push(listener) + listenerIds.push(listenerId) + // 同时添加到全局监听器管理中 + if (!globalListeners.has(windowLabel)) { + globalListeners.set(windowLabel, []) + } + globalListeners.get(windowLabel)!.push(listener) + } + } + + /** + * 批量添加事件监听器 + * @param listenerPromises Promise数组 + */ + const pushListeners = (listenerPromises: Promise[]) => { + listeners.push(...listenerPromises) + + // 同时添加到全局监听器管理中 + if (!globalListeners.has(windowLabel)) { + globalListeners.set(windowLabel, []) + } + globalListeners.get(windowLabel)!.push(...listenerPromises) + + return listenerPromises + } + + /** + * 清理当前组件的监听器 + */ + const cleanup = async () => { + // 标记组件为未挂载状态 + isComponentMounted = false + + // 只有当存在监听器时才打印日志和执行清理 + if (listeners.length > 0) { + const componentName = instance?.type?.name || instance?.type?.__name || '未知组件' + info(`[useTauriListener]清除组件[${componentName}]的Tauri 监听器,监听器数量:[${listeners.length}]`) + try { + // 等待所有的 unlisten 函数 resolve + const unlistenFns = await Promise.all(listeners) + // 执行所有的 unlisten 函数 + unlistenFns.forEach((unlisten) => safeUnlisten(unlisten)) + + // 移除全局引用,防止 Promise 长驻内存 + const windowListeners = globalListeners.get(windowLabel) + if (windowListeners?.length) { + const removable = new Set(listeners) + const filtered = windowListeners.filter((item) => !removable.has(item)) + if (filtered.length === 0) { + globalListeners.delete(windowLabel) + } else { + globalListeners.set(windowLabel, filtered) + } + } + + // 删除对应的监听 ID 记录 + listenerIds.forEach((id) => listenerIdMap.delete(id)) + listenerIds.length = 0 + listeners.length = 0 + } catch (error) { + console.error('清理监听器失败:', error) + } + } + } + + /** + * 清理指定窗口的所有监听器(全局清理) + */ + const cleanupAllListenersForWindow = async (windowLabel: string) => { + const windowListeners = globalListeners.get(windowLabel) + if (!windowListeners) return + + info(`[useTauriListener]清除窗口[${windowLabel}]的所有Tauri监听器,监听器数量:[${windowListeners.length}]`) + try { + // 等待所有的 unlisten 函数 resolve + const unlistenFns = await Promise.all(windowListeners) + // 执行所有的 unlisten 函数 + unlistenFns.forEach((unlisten) => safeUnlisten(unlisten)) + + // 清理全局状态 + globalListeners.delete(windowLabel) + + // 同步清理 listenerIdMap 里对应的 Promise 引用 + for (const [id, promise] of Array.from(listenerIdMap.entries())) { + if (windowListeners.includes(promise)) { + listenerIdMap.delete(id) + } + } + } catch (error) { + console.error('清理监听器失败:', error) + } + } + + // 监听窗口关闭事件来自动清理监听器 + const setupWindowCloseListener = async () => { + try { + const appWindow = WebviewWindow.getCurrent() + const currentWindowLabel = appWindow.label + + // 检查是否已经为该窗口设置过监听器 + if (windowCloseListenerSetup.has(currentWindowLabel)) { + return + } + + // 监听窗口关闭请求事件 + if (currentWindowLabel !== 'home') { + info(`[useTauriListener]当前窗口标签设置关闭监听: ${currentWindowLabel}`) + const closeUnlisten = await appWindow.onCloseRequested(async () => { + info(`[useTauriListener]监听[${currentWindowLabel}]窗口关闭事件-清理所有监听器`) + // 清理该窗口的所有监听器 + await cleanupAllListenersForWindow(currentWindowLabel) + // 清理窗口关闭监听器 + windowCloseListenerSetup.delete(currentWindowLabel) + }) + + // 保存窗口关闭监听器 + windowCloseListenerSetup.set(currentWindowLabel, closeUnlisten) + } + } catch (error) { + console.warn('设置窗口关闭监听器失败:', error) + } + } + + // 设置窗口关闭监听器 + setupWindowCloseListener() + + // 只在组件实例存在时才注册 onUnmounted 钩子 + if (instance) { + onUnmounted(() => { + // 检查组件是否仍然挂载,避免重复执行清理 + if (isComponentMounted) { + cleanup() + } + }) + } + + return { + addListener, + pushListeners, + // 暴露清理方法,以便在非组件环境中手动清理 + cleanup + } +} diff --git a/src/hooks/useTrigger.ts b/src/hooks/useTrigger.ts new file mode 100644 index 0000000..396b4d9 --- /dev/null +++ b/src/hooks/useTrigger.ts @@ -0,0 +1,202 @@ +import { nextTick, type Ref } from 'vue' +import { TriggerEnum } from '@/enums' + +// 添加选择器常量 +const SELECTORS = { + MENTION: '.ait-options', + AI: '.AI-options', + TOPIC: '.topic-options' +} as const + +interface TriggerContext { + range: Range + selection: Selection + keyword: string +} + +export const useTrigger = ( + personList: Ref, + groupedAIModels: Ref, + topicList: Ref, + ait: Ref, + aitKey: Ref, + aiDialogVisible: Ref, + aiKeyword: Ref, + topicDialogVisible: Ref, + topicKeyword: Ref +) => { + // 产品阶段暂不使用 / 唤起 AI,保留开关便于后续快速恢复 + const enableAITrigger = false + + /** 重置所有状态 */ + const resetAllStates = () => { + ait.value = false + aitKey.value = '' + aiDialogVisible.value = false + aiKeyword.value = '' + topicDialogVisible.value = false + topicKeyword.value = '' + } + + /** 处理 @ 提及 */ + const handleMention = async (context: TriggerContext) => { + if (personList.value.length === 0) { + resetAllStates() + return + } + + ait.value = true + aitKey.value = context.keyword + + const res = context.range.getBoundingClientRect() + await nextTick(() => { + const dom = document.querySelector(SELECTORS.MENTION) as HTMLElement + if (!dom) return + dom.style.position = 'fixed' + dom.style.height = 'auto' + dom.style.maxHeight = '190px' + dom.style.left = `${res.x - 20}px` + dom.style.top = `${res.y - (dom.offsetHeight + 5)}px` + }) + } + + /** 处理AI对话 */ + const handleAI = async (context: TriggerContext) => { + if (!enableAITrigger) { + // 当功能关闭时直接返回,避免弹层状态被重新打开 + return + } + + if (groupedAIModels.value.length === 0) { + resetAllStates() + return + } + + const keyword = context.keyword?.trim?.() ?? '' + if (!keyword) { + resetAllStates() + return + } + + aiKeyword.value = keyword + + const res = context.range.getBoundingClientRect() + await nextTick() + if (groupedAIModels.value.length === 0) { + resetAllStates() + return + } + aiDialogVisible.value = true + const dom = document.querySelector(SELECTORS.AI) as HTMLElement + if (!dom) return + dom.style.position = 'fixed' + dom.style.height = 'auto' + dom.style.maxHeight = '190px' + dom.style.left = `${res.x - 20}px` + dom.style.top = `${res.y - (dom.offsetHeight + 5)}px` + } + + /** 处理话题标签 */ + const handleTopic = async (context: TriggerContext) => { + if (topicList.value.length === 0) { + resetAllStates() + return + } + + topicDialogVisible.value = true + topicKeyword.value = context.keyword + + const res = context.range.getBoundingClientRect() + await nextTick(() => { + const dom = document.querySelector(SELECTORS.TOPIC) as HTMLElement + if (!dom) return + dom.style.position = 'fixed' + dom.style.height = 'auto' + dom.style.maxHeight = '190px' + dom.style.left = `${res.x - 20}px` + dom.style.top = `${res.y - (dom.offsetHeight + 5)}px` + }) + } + + /** 检查是否应该触发 */ + const shouldTrigger = (text: string, cursorPosition: number, triggerSymbol: string) => { + try { + // 确保有效的文本和光标位置 + if (!text || cursorPosition === undefined || cursorPosition < 0) { + return false + } + + const searchStr = text.slice(0, cursorPosition) + const pattern = new RegExp(`\\${triggerSymbol}([^\\${triggerSymbol}]*)$`) + return pattern.test(searchStr) + } catch (err) { + console.error('检查触发条件出错:', err) + return false + } + } + + /** 提取关键词 */ + const extractKeyword = (text: string, cursorPosition: number, triggerSymbol: string) => { + try { + if (!text || cursorPosition === undefined || cursorPosition < 0) { + return null + } + + const searchStr = text.slice(0, cursorPosition) + const pattern = new RegExp(`\\${triggerSymbol}([^\\${triggerSymbol}]*)$`) + const matches = pattern.exec(searchStr) + return matches && matches.length > 1 ? matches[1] : null + } catch (err) { + console.error('提取关键词出错:', err) + return null + } + } + + /** 处理触发 */ + const handleTrigger = async (text: string, cursorPosition: number, context: TriggerContext) => { + try { + let hasTriggered = false + + // 检查@提及 + if (shouldTrigger(text, cursorPosition, TriggerEnum.MENTION)) { + const keyword = extractKeyword(text, cursorPosition, TriggerEnum.MENTION) + if (keyword !== null) { + await handleMention({ ...context, keyword }) + hasTriggered = ait.value + } + } + // 检查AI对话 + // 仅在开关开启时解析 / 触发,避免误触发已禁用的逻辑 + else if (enableAITrigger && shouldTrigger(text, cursorPosition, TriggerEnum.AI)) { + const keyword = extractKeyword(text, cursorPosition, TriggerEnum.AI) + if (keyword !== null) { + await handleAI({ ...context, keyword }) + hasTriggered = aiDialogVisible.value + } + } + // 检查话题标签 + else if (shouldTrigger(text, cursorPosition, TriggerEnum.TOPIC)) { + const keyword = extractKeyword(text, cursorPosition, TriggerEnum.TOPIC) + if (keyword !== null) { + await handleTopic({ ...context, keyword }) + hasTriggered = topicDialogVisible.value + } + } + + if (!hasTriggered) { + resetAllStates() + } + + return hasTriggered + } catch (err) { + console.error('处理触发事件出错:', err) + resetAllStates() + return false + } + } + + return { + handleTrigger, + resetAllStates + } +} diff --git a/src/hooks/useUpload.ts b/src/hooks/useUpload.ts new file mode 100644 index 0000000..aef1141 --- /dev/null +++ b/src/hooks/useUpload.ts @@ -0,0 +1,770 @@ +import { Channel, invoke } from '@tauri-apps/api/core' +import { BaseDirectory, stat, writeFile } from '@tauri-apps/plugin-fs' +import { fetch } from '@tauri-apps/plugin-http' +import { createEventHook } from '@vueuse/core' +import { TauriCommand, UploadSceneEnum } from '@/enums' +import { useConfigStore } from '@/stores/config' +import { useUserStore } from '@/stores/user' +import { extractFileName } from '@/utils/Formatting' +import { getImageDimensions } from '@/utils/ImageUtils' +import { getQiniuToken, getUploadProvider } from '@/utils/ImRequestUtils' +import { isAndroid, isMobile } from '@/utils/PlatformConstants' +import { getWasmMd5 } from '@/utils/Md5Util' +import { removeTempFile } from '@/utils/TempFileManager' + +/** 文件信息类型 */ +export type FileInfoType = { + name: string + type: string + size: number + suffix: string + width?: number + height?: number + downloadUrl?: string + second?: number + thumbWidth?: number + thumbHeight?: number + thumbUrl?: string +} + +/** 上传方式 */ +export enum UploadProviderEnum { + /** 默认上传方式 */ + DEFAULT = 'default', + /** 七牛云上传 */ + QINIU = 'qiniu', + /** MinIO 上传 */ + MINIO = 'minio' +} + +/** 上传配置 */ +export interface UploadOptions { + /** 上传方式 */ + provider?: UploadProviderEnum + /** 上传场景 */ + scene?: UploadSceneEnum + /** 是否使用分片上传(仅对七牛云有效) */ + useChunks?: boolean + /** 分片大小(单位:字节,默认4MB) */ + chunkSize?: number + /** 是否启用文件去重(使用文件哈希作为文件名) */ + enableDeduplication?: boolean +} + +/** 分片上传进度信息 */ +interface ChunkProgressInfo { + uploadedChunks: number + totalChunks: number + currentChunkProgress: number +} + +const Max = 500 // 单位M +const MAX_FILE_SIZE = Max * 1024 * 1024 // 最大上传限制 +const QINIU_CHUNK_SIZE = 4 * 1024 * 1024 // 七牛云分片大小:4MB +const CHUNK_THRESHOLD = 4 * 1024 * 1024 // 4MB,超过此大小的文件将使用分片上传 + +let cryptoJS: any | null = null + +const isAbsolutePath = (path: string): boolean => { + return /^(\/|[A-Za-z]:[\\/]|\\\\)/.test(path) +} + +const loadCryptoJS = async () => { + if (!cryptoJS) { + const module = await import('crypto-js') + cryptoJS = module.default ?? module + } + return cryptoJS as { + lib: { WordArray: { create: (arr: ArrayBuffer | Uint8Array) => any } } + MD5: (wordArray: any) => { toString: () => string } + } +} + +/** + * 文件上传Hook + */ +export const useUpload = () => { + // 获取configStore配置中的ossDomain + const configStore = useConfigStore() + const userStore = useUserStore() + const isUploading = ref(false) // 是否正在上传 + const progress = ref(0) // 进度 + const fileInfo = ref(null) // 文件信息 + const currentProvider = ref(UploadProviderEnum.DEFAULT) // 当前上传方式 + + const { on: onChange, trigger } = createEventHook() + const onStart = createEventHook() + + const uploadFileWithTauriPut = async (targetUrl: string, file: File, contentType: string) => { + const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache + const baseDirName = isMobile() ? 'AppData' : 'AppCache' + const safeFileName = file.name.replace(/[\\/]/g, '_') + const tempPath = `temp-upload-${Date.now()}-${safeFileName}` + + try { + await writeFile(tempPath, file.stream(), { baseDir }) + + const onProgress = new Channel<{ progressTotal: number; total: number }>() + let lastProgress = -1 + onProgress.onmessage = ({ progressTotal, total }) => { + const pct = total > 0 ? Math.floor((progressTotal / total) * 100) : 0 + if (pct !== lastProgress) { + lastProgress = pct + progress.value = pct + trigger('progress') + } + } + + await invoke(TauriCommand.UPLOAD_FILE_PUT, { + url: targetUrl, + path: tempPath, + baseDir: baseDirName, + headers: { 'Content-Type': contentType }, + onProgress + }) + } finally { + await removeTempFile(tempPath, { baseDir, silent: true }) + } + } + + /** + * 计算文件的MD5哈希值 + * @param file 文件 + * @returns MD5哈希值 + */ + const calculateFileHash = async (file: File): Promise => { + const startTime = performance.now() + try { + console.log('开始计算MD5哈希值,文件大小:', file.size, 'bytes') + const arrayBuffer = await file.arrayBuffer() + const uint8Array = new Uint8Array(arrayBuffer) + let hash: string + + if (isAndroid()) { + const CryptoJS = await loadCryptoJS() + const wordArray = CryptoJS.lib.WordArray.create(arrayBuffer as ArrayBuffer) + hash = CryptoJS.MD5(wordArray).toString() + } else { + const Md5 = await getWasmMd5() + hash = await Md5.digest_u8(uint8Array) + } + const endTime = performance.now() + const duration = (endTime - startTime).toFixed(2) + console.log(`MD5计算完成,耗时: ${duration}ms,哈希值: ${hash}`) + return hash.toLowerCase() + } catch (error) { + const endTime = performance.now() + const duration = (endTime - startTime).toFixed(2) + console.error(`计算文件哈希值失败,耗时: ${duration}ms:`, error) + // 如果计算失败,返回时间戳作为备用方案 + return Date.now().toString() + } + } + + /** + * 生成文件哈希 + * @param options 上传配置 + * @param fileObj 文件对象 + * @param fileName 文件名 + * @returns 文件哈希 + */ + const generateHashKey = async ( + options: { scene: UploadSceneEnum; enableDeduplication: boolean }, + fileObj: File, + fileName: string + ) => { + let key: string + + if (options.enableDeduplication) { + // 使用文件哈希作为文件名的一部分,实现去重 + const fileHash = await calculateFileHash(fileObj) + const fileSuffix = fileName.split('.').pop() || '' + // 获取当前登录用户的account + const account = userStore.userInfo!.account + key = `${options.scene}/${account}/${fileHash}.${fileSuffix}` + console.log('使用文件去重模式,文件哈希:', fileHash) + } else { + // 使用时间戳生成唯一的文件名 + key = `${options.scene}/${Date.now()}_${fileName}` + } + return key + } + + /** + * 上传文件到七牛云 + * @param file 文件 + * @param qiniuConfig 七牛云配置 + * @param enableDeduplication 是否启用文件去重 + */ + const uploadToQiniu = async ( + file: File, + scene: UploadSceneEnum, + qiniuConfig: { token: string; domain: string; storagePrefix: string; region?: string }, + enableDeduplication: boolean = true + ) => { + isUploading.value = true + progress.value = 0 + + try { + // 创建FormData对象 + const formData = new FormData() + + // 生成文件名 + const key = await generateHashKey({ scene, enableDeduplication }, file, file.name) + + // 添加七牛云上传所需参数 + formData.append('token', qiniuConfig.token) + formData.append('key', key) + formData.append('file', file) + + // 使用fetch API进行上传 + const response = await fetch(qiniuConfig.domain, { + method: 'POST', + body: formData + }) + + isUploading.value = false + + if (response.ok) { + const result = await response.json() + const downloadUrl = `${configStore.config.qiNiu.ossDomain}/${result.key || key}` + trigger('success') + return { downloadUrl, key } + } else { + trigger('fail') + return { error: 'Upload failed' } + } + } catch (error) { + isUploading.value = false + console.error('Qiniu upload failed:', error) + return { error: 'Upload failed' } + } + } + + /** + * 将文件分片并上传到七牛云 + * @param file 文件 + * @param qiniuConfig 七牛云配置 + * @param chunkSize 分片大小(字节) + * @param inner 是否内部调用 + */ + const uploadToQiniuWithChunks = async ( + file: File, + qiniuConfig: { token: string; domain: string; storagePrefix: string; region?: string }, + chunkSize: number = QINIU_CHUNK_SIZE, + inner?: boolean + ) => { + isUploading.value = true + progress.value = 0 + + try { + // 生成唯一的文件名 + const key = `${qiniuConfig.storagePrefix}/${Date.now()}_${file.name}` + + // 计算分片数量 + const totalSize = file.size + const totalChunks = Math.ceil(totalSize / chunkSize) + + // 创建进度跟踪对象 + const progressInfo: ChunkProgressInfo = { + uploadedChunks: 0, + totalChunks, + currentChunkProgress: 0 + } + + console.log('开始七牛云分片上传:', { + fileName: file.name, + fileSize: totalSize, + chunkSize, + totalChunks, + token: qiniuConfig.token.substring(0, 10) + '...', + domain: qiniuConfig.domain + }) + + // 使用七牛云的分片上传API v2 - 创建上传块 + const contexts: string[] = [] + + for (let i = 0; i < totalChunks; i++) { + const start = i * chunkSize + const end = Math.min(start + chunkSize, totalSize) + const chunkData = await file.slice(start, end).arrayBuffer() + const currentChunkSize = end - start + + // 创建块 + const blockResponse = await fetch(`${qiniuConfig.domain}/mkblk/${currentChunkSize}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + Authorization: `UpToken ${qiniuConfig.token}` + }, + body: chunkData + }) + + if (!blockResponse.ok) { + const errorText = await blockResponse.text() + console.error(`上传分片 ${i + 1}/${totalChunks} 失败:`, { + status: blockResponse.status, + statusText: blockResponse.statusText, + errorText + }) + throw new Error(`上传分片 ${i + 1}/${totalChunks} 失败: ${blockResponse.statusText}`) + } + + const blockResult = await blockResponse.json() + contexts.push(blockResult.ctx) + progressInfo.uploadedChunks++ + + progress.value = Math.floor((progressInfo.uploadedChunks / progressInfo.totalChunks) * 100) + + console.log(`上传分片 ${progressInfo.uploadedChunks}/${progressInfo.totalChunks} 成功:`, { + ctx: blockResult.ctx.substring(0, 10) + '...', + progress: progress.value + '%' + }) + } + + // 完成上传 - 合并所有块 + const completeResponse = await fetch(`${qiniuConfig.domain}/mkfile/${totalSize}/key/${btoa(key)}`, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + Authorization: `UpToken ${qiniuConfig.token}` + }, + body: contexts.join(',') + }) + + if (!completeResponse.ok) { + throw new Error(`完成分片上传失败: ${completeResponse.statusText}`) + } + + const completeResult = await completeResponse.json() + console.log('完成分片上传:', completeResult) + + isUploading.value = false + progress.value = 100 + + if (inner) return { key, domain: qiniuConfig.domain } + + const downloadUrl = `${qiniuConfig.domain}/${completeResult.key || key}` + trigger('success') + return { downloadUrl, key } + } catch (error) { + isUploading.value = false + if (!inner) { + trigger('fail') + } + console.error('七牛云分片上传失败:', error) + return { error: 'Upload failed' } + } + } + + /** + * 获取图片宽高 + */ + const getImgWH = async (file: File) => { + try { + const result = await getImageDimensions(file, { includePreviewUrl: true }) + return { + width: result.width, + height: result.height, + tempUrl: result.previewUrl! + } + } catch (_error) { + return { width: 0, height: 0, url: null } + } + } + + /** + * 获取音频时长 + */ + const getAudioDuration = (file: File) => { + return new Promise((resolve, reject) => { + const audio = new Audio() + const tempUrl = URL.createObjectURL(file) + audio.src = tempUrl + // 计算音频的时长 + const countAudioTime = async () => { + while (isNaN(audio.duration) || audio.duration === Infinity) { + // 防止浏览器卡死 + await new Promise((resolve) => setTimeout(resolve, 100)) + // 随机进度条位置 + audio.currentTime = 100000 * Math.random() + } + // 取整 + const second = Math.round(audio.duration || 0) + resolve({ second, tempUrl }) + } + countAudioTime() + audio.onerror = () => { + reject({ second: 0, tempUrl }) + } + }) + } + + /** + * 解析文件 + * @param file 文件 + * @param addParams 参数 + * @returns 文件大小、文件类型、文件名、文件后缀... + */ + const parseFile = async (file: File, addParams: Record = {}) => { + const { name, size, type } = file + const suffix = name.split('.').pop()?.trim().toLowerCase() || '' + const baseInfo = { name, size, type, suffix, ...addParams } + + // TODO:这里应该不需要进行类型判断了,可以直接返回baseInfo + if (type.includes('image')) { + const { width, height, tempUrl } = (await getImgWH(file)) as any + return { ...baseInfo, width, height, tempUrl } + } + + if (type.includes('audio')) { + const { second, tempUrl } = (await getAudioDuration(file)) as any + return { second, tempUrl, ...baseInfo } + } + // 如果是视频 + if (type.includes('video')) { + return { ...baseInfo } + } + + return baseInfo + } + + /** + * 上传文件 + * @param file 文件 + * @param options 上传选项 + */ + const uploadFile = async (file: File, options?: UploadOptions) => { + if (isUploading.value || !file) return + + // 设置当前上传方式 + if (options?.provider) { + currentProvider.value = options.provider + } + // 如果未指定 provider,读取后端默认 provider + if (!options?.provider) { + try { + const res = await getUploadProvider() + if (res?.provider === 'minio') currentProvider.value = UploadProviderEnum.MINIO + else if (res?.provider === 'qiniu') currentProvider.value = UploadProviderEnum.QINIU + } catch {} + } + + const info = await parseFile(file, options) + + // 限制文件大小 + if (info.size > MAX_FILE_SIZE) { + window.$message.error(`文件大小不能超过 ${Max}MB`) + return + } + + // 根据上传方式选择不同的上传逻辑 + if (currentProvider.value === UploadProviderEnum.QINIU) { + try { + const cred = await getQiniuToken({ scene: options?.scene, fileName: file.name }) + fileInfo.value = { ...info } + await onStart.trigger(fileInfo) + + if ((cred as any)?.uploadUrl) { + const contentType = file.type || 'application/octet-stream' + + isUploading.value = true + progress.value = 0 + + await uploadFileWithTauriPut((cred as any).uploadUrl, file, contentType) + + isUploading.value = false + progress.value = 100 + fileInfo.value = { ...fileInfo.value!, downloadUrl: (cred as any).downloadUrl } + trigger('success') + return { downloadUrl: (cred as any).downloadUrl } + } + + console.log(`uploadFile - 文件大小检查: ${file.size} bytes, 阈值: ${CHUNK_THRESHOLD} bytes`) + if (file.size > CHUNK_THRESHOLD) { + console.log('uploadFile - 使用分片上传方式') + const result = (await uploadToQiniuWithChunks(file, cred as any, QINIU_CHUNK_SIZE)) as any + if (result && result.downloadUrl) { + fileInfo.value = { ...info, downloadUrl: result.downloadUrl } + } + return result + } else { + console.log('uploadFile - 使用默认的普通上传方式') + const result = await uploadToQiniu( + file, + options?.scene || UploadSceneEnum.CHAT, + cred as any, + options?.enableDeduplication || true + ) + if (result && result.downloadUrl) { + fileInfo.value = { ...info, downloadUrl: result.downloadUrl } + } + return result + } + } catch (error) { + console.error('获取上传凭证失败:', error) + await trigger('fail') + } + } else if (currentProvider.value === UploadProviderEnum.MINIO) { + try { + fileInfo.value = { ...(await parseFile(file, options)) } + await onStart.trigger(fileInfo) + + const presign = await getQiniuToken({ scene: options?.scene, fileName: file.name }) + const contentType = file.type || 'application/octet-stream' + + isUploading.value = true + progress.value = 0 + + await uploadFileWithTauriPut(presign.uploadUrl, file, contentType) + + isUploading.value = false + progress.value = 100 + + fileInfo.value = { ...fileInfo.value!, downloadUrl: presign.downloadUrl } + trigger('success') + return { downloadUrl: presign.downloadUrl } + } catch (error) { + isUploading.value = false + console.error('MinIO 上传失败:', error) + await trigger('fail') + } + } + } + + /** + * 获取上传和下载URL + * 如果是默认上传方式,获取上传和下载URL,执行上传 + * 如果是七牛云上传方式,获取七牛云token,不执行上传 + * @param path 文件路径 + * @param options 上传选项 + */ + const getUploadAndDownloadUrl = async ( + _path: string, + options?: UploadOptions + ): Promise<{ uploadUrl: string; downloadUrl: string; config?: any }> => { + // 设置当前上传方式 + if (options?.provider) { + currentProvider.value = options.provider + } + // 如果未指定 provider,读取后端默认 provider + if (!options?.provider) { + try { + const res = await getUploadProvider() + if (res?.provider === 'minio') currentProvider.value = UploadProviderEnum.MINIO + else if (res?.provider === 'qiniu') currentProvider.value = UploadProviderEnum.QINIU + } catch {} + } + + // 根据上传方式选择不同的上传逻辑 + if (currentProvider.value === UploadProviderEnum.QINIU) { + try { + const cred = await getQiniuToken({ scene: options?.scene, fileName: extractFileName(_path) }) + if ((cred as any)?.token) { + const config = { ...cred, provider: options?.provider, scene: options?.scene } + return { uploadUrl: UploadProviderEnum.QINIU, downloadUrl: (cred as any).domain, config } + } + return { + uploadUrl: (cred as any).uploadUrl, + downloadUrl: (cred as any).downloadUrl, + config: { objectKey: (cred as any).objectKey, provider: UploadProviderEnum.MINIO } + } + } catch (_error) { + throw new Error('获取上传凭证失败,请重试') + } + } + if (currentProvider.value === UploadProviderEnum.MINIO) { + const resp = await getQiniuToken({ scene: options?.scene, fileName: extractFileName(_path) }) + return { + uploadUrl: resp.uploadUrl, + downloadUrl: resp.downloadUrl, + config: { objectKey: resp.objectKey, provider: UploadProviderEnum.MINIO } + } + } + return { uploadUrl: '', downloadUrl: '' } + } + + /** + * 执行实际的文件上传 + * @param path 文件路径 + * @param uploadUrl 上传URL + * @param options 上传选项 + */ + const doUpload = async (path: string, uploadUrl: string, options?: any): Promise<{ qiniuUrl: string } | string> => { + const absolutePath = isAbsolutePath(path) + + // 如果是七牛云上传 + if (uploadUrl === UploadProviderEnum.QINIU && options) { + const fileName = extractFileName(path) + // 如果没有提供七牛云配置,尝试获取 + if (!options.domain || !options.token) { + try { + const cred = await getQiniuToken({ scene: options.scene, fileName }) + if ((cred as any)?.token) { + options.domain = (cred as any).domain + options.token = (cred as any).token + options.storagePrefix = (cred as any).storagePrefix + options.region = (cred as any).region + } else if ((cred as any)?.uploadUrl) { + const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache + const baseDirName = isMobile() ? 'AppData' : 'AppCache' + const fileStat = absolutePath ? await stat(path) : await stat(path, { baseDir }) + + if (fileStat.size > MAX_FILE_SIZE) { + throw new Error(`文件大小不能超过${Max}MB`) + } + + isUploading.value = true + progress.value = 0 + + const onProgress = new Channel<{ progressTotal: number; total: number }>() + let lastProgress = -1 + onProgress.onmessage = ({ progressTotal, total }) => { + const pct = total > 0 ? Math.floor((progressTotal / total) * 100) : 0 + if (pct !== lastProgress) { + lastProgress = pct + progress.value = pct + trigger('progress') + options?.progressCallback?.(pct) + } + } + + await invoke(TauriCommand.UPLOAD_FILE_PUT, { + url: (cred as any).uploadUrl, + path, + ...(absolutePath ? {} : { baseDir: baseDirName }), + headers: { 'Content-Type': 'application/octet-stream' }, + onProgress + }) + + isUploading.value = false + progress.value = 100 + trigger('success') + return (cred as any).downloadUrl + } + } catch (error) { + console.error('获取上传凭证失败', error) + } + } + + try { + if (!options.domain || !options.token) { + throw new Error('获取上传凭证失败,请重试') + } + + const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache + const baseDirName = isMobile() ? 'AppData' : 'AppCache' + const fileStat = absolutePath ? await stat(path) : await stat(path, { baseDir }) + + if (fileStat.size > MAX_FILE_SIZE) { + throw new Error(`文件大小不能超过${Max}MB`) + } + + isUploading.value = true + progress.value = 0 + + const onProgress = new Channel<{ progressTotal: number; total: number }>() + let lastProgress = -1 + onProgress.onmessage = ({ progressTotal, total }) => { + const pct = total > 0 ? Math.floor((progressTotal / total) * 100) : 0 + if (pct !== lastProgress) { + lastProgress = pct + progress.value = pct + trigger('progress') + options?.progressCallback?.(pct) + } + } + + const key = await invoke(TauriCommand.QINIU_UPLOAD_RESUMABLE, { + path, + ...(absolutePath ? {} : { baseDir: baseDirName }), + token: options.token, + domain: options.domain, + scene: options.scene, + account: userStore.userInfo?.account, + storagePrefix: options.storagePrefix, + enableDeduplication: Boolean(options.enableDeduplication), + onProgress + }) + + isUploading.value = false + progress.value = 100 + trigger('success') + return `${configStore.config.qiNiu.ossDomain}/${key}` + } catch (error) { + isUploading.value = false + trigger('fail') + console.error('七牛云上传失败:', error) + throw new Error('文件上传失败,请重试') + } + } else { + // 使用默认上传方式 + console.log('执行文件上传:', path) + try { + if (!uploadUrl) { + throw new Error('获取上传链接失败,请重试') + } + + const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache + const baseDirName = isMobile() ? 'AppData' : 'AppCache' + const fileStat = absolutePath ? await stat(path) : await stat(path, { baseDir }) + + // 添加文件大小检查 + if (fileStat.size > MAX_FILE_SIZE) { + throw new Error(`文件大小不能超过${Max}MB`) + } + + isUploading.value = true + progress.value = 0 + + const onProgress = new Channel<{ progressTotal: number; total: number }>() + let lastProgress = -1 + onProgress.onmessage = ({ progressTotal, total }) => { + const pct = total > 0 ? Math.floor((progressTotal / total) * 100) : 0 + if (pct !== lastProgress) { + lastProgress = pct + progress.value = pct + trigger('progress') + options?.progressCallback?.(pct) + } + } + + await invoke(TauriCommand.UPLOAD_FILE_PUT, { + url: uploadUrl, + path, + ...(absolutePath ? {} : { baseDir: baseDirName }), + headers: { 'Content-Type': 'application/octet-stream' }, + onProgress + }) + + isUploading.value = false + progress.value = 100 + console.log('文件上传成功') + trigger('success') + + // 返回下载URL + return options?.downloadUrl + } catch (error) { + isUploading.value = false + trigger('fail') + console.error('文件上传失败:', error) + throw new Error('文件上传失败,请重试') + } + } + } + + return { + fileInfo, + isUploading, + progress, + onStart: onStart.on, + onChange, + uploadFile, + parseFile, + uploadToQiniu, + getUploadAndDownloadUrl, + doUpload, + UploadProviderEnum, + generateHashKey + } +} diff --git a/src/hooks/useVideoViewer.ts b/src/hooks/useVideoViewer.ts new file mode 100644 index 0000000..c063529 --- /dev/null +++ b/src/hooks/useVideoViewer.ts @@ -0,0 +1,188 @@ +import { appDataDir, join, resourceDir } from '@tauri-apps/api/path' +import { WebviewWindow } from '@tauri-apps/api/webviewWindow' +import { BaseDirectory, exists } from '@tauri-apps/plugin-fs' +import { MsgEnum } from '@/enums' +import { useWindow } from '@/hooks/useWindow' +import { useChatStore } from '@/stores/chat' +import { useUserStore } from '@/stores/user' +import { useVideoViewer as useVideoViewerStore } from '@/stores/videoViewer' +import { isMobile } from '@/utils/PlatformConstants' + +/** 视频处理 */ +export const useVideoViewer = () => { + const { createWebviewWindow } = useWindow() + const VideoViewerStore = useVideoViewerStore() + const userStore = useUserStore() + + // 获取视频文件名 + const getVideoFilename = (url: string) => { + if (!url) return 'video.mp4' + // 从URL中提取文件名 + const urlParts = url.split('/') + const filename = urlParts[urlParts.length - 1] + if (filename && filename.includes('.')) { + return filename + } + return 'video.mp4' + } + + // // 获取省略显示的视频文件名 + // const getVideoFilenameEllipsis = (url: string, maxLength: number = 20) => { + // const filename = getVideoFilename(url) + // if (filename.length <= maxLength) { + // return filename + // } + + // // 找到最后一个点的位置(文件扩展名) + // const lastDotIndex = filename.lastIndexOf('.') + // if (lastDotIndex === -1) { + // // 没有扩展名,直接截断 + // return filename.substring(0, maxLength - 3) + '...' + // } + + // const extension = filename.substring(lastDotIndex) + // const nameWithoutExt = filename.substring(0, lastDotIndex) + + // // 计算可用于文件名主体的长度(减去扩展名和省略号的长度) + // const availableLength = maxLength - extension.length - 3 + + // if (availableLength <= 0) { + // // 如果扩展名太长,只显示省略号和扩展名 + // return '...' + extension + // } + + // return nameWithoutExt.substring(0, availableLength) + '...' + extension + // } + + // 获取本地视频路径 + const getLocalVideoPath = async (url: string) => { + if (!url) return '' + const filename = getVideoFilename(url) + const videosDir = await userStore.getUserRoomDir() + return await join(videosDir, filename) + } + + // 检查视频是否已下载到本地 + const checkVideoDownloaded = async (url: string) => { + if (!url) return false + try { + const localPath = await getLocalVideoPath(url) + if (localPath) { + const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.Resource + return await exists(localPath, { baseDir }) + } + } catch (error) { + console.error('检查视频下载状态失败:', error) + } + return false + } + + // 获取视频的实际播放路径(本地路径优先) + const getVideoPlayPath = async (url: string) => { + const isDownloaded = await checkVideoDownloaded(url) + if (isDownloaded) { + const localPath = await getLocalVideoPath(url) + // 使用与下载时一致的基础目录 + const baseDirPath = isMobile() ? await appDataDir() : await resourceDir() + return await join(baseDirPath, localPath) + } + return url + } + + // 媒体获取(支持类型过滤和索引定位) + const getAllMediaFromChat = (url: string, includeTypes: MsgEnum[] = [MsgEnum.VIDEO]) => { + const chatStore = useChatStore() + const messages = [...Object.values(chatStore.currentMessageMap || {})] + const mediaUrls: string[] = [] + let currentIndex = -1 + messages.forEach((msg) => { + if (includeTypes.includes(msg.message?.type) && msg.message.body?.url) { + const isTarget = msg.message.body.url === url + mediaUrls.push(msg.message.body.url) + // 在添加元素后判断是否目标URL + if (isTarget) { + currentIndex = mediaUrls.length - 1 // 使用数组最后一位索引 + } + } + }) + return { + list: mediaUrls, + index: Math.max(currentIndex, 0) + } + } + + /** + * 视频加载处理 + * @param url 视频链接 + * @param includeTypes 支持类型 + * @param customVideoList 自定义视频列表,用于聊天历史等场景 + */ + const openVideoViewer = async ( + url: string, + includeTypes: MsgEnum[] = [MsgEnum.VIDEO], + customVideoList?: string[] + ) => { + if (isMobile()) return + if (!url) return + + let list: string[] + let index: number + + if (customVideoList && customVideoList.length > 0) { + // 使用自定义视频列表 + list = customVideoList + index = customVideoList.indexOf(url) + if (index === -1) { + // 如果当前视频不在列表中,将其添加到列表开头 + list = [url, ...customVideoList] + index = 0 + } + } else { + // 使用默认逻辑从聊天中获取 + const result = getAllMediaFromChat(url, includeTypes) + list = result.list + index = result.index + } + + // 为每个视频URL检查本地下载状态,优先使用本地路径 + const processedList = await Promise.all( + list.map(async (videoUrl) => { + return await getVideoPlayPath(videoUrl) + }) + ) + + // 找到当前视频在处理后列表中的索引 + const currentVideoPath = await getVideoPlayPath(url) + const processedIndex = processedList.findIndex((path) => path === currentVideoPath || path === url) + const finalIndex = processedIndex !== -1 ? processedIndex : index + + // 统一使用列表模式,不再区分单视频模式 + VideoViewerStore.resetVideoListOptimized(processedList, finalIndex) + VideoViewerStore.$patch({ + videoList: [...processedList], + currentVideoIndex: finalIndex + }) + + // 检查现有窗口 + const existingWindow = await WebviewWindow.getByLabel('videoViewer') + if (existingWindow) { + await existingWindow.emit('video-updated', { + list: processedList, + index: finalIndex, + currentVideoPath + }) + await existingWindow.show() + await existingWindow.setFocus() + return + } + + await createWebviewWindow('视频查看器', 'videoViewer', 800, 600, '', true, 800, 600) + } + + return { + openVideoViewer, + getLocalVideoPath, + checkVideoDownloaded, + getVideoFilename + } +} diff --git a/src/hooks/useViewport.ts b/src/hooks/useViewport.ts new file mode 100644 index 0000000..004b0a6 --- /dev/null +++ b/src/hooks/useViewport.ts @@ -0,0 +1,14 @@ +const vw = ref(document.documentElement.clientWidth) +const vh = ref(document.documentElement.clientHeight) + +/** 获取视口的宽高 */ +export const useViewport = () => { + window.addEventListener('resize', () => { + vw.value = document.documentElement.clientWidth + vh.value = document.documentElement.clientHeight + }) + return { + vw, + vh + } +} diff --git a/src/hooks/useVoiceDragControl.ts b/src/hooks/useVoiceDragControl.ts new file mode 100644 index 0000000..c79eeea --- /dev/null +++ b/src/hooks/useVoiceDragControl.ts @@ -0,0 +1,298 @@ +import { useThrottleFn } from '@vueuse/core' +import type { Ref } from 'vue' +import { isMobile } from '@/utils/PlatformConstants' + +/** + * 拖拽控制器返回接口 + */ +export type VoiceDragControlReturn = { + // 状态 + isDragging: Ref + previewTime: Ref + showTimePreview: Ref + + // 内部状态(用于调试和监控) + dragStartX: Ref + wasPlayingBeforeDrag: Ref + + // 方法 + handleDragStart: (event: MouseEvent | TouchEvent) => void + calculateTimeFromPosition: (clientX: number) => number + cleanup: () => void + + // 事件绑定 + bindDragEvents: (canvasElement: HTMLCanvasElement) => void +} + +/** + * 语音拖拽交互处理Hook + * @param canvasElement Canvas元素引用 + * @param duration 音频时长 + * @param isPlaying 播放状态 + * @param onDragSeek 拖拽跳转回调 + * @param onPlayToggle 播放切换回调 + * @returns 拖拽控制接口 + */ +export const useVoiceDragControl = ( + canvasElement: Ref, + duration: Ref, + isPlaying: Ref, + onDragSeek: (time: number) => void, + onPlayToggle: (shouldPlay: boolean) => Promise +): VoiceDragControlReturn => { + const isDragging = ref(false) + const previewTime = ref(0) + const showTimePreview = ref(false) + const dragStartX = ref(0) + const wasPlayingBeforeDrag = ref(false) + + const dragThreshold = 5 // 拖拽触发的最小移动距离(像素) + const isMobileDevice = isMobile() + + /** + * 增强的边界处理 + */ + const enhancedClampTime = (time: number) => { + if (!Number.isFinite(time) || isNaN(time)) { + return 0 + } + + const maxTime = Math.max(0, duration.value || 0) + return Math.max(0, Math.min(maxTime, time)) + } + + /** + * 恢复移动端页面状态 + */ + const restoreMobilePageState = () => { + if (isMobileDevice) { + const scrollY = parseInt(document.body.dataset.scrollY || '0', 10) + + document.body.style.overflow = '' + document.body.style.position = '' + document.body.style.top = '' + document.body.style.width = '' + + // 恢复滚动位置 + window.scrollTo(0, scrollY) + + // 清理数据属性 + delete document.body.dataset.scrollY + } + } + + /** + * 位置计算算法 + * @param clientX 鼠标/触摸点的X坐标 + * @returns 对应的时间(秒) + */ + const calculateTimeFromPosition = (clientX: number): number => { + if (!canvasElement.value) return 0 + + const rect = canvasElement.value.getBoundingClientRect() + const relativeX = clientX - rect.left + const progress = Math.max(0, Math.min(1, relativeX / rect.width)) + return progress * duration.value + } + + /** + * 处理拖拽开始 + * @param event 鼠标或触摸事件 + */ + const handleDragStart = (event: MouseEvent | TouchEvent) => { + if (!canvasElement.value) return + + try { + event.preventDefault() + event.stopPropagation() + + // 移动端防止页面滚动 + if (isMobileDevice) { + const currentScrollY = window.scrollY + + document.body.style.overflow = 'hidden' + document.body.style.position = 'fixed' + document.body.style.top = `-${currentScrollY}px` + document.body.style.width = '100%' + + // 保存滚动位置以便恢复 + document.body.dataset.scrollY = currentScrollY.toString() + } + + // 获取开始位置 + const clientX = 'touches' in event ? event.touches[0]?.clientX : event.clientX + if (clientX === undefined) { + return + } + + dragStartX.value = clientX + + // 保存拖拽前的播放状态 + wasPlayingBeforeDrag.value = isPlaying.value + + // 绑定移动和结束事件 + if (isMobileDevice || 'ontouchstart' in window) { + document.addEventListener('touchmove', handleDragMove, { passive: false }) + document.addEventListener('touchend', handleDragEnd, { passive: false }) + document.addEventListener('touchcancel', handleDragEnd, { passive: false }) + } else { + document.addEventListener('mousemove', handleDragMove) + document.addEventListener('mouseup', handleDragEnd) + document.addEventListener('mouseleave', handleDragEnd) + } + } catch (error) { + console.error('拖拽开始处理错误:', error) + // 错误时恢复页面状态 + restoreMobilePageState() + } + } + + /** + * 处理拖拽移动(节流处理) + */ + const handleDragMove = useThrottleFn((event: MouseEvent | TouchEvent) => { + try { + if (!isDragging.value) { + // 检查是否超过拖拽阈值 + const clientX = 'touches' in event ? event.touches[0]?.clientX : event.clientX + if (clientX === undefined) { + console.warn('无法获取移动位置') + return + } + + const moveDistance = Math.abs(clientX - dragStartX.value) + + if (moveDistance >= dragThreshold) { + // 开始拖拽 + isDragging.value = true + showTimePreview.value = true + + // 暂停播放(如果正在播放) + if (isPlaying.value) { + try { + onPlayToggle(false) + } catch (pauseError) { + console.warn('暂停播放失败:', pauseError) + } + } + } else { + return + } + } + + // 防止页面滚动(移动端) + event.preventDefault() + + // 计算当前位置 + const clientX = 'touches' in event ? event.touches[0]?.clientX : event.clientX + if (clientX === undefined) { + console.warn('无法获取拖拽位置') + return + } + + const targetTime = calculateTimeFromPosition(clientX) + previewTime.value = enhancedClampTime(targetTime) + } catch (error) { + console.error('拖拽移动处理错误:', error) + // 错误时结束拖拽 + handleDragEnd() + } + }, 16) + + /** + * 处理拖拽结束 + */ + const handleDragEnd = () => { + try { + restoreMobilePageState() + + // 清理全局事件监听器 + document.removeEventListener('mousemove', handleDragMove) + document.removeEventListener('mouseup', handleDragEnd) + document.removeEventListener('mouseleave', handleDragEnd) + document.removeEventListener('touchmove', handleDragMove) + document.removeEventListener('touchend', handleDragEnd) + document.removeEventListener('touchcancel', handleDragEnd) + + if (isDragging.value) { + try { + const clampedTime = enhancedClampTime(previewTime.value) + + // 通知外部进行跳转 + onDragSeek(clampedTime) + + // 如果拖拽前在播放,则恢复播放 + if (wasPlayingBeforeDrag.value) { + onPlayToggle(true).catch((playError) => { + console.error('恢复播放失败:', playError) + }) + } + } catch (seekError) { + console.error('设置播放位置失败:', seekError) + } + } + } catch (error) { + console.error('拖拽结束处理错误:', error) + // 确保在错误情况下也能恢复页面状态 + restoreMobilePageState() + } finally { + // 始终重置拖拽状态 + isDragging.value = false + showTimePreview.value = false + wasPlayingBeforeDrag.value = false + dragStartX.value = 0 + previewTime.value = 0 + } + } + + /** + * 绑定Canvas元素的拖拽事件 + * @param canvas Canvas元素 + */ + const bindDragEvents = (canvas: HTMLCanvasElement) => { + canvas.addEventListener('mousedown', handleDragStart) + canvas.addEventListener('touchstart', handleDragStart) + } + + /** + * 清理资源和事件监听器 + */ + const cleanup = () => { + // 清理全局事件监听器 + document.removeEventListener('mousemove', handleDragMove) + document.removeEventListener('mouseup', handleDragEnd) + document.removeEventListener('mouseleave', handleDragEnd) + document.removeEventListener('touchmove', handleDragMove) + document.removeEventListener('touchend', handleDragEnd) + document.removeEventListener('touchcancel', handleDragEnd) + + // 恢复页面状态 + restoreMobilePageState() + + // 重置状态 + isDragging.value = false + showTimePreview.value = false + wasPlayingBeforeDrag.value = false + dragStartX.value = 0 + previewTime.value = 0 + } + + return { + // 状态 + isDragging, + previewTime, + showTimePreview, + + // 内部状态 + dragStartX, + wasPlayingBeforeDrag, + + // 方法 + handleDragStart, + calculateTimeFromPosition, + cleanup, + + // 事件绑定 + bindDragEvents + } +} diff --git a/src/hooks/useVoiceRecordRust.ts b/src/hooks/useVoiceRecordRust.ts new file mode 100644 index 0000000..2dddc6c --- /dev/null +++ b/src/hooks/useVoiceRecordRust.ts @@ -0,0 +1,303 @@ +import { BaseDirectory, create, exists, mkdir, readFile } from '@tauri-apps/plugin-fs' +import { startRecording, stopRecording } from 'tauri-plugin-mic-recorder-api' +import { useUserStore } from '@/stores/user' +import { calculateCompressionRatio, compressAudioToMp3, getAudioInfo } from '@/utils/AudioCompression' +import { getImageCache } from '@/utils/PathUtil.ts' +import { isMobile } from '@/utils/PlatformConstants' +import { UploadSceneEnum } from '../enums' +import { useUpload } from './useUpload' +import { removeTempFile } from '@/utils/TempFileManager' + +// 导入worker计时器 +let timerWorker: Worker | null = null + +type VoiceRecordRustOptions = { + onStart?: () => void + onStop?: (audioBlob: Blob, duration: number, localPath: string) => void + onError?: (error: string) => void +} + +export const useVoiceRecordRust = (options: VoiceRecordRustOptions = {}) => { + // 用户store + const userStore = useUserStore() + const isRecording = ref(false) + const recordingTime = ref(0) + const audioLevel = ref(0) + const startTime = ref(0) + const audioMonitor = ref(null) + const timerMsgId = 'voiceRecordTimer' // worker计时器的消息ID + const { generateHashKey } = useUpload() + + /** 开始录音 */ + const startRecordingAudio = async () => { + try { + // 如果有录音正在进行,先停止再开始新录音 + if (isRecording.value) { + await stopRecordingAudio() + } + + // 调用Rust后端开始录音 + await startRecording() + + isRecording.value = true + startTime.value = Date.now() + recordingTime.value = 0 + + // 初始化worker计时器 + if (!timerWorker) { + timerWorker = new Worker(new URL('../workers/timer.worker.ts', import.meta.url)) + + // 监听worker消息 + timerWorker.onmessage = (e) => { + const { type, msgId } = e.data + + if (type === 'timeout' && msgId === timerMsgId) { + // 每秒更新录音时间 + if (isRecording.value) { + const currentTime = Math.floor((Date.now() - startTime.value) / 1000) + recordingTime.value = currentTime + + // 检查是否达到60秒限制 + if (currentTime === 59) { + // 达到60秒,自动停止录音 + stopRecordingAudio() + return + } + + // 重新启动1秒定时器 + timerWorker?.postMessage({ + type: 'startTimer', + msgId: timerMsgId, + duration: 1000 + }) + } + } + } + + timerWorker.onerror = (error) => { + console.error('[VoiceRecord Worker Error]', error) + } + } + + // 开始worker计时 + timerWorker.postMessage({ + type: 'startTimer', + msgId: timerMsgId, + duration: 1000 + }) + + options.onStart?.() + } catch (error) { + console.error('开始录音失败:', error) + window.$message?.error('录音失败') + options.onError?.('录音失败') + } + } + + /** 停止录音 */ + const stopRecordingAudio = async () => { + try { + if (!isRecording.value) return + + // 调用Rust后端停止录音 + const audioPath = await stopRecording() + + isRecording.value = false + const duration = (Date.now() - startTime.value) / 1000 + + // 清理worker定时器 + if (timerWorker) { + timerWorker.postMessage({ + type: 'clearTimer', + msgId: timerMsgId + }) + } + + if (audioMonitor.value) { + clearInterval(audioMonitor.value) + audioMonitor.value = null + } + + // 如果有音频文件路径,立即处理并显示录音结果 + if (audioPath) { + // 读取录音文件 + const audioData = await readFile(audioPath) + + // 获取原始音频信息 + const originalInfo = await getAudioInfo(audioData.buffer as any) + console.log('原始音频信息:', { + duration: `${originalInfo.duration.toFixed(2)}秒`, + sampleRate: `${originalInfo.sampleRate}Hz`, + channels: originalInfo.channels, + size: `${(originalInfo.size / 1024 / 1024).toFixed(2)}MB` + }) + + // 压缩音频为MP3格式 + const compressedBlob = await compressAudioToMp3(audioData.buffer as any, { + channels: 1, // 单声道 + sampleRate: 22050, // 降低采样率 + bitRate: 64 // 较低比特率 + }) + + // 计算压缩比 + const compressionRatio = calculateCompressionRatio(originalInfo.size, compressedBlob.size) + console.log('音频压缩完成:', { + originalSize: `${(originalInfo.size / 1024 / 1024).toFixed(2)}MB`, + compressedSize: `${(compressedBlob.size / 1024 / 1024).toFixed(2)}MB`, + compressionRatio: `${compressionRatio}%` + }) + + // 立即回调显示录音结果,传递压缩后的音频 + options.onStop?.(compressedBlob, duration, audioPath) + + // 异步处理缓存,不阻塞UI更新 + saveAudioToCache(compressedBlob, duration).catch((error) => { + console.error('缓存音频文件失败:', error) + // 缓存失败不影响主要功能,只记录错误 + }) + + // 删除原始的wav文件,释放磁盘空间 + await removeTempFile(audioPath, { reason: '删除原始录音文件失败' }) + } + } catch (error) { + console.error('停止录音或压缩失败:', error) + + // 确保录音状态被正确重置 + isRecording.value = false + + // 清理worker定时器 + if (timerWorker) { + timerWorker.postMessage({ + type: 'clearTimer', + msgId: timerMsgId + }) + } + + if (audioMonitor.value) { + clearInterval(audioMonitor.value) + audioMonitor.value = null + } + options.onError?.('停止录音失败') + } + } + + /** 取消录音 */ + const cancelRecordingAudio = async () => { + try { + if (!isRecording.value) return + + // 调用Rust后端停止录音,但不处理返回的音频文件 + await stopRecording() + console.log('取消录音') + + isRecording.value = false + + // 清理worker定时器 + if (timerWorker) { + timerWorker.postMessage({ + type: 'clearTimer', + msgId: timerMsgId + }) + } + + if (audioMonitor.value) { + clearInterval(audioMonitor.value) + audioMonitor.value = null + } + } catch (error) { + console.error('取消录音失败:', error) + // 确保状态被重置 + isRecording.value = false + options.onError?.('取消录音失败') + } + } + + /** 保存音频到本地缓存 */ + const saveAudioToCache = async (audioBlob: Blob, duration: number) => { + const getFileHashName = async (tempFileName: string) => { + const audioFile = new File([audioBlob], tempFileName) + // 计算文件真正的资源哈希文件名 + const resourceFileName = await generateHashKey( + { scene: UploadSceneEnum.CHAT, enableDeduplication: true }, // 这里的UploadSceneEnum随便选,反正只需要哈希值文件名 + audioFile, + tempFileName + ) + + return resourceFileName.split('/').pop() as string + } + + try { + const userUid = userStore.userInfo!.uid + if (!userUid) { + throw new Error('用户未登录') + } + + // 生成文件名 + const timestamp = Date.now() + const fileName = `voice_${timestamp}.mp3` + + // 获取缓存路径 + const audioFolder = 'audio' + const cachePath = getImageCache(audioFolder, userUid.toString()) + + const fileHashName = await getFileHashName(fileName) + const fullPath = cachePath + fileHashName + + // 确保目录存在 + const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache + const dirExists = await exists(cachePath, { baseDir }) + if (!dirExists) { + await mkdir(cachePath, { baseDir, recursive: true }) + } + + // 将Blob转换为ArrayBuffer + const arrayBuffer = await audioBlob.arrayBuffer() + + // 保存到本地文件 + const file = await create(fullPath, { baseDir }) + await file.write(new Uint8Array(arrayBuffer)) + await file.close() + + console.log('音频文件已保存到:', fullPath) + + // 调用回调,传递本地路径 + options.onStop?.(audioBlob, duration, fullPath) + } catch (error) { + console.error('保存音频文件失败:', error) + window.$message?.error('音频保存失败') + options.onError?.('音频保存失败') + } + } + + // 格式化录音时间 + const formatTime = (seconds: number) => { + const roundedSeconds = Math.round(seconds) + const mins = Math.floor(roundedSeconds / 60) + const secs = roundedSeconds % 60 + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } + + // 清理资源 + onUnmounted(() => { + cancelRecordingAudio() + // 清理worker + if (timerWorker) { + timerWorker.postMessage({ + type: 'clearTimer', + msgId: timerMsgId + }) + timerWorker.terminate() + timerWorker = null + } + }) + + return { + isRecording: readonly(isRecording), + recordingTime: readonly(recordingTime), + audioLevel: readonly(audioLevel), + startRecording: startRecordingAudio, + stopRecording: stopRecordingAudio, + cancelRecording: cancelRecordingAudio, + formatTime + } +} diff --git a/src/hooks/useWaveformRenderer.ts b/src/hooks/useWaveformRenderer.ts new file mode 100644 index 0000000..dee9ded --- /dev/null +++ b/src/hooks/useWaveformRenderer.ts @@ -0,0 +1,349 @@ +import { useThrottleFn } from '@vueuse/core' +import type { Ref } from 'vue' + +/** + * 波形颜色配置接口 + */ +export type WaveformColors = { + playedColor: string + unplayedColor: string +} + +/** + * 波形渲染器返回接口 + */ +export type WaveformRendererReturn = { + // 状态 + waveformData: Ref + waveformCanvas: Ref + waveformWidth: Ref + scanLinePosition: Ref + + // 缓存管理 + waveformImageCache: Ref + shouldUpdateCache: Ref + + // 方法 + generateWaveformData: (audioBuffer: ArrayBuffer | Uint8Array | SharedArrayBuffer) => Promise + drawWaveform: () => void + drawWaveformThrottled: () => void + drawWaveformImmediate: () => void + updateColors: (colors: WaveformColors) => void + + // 配置 + setDimensions: (width: number, height: number) => void +} + +/** + * 波形可视化渲染Hook + * @param duration 音频时长(秒) + * @param playbackProgress 播放进度百分比 + * @param isDragging 是否正在拖拽 + * @param previewTime 预览时间(拖拽时使用) + * @param getColors 获取颜色配置的函数 + * @returns 波形渲染接口 + */ +export const useWaveformRenderer = ( + duration: Ref, + playbackProgress: Ref, + isDragging: Ref, + previewTime: Ref, + getColors: () => WaveformColors +): WaveformRendererReturn => { + const waveformData = ref([]) + const waveformCanvas = ref(null) + const audioContext = ref(null) + + const waveformImageCache = ref(null) + const shouldUpdateCache = ref(true) + const lastCacheKey = ref('') + + /** + * 根据音频时长计算波形宽度 (最短10px,最长100px) + */ + const waveformWidth = computed(() => { + const dur = duration.value + const baseWidth = 10 // 基础宽度 + const maxWidth = 100 // 最大宽度 + const pixelsPerSecond = 4 // 每秒对应的像素数 + + const calculatedWidth = baseWidth + dur * pixelsPerSecond + return Math.min(maxWidth, Math.max(baseWidth, calculatedWidth)) + }) + + /** + * 根据宽度计算波形条数 (每2像素一个条) + */ + const waveformSamples = computed(() => { + return Math.floor(waveformWidth.value / 2) + }) + + /** + * 计算扫描线的精确位置 (像素位置) + */ + const scanLinePosition = computed(() => { + if (!waveformCanvas.value || waveformData.value.length === 0) return 0 + + const canvasWidth = waveformWidth.value + + // 拖拽状态下显示预览位置,否则显示播放进度位置 + if (isDragging.value) { + const progress = previewTime.value / (duration.value || 1) + return progress * canvasWidth + } else { + const progress = playbackProgress.value / 100 + return progress * canvasWidth + } + }) + + /** + * 生成缓存键,用于检测是否需要重新生成缓存 + */ + const generateCacheKey = (colors: WaveformColors) => { + const playbackState = isDragging.value ? 'dragging' : 'normal' + return `${colors.playedColor}-${colors.unplayedColor}-${playbackState}-${waveformWidth.value}-${waveformData.value.length}` + } + + /** + * 生成音频波形数据 + * @param input 可以是 ArrayBuffer、Uint8Array 或 SharedArrayBuffer + */ + const generateWaveformData = async (input: ArrayBuffer | Uint8Array | SharedArrayBuffer) => { + try { + // 创建 AudioContext + if (!audioContext.value) { + audioContext.value = new (window.AudioContext || window.AudioContext)() + } + + // 确保 arrayBuffer 是 ArrayBuffer 类型 + let arrayBuffer: ArrayBuffer + if (input instanceof Uint8Array) { + // 如果 buffer 是 ArrayBuffer,直接使用;否则创建新的 ArrayBuffer + if (input.buffer instanceof ArrayBuffer) { + arrayBuffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength) + } else { + // 如果是 SharedArrayBuffer,转换为 ArrayBuffer + arrayBuffer = new ArrayBuffer(input.byteLength) + new Uint8Array(arrayBuffer).set(input) + } + } else if (input instanceof ArrayBuffer) { + arrayBuffer = input + } else { + // 如果是 SharedArrayBuffer,转换为 ArrayBuffer + arrayBuffer = new ArrayBuffer(input.byteLength) + new Uint8Array(arrayBuffer).set(new Uint8Array(input)) + } + + // 解码音频数据 + const buffer = await audioContext.value.decodeAudioData(arrayBuffer) + const channelData = buffer.getChannelData(0) + const samples = waveformSamples.value + const blockSize = Math.floor(channelData.length / samples) + const waveform: number[] = [] + + for (let i = 0; i < samples; i++) { + const start = i * blockSize + const end = start + blockSize + let sum = 0 + let max = 0 + + for (let j = start; j < end && j < channelData.length; j++) { + const value = Math.abs(channelData[j]) + sum += value + max = Math.max(max, value) + } + + const rms = Math.sqrt(sum / blockSize) + const intensity = Math.min(1, (rms + max) / 2) + waveform.push(intensity) + } + + waveformData.value = waveform + shouldUpdateCache.value = true + drawWaveform() + } catch (error) { + console.error('生成波形数据失败:', error) + // 生成默认波形 + waveformData.value = Array.from({ length: waveformSamples.value }, () => Math.random() * 0.8 + 0.2) + shouldUpdateCache.value = true + drawWaveform() + } + } + + /** + * 绘制波形 + */ + const drawWaveform = () => { + if (!waveformCanvas.value || waveformData.value.length === 0) return + + const canvas = waveformCanvas.value + const ctx = canvas.getContext('2d') + if (!ctx) return + + const width = waveformWidth.value + const height = canvas.height + const barWidth = width / waveformData.value.length + + // 清除画布 + ctx.clearRect(0, 0, canvas.width, canvas.height) + + // 获取颜色状态 + const colors = getColors() + + // 检查是否需要更新缓存 + const currentCacheKey = generateCacheKey(colors) + const needUpdateCache = + shouldUpdateCache.value || !waveformImageCache.value || lastCacheKey.value !== currentCacheKey + + // 只在需要更新或缓存不存在时才重新生成静态波形 + if (needUpdateCache) { + // 创建离屏Canvas渲染静态波形 + const offscreenCanvas = new OffscreenCanvas(width, height) + const offscreenCtx = offscreenCanvas.getContext('2d') + + if (offscreenCtx) { + waveformData.value.forEach((intensity, index) => { + const x = index * barWidth + const barHeight = Math.max(2, intensity * height * 0.8) + const y = (height - barHeight) / 2 + + offscreenCtx.fillStyle = colors.unplayedColor + offscreenCtx.fillRect(x, y, Math.max(1, barWidth - 1), barHeight) + }) + + // 缓存静态波形图像 + waveformImageCache.value = offscreenCtx.getImageData(0, 0, width, height) + lastCacheKey.value = currentCacheKey + shouldUpdateCache.value = false + } + } + + // 绘制缓存的静态波形 + if (waveformImageCache.value) { + ctx.putImageData(waveformImageCache.value, 0, 0) + } + + // 根据播放进度绘制已播放部分(动态层) + const progress = isDragging.value ? previewTime.value / (duration.value || 1) : playbackProgress.value / 100 + const progressX = progress * width + + if (progressX > 0) { + try { + const startBarIndex = 0 + const endBarIndex = Math.ceil(progressX / barWidth) + + for (let index = startBarIndex; index < endBarIndex && index < waveformData.value.length; index++) { + const intensity = waveformData.value[index] + const x = index * barWidth + + // 只绘制在进度线左侧的部分 + const barRight = x + Math.max(1, barWidth - 1) + if (barRight <= progressX) { + const barHeight = Math.max(2, intensity * height * 0.8) + const y = (height - barHeight) / 2 + + ctx.fillStyle = colors.playedColor + ctx.fillRect(x, y, Math.max(1, barWidth - 1), barHeight) + } else if (x < progressX) { + // 部分在进度线左侧的柱子 + const barHeight = Math.max(2, intensity * height * 0.8) + const y = (height - barHeight) / 2 + const clippedWidth = progressX - x + + ctx.fillStyle = colors.playedColor + ctx.fillRect(x, y, clippedWidth, barHeight) + } + } + } catch (error) { + console.error('绘制播放进度失败:', error) + } + } + } + + /** + * 节流版本的波形绘制(用于播放进度更新) + */ + const drawWaveformThrottled = useThrottleFn(() => { + if (!isDragging.value) { + drawWaveform() + } + }, 16) + + /** + * 立即绘制波形(用于拖拽时的实时响应) + */ + const drawWaveformImmediate = () => { + drawWaveform() + } + + /** + * 更新颜色配置并重绘 + * @param colors 新的颜色配置 + */ + const updateColors = (_colors: WaveformColors) => { + shouldUpdateCache.value = true + drawWaveform() + } + + /** + * 设置Canvas尺寸 + * @param width 宽度 + * @param height 高度 + */ + const setDimensions = (width: number, height: number) => { + if (waveformCanvas.value) { + waveformCanvas.value.width = width + waveformCanvas.value.height = height + shouldUpdateCache.value = true + drawWaveform() + } + } + + // 监听播放进度变化重绘波形(节流处理) + watch(playbackProgress, () => { + drawWaveformThrottled() + }) + + // 监听拖拽状态变化重绘波形 + watch(isDragging, () => { + shouldUpdateCache.value = true + drawWaveform() + }) + + // 监听波形数据变化 + watch(waveformData, () => { + shouldUpdateCache.value = true + drawWaveform() + }) + + // 监听尺寸变化 + watch(waveformWidth, () => { + shouldUpdateCache.value = true + if (waveformCanvas.value) { + waveformCanvas.value.width = waveformWidth.value + drawWaveform() + } + }) + + return { + // 状态 + waveformData, + waveformCanvas, + waveformWidth, + scanLinePosition, + + // 缓存管理 + waveformImageCache, + shouldUpdateCache, + + // 方法 + generateWaveformData, + drawWaveform, + drawWaveformThrottled, + drawWaveformImmediate, + updateColors, + + // 配置 + setDimensions + } +} diff --git a/src/hooks/useWebRtc.ts b/src/hooks/useWebRtc.ts new file mode 100644 index 0000000..4568431 --- /dev/null +++ b/src/hooks/useWebRtc.ts @@ -0,0 +1,1268 @@ +import { listen } from '@tauri-apps/api/event' +import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' +import { error, info } from '@tauri-apps/plugin-log' +import { useI18n } from 'vue-i18n' +import { initConfig } from '@/utils/ImRequestUtils' +import { CallTypeEnum, RTCCallStatus } from '@/enums' +import rustWebSocketClient from '@/services/webSocketRust' +import { useUserStore } from '@/stores/user' +import { WsRequestMsgType, WsResponseMessageType } from '../services/wsType' +import { isMobile } from '../utils/PlatformConstants' +import { type DeviceKind, useDevicePermission } from './useDevicePermission' +import { useMitt } from './useMitt' +import { useTauriListener } from './useTauriListener' + +interface RtcMsgVO { + roomId: string + callType: CallTypeEnum + callerId: string + [key: string]: any +} + +// 信令类型枚举 +export enum SignalTypeEnum { + JOIN = 'join', + OFFER = 'offer', + ANSWER = 'answer', + CANDIDATE = 'candidate', + LEAVE = 'leave' +} + +export interface WSRtcCallMsg { + // 房间ID + roomId: string + // 通话ID + callerId: string + // 信令类型 + signalType: SignalTypeEnum + // 信令 + signal: string + // 接收者ID列表 + receiverIds: string[] + // 发送者ID + senderId?: string + // 通话状态 + status: RTCCallStatus + // 是否是视频通话 + video: boolean + // 目标uid + targetUid: string +} + +// const TURN_SERVER = import.meta.env.VITE_TURN_SERVER_URL +const MAX_TIME_OUT_SECONDS = 30 +let configuration: RTCConfiguration = { + iceServers: [ + { urls: 'stun:117.72.67.248:3478' }, + { + urls: ['turn:117.72.67.248:3478?transport=udp', 'turn:117.72.67.248:3478?transport=tcp'], + username: 'chr', + credential: '123456' + } + ], + iceTransportPolicy: 'all' +} + +const loadIceServers = async () => { + try { + const init: any = await initConfig() + const ice = init?.iceServer + if (ice && Array.isArray(ice.urls) && ice.urls.length > 0) { + const entry: RTCIceServer = + ice.username && ice.credential + ? { urls: ice.urls, username: ice.username, credential: ice.credential } + : { urls: ice.urls } + configuration = { iceServers: [entry], iceTransportPolicy: 'all' } + info(`ICE 配置已加载: ${JSON.stringify(configuration)}`) + } else { + info('ICE 配置为空,使用内置默认配置') + } + } catch (e) { + error(`加载 ICE 配置失败: ${String(e)}`) + } +} + +// const settings = await getSettings() +// configuration.iceServers?.push(settings.ice_server) +// const isSupportScreenSharing = !!navigator?.mediaDevices?.getDisplayMedia +// TODO 改成动态配置 +const rtcCallBellUrl = '/sound/hula_bell.mp3' + +/** + * webrtc 相关 + * @returns rtc 相关的状态和方法 + */ +export const useWebRtc = (roomId: string, remoteUserId: string, callType: CallTypeEnum, isReceiver: boolean) => { + const { addListener } = useTauriListener() + + const router = useRouter() + + info(`useWebRtc, roomId: ${roomId}, remoteUserId: ${remoteUserId}, callType: ${callType}, isReceiver: ${isReceiver}`) + const rtcMsg = ref>({ + roomId: undefined, + callType: undefined, + callerId: undefined + }) + const userStore = useUserStore() + const { t } = useI18n() + const { isPermissionDenied, resolveDeniedDeviceKind, openSystemSettings } = useDevicePermission() + + // 设备相关状态 + const audioDevices = ref([]) + const videoDevices = ref([]) + const selectedAudioDevice = ref(null) + const selectedVideoDevice = ref(null) + + // 状态 + const connectionStatus = ref(undefined) + const isDeviceLoad = ref(false) + const deviceAccessError = ref<'permission' | 'unavailable' | undefined>(undefined) + const isLinker = ref(false) // 判断是否是 webrtc 连接的参与者 + + // rtc状态 + const rtcStatus = ref(undefined) + // const isRtcConnecting = computed(() => rtcStatus.value === 'connecting') + // 流相关状态 + const localStream = ref(null) + const remoteStream = ref(null) + // WebRTC 连接对象 + const peerConnection = ref(null) + const channel = ref(null) + const channelStatus = ref(undefined) + // 待发送ice列表 + const pendingCandidates = ref([]) + // 添加铃声相关状态 + const bellAudio = ref(null) + + // 添加计时器引用 + const callTimer = ref(null) + + // 添加计时相关的变量 + const callDuration = ref(0) + const animationFrameId = ref(null) + const startTime = ref(0) + + // 添加桌面共享相关状态 + const isScreenSharing = ref(false) + const offer = ref() + + // 接通后确保窗口聚焦显示 + const focusCurrentWindow = async () => { + try { + const currentWindow = getCurrentWebviewWindow() + const visible = await currentWindow.isVisible() + if (!visible) { + await currentWindow.show() + } + const minimized = await currentWindow.isMinimized() + if (minimized) { + await currentWindow.unminimize() + } + await currentWindow.setFocus() + } catch (e) { + console.warn('设置窗口聚焦失败:', e) + } + } + + // 开始计时 + const startCallTimer = () => { + // 获取高精度时间戳 + startTime.value = performance.now() + const animate = (currentTime: number) => { + // 计算已经过去的秒数 + const elapsed = Math.floor((currentTime - startTime.value) / 1000) + callDuration.value = elapsed + // 递归调用,形成动画循环 + animationFrameId.value = requestAnimationFrame(animate) + } + animationFrameId.value = requestAnimationFrame(animate) // 启动动画循环 + } + + // 停止计时 + const stopCallTimer = () => { + if (animationFrameId.value) { + cancelAnimationFrame(animationFrameId.value) + animationFrameId.value = null + } + callDuration.value = 0 + startTime.value = 0 + } + + /** + * 打开铃声 + */ + const startBell = () => { + if (!rtcCallBellUrl) { + console.log('rtc通话已经静音') + bellAudio.value = null + return + } + bellAudio.value = new Audio(rtcCallBellUrl) + bellAudio.value!.loop = true + bellAudio.value?.play?.() + } + + /** + * 发送通话请求 + */ + const sendCall = async () => { + try { + await rustWebSocketClient.sendMessage({ + type: WsRequestMsgType.VIDEO_CALL_REQUEST, + data: { + roomId: roomId, + targetUid: remoteUserId, + isVideo: callType === CallTypeEnum.VIDEO + } + }) + } catch (error) { + console.error('发送通话请求失败:', error) + } + } + + /** + * 关闭铃声 + */ + const stopBell = () => { + bellAudio.value?.pause?.() + bellAudio.value = null + } + + const pauseBell = () => { + bellAudio.value?.pause?.() + } + + const playBell = () => { + bellAudio.value?.play?.() + } + + /** + * 接听电话响应事件 + */ + const handleCallResponse = async (status: number) => { + try { + info('[收到通知] 接听电话响应事件') + // 发送挂断消息 + sendRtcCall2VideoCallResponse(status) + await endCall() + } finally { + clear() + } + } + + /** + * 结束通话 + */ + const endCall = async () => { + try { + info('[收到通知] 结束通话') + // 移动端router 回退 + if (!isMobile()) { + await getCurrentWebviewWindow().close() + } else { + router.back() + } + } finally { + clear() + } + } + + // 发送 ws 请求,通知双方通话状态 + // -1 = 超时 0 = 拒绝 1 = 接通 2 = 挂断 + const sendRtcCall2VideoCallResponse = async (status: number) => { + try { + info(`发送 ws 请求,通知双方通话状态 ${status}`) + await rustWebSocketClient.sendMessage({ + type: WsRequestMsgType.VIDEO_CALL_RESPONSE, + data: { + callerUid: remoteUserId, + roomId: roomId, + accepted: status + } + }) + } catch (error) { + console.error('发送通话响应失败:', error) + } + } + + /** + * 权限被拒绝时给用户友好提示,并提供“打开系统设置”入口 + * @param kind 设备类型,决定提示文案与跳转的设置页 + * @param err 原始错误,用于判定是否为权限拒绝 + */ + const getRequiredDeviceKinds = (type: CallTypeEnum): DeviceKind[] => { + return type === CallTypeEnum.VIDEO ? ['microphone', 'camera'] : ['microphone'] + } + + const getDeviceProbeConstraints = (type: CallTypeEnum): MediaStreamConstraints => { + return { + audio: true, + video: type === CallTypeEnum.VIDEO + } + } + + const showDeviceUnavailableMessage = (kind: DeviceKind) => { + const key = kind === 'camera' ? 'message.permission.camera.unavailable' : 'message.permission.mic.unavailable' + window.$message.error(t(key)) + } + + const showPermissionDeniedMessage = (kind: DeviceKind, err?: unknown, onHangup?: () => void | Promise) => { + const titleKey = kind === 'camera' ? 'message.permission.camera.title' : 'message.permission.mic.title' + const key = kind === 'camera' ? 'message.permission.camera.denied' : 'message.permission.mic.denied' + window.$dialog.warning({ + title: t(titleKey), + content: t(key), + positiveText: t('message.permission.open_settings'), + negativeText: t('message.permission.hangup'), + onPositiveClick: () => { + void openSystemSettings(kind) + return false + }, + onNegativeClick: () => { + void onHangup?.() + } + }) + error(`设备权限被拒绝(${kind}): ${String(err ?? '')}`) + } + + // 获取设备列表 + const getDevices = async (type: CallTypeEnum = callType, onPermissionHangup?: () => void | Promise) => { + try { + info('start getDevices') + isDeviceLoad.value = true + deviceAccessError.value = undefined + + // 先请求权限以获取完整的设备信息 + try { + const stream = await navigator.mediaDevices.getUserMedia(getDeviceProbeConstraints(type)) + stream.getTracks().forEach((track) => track.stop()) // 立即停止流 + } catch (permissionError) { + // 权限被拒绝:提示用户并中止获取设备,避免在无权限情况下继续通话 + if (isPermissionDenied(permissionError)) { + const kind = await resolveDeniedDeviceKind(getRequiredDeviceKinds(type)) + showPermissionDeniedMessage(kind, permissionError, onPermissionHangup) + deviceAccessError.value = 'permission' + isDeviceLoad.value = false + return false + } + // 非权限问题(如设备暂不可用):记录后继续用 enumerateDevices 兜底 + info(`getUserMedia 探针失败,将尝试获取受限设备信息: ${String(permissionError)}`) + } + + const devices = (await navigator.mediaDevices.enumerateDevices()) || [] + info(`getDevices, devices: ${JSON.stringify(devices)}`) + if (devices.length === 0) { + deviceAccessError.value = 'unavailable' + return false + } + audioDevices.value = devices.filter((device) => device.kind === 'audioinput') + videoDevices.value = devices.filter((device) => device.kind === 'videoinput') + if (audioDevices.value.length === 0) { + showDeviceUnavailableMessage('microphone') + deviceAccessError.value = 'unavailable' + isDeviceLoad.value = false + return false + } + if (type === CallTypeEnum.VIDEO && videoDevices.value.length === 0) { + showDeviceUnavailableMessage('camera') + deviceAccessError.value = 'unavailable' + isDeviceLoad.value = false + return false + } + // 默认选择 “default” | "第一个" 设备 + selectedAudioDevice.value = + audioDevices.value.find((device) => device.deviceId === 'default')?.deviceId || + audioDevices.value?.[0]?.deviceId + selectedVideoDevice.value = + videoDevices.value.find((device) => device.deviceId === 'default')?.deviceId || + videoDevices.value?.[0]?.deviceId + isDeviceLoad.value = false + return true + } catch (err) { + if (deviceAccessError.value !== 'permission') { + window.$message.error('获取设备失败!') + } + error(`获取设备失败: ${err}`) + // 默认没有设备 + selectedAudioDevice.value = selectedAudioDevice.value || null + selectedVideoDevice.value = selectedVideoDevice.value || null + isDeviceLoad.value = false + return false + } + } + + // 获取本地媒体流 + const getLocalStream = async (type: CallTypeEnum, onPermissionHangup?: () => void | Promise) => { + try { + info('获取本地媒体流') + const constraints = { + audio: audioDevices.value.length > 0 ? { deviceId: selectedAudioDevice.value || undefined } : false, + video: + type === CallTypeEnum.VIDEO && videoDevices.value.length > 0 + ? { deviceId: selectedVideoDevice.value || undefined } + : false + } + if (!constraints.audio && !constraints.video) { + window.$message.error('没有可用的设备!') + // 没有可用设备时自动挂断并关闭窗口 + setTimeout(async () => { + if (isReceiver) { + // 接听方:发送拒绝响应 + await handleCallResponse(0) + } else { + // 发起方:直接结束通话 + await handleCallResponse(2) + } + }, 1000) + return false + } + localStream.value = await navigator.mediaDevices.getUserMedia(constraints) + // 打印 localStream 的信息(不能直接序列号 stream,不然会返回null) + info(`get localStream success`) + info(`localStream.id: ${localStream.value?.id}`) + info(`localStream.active: ${localStream.value?.active}`) + info(`localStream.getTracks().length: ${localStream.value?.getTracks()?.length}`) + // 打印每个轨道的信息 + localStream.value?.getTracks()?.forEach((track, index) => { + info(`Track ${index}: kind=${track.kind}, label=${track.label}, enabled=${track.enabled}`) + }) + + const audioTrack = localStream.value.getAudioTracks()[0] + if (audioTrack) { + // 检查音频轨道是否真的在工作 + info(`Audio track enabled: ${audioTrack.enabled}`) + info(`Audio track muted: ${audioTrack.muted}`) + info(`Audio track readyState: ${audioTrack.readyState}`) + + // 强制启用音频轨道 + audioTrack.enabled = true + } + + return true + } catch (err) { + console.error('获取本地流失败:', err) + if (isPermissionDenied(err)) { + // 权限拒绝:根据通话类型提示对应权限被拒 + const kind = await resolveDeniedDeviceKind(getRequiredDeviceKinds(type)) + showPermissionDeniedMessage(kind, err, onPermissionHangup) + deviceAccessError.value = 'permission' + } else { + window.$message.error('获取本地媒体流失败,请检查设备!') + error(`获取本地媒体流失败,请检查设备! ${err}`) + await sendRtcCall2VideoCallResponse(2) + } + return false + } + } + + // 创建 RTCPeerConnection + const createPeerConnection = (roomId: string) => { + try { + const pc = new RTCPeerConnection(configuration) + + // 监听远程流 + pc.ontrack = (event) => { + info('pc 监听到 ontrack 事件') + if (event.streams[0]) { + console.log('收到远程流:', event.streams[0]) + remoteStream.value = event.streams[0] + } else { + remoteStream.value = null + } + } + + // 添加本地流 + info('添加本地流到 PC') + if (localStream.value) { + localStream.value.getTracks().forEach((track) => { + localStream.value && pc.addTrack(track, localStream.value) + }) + } else { + console.warn('localStream 为 null,无法添加本地流到 PeerConnection') + } + + // 连接状态变化 "closed" | "connected" | "connecting" | "disconnected" | "failed" | "new"; + pc.onconnectionstatechange = (e) => { + info(`RTC 连接状态变化: ${pc.connectionState}`) + switch (pc.connectionState) { + case 'new': + info('RTC 连接新建') + break + case 'connecting': + info('RTC 连接中') + connectionStatus.value = RTCCallStatus.CALLING + break + case 'connected': + info('RTC 连接成功') + connectionStatus.value = RTCCallStatus.ACCEPT + startCallTimer() // 开始计时 + // 接通后将窗口置顶展示并聚焦 + void focusCurrentWindow() + break + case 'disconnected': + info('RTC 连接断开') + connectionStatus.value = RTCCallStatus.END + window.$message.error('RTC通讯连接失败!') + setTimeout(async () => { + await endCall() + }, 500) + break + case 'closed': + info('RTC 连接关闭') + connectionStatus.value = RTCCallStatus.END + setTimeout(async () => { + await endCall() + }, 500) + break + case 'failed': + connectionStatus.value = RTCCallStatus.ERROR + info('RTC 连接失败') + window.$message.error('RTC通讯连接失败!') + setTimeout(async () => { + await endCall() + }, 500) + break + default: + info('RTC 连接状态变化: ', pc.connectionState) + break + } + // @ts-expect-error + rtcStatus.value = (e?.currentTarget?.connectionState || pc.connectionState) as RTCPeerConnectionState + } + // 创建信道 + channel.value = pc.createDataChannel('chat') + channel.value.onopen = () => { + // console.log("信道已打开"); + } + channel.value.onmessage = (_event) => { + // console.log("收到消息:", event.data); + } + channel.value.onerror = (event) => { + console.warn('信道出错:', event) + } + channel.value.onclose = () => { + // console.log("信道已关闭"); + } + pc.onicecandidate = async (event) => { + info('pc 监听到 onicecandidate 事件') + if (event.candidate && roomId) { + try { + pendingCandidates.value.push(event.candidate) + } catch (err) { + console.error('发送ICE候选者出错:', err) + } + } + } + peerConnection.value = pc + } catch (err) { + console.error('创建 PeerConnection 失败:', err) + connectionStatus.value = RTCCallStatus.ERROR + throw err + } + } + + // 发起通话 + const startCall = async (roomId: string, type: CallTypeEnum, uidList?: string[]) => { + try { + if (!roomId) { + return false + } + clear() // 清理资源 + if (!(await getDevices(type, endCall))) { + if (deviceAccessError.value === 'permission') { + return false + } + window.$message.error('获取设备失败!') + // 获取设备失败时自动关闭窗口 + setTimeout(async () => { + await handleCallResponse(0) + }, 1000) + return + } + // 保存通话信息 + rtcMsg.value = { + roomId, + callType: type, + callerId: userStore.userInfo!.uid, + uidList: uidList || [] + } + isLinker.value = true // 标记是会话人 + // 设置30秒超时定时器 + callTimer.value = setTimeout(() => { + if (connectionStatus.value === RTCCallStatus.CALLING) { + window.$message.warning('通话无人接听,自动挂断') + endCall() + } + }, MAX_TIME_OUT_SECONDS * 1000) + + if (!(await getLocalStream(type, endCall))) { + if (deviceAccessError.value === 'permission') { + return false + } + clear() + // 获取本地媒体流失败时自动关闭窗口 + setTimeout(async () => { + await endCall() + }, 1000) + return false + } + + // 1. 创建 RTCPeerConnection + createPeerConnection(roomId) + // 创建并发送 offer + const rtcOffer = await peerConnection.value!.createOffer() + offer.value = rtcOffer + await peerConnection.value!.setLocalDescription(rtcOffer) + // 发起通话请求 + await sendCall() + // 播放铃声 + startBell() + + // 开始通话 + connectionStatus.value = RTCCallStatus.CALLING + rtcStatus.value = 'new' + } catch (err) { + console.error('开始通话失败:', err) + window.$message.error('RTC通讯连接失败!') + clear() + return false + } + } + + // 发送SDP offer + const sendOffer = async (offer: RTCSessionDescriptionInit) => { + try { + const signalData = { + callerUid: userStore.userInfo!.uid, + roomId: roomId, + signal: JSON.stringify(offer), + signalType: 'offer', + targetUid: remoteUserId, + video: callType === CallTypeEnum.VIDEO + } + + info('ws发送 offer') + await rustWebSocketClient.sendMessage({ + type: WsRequestMsgType.WEBRTC_SIGNAL, + data: signalData + }) + } catch (error) { + console.error('Failed to send SDP offer:', error) + } + } + + const clear = () => { + try { + // 停止铃声并重置 + stopBell() + // 清除超时定时器 + if (callTimer.value) { + clearTimeout(callTimer.value) + callTimer.value = null + } + // 停止计时器 + stopCallTimer() + // 关闭信道 + channel.value?.close?.() + // 关闭连接 + peerConnection.value?.close?.() + // 关闭媒体流 + localStream.value?.getTracks().forEach((track) => track.stop()) + remoteStream.value?.getTracks().forEach((track) => track.stop()) + } catch (error) { + window.$message.error('部分资源清理失败!') + console.error('清理资源失败:', error) + } finally { + // 重置状态 + rtcMsg.value = { + roomId: undefined, + callType: undefined, + senderId: undefined + } + pendingCandidates.value = [] + audioDevices.value = [] + videoDevices.value = [] + deviceAccessError.value = undefined + selectedAudioDevice.value = null + selectedVideoDevice.value = null + localStream.value = null + remoteStream.value = null + connectionStatus.value = undefined + rtcStatus.value = undefined + isScreenSharing.value = false + isLinker.value = false + // 关闭连接 + peerConnection.value = null + channel.value = null + channelStatus.value = undefined + } + } + + // 发送ICE候选者 + const sendIceCandidate = async (candidate: RTCIceCandidate) => { + try { + info('发送ICE候选者') + const signalData = { + roomId: roomId, + signal: JSON.stringify(candidate), + signalType: 'candidate', + targetUid: remoteUserId, + mediaType: callType === CallTypeEnum.VIDEO ? 'VideoSignal' : 'AudioSignal' + } + + await rustWebSocketClient.sendMessage({ + type: WsRequestMsgType.WEBRTC_SIGNAL, + data: signalData + }) + } catch (error) { + console.error('Failed to send ICE candidate:', error) + } + } + + // 处理收到的 offer - 接听者 + const handleOffer = async (signal: RTCSessionDescriptionInit, video: boolean, roomId: string) => { + try { + console.log('处理 offer') + connectionStatus.value = RTCCallStatus.CALLING + await nextTick() + + if (!(await getDevices(video ? CallTypeEnum.VIDEO : CallTypeEnum.AUDIO, handleCallResponse.bind(null, 0)))) { + if (deviceAccessError.value === 'permission') { + return false + } + await handleCallResponse(0) + return false + } + const hasLocalStream = await getLocalStream( + video ? CallTypeEnum.VIDEO : CallTypeEnum.AUDIO, + handleCallResponse.bind(null, 0) + ) + + // 停止铃声 + stopBell() + + // 检查本地媒体流是否获取成功 + if (!hasLocalStream || !localStream.value) { + if (deviceAccessError.value === 'permission') { + return false + } + // 睡眠 3s + await new Promise((resolve) => setTimeout(resolve, 3000)) + await handleCallResponse(0) + return false + } + + // 2. 创建 RTCPeerConnection + await nextTick() // 等待一帧 + createPeerConnection(roomId) + rtcStatus.value = 'new' + + // 3. 设置远程描述 + info('设置远程描述') + await peerConnection.value!.setRemoteDescription(signal) + + // 4. 创建并发送 answer + const answer = await peerConnection.value!.createAnswer() + await peerConnection.value!.setLocalDescription(answer) + + if (!roomId) { + window.$message.error('房间号不存在,请重新连接!') + return false + } + + isLinker.value = true // 标记是会话人 + // 6. 发送 answer 信令到远端 + await sendAnswer(answer) + connectionStatus.value = RTCCallStatus.ACCEPT + info('处理 offer 结束') + } catch (e) { + error(`处理 offer 失败: ${e}`) + await endCall() + } + } + + // 发送SDP answer + const sendAnswer = async (answer: RTCSessionDescriptionInit) => { + try { + const signalData = { + callerUid: userStore.userInfo!.uid, + roomId: roomId, + signal: JSON.stringify(answer), + signalType: SignalTypeEnum.ANSWER, + targetUid: remoteUserId, + video: callType === CallTypeEnum.VIDEO + } + + console.log('发送SDP answer', signalData) + await rustWebSocketClient.sendMessage({ + type: WsRequestMsgType.WEBRTC_SIGNAL, + data: signalData + }) + + console.log('SDP answer sent via WebSocket:', answer) + } catch (error) { + console.error('Failed to send SDP answer:', error) + } + } + + const handleAnswer = async (answer: RTCSessionDescriptionInit, roomId: string) => { + try { + info('处理 answer 消息') + if (peerConnection.value) { + // 清除超时定时器 + if (callTimer.value) { + clearTimeout(callTimer.value) + callTimer.value = null + } + + // 2. 停止铃声 + stopBell() + + // 3. 通知服务器通话已建立 + if (!isReceiver) { + if (!roomId) { + window.$message.error('房间号不存在,请重新连接!') + await endCall() + return + } + // 4. 发起者 - 设置远程描述 + console.log('发起者 - 设置远程描述', answer) + await peerConnection.value.setRemoteDescription(answer) + } + } + } catch (error) { + console.error('处理 answer 失败:', error) + connectionStatus.value = RTCCallStatus.ERROR + await endCall() + } + } + + // 处理 ICE candidate + const handleCandidate = async (signal: RTCIceCandidateInit) => { + try { + if (peerConnection.value && peerConnection.value.remoteDescription) { + info('添加 candidate') + await peerConnection.value!.addIceCandidate(signal) + } + } catch (error) { + console.error('处理 candidate 失败:', error) + } + } + + // 视频轨道状态 + const isVideoEnabled = ref(callType === CallTypeEnum.VIDEO) + + // 切换静音 + const toggleMute = () => { + if (localStream.value) { + const audioTrack = localStream.value.getAudioTracks()[0] + if (audioTrack) { + audioTrack.enabled = !audioTrack.enabled + } + } + } + + // 切换视频 + const toggleVideo = async () => { + if (localStream.value) { + const videoTrack = localStream.value.getVideoTracks()[0] + if (videoTrack) { + // 切换视频轨道的启用状态 + videoTrack.enabled = !videoTrack.enabled + isVideoEnabled.value = videoTrack.enabled + + console.log(`视频轨道${videoTrack.enabled ? '开启' : '关闭'}`) + + // 如果是关闭视频,通知对方 + if (!videoTrack.enabled) { + console.log('本地视频已关闭,对方将看不到视频') + } else { + console.log('本地视频已开启,对方可以看到视频') + } + } else if (callType === CallTypeEnum.VIDEO) { + // 如果没有视频轨道但是视频通话,尝试重新获取 + try { + const constraints = { + audio: false, + video: videoDevices.value.length > 0 ? { deviceId: selectedVideoDevice.value || undefined } : true + } + + const newStream = await navigator.mediaDevices.getUserMedia(constraints) + const newVideoTrack = newStream.getVideoTracks()[0] + + if (newVideoTrack && peerConnection.value) { + // 添加新的视频轨道 + peerConnection.value.addTrack(newVideoTrack, localStream.value!) + localStream.value!.addTrack(newVideoTrack) + isVideoEnabled.value = true + + console.log('重新获取视频轨道成功') + } + } catch (error) { + console.error('重新获取视频轨道失败:', error) + window.$message.error('无法开启摄像头') + } + } + } + } + + // 切换音频设备 + const switchAudioDevice = async (deviceId: string) => { + try { + selectedAudioDevice.value = deviceId + if (localStream.value) { + const newStream = await navigator?.mediaDevices?.getUserMedia({ + audio: { deviceId: { exact: deviceId } }, + video: + rtcMsg.value.callType === CallTypeEnum.VIDEO + ? selectedVideoDevice.value + ? { deviceId: { exact: selectedVideoDevice.value || undefined } } + : false + : false + }) + // 替换现有轨道 + const newAudioTrack = newStream.getAudioTracks()[0] + const oldAudioTrack = localStream.value.getAudioTracks()[0] + + if (newAudioTrack) { + if (!oldAudioTrack) { + localStream.value.addTrack(newAudioTrack) + peerConnection.value?.addTrack(newAudioTrack, localStream.value) + return + } + peerConnection.value?.getSenders().forEach((sender) => { + if (sender.track && sender.track.kind === 'audio') { + sender?.replaceTrack?.(newAudioTrack) + } + }) + oldAudioTrack && localStream.value.removeTrack(oldAudioTrack) + localStream.value.addTrack(newAudioTrack) + } else { + window.$message.error('切换设备不存在或不支持,请重新选择!') + } + } + } catch (error) { + window.$message.error('切换音频设备失败!') + console.error('切换音频设备失败:', error) + } + } + + // 获取前置和后置摄像头设备 + const getFrontAndBackCameras = () => { + const frontCamera = videoDevices.value.find( + (device) => + device.label.toLowerCase().includes('front') || + device.label.toLowerCase().includes('前置') || + device.label.toLowerCase().includes('user') + ) + + const backCamera = videoDevices.value.find( + (device) => + device.label.toLowerCase().includes('back') || + device.label.toLowerCase().includes('后置') || + device.label.toLowerCase().includes('environment') || + device.label.toLowerCase().includes('rear') + ) + + return { frontCamera, backCamera } + } + + // 切换前置/后置摄像头(移动端专用) + const switchCameraFacing = async () => { + if (!isMobile) { + console.warn('摄像头翻转功能仅在移动端可用') + return + } + + try { + const { frontCamera, backCamera } = getFrontAndBackCameras() + + if (!frontCamera || !backCamera) { + // 如果无法通过设备名称识别,则使用 facingMode 约束 + await switchVideoDevice('user') + return + } + + // 如果能识别前置和后置摄像头,直接切换 + const currentDevice = selectedVideoDevice.value + const targetDevice = currentDevice === frontCamera.deviceId ? backCamera : frontCamera + await switchVideoDevice(targetDevice.deviceId) + } catch (error) { + window.$message.error('摄像头翻转失败!') + console.error('摄像头翻转失败:', error) + } + } + + // 切换视频设备 + const switchVideoDevice = async (deviceId: string) => { + try { + // 前置校验 + selectedVideoDevice.value = deviceId + if (localStream.value && localStream.value.getVideoTracks().length > 0) { + const newStream = await navigator.mediaDevices.getUserMedia({ + audio: selectedAudioDevice.value ? { deviceId: { exact: selectedAudioDevice.value || undefined } } : false, + video: { deviceId: { exact: deviceId } } + }) + + // 替换现有轨道 + const newVideoTrack = newStream.getVideoTracks()[0] + const oldVideoTrack = localStream.value.getVideoTracks()[0] + // console.log(oldVideoTrack, newVideoTrack); + + if (newVideoTrack) { + if (!oldVideoTrack) { + localStream.value.addTrack(newVideoTrack) + peerConnection.value?.addTrack(newVideoTrack, localStream.value) + return + } + peerConnection.value?.getSenders().forEach((sender) => { + if (sender.track && sender.track.kind === 'video') { + sender.replaceTrack(newVideoTrack) + } + }) + oldVideoTrack && localStream.value.removeTrack(oldVideoTrack) + localStream.value.addTrack(newVideoTrack) + } else { + window.$message.error('切换设备不存在或不支持,请重新选择!') + } + } + } catch (error) { + window.$message.error('切换视频设备失败!') + console.error('切换视频设备失败:', error) + } + } + + // 停止桌面共享 + const stopScreenShare = () => { + if (isScreenSharing.value) { + isScreenSharing.value = false + // 停止当前的本地流 + if (localStream.value) { + localStream.value.getTracks().forEach((track) => track.stop()) + } + if (!selectedVideoDevice.value || !rtcMsg.value.callType) { + return false + } + // 切换到默认设备 + getLocalStream(rtcMsg.value.callType) + // 切换原来的视频轨道 + selectedVideoDevice.value && switchVideoDevice(selectedVideoDevice.value) + return true + } + return false + } + + // 开始桌面共享 + const startScreenShare = async () => { + try { + if (!navigator?.mediaDevices?.getDisplayMedia) { + window.$message.warning('当前设备不支持桌面共享功能!') + return + } + const screenStream = await navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: true // 如果需要共享音频 + }) + if (!screenStream) { + return + } + + // 停止当前的本地流 + if (localStream.value) { + localStream.value.getTracks().forEach((track) => track.stop()) + } + + // 替换本地流为桌面共享流 + localStream.value = screenStream + // 添加新的视频轨道到连接 + screenStream.getTracks().forEach((track) => { + if (localStream.value) { + peerConnection.value?.addTrack(track, localStream.value) + } + }) + // 远程替换为桌面共享流 + const newVideoTrack = screenStream.getVideoTracks()[0] + const oldVideoTrack = localStream.value.getVideoTracks()[0] + if (!newVideoTrack) { + window.$message.error('桌面共享失败,请检查权限设置!') + return + } + newVideoTrack.onended = () => { + window.$message.warning('屏幕共享已结束 ~') + stopScreenShare() + } + peerConnection.value?.getSenders().forEach((sender) => { + if (sender.track && sender.track.kind === 'video') { + sender.replaceTrack(newVideoTrack) + } + }) + oldVideoTrack && localStream.value.removeTrack(oldVideoTrack) + localStream.value.addTrack(newVideoTrack) + isScreenSharing.value = true // 开始桌面共享 + } catch (error: any) { + console.error('开始桌面共享失败:', error) + isScreenSharing.value = false + stopScreenShare() + if (error?.name === 'NotAllowedError') { + window.$message.warning('已取消屏幕共享...') + return + } + window.$message.error('桌面共享失败,请检查权限设置!') + } + } + + const lisendCandidate = async () => { + if (!peerConnection.value) { + return + } + + info('第一次交换 ICE candidates...') + if (pendingCandidates.value.length > 0) { + pendingCandidates.value.forEach(async (candidate) => { + await sendIceCandidate(candidate) + }) + } + + pendingCandidates.value = [] + + peerConnection.value.onicecandidate = async (event) => { + if (event.candidate) { + info('第二次交换 ICE candidates...') + await sendIceCandidate(event.candidate) + } + } + } + + // 处理接收到的信令消息 + const handleSignalMessage = async (data: WSRtcCallMsg) => { + try { + info('处理信令消息') + const signal = JSON.parse(data.signal) + + switch (data.signalType) { + case SignalTypeEnum.OFFER: + await handleOffer(signal, true, roomId) + await lisendCandidate() + break + + case SignalTypeEnum.ANSWER: + await handleAnswer(signal, roomId) + // offer 发送 candidate + await lisendCandidate() + break + + case SignalTypeEnum.CANDIDATE: + if (signal.candidate) { + info('收到 candidate 信令') + await handleCandidate(signal) + } + break + + default: + console.log('未知信令类型:', data.signalType) + } + } catch (error) { + console.error('处理信令消息错误:', error) + } + } + + // 监听 WebRTC 信令消息(注册并保存卸载函数) + // useMitt.on(WsResponseMessageType.WEBRTC_SIGNAL, handleSignalMessage) + void (async () => { + await addListener( + listen('ws-webrtc-signal', (event: any) => { + info(`收到信令消息: ${JSON.stringify(event.payload)}`) + handleSignalMessage(event.payload) + }), + `${roomId}-ws-webrtc-signal` + ) + await addListener( + listen('ws-call-accepted', (event: any) => { + info(`通话被接受: ${JSON.stringify(event.payload)}`) + // // 接受方,发送是否接受 + // info(`收到 CallAccepted'消息 ${isReceiver}`) + if (!isReceiver) { + sendOffer(offer.value!) + // 对方接通后,主叫方窗口前置并聚焦 + void focusCurrentWindow() + } + }), + `${roomId}-ws-call-accepted` + ) + await addListener( + listen('ws-room-closed', (event: any) => { + info(`房间已关闭: ${JSON.stringify(event.payload)}`) + endCall() + }), + `${roomId}-ws-room-closed` + ) + await addListener( + listen('ws-dropped', (_: any) => { + endCall() + }), + `${roomId}-ws-dropped` + ) + await addListener( + listen('ws-call-rejected', (event: any) => { + info(`通话被拒绝: ${JSON.stringify(event.payload)}`) + endCall() + }), + `${roomId}-ws-call-rejected` + ) + await addListener( + listen('ws-cancel', (event: any) => { + info(`已取消通话: ${JSON.stringify(event.payload)}`) + endCall() + }), + `${roomId}-ws-cancel` + ) + await addListener( + listen('ws-timeout', (event: any) => { + info(`已取消通话: ${JSON.stringify(event.payload)}`) + endCall() + }), + `${roomId}-ws-timeout` + ) + })() + + onMounted(async () => { + await loadIceServers() + if (!isReceiver) { + console.log(`调用方发送${callType === CallTypeEnum.VIDEO ? '视频' : '语音'}通话请求`) + await startCall(roomId, callType, [remoteUserId]) + } + }) + + onUnmounted(() => { + // 移除 WebRTC 信令消息监听器 + useMitt.off(WsResponseMessageType.WEBRTC_SIGNAL, handleSignalMessage) + }) + + return { + startCallTimer, + stopScreenShare, + startScreenShare, + toggleVideo, + switchVideoDevice, + switchCameraFacing, + switchAudioDevice, + isScreenSharing, + selectedVideoDevice, + selectedAudioDevice, + localStream, + remoteStream, + peerConnection, + getLocalStream, + startCall, + handleCallResponse, + callDuration, + connectionStatus, + toggleMute, + sendRtcCall2VideoCallResponse, + isVideoEnabled, + stopBell, + startBell, + pauseBell, + playBell + } +} diff --git a/src/hooks/useWindow.ts b/src/hooks/useWindow.ts new file mode 100644 index 0000000..bb3f65c --- /dev/null +++ b/src/hooks/useWindow.ts @@ -0,0 +1,520 @@ +import { invoke } from '@tauri-apps/api/core' +import { LogicalSize } from '@tauri-apps/api/dpi' +import { WebviewWindow } from '@tauri-apps/api/webviewWindow' +import { UserAttentionType, primaryMonitor, type Monitor } from '@tauri-apps/api/window' +import { info } from '@tauri-apps/plugin-log' +import { assign } from 'es-toolkit/compat' +import { CallTypeEnum, EventEnum, RoomTypeEnum } from '@/enums' +import { useGlobalStore } from '@/stores/global' +import { isCompatibility, isDesktop, isMac, isWindows, isWindows10 } from '@/utils/PlatformConstants' + +/** 判断是兼容的系统 */ +const isCompatibilityMode = computed(() => isCompatibility()) +const WINDOW_SAFE_PADDING = 32 +const MIN_LOGICAL_WIDTH = 320 +const MIN_LOGICAL_HEIGHT = 200 +const MAC_TRAFFIC_LIGHTS_SPACING = 6 + +const clampSizeToMonitor = (width: number, height: number, monitor?: Monitor | null) => { + if (!monitor) { + return { width, height } + } + + const scaleFactor = monitor.scaleFactor ?? 1 + const maxLogicalWidth = Math.max(MIN_LOGICAL_WIDTH, monitor.size.width / scaleFactor - WINDOW_SAFE_PADDING) + const maxLogicalHeight = Math.max(MIN_LOGICAL_HEIGHT, monitor.size.height / scaleFactor - WINDOW_SAFE_PADDING) + + return { + width: Math.min(width, Math.floor(maxLogicalWidth)), + height: Math.min(height, Math.floor(maxLogicalHeight)) + } +} + +// Mac 端用于模拟父窗口禁用态的透明蒙层 +const MAC_MODAL_OVERLAY_ID = 'mac-modal-overlay' +// 记录当前已经打开模态窗口的 label,方便在最后一个关闭时移除蒙层 +const activeMacModalLabels = new Set() + +// 创建或复用蒙层 DOM +const ensureMacOverlayElement = () => { + if (typeof document === 'undefined') return + if (document.getElementById(MAC_MODAL_OVERLAY_ID)) return + const overlay = document.createElement('div') + overlay.id = MAC_MODAL_OVERLAY_ID + assign(overlay.style, { + position: 'fixed', + inset: '0', + zIndex: '9999', + backgroundColor: 'transparent', + pointerEvents: 'auto', + width: '100vw', + height: '100vh', + userSelect: 'none', + cursor: 'not-allowed' + }) + const mountPoint = document.body ?? document.documentElement + mountPoint?.appendChild(overlay) +} + +// 移除蒙层 +const removeMacOverlayElement = () => { + if (typeof document === 'undefined') return + document.getElementById(MAC_MODAL_OVERLAY_ID)?.remove() +} + +// 记录当前窗口并展示蒙层 +const attachMacModalOverlay = (label: string) => { + if (!isMac()) return + activeMacModalLabels.add(label) + ensureMacOverlayElement() +} + +// 解除当前窗口的蒙层记录,如果没有其他窗口则移除蒙层 +const detachMacModalOverlay = (label: string) => { + if (!isMac()) return + activeMacModalLabels.delete(label) + if (activeMacModalLabels.size === 0) { + removeMacOverlayElement() + } +} + +export const useWindow = () => { + const globalStore = useGlobalStore() + /** + * 创建窗口 + * @param title 窗口标题 + * @param label 窗口名称 + * @param width 窗口宽度 + * @param height 窗口高度 + * @param wantCloseWindow 创建后需要关闭的窗口 + * @param resizable 调整窗口大小 + * @param minW 窗口最小宽度 + * @param minH 窗口最小高度 + * @param transparent 是否透明 + * @param visible 是否显示 + * @param queryParams URL查询参数 + * */ + const createWebviewWindow = async ( + title: string, + label: string, + width: number, + height: number, + wantCloseWindow?: string, + resizable = false, + minW = 330, + minH = 495, + transparent?: boolean, + visible = false, + queryParams?: Record + ) => { + // 移动端不支持窗口管理,直接返回空对象 + if (!isDesktop()) { + return null + } + const originalLabel = label + const isMultiMsgWindow = originalLabel.includes(EventEnum.MULTI_MSG) + + const checkLabel = () => { + /** 如果是打开独立窗口就截取label中的固定label名称 */ + if (label.includes(EventEnum.ALONE)) { + return label.replace(/\d/g, '') + } else { + return label + } + } + + // 对于multiMsg类型的窗口,保留原始label用于窗口标识,但URL路由统一指向 /multiMsg + label = isMultiMsgWindow ? originalLabel : checkLabel() + + // 构建URL,包含查询参数 + let url = isMultiMsgWindow ? `/${EventEnum.MULTI_MSG}` : `/${label.split('--')[0]}` + + if (queryParams && Object.keys(queryParams).length > 0) { + const searchParams = new URLSearchParams() + Object.entries(queryParams).forEach(([key, value]) => { + searchParams.append(key, String(value)) + }) + url += `?${searchParams.toString()}` + } + + const monitor = await primaryMonitor() + const clampedSize = clampSizeToMonitor(width, height, monitor) + const clampedMinWidth = Math.min(minW, clampedSize.width) + const clampedMinHeight = Math.min(minH, clampedSize.height) + + const webview = new WebviewWindow(label, { + title: title, + url: url, + fullscreen: false, + resizable: resizable, + center: true, + width: clampedSize.width, + height: clampedSize.height, + minHeight: clampedMinHeight, + minWidth: clampedMinWidth, + skipTaskbar: false, + decorations: !isCompatibilityMode.value, + transparent: transparent || isCompatibilityMode.value, + titleBarStyle: 'overlay', // mac覆盖标签栏 + hiddenTitle: true, // mac隐藏标题栏 + visible: visible, + dragDropEnabled: true, // 启用文件拖放 + ...(isWindows10() ? { shadow: false } : {}) + }) + + await webview.once('tauri://created', async () => { + if (isMac()) { + try { + await invoke('set_macos_traffic_lights_spacing', { + windowLabel: label, + spacing: MAC_TRAFFIC_LIGHTS_SPACING + }) + } catch {} + } + if (wantCloseWindow) { + const win = await WebviewWindow.getByLabel(wantCloseWindow) + win?.close() + } + }) + + await webview.once('tauri://error', async () => { + info('窗口创建失败') + // TODO 这里利用错误处理的方式来查询是否是已经创建了窗口,如果一开始就使用WebviewWindow.getByLabel来查询在刷新的时候就会出现问题 (nyh -> 2024-03-06 23:54:17) + await checkWinExist(label) + }) + + return webview + } + + /** + * 向指定标签的窗口发送载荷(payload),可用于窗口之间通信。 + * + * @param windowLabel - 要发送载荷的窗口标签,通常是在创建窗口时指定的 label。 + * @param payload - 要发送的 JSON 数据对象,不限制字段内容。 + * @returns 返回一个 Promise,表示调用 Rust 后端命令的完成情况。 + */ + const sendWindowPayload = async (windowLabel: string, payload: any) => { + // 移动端不支持窗口管理 + if (!isDesktop()) { + return Promise.resolve() + } + console.log('新窗口的载荷:', payload) + return invoke('push_window_payload', { + label: windowLabel, + // 这个payload只要是json就能传,不限制字段 + payload + }) + } + + /** + * 获取指定窗口的当前载荷(payload),用于初始化窗口时获取传递的数据。 + * + * @param windowLabel - 要获取载荷的窗口标签。 + * @returns 返回一个 Promise,解析后为泛型 T,表示窗口中保存的 payload 数据。 + * 可以通过泛型指定返回的结构类型。 + * + * @example + * interface MyPayload { + * userId: string; + * token: string; + * } + * + * const payload = await getWindowPayload('my-window') + */ + const getWindowPayload = async (windowLabel: string, once: boolean = true) => { + // 移动端不支持窗口管理 + if (!isDesktop()) { + return Promise.resolve({} as T) + } + return await invoke('get_window_payload', { label: windowLabel, once }) + } + + /** + * 注册指定窗口的载荷更新事件监听器。当该窗口的 payload 被更新时触发回调。 + * + * @param this - 可选的绑定上下文对象,内部通过 `Function.prototype.call` 使用。 + * @param windowLabel - 窗口标签,用于构造监听的事件名称 `${label}:update`。 + * @param callback - 在 payload 更新时调用的函数,回调参数为 `TauriEvent`。 + * @returns 返回一个 Promise,解析后为 `UnlistenFn`(一个函数),调用它可以注销监听器。 + * + * @example + * const unlisten = await getWindowPayloadListener('my-window', (event) => { + * console.log('收到 payload 更新:', event.payload) + * }) + * + * // 需要时手动取消监听 + * unlisten() + */ + // async function getWindowPayloadListener(this: any, windowLabel: string, callback: (event: any) => void) { + // const listenLabel = `${windowLabel}:update` + + // return addListener( + // listen(listenLabel, (event) => { + // callback.call(this, event) + // }) + // ) + // } + + /** + * 创建模态子窗口 + * @param title 窗口标题 + * @param label 窗口标识 + * @param width 窗口宽度 + * @param height 窗口高度 + * @param parent 父窗口 + * @param payload 传递给子窗口的数据 + * @returns 创建的窗口实例或已存在的窗口实例 + */ + const createModalWindow = async ( + title: string, + label: string, + width: number, + height: number, + parent: string, + payload?: Record, + options?: { + minWidth?: number + minHeight?: number + } + ) => { + // 移动端不支持窗口管理 + if (!isDesktop()) { + return null + } + // 检查窗口是否已存在 + const existingWindow = await WebviewWindow.getByLabel(label) + const parentWindow = parent ? await WebviewWindow.getByLabel(parent) : null + + if (existingWindow) { + if (isMac()) { + attachMacModalOverlay(label) + } + // 如果窗口已存在,则聚焦到现有窗口并使其闪烁 + existingWindow.requestUserAttention(UserAttentionType.Critical) + return existingWindow + } + + // 创建新窗口 + const monitor = await primaryMonitor() + const clampedSize = clampSizeToMonitor(width, height, monitor) + const clampedMinWidth = Math.min(options?.minWidth ?? 500, clampedSize.width) + const clampedMinHeight = Math.min(options?.minHeight ?? 500, clampedSize.height) + + const modalWindow = new WebviewWindow(label, { + url: `/${label}`, + title: title, + width: clampedSize.width, + height: clampedSize.height, + resizable: false, + center: true, + minWidth: clampedMinWidth, + minHeight: clampedMinHeight, + focus: true, + minimizable: false, + parent: parentWindow ? parentWindow : parent, + decorations: !isCompatibilityMode.value, + transparent: isCompatibilityMode.value, + titleBarStyle: 'overlay', // mac覆盖标签栏 + hiddenTitle: true, // mac隐藏标题栏 + visible: false, + dragDropEnabled: true, // 启用文件拖放 + ...(isWindows10() ? { shadow: false } : {}) + }) + + // 监听窗口创建完成事件 + modalWindow.once('tauri://created', async () => { + if (isWindows()) { + // 禁用父窗口,模拟模态窗口效果 + await parentWindow?.setEnabled(false) + } + + // 如果有 payload,发送到子窗口 + if (payload) { + await sendWindowPayload(label, payload) + } + + // 设置窗口为焦点 + await modalWindow.setFocus() + + if (isMac()) { + try { + await invoke('set_window_movable', { + windowLabel: label, + movable: false + }) + } catch (error) { + console.error('设置子窗口不可拖动失败:', error) + } + try { + await invoke('set_macos_traffic_lights_spacing', { + windowLabel: label, + spacing: MAC_TRAFFIC_LIGHTS_SPACING + }) + } catch {} + attachMacModalOverlay(label) + } + }) + + // 监听错误事件 + modalWindow.once('tauri://error', async (e) => { + console.error(`${title}窗口创建失败:`, e) + window.$message?.error(`创建${title}窗口失败`) + await parentWindow?.setEnabled(true) + }) + + void modalWindow.once('tauri://destroyed', async () => { + if (isMac()) { + detachMacModalOverlay(label) + } + if (isWindows()) { + try { + await parentWindow?.setEnabled(true) + } catch (error) { + console.error('重新启用父窗口失败:', error) + } + } + }) + + return modalWindow + } + + /** + * 调整窗口大小 + * @param label 窗口名称 + * @param width 窗口宽度 + * @param height 窗口高度 + * */ + const resizeWindow = async (label: string, width: number, height: number) => { + // 移动端不支持窗口管理 + if (!isDesktop()) { + return Promise.resolve() + } + const webview = await WebviewWindow.getByLabel(label) + const monitor = await primaryMonitor() + const clampedSize = clampSizeToMonitor(width, height, monitor) + // 创建一个新的尺寸对象 + const newSize = new LogicalSize(clampedSize.width, clampedSize.height) + // 调用窗口的 setSize 方法进行尺寸调整 + await webview?.setSize(newSize).catch((error) => { + console.error('无法调整窗口大小:', error) + }) + } + + /** + * 检查窗口是否存在 + * @param L 窗口标签 + */ + const checkWinExist = async (L: string) => { + // 移动端不支持窗口管理 + if (!isDesktop()) { + return Promise.resolve() + } + const isExistsWinds = await WebviewWindow.getByLabel(L) + if (isExistsWinds) { + nextTick().then(async () => { + // 如果窗口已存在,首先检查是否最小化了 + const minimized = await isExistsWinds.isMinimized() + // 检查是否是隐藏 + const hidden = await isExistsWinds.isVisible() + if (!hidden) { + await isExistsWinds.show() + } + if (minimized) { + // 如果已最小化,恢复窗口 + await isExistsWinds.unminimize() + } + // 如果窗口已存在,则给它焦点,使其在最前面显示 + await isExistsWinds.setFocus() + }) + } + } + + /** + * 设置窗口是否可调整大小 + * @param label 窗口名称 + * @param resizable 是否可调整大小 + */ + const setResizable = async (label: string, resizable: boolean) => { + // 移动端不支持窗口管理 + if (!isDesktop()) { + return Promise.resolve() + } + const webview = await WebviewWindow.getByLabel(label) + if (webview) { + await webview.setResizable(resizable).catch((error) => { + console.error('设置窗口可调整大小失败:', error) + }) + } + } + + const startRtcCall = async (callType: CallTypeEnum) => { + try { + const currentSession = globalStore.currentSession + if (!currentSession) { + window.$message?.warning?.('当前会话尚未准备好') + return + } + // 判断是否为群聊,如果是群聊则跳过 + if (currentSession.type === RoomTypeEnum.GROUP) { + window.$message.warning('群聊暂不支持音视频通话') + return + } + + // 获取当前房间好友的ID(单聊时使用detailId作为remoteUid) + const remoteUid = currentSession.detailId + if (!remoteUid) { + window.$message.error('无法获取对方用户信息') + return + } + await createRtcCallWindow(false, remoteUid, globalStore.currentSessionRoomId, callType) + } catch (error) { + console.error('创建视频通话窗口失败:', error) + } + } + + const createRtcCallWindow = async ( + isIncoming: boolean, + remoteUserId: string, + roomId: string, + callType: CallTypeEnum + ) => { + // 根据是否来电决定窗口尺寸 + const windowConfig = isIncoming + ? { width: 360, height: 90, minWidth: 360, minHeight: 90 } // 来电通知尺寸 + : callType === CallTypeEnum.VIDEO + ? { width: 850, height: 580, minWidth: 850, minHeight: 580 } // 视频通话尺寸 + : { width: 500, height: 650, minWidth: 500, minHeight: 650 } // 语音通话尺寸 + + const type = callType === CallTypeEnum.VIDEO ? '视频通话' : '语音通话' + await createWebviewWindow( + type, // 窗口标题 + 'rtcCall', // 窗口标签 + windowConfig.width, // 宽度 + windowConfig.height, // 高度 + undefined, // 不需要关闭其他窗口 + true, // 可调整大小 + windowConfig.minWidth, // 最小宽度 + windowConfig.minHeight, // 最小高度 + false, // 不透明 + false, // 显示窗口 + { + remoteUserId, + roomId: roomId, + callType, + isIncoming + } + ) + } + + return { + createWebviewWindow, + createModalWindow, + resizeWindow, + checkWinExist, + setResizable, + sendWindowPayload, + getWindowPayload, + startRtcCall, + createRtcCallWindow + } +} diff --git a/src/layout/center/index.vue b/src/layout/center/index.vue new file mode 100644 index 0000000..8e9a68f --- /dev/null +++ b/src/layout/center/index.vue @@ -0,0 +1,474 @@ + + + + + diff --git a/src/layout/center/model.tsx b/src/layout/center/model.tsx new file mode 100644 index 0000000..c06186f --- /dev/null +++ b/src/layout/center/model.tsx @@ -0,0 +1,255 @@ +import type { TransferRenderSourceList, TransferRenderTargetLabel } from 'naive-ui' +import { NAvatar, NCheckbox } from 'naive-ui' +import { useContactStore } from '@/stores/contacts.ts' +import { useGlobalStore } from '@/stores/global.ts' +import { useGroupStore } from '@/stores/group.ts' +import { AvatarUtils } from '@/utils/AvatarUtils' +import { UserType } from '@/enums' + +const contactStore = useContactStore() +const groupStore = useGroupStore() +const globalStore = useGlobalStore() + +export const options = computed(() => { + return contactStore.contactsList + .map((item) => { + const userInfo = groupStore.getUserInfo(item.uid) + const contactAccount = (item as any).account + const isBotAccount = + (userInfo?.account && userInfo.account.toLowerCase() === UserType.BOT) || + (typeof contactAccount === 'string' && contactAccount.toLowerCase() === UserType.BOT) + + if (isBotAccount) { + return null + } + + return { + label: userInfo?.name || item.remark, + value: item.uid, + avatar: AvatarUtils.getAvatarUrl(userInfo?.avatar || '/logoD.png') + } + }) + .filter(Boolean) as any +}) + +// 获取已禁用选项的值列表 +export const getDisabledOptions = () => { + // 当前选中的房间id + const currentRoomId = globalStore.currentSessionRoomId + + if (!currentRoomId || !groupStore.userList.length) return [] + + // 确保返回群内所有成员的UID + const result = groupStore.userList.map((member) => member.uid) + return result +} + +// 获取过滤后的选项列表 +export const getFilteredOptions = () => { + // 获取禁用选项列表 + const disabledOptions = getDisabledOptions() + // 当前选中的房间id + const currentRoomId = globalStore.currentSessionRoomId + // 如果没有房间ID,返回所有好友 + if (!currentRoomId) return options.value + + // 标记已在群内的好友 + return options.value.map((option: { value: string; label: string; avatar?: string; [key: string]: any }) => { + const isInGroup = disabledOptions.includes(option.value) + + if (isInGroup) { + // 对于已在群内的好友,添加禁用标记,但保持所有原始属性不变 + return { + ...option, + disabled: true + } + } else { + // 对于未在群内的好友,保持原样 + return option + } + }) +} + +// 统一的源列表渲染函数,通过参数控制是否使用过滤后的选项 +export const renderSourceList = ( + preSelectedFriendId = '', + enablePreSelection = true, + placeholder = '' +): TransferRenderSourceList => { + return ({ onCheck, checkedOptions, pattern }) => { + // 使用过滤后的选项列表,确保已在群内的好友被正确标记为禁用 + const baseOptions = getFilteredOptions() + + // 根据搜索模式进一步过滤 + const displayOptions = pattern + ? baseOptions.filter((option: { label: string }) => option.label?.toLowerCase().includes(pattern.toLowerCase())) + : baseOptions + + return ( +

+ {placeholder &&
{placeholder}
} + {displayOptions.map((option: any) => { + // 判断是否是预选中的好友(仅在启用预选中时生效) + const isPreSelected = enablePreSelection && option.value === preSelectedFriendId + // 判断是否被禁用(已在群内)(仅在启用预选中时生效) + const isDisabled = enablePreSelection && option.disabled === true + // 如果是预选中的好友或已被选中,则显示为选中状态 + const checked = isPreSelected || checkedOptions.some((o) => o.value === option.value) + + return ( +
{ + if (isDisabled) return + + const index = checkedOptions.findIndex((o) => o.value === option.value) + if (index === -1) { + onCheck([...checkedOptions.map((o) => o.value), option.value]) + } else { + // 如果是预选中的好友且启用了预选中,不允许取消选中 + if (enablePreSelection && isPreSelected) return + const newCheckedOptions = [...checkedOptions] + newCheckedOptions.splice(index, 1) + onCheck(newCheckedOptions.map((o) => o.value)) + } + }}> + +
+ {option.avatar ? ( + + ) : ( + + {option.label?.slice(0, 1)} + + )} +
{option.label}
+
+
+ ) + })} +
+ ) + } +} + +export const renderLabel: TransferRenderTargetLabel = ({ option }: { option: any }) => { + return ( +
+ {option.avatar ? ( + + ) : ( + + {option.label.slice(0, 1)} + + )} +
{option.label}
+
+ ) +} + +// 创建自定义的目标列表渲染函数 +export const renderTargetList = ( + preSelectedFriendId = '', + enablePreSelection = true, + placeholder = '', + requiredTag = '' +) => { + return ({ + onCheck, + checkedOptions, + pattern + }: { + onCheck: (checkedValueList: Array) => void + checkedOptions: any[] + pattern: string + }) => { + // 根据搜索模式过滤选项 + const displayOptions = pattern + ? checkedOptions.filter((option: { label: string }) => + option.label?.toLowerCase().includes(pattern.toLowerCase()) + ) + : checkedOptions + + return ( +
+ {placeholder &&
{placeholder}
} + {displayOptions.map((option: any) => { + const isPreSelected = enablePreSelection && option.value === preSelectedFriendId + + return ( +
+
+ {option.avatar ? ( + + ) : ( + + {option.label?.slice(0, 1)} + + )} +
{option.label}
+
+ + {!isPreSelected && ( + { + const newCheckedOptions = checkedOptions.filter((o: any) => o.value !== option.value) + onCheck(newCheckedOptions.map((o: any) => o.value)) + }}> + + + )} + + {isPreSelected && requiredTag && ( +
+ {requiredTag} +
+ )} +
+ ) + })} +
+ ) + } +} diff --git a/src/layout/center/style.scss b/src/layout/center/style.scss new file mode 100644 index 0000000..0e3cda3 --- /dev/null +++ b/src/layout/center/style.scss @@ -0,0 +1,101 @@ +@use '@/styles/scss/global/variable.scss' as *; + +.resizable { + height: 100%; + position: relative; + overflow: hidden; + background: var(--center-bg-color); +} + +.resize-handle { + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 3px; + cursor: ew-resize; + z-index: 9999; + background: transparent; + &:hover { + .drag-icon { + opacity: 1; + } + } +} + +.add-item { + @include menu-item-style(absolute); + top: 36px; + right: 10px; + @include menu-list(); +} + +/** ======================================== */ +:deep(.n-transfer .n-transfer-list .n-transfer-list__border) { + border: none; +} +:deep(.n-transfer .n-transfer-list.n-transfer-list--source .n-transfer-list__border) { + border-right: 1px solid var(--n-divider-color); +} + +:deep(.n-transfer .n-transfer-list .n-transfer-list-body .n-transfer-filter) { + padding: 0; + margin: 4px 8px; + box-sizing: border-box; + transition: + border-color 0.3s var(--n-bezier), + background-color 0.3s var(--n-bezier); + border: 1px solid #e3e3e3; + border-radius: 10px; +} + +:deep( + .n-transfer + .n-transfer-list + .n-transfer-list-body + .n-transfer-list-flex-container + .n-transfer-list-content + .n-transfer-list-item:not(.n-transfer-list-item--disabled):hover + .n-transfer-list-item__background + ) { + background-color: var(--n-item-color-pending); + border-radius: 8px; +} +:deep( + .n-transfer + .n-transfer-list + .n-transfer-list-body + .n-transfer-list-flex-container + .n-transfer-list-content + .n-transfer-list-item + .n-transfer-list-item__background + ) { + transition: all 0.3s var(--n-bezier); +} +:deep( + .n-transfer + .n-transfer-list + .n-transfer-list-body + .n-transfer-list-flex-container + .n-transfer-list-content + .n-transfer-list-item:not(.n-transfer-list-item--disabled):hover + .n-transfer-list-item__close + ) { + border-radius: 4px; +} + +:deep( + .n-transfer + .n-transfer-list + .n-transfer-list-body + .n-transfer-list-flex-container + .n-transfer-list-content + .n-transfer-list-item + .n-transfer-list-item__close + ) { + transition: + border 0.3s var(--n-bezier), + opacity 0.3s var(--n-bezier), + background-color 0.3s var(--n-bezier), + color 0.3s var(--n-bezier); +} diff --git a/src/layout/index.vue b/src/layout/index.vue new file mode 100644 index 0000000..9328fde --- /dev/null +++ b/src/layout/index.vue @@ -0,0 +1,571 @@ + + + + + diff --git a/src/layout/left/components/ActionList.vue b/src/layout/left/components/ActionList.vue new file mode 100644 index 0000000..ea88e61 --- /dev/null +++ b/src/layout/left/components/ActionList.vue @@ -0,0 +1,409 @@ + + + diff --git a/src/layout/left/components/InfoEdit.vue b/src/layout/left/components/InfoEdit.vue new file mode 100644 index 0000000..a181bd2 --- /dev/null +++ b/src/layout/left/components/InfoEdit.vue @@ -0,0 +1,261 @@ + + + diff --git a/src/layout/left/components/LeftAvatar.vue b/src/layout/left/components/LeftAvatar.vue new file mode 100644 index 0000000..bdb415f --- /dev/null +++ b/src/layout/left/components/LeftAvatar.vue @@ -0,0 +1,116 @@ + + + diff --git a/src/layout/left/components/definePlugins/Card.vue b/src/layout/left/components/definePlugins/Card.vue new file mode 100644 index 0000000..93d014f --- /dev/null +++ b/src/layout/left/components/definePlugins/Card.vue @@ -0,0 +1,332 @@ + + + + diff --git a/src/layout/left/components/definePlugins/List.vue b/src/layout/left/components/definePlugins/List.vue new file mode 100644 index 0000000..d20e42d --- /dev/null +++ b/src/layout/left/components/definePlugins/List.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/src/layout/left/components/definePlugins/index.vue b/src/layout/left/components/definePlugins/index.vue new file mode 100644 index 0000000..5e28c98 --- /dev/null +++ b/src/layout/left/components/definePlugins/index.vue @@ -0,0 +1,98 @@ + + + + diff --git a/src/layout/left/config.tsx b/src/layout/left/config.tsx new file mode 100644 index 0000000..485d140 --- /dev/null +++ b/src/layout/left/config.tsx @@ -0,0 +1,236 @@ +import { storeToRefs } from 'pinia' +import { useI18n } from 'vue-i18n' +import { MittEnum, ModalEnum, PluginEnum } from '@/enums' +import { useLogin } from '@/hooks/useLogin.ts' +import { useMitt } from '@/hooks/useMitt.ts' +import { useWindow } from '@/hooks/useWindow.ts' +import { useSettingStore } from '@/stores/setting' +import * as ImRequestUtils from '@/utils/ImRequestUtils' + +/** + * 这里的顶部的操作栏使用pinia写入了localstorage中 + */ +/** 下半部分操作栏配置 */ +const baseItemsBottom: Array> = [ + { + url: 'fileManager', + icon: 'file', + iconAction: 'file-action', + size: { + width: 840, + height: 600 + }, + window: { + resizable: false + } + }, + { + url: 'mail', + icon: 'collect', + iconAction: 'collect-action', + size: { + width: 840, + height: 600 + }, + window: { + resizable: true + } + } +] + +const useItemsBottom = () => + (() => { + const { t } = useI18n() + return computed(() => [ + { + ...baseItemsBottom[0], + title: t('home.action.file_manager'), + shortTitle: t('home.action.file_manager_short_title') + }, + { + ...baseItemsBottom[1], + title: t('home.action.favorite'), + shortTitle: t('home.action.favorite_short_title') + } + ]) + })() +/** 设置列表菜单项 */ +const useMoreList = () => { + const { t } = useI18n() + const { createWebviewWindow } = useWindow() + const settingStore = useSettingStore() + const { login } = storeToRefs(settingStore) + const { logout, resetLoginState } = useLogin() + + return computed(() => [ + { + label: t('menu.check_update'), + icon: 'arrow-circle-up', + click: () => { + useMitt.emit(MittEnum.LEFT_MODAL_SHOW, { + type: ModalEnum.CHECK_UPDATE + }) + } + }, + { + label: t('menu.lock_screen'), + icon: 'lock', + click: () => { + useMitt.emit(MittEnum.LEFT_MODAL_SHOW, { + type: ModalEnum.LOCK_SCREEN + }) + } + }, + { + label: t('menu.settings'), + icon: 'settings', + click: async () => { + await createWebviewWindow('设置', 'settings', 840, 840, '', true, 840, 600) + } + }, + { + label: t('menu.about'), + icon: 'info', + click: async () => { + await createWebviewWindow('关于', 'about', 360, 480) + } + }, + { + label: t('menu.sign_out'), + icon: 'power', + click: async () => { + try { + await ImRequestUtils.logout({ autoLogin: login.value.autoLogin }) + await resetLoginState() + await logout() + } catch (error) { + console.error('退出登录失败:', error) + window.$message.error('退出登录失败,请重试') + } + } + } + ]) +} + +/** 插件列表 */ +const basePluginsList: Array, 'title' | 'shortTitle'>> = [ + { + url: 'dynamic', + icon: 'fire', + iconAction: 'fire-action', + state: PluginEnum.BUILTIN, + isAdd: true, + dot: false, + progress: 0, + size: { + width: 600, + height: 800, + minWidth: 600, + minHeight: 550 + }, + window: { + resizable: true + }, + miniShow: false + }, + { + icon: 'robot', + iconAction: 'GPT', + url: 'robot', + state: PluginEnum.BUILTIN, + isAdd: true, + dot: false, + progress: 0, + size: { + minWidth: 1240, + width: 1380, + height: 800 + }, + window: { + resizable: true + }, + miniShow: false + } + // { + // icon: 'Music', + // url: 'music', + // title: 'HuLa云音乐', + // shortTitle: '云音乐', + // tip: 'HuLa云音乐开发中,敬请期待', + // state: PluginEnum.NOT_INSTALLED, + // version: 'v1.0.0-Alpha', + // isAdd: false, + // dot: true, + // progress: 0, + // size: { + // minWidth: 780, + // width: 980, + // height: 800 + // }, + // window: { + // resizable: true + // }, + // miniShow: false + // }, + // { + // icon: 'UimSlack', + // url: 'collaboration', + // title: 'HuLa协作', + // shortTitle: '协作', + // tip: 'HuLa协作开发中,敬请期待', + // state: PluginEnum.NOT_INSTALLED, + // version: 'v1.0.0-Alpha', + // isAdd: false, + // dot: true, + // progress: 0, + // size: { + // minWidth: 780, + // width: 980, + // height: 800 + // }, + // window: { + // resizable: true + // }, + // miniShow: false + // }, + // { + // icon: 'vigo', + // url: 'collaboration', + // title: 'HuLa短视频', + // shortTitle: '短视频', + // tip: 'HuLa短视频开发中,敬请期待', + // state: PluginEnum.NOT_INSTALLED, + // version: 'v1.0.0-Alpha', + // isAdd: false, + // dot: true, + // progress: 0, + // size: { + // minWidth: 780, + // width: 980, + // height: 800 + // }, + // window: { + // resizable: true + // }, + // miniShow: false + // } +] + +const usePluginsList = () => + (() => { + const { t } = useI18n() + return computed[]>(() => [ + { + ...basePluginsList[0], + title: t('home.plugins.dynamic'), + shortTitle: t('home.plugins.dynamic_short_title') + }, + { + ...basePluginsList[1], + title: t('home.plugins.chatbot'), + shortTitle: t('home.plugins.chatbot_short_title') + } + ]) + })() + +export { useItemsBottom, useMoreList, usePluginsList } diff --git a/src/layout/left/hook.ts b/src/layout/left/hook.ts new file mode 100644 index 0000000..fd1d12b --- /dev/null +++ b/src/layout/left/hook.ts @@ -0,0 +1,231 @@ +import { info } from '@tauri-apps/plugin-log' +import { useTimeoutFn } from '@vueuse/core' +import { IsYesEnum, MittEnum, ThemeEnum } from '@/enums' +import { useMitt } from '@/hooks/useMitt.ts' +import { useWindow } from '@/hooks/useWindow.ts' +import router from '@/router' +import type { BadgeType, UserInfoType } from '@/services/types.ts' +import { useGroupStore } from '@/stores/group' +import { useLoginHistoriesStore } from '@/stores/loginHistory.ts' +import { useMenuTopStore } from '@/stores/menuTop.ts' +import { useSettingStore } from '@/stores/setting.ts' +import { useUserStore } from '@/stores/user.ts' +import { useUserStatusStore } from '@/stores/userStatus.ts' +import { ModifyUserInfo, setUserBadge } from '@/utils/ImRequestUtils' +import { storeToRefs } from 'pinia' + +export const leftHook = () => { + const prefers = matchMedia('(prefers-color-scheme: dark)') + const { createWebviewWindow } = useWindow() + const settingStore = useSettingStore() + const { menuTop } = storeToRefs(useMenuTopStore()) + const loginHistoriesStore = useLoginHistoriesStore() + const userStore = useUserStore() + const { themes } = settingStore + const userStatusStore = useUserStatusStore() + const { currentState } = storeToRefs(userStatusStore) + const activeUrl = ref(menuTop.value?.[0]?.url || 'message') + const settingShow = ref(false) + const shrinkStatus = ref(false) + const groupStore = useGroupStore() + /** 是否展示个人信息浮窗 */ + const infoShow = ref(false) + /** 是否显示上半部分操作栏中的提示 */ + const tipShow = ref(true) + const themeColor = ref(themes.content === ThemeEnum.DARK ? 'rgba(63,63,63, 0.2)' : 'rgba(241,241,241, 0.2)') + /** 已打开窗口的列表 */ + const openWindowsList = ref(new Set()) + /** 编辑资料弹窗 */ + const editInfo = ref<{ + show: boolean + content: Partial + badgeList: BadgeType[] + }>({ + show: false, + content: {}, + badgeList: [] + }) + /** 当前用户佩戴的徽章 */ + const currentBadge = computed(() => + editInfo.value.badgeList.find((item) => item.obtain === IsYesEnum.YES && item.wearing === IsYesEnum.YES) + ) + + /* =================================== 方法 =============================================== */ + + /** 跟随系统主题模式切换主题 */ + const followOS = () => { + themeColor.value = prefers.matches ? 'rgba(63,63,63, 0.2)' : 'rgba(241,241,241, 0.2)' + } + + watchEffect(() => { + /** 判断是否是跟随系统主题 */ + if (themes.pattern === ThemeEnum.OS) { + followOS() + prefers.addEventListener('change', followOS) + } else { + prefers.removeEventListener('change', followOS) + } + }) + + /** 更新缓存里面的用户信息 */ + const updateCurrentUserCache = (key: 'name' | 'wearingItemId' | 'avatar', value: any) => { + const currentUser = userStore.userInfo!.uid && groupStore.getUserInfo(userStore.userInfo!.uid) + if (currentUser) { + currentUser[key] = value // 更新缓存里面的用户信息 + } + } + + /** 保存用户信息 */ + const saveEditInfo = (localUserInfo: any) => { + if (!localUserInfo.name || localUserInfo.name.trim() === '') { + window.$message.error('昵称不能为空') + return + } + if (localUserInfo.modifyNameChance === 0) { + window.$message.error('改名次数不足') + return + } + ModifyUserInfo(localUserInfo).then(() => { + // 更新本地缓存的用户信息 + userStore.userInfo!.name = localUserInfo.name! + loginHistoriesStore.updateLoginHistory(userStore.userInfo) // 更新登录历史记录 + updateCurrentUserCache('name', localUserInfo.name) // 更新缓存里面的用户信息 + if (!editInfo.value.content.modifyNameChance) return + editInfo.value.content.modifyNameChance -= 1 + window.$message.success('保存成功') + }) + } + + /** 佩戴徽章 */ + const toggleWarningBadge = async (badge: BadgeType) => { + if (!badge?.id) return + try { + await setUserBadge({ badgeId: badge.id }) + // 更新本地缓存中的用户徽章信息 + const currentUser = userStore.userInfo!.uid && groupStore.getUserInfo(userStore.userInfo!.uid) + if (currentUser) { + // 更新当前佩戴的徽章ID + currentUser.wearingItemId = badge.id + // 更新用户信息中的佩戴徽章ID + userStore.userInfo!.wearingItemId = badge.id + // 更新徽章列表中的佩戴状态 + editInfo.value.badgeList = editInfo.value.badgeList.map((item) => ({ + ...item, + wearing: item.id === badge.id ? IsYesEnum.YES : IsYesEnum.NO, + obtain: item.obtain // 保持原有的obtain状态 + })) + } + // 确保在状态更新后再显示成功消息 + nextTick(() => { + window.$message.success('佩戴成功') + }) + } catch (_error) { + window.$message.error('佩戴失败,请稍后重试') + } + } + + /* 打开并且创建modal */ + const handleEditing = () => { + // TODO 暂时使用mitt传递参数,不然会导致子组件的响应式丢失 (nyh -> 2024-06-25 09:53:43) + useMitt.emit(MittEnum.OPEN_EDIT_INFO) + } + + /** + * 侧边栏部分跳转窗口路由事件 + * @param url 跳转的路由 + * @param title 创建窗口时的标题 + * @param size 窗口的大小 + * @param window 窗口参数 + * */ + const pageJumps = ( + url: string, + title?: string, + size?: { width: number; height: number; minWidth?: number; minHeight?: number }, + window?: { resizable: boolean } + ) => { + if (window) { + useTimeoutFn(async () => { + info(`打开窗口: ${title}`) + const webview = await createWebviewWindow( + title!, + url, + size?.width, + size?.height, + '', + window?.resizable, + size?.minWidth, + size?.minHeight + ) + openWindowsList.value.add(url) + + const unlisten = await webview?.onCloseRequested(() => { + openWindowsList.value.delete(url) + if (unlisten) unlisten() + }) + }, 300) + } else { + activeUrl.value = url + router.push(`/${url}`) + } + } + + /** + * 打开内容对应窗口 + * @param title 窗口的标题 + * @param label 窗口的标识 + * @param w 窗口的宽度 + * @param h 窗口的高度 + * */ + const openContent = (title: string, label: string, w = 840, h = 600) => { + useTimeoutFn(async () => { + await createWebviewWindow(title, label, w, h) + }, 300) + infoShow.value = false + } + + const closeMenu = (event: any) => { + if (!event.target.matches('.setting-item, .more, .more *')) { + settingShow.value = false + } + } + + onMounted(async () => { + /** 页面加载的时候默认显示消息列表 */ + pageJumps(activeUrl.value) + window.addEventListener('click', closeMenu, true) + + useMitt.on(MittEnum.SHRINK_WINDOW, (event: any) => { + shrinkStatus.value = event as boolean + }) + useMitt.on(MittEnum.CLOSE_INFO_SHOW, () => { + infoShow.value = false + }) + useMitt.on(MittEnum.TO_SEND_MSG, (event: any) => { + activeUrl.value = event.url + }) + }) + + onUnmounted(() => { + window.removeEventListener('click', closeMenu, true) + }) + + return { + currentState, + activeUrl, + settingShow, + shrinkStatus, + infoShow, + tipShow, + themeColor, + openWindowsList, + editInfo, + currentBadge, + handleEditing, + pageJumps, + openContent, + saveEditInfo, + toggleWarningBadge, + updateCurrentUserCache, + followOS + } +} diff --git a/src/layout/left/index.vue b/src/layout/left/index.vue new file mode 100644 index 0000000..7480d5b --- /dev/null +++ b/src/layout/left/index.vue @@ -0,0 +1,55 @@ + + + + diff --git a/src/layout/left/model.tsx b/src/layout/left/model.tsx new file mode 100644 index 0000000..2a3d393 --- /dev/null +++ b/src/layout/left/model.tsx @@ -0,0 +1,418 @@ +import { + type FormInst, + NAvatar, + NButton, + NFlex, + NForm, + NFormItem, + NIcon, + NInput, + NModal, + NProgress, + NScrollbar, + NSkeleton, + NTimeline, + NTimelineItem +} from 'naive-ui' +import { emit } from '@tauri-apps/api/event' +import { EventEnum } from '@/enums' +import { handRelativeTime } from '@/utils/ComputedTime' +import './style.scss' +import { getVersion } from '@tauri-apps/api/app' +import { confirm } from '@tauri-apps/plugin-dialog' +import { relaunch } from '@tauri-apps/plugin-process' +import { check } from '@tauri-apps/plugin-updater' +import { useSettingStore } from '@/stores/setting.ts' +import { useUserStore } from '@/stores/user.ts' +import { AvatarUtils } from '@/utils/AvatarUtils' +import { isMac } from '@/utils/PlatformConstants' +import { useI18n } from 'vue-i18n' + +const formRef = ref() +const formValue = ref({ + lockPassword: '' +}) +export const modalShow = ref(false) +export const lock = ref({ + loading: false, + rules: { + lockPassword: { + required: true, + message: '', + trigger: ['input'] + } + }, + async handleLock() { + const settingStore = useSettingStore() + const { lockScreen } = storeToRefs(settingStore) + formRef.value?.validate((errors) => { + if (errors) return + lock.value.loading = true + lockScreen.value.password = formValue.value.lockPassword + lockScreen.value.enable = true + setTimeout(async () => { + /** 发送锁屏事件,当打开的窗口接受到后会自动锁屏 */ + await emit(EventEnum.LOCK_SCREEN) + lock.value.loading = false + modalShow.value = false + formValue.value.lockPassword = '' + }, 1000) + }) + formRef.value?.restoreValidation() + } +}) + +/*============================================ model =====================================================*/ +/** 锁屏弹窗 */ +export const LockScreen = defineComponent(() => { + const userStore = useUserStore() + const { t } = useI18n() + lock.value.rules.lockPassword.message = t('message.lock_screen.validation_required') + return () => ( + +
+ {isMac() ? ( +
(modalShow.value = false)} + class="mac-close relative size-13px shadow-inner bg-#ed6a5eff rounded-50% select-none"> + +
+ ) : ( + (modalShow.value = false)} class="w-12px h-12px ml-a cursor-pointer select-none"> + + + )} +
+ + {t('message.lock_screen.title')} + + + +

{userStore.userInfo!.name}

+
+ + + + + + + + {t('message.lock_screen.confirm_button')} + +
+
+
+ ) +}) + +/** + * 检查更新弹窗 + */ +export const CheckUpdate = defineComponent(() => { + const { t } = useI18n() + /** 项目提交日志记录 */ + const commitLog = ref<{ message: string; icon: string }[]>([]) + const newCommitLog = ref<{ message: string; icon: string }[]>([]) + type ButtonState = 'check_now' | 'update_now' | 'downloading' | 'update_success' + const buttonState = ref('check_now') + const currentVersion = ref('') + const newVersion = ref('') + const loading = ref(false) + const checkLoading = ref(false) + const updating = ref(false) + /** 版本更新日期 */ + const versionTime = ref('') + const newVersionTime = ref('') + const percentage = ref(0) + const total = ref(0) + const downloaded = ref(0) + + const commitTypeMap: { [key: string]: string } = { + feat: 'comet', + fix: 'bug', + docs: 'memo', + style: 'lipstick', + refactor: 'recycling-symbol', + perf: 'rocket', + test: 'test-tube', + build: 'package', + ci: 'gear', + revert: 'right-arrow-curving-left', + chore: 'hammer-and-wrench' + } + + const mapCommitType = (commitMessage: string) => { + for (const type in commitTypeMap) { + if (new RegExp(`^${type}`, 'i').test(commitMessage)) { + return commitTypeMap[type] + } + } + } + + /* 记录检测更新的版本 */ + //let lastVersion: string | null = null + + const getCommitLog = async (url: string, isNew = false) => { + fetch(url).then((res) => { + if (!res.ok) { + commitLog.value = [{ message: t('message.check_update.fetch_log_failed'), icon: 'cloudError' }] + loading.value = false + return + } + res.json().then(async (data) => { + isNew ? (newVersionTime.value = data.created_at) : (versionTime.value = data.created_at) + await nextTick(() => { + // 使用正则表达式提取 * 号后面的内容 + const regex = /\* (.+)/g + let match + const logs = [] + while ((match = regex.exec(data.body)) !== null) { + logs.push(match[1]) + } + const processedLogs = logs.map((commit) => { + // 获取最后一个 : 号的位置 + const lastColonIndex = commit.lastIndexOf(':') + // 截取最后一个 : 号后的内容 + const message = lastColonIndex !== -1 ? commit.substring(lastColonIndex + 1).trim() : commit + return { + message: message, + icon: mapCommitType(commit) || 'alien-monster' + } + }) + isNew ? (newCommitLog.value = processedLogs) : (commitLog.value = processedLogs) + loading.value = false + }) + }) + }) + } + + const doUpdate = async () => { + if (!(await confirm(t('message.check_update.confirm_update')))) { + return + } + buttonState.value = 'downloading' + updating.value = true + checkLoading.value = true + await check() + .then(async (e) => { + if (!e?.available) { + return + } + + await e.downloadAndInstall((event) => { + switch (event.event) { + case 'Started': + total.value = event.data.contentLength ? event.data.contentLength : 0 + break + case 'Progress': + downloaded.value += event.data.chunkLength + percentage.value = parseFloat(((downloaded.value / total.value) * 100 + '').substring(0, 4)) + break + case 'Finished': + window.$message.success(t('message.check_update.download_success_toast')) + buttonState.value = 'update_success' + updating.value = false + break + } + }) + try { + await relaunch() + } catch (e) { + console.log(e) + window.$message.error(t('message.check_update.restart_failed')) + } + }) + .catch(() => { + window.$message.error(t('message.check_update.update_error')) + }) + .finally(() => { + checkLoading.value = false + updating.value = false + }) + } + + const checkUpdate = async () => { + checkLoading.value = true + buttonState.value = 'check_now' + await check() + .then((e) => { + if (!e?.available) { + checkLoading.value = false + return + } + newVersion.value = e.version + // 检查版本之间不同的提交信息和提交日期 + const url = `https://gitee.com/api/v5/repos/HuLaSpark/HuLa/releases/tags/v${newVersion.value}?access_token=${import.meta.env.VITE_GITEE_TOKEN}` + getCommitLog(url, true) + buttonState.value = 'update_now' + checkLoading.value = false + }) + .catch(() => { + checkLoading.value = false + window.$message.error(t('message.check_update.update_error')) + }) + } + + const init = async () => { + loading.value = true + currentVersion.value = await getVersion() + } + + onMounted(async () => { + await init() + const url = `https://gitee.com/api/v5/repos/HuLaSpark/HuLa/releases/tags/v${currentVersion.value}?access_token=${import.meta.env.VITE_GITEE_TOKEN}` + await getCommitLog(url) + await checkUpdate() + }) + return () => ( + +
+ {isMac() ? ( +
(modalShow.value = false)} + class="mac-close relative size-13px shadow-inner bg-#ed6a5eff rounded-50% select-none"> + +
+ ) : ( + (modalShow.value = false)} class="w-12px h-12px ml-a cursor-pointer select-none"> + + + )} + {loading.value ? ( + + + + + + ) : ( + + + + +

{t('message.check_update.current_version')}:

+

{currentVersion.value}

+
+ + {newVersion.value ? ( + + + + + +

{newVersion.value}

+ + + {t('message.check_update.new_tag')} + +
+ ) : null} +
+ + {newVersionTime.value ? ( + <> +

{t('message.check_update.new_release_date')}

+

{handRelativeTime(newVersionTime.value)}

+ + ) : ( + <> +

{t('message.check_update.release_date')}

+

{handRelativeTime(versionTime.value)}

+ + )} +
+
+

{t('message.check_update.log_title')}

+ + {newCommitLog.value.length > 0 ? ( + <> +
+ {newVersion.value} +
+ + + {newCommitLog.value.map((log, index) => ( + + {{ + icon: () => ( + + + + ) + }} + + ))} + + + + + + + + + + {currentVersion.value} + + + + + ) : null} + + + {commitLog.value.map((log, index) => ( + + {{ + icon: () => ( + + {/**/} + {/* */} + {/**/} + + + ) + }} + + ))} + +
+ + + {t(`message.check_update.${buttonState.value}`)} + {buttonState.value === 'downloading' && + total.value > 0 && + `${parseFloat((total.value / 1024 / 1024).toString()).toFixed(2)}M`} + + {updating.value && ( + + )} + +
+ )} +
+
+ ) +}) diff --git a/src/layout/left/style.scss b/src/layout/left/style.scss new file mode 100644 index 0000000..b170a0e --- /dev/null +++ b/src/layout/left/style.scss @@ -0,0 +1,56 @@ +@use '@/styles/scss/global/variable.scss' as *; + +@mixin action() { + &:not(.active):hover { + background: var(--left-bg-hover); + border-radius: 8px; + color: var(--left-active-hover); + cursor: pointer; + animation: linearAnimation 3s linear forwards; + } +} + +.left { + background: var(--left-bg-color); +} + +.top-action, +.bottom-action, +.more { + @include action; +} + +/** 通用的 active 样式 */ +.active { + background: var(--left-active-bg-color); + border-radius: 8px; + color: var(--left-active-icon-color); + img { + background: var(--icon-hover-color); + } +} + +.setting-item { + @include menu-item-style(absolute); + @include menu-list(); +} + +.mac-close:hover { + svg { + display: block; + } +} + +.item-hover { + @apply select-none hover:bg-[--info-hover] cursor-pointer w-fit rounded-10px p-4px; + transition: all 0.4s ease-in-out; +} + +:deep(.n-badge .n-badge-sup) { + font-weight: bold; + font-size: 10px; +} + +:deep(.n-badge) { + color: inherit; +} diff --git a/src/layout/right/index.vue b/src/layout/right/index.vue new file mode 100644 index 0000000..2df0cbf --- /dev/null +++ b/src/layout/right/index.vue @@ -0,0 +1,81 @@ + + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..baad592 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,77 @@ +import 'uno.css' +import '@unocss/reset/eric-meyer.css' // unocss提供的浏览器默认样式重置 +import TlbsMap from 'tlbs-map-vue' +import { setupI18n } from '@/services/i18n' +import { AppException } from '@/common/exception.ts' +import vResize from '@/directives/v-resize' +import vSlide from '@/directives/v-slide.ts' +import router from '@/router' +import { pinia } from '@/stores' +import { initializePlatform, isIOS, isMobile } from '@/utils/PlatformConstants' +import { startWebVitalObserver } from '@/utils/WebVitalsObserver' +import { invoke } from '@tauri-apps/api/core' +import App from '@/App.vue' + +initializePlatform() +startWebVitalObserver() + +if (isIOS()) { + invoke('request_ios_badge_authorization').catch((error) => { + console.warn('[HuLaBadge] 请求 iOS 角标权限失败', error) + }) +} + +import('@/services/webSocketAdapter') + +if (process.env.NODE_ENV === 'development') { + import('@/utils/Console.ts').then((module) => { + /**! 控制台打印项目版本信息(不需要可手动关闭)*/ + module.consolePrint() + }) + + if (isMobile()) { + import('eruda').then((module) => { + const eruda = 'default' in module ? module.default : module + eruda.init() + }) + } +} + +export const forceUpdateMessageTop = (topValue: number) => { + // 获取所有符合条件的元素 + const messages = document.querySelectorAll('.n-message-container.n-message-container--top') + + messages.forEach((el) => { + const dom = el as HTMLElement + dom.style.top = `${topValue}px` + }) +} + +if (isMobile()) { + if (document.readyState === 'loading') { + window.addEventListener('DOMContentLoaded', setup) + } else { + setup() + } +} + +async function setup() { + await invoke('set_complete', { task: 'frontend' }) +} + +const app = createApp(App) +app + .use(router) + .use(pinia) + .use(TlbsMap) + .use(setupI18n) + .directive('resize', vResize) + .directive('slide', vSlide) + .mount('#app') +app.config.errorHandler = (err) => { + if (err instanceof AppException) { + window.$message.error(err.message) + return + } + throw err +} diff --git a/src/mobile/components/ImagePreview.vue b/src/mobile/components/ImagePreview.vue new file mode 100644 index 0000000..4322d37 --- /dev/null +++ b/src/mobile/components/ImagePreview.vue @@ -0,0 +1,381 @@ + + + + + diff --git a/src/mobile/components/MobileLayout.vue b/src/mobile/components/MobileLayout.vue new file mode 100644 index 0000000..54795f2 --- /dev/null +++ b/src/mobile/components/MobileLayout.vue @@ -0,0 +1,188 @@ + + + + + + diff --git a/src/mobile/components/MobileScaffold.vue b/src/mobile/components/MobileScaffold.vue new file mode 100644 index 0000000..cde9ed1 --- /dev/null +++ b/src/mobile/components/MobileScaffold.vue @@ -0,0 +1,64 @@ + + + + diff --git a/src/mobile/components/PullToRefresh.vue b/src/mobile/components/PullToRefresh.vue new file mode 100644 index 0000000..17f67de --- /dev/null +++ b/src/mobile/components/PullToRefresh.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/src/mobile/components/RtcCallFloatCell.vue b/src/mobile/components/RtcCallFloatCell.vue new file mode 100644 index 0000000..228134b --- /dev/null +++ b/src/mobile/components/RtcCallFloatCell.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/src/mobile/components/VideoPreview.vue b/src/mobile/components/VideoPreview.vue new file mode 100644 index 0000000..41f2e30 --- /dev/null +++ b/src/mobile/components/VideoPreview.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/src/mobile/components/chat-room/FooterBar.vue b/src/mobile/components/chat-room/FooterBar.vue new file mode 100644 index 0000000..4714f69 --- /dev/null +++ b/src/mobile/components/chat-room/FooterBar.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/src/mobile/components/chat-room/HeaderBar.vue b/src/mobile/components/chat-room/HeaderBar.vue new file mode 100644 index 0000000..345feef --- /dev/null +++ b/src/mobile/components/chat-room/HeaderBar.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/src/mobile/components/chat-room/MessageContainer.vue b/src/mobile/components/chat-room/MessageContainer.vue new file mode 100644 index 0000000..fb62d1f --- /dev/null +++ b/src/mobile/components/chat-room/MessageContainer.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/src/mobile/components/chat-room/panel/More.vue b/src/mobile/components/chat-room/panel/More.vue new file mode 100644 index 0000000..9c72519 --- /dev/null +++ b/src/mobile/components/chat-room/panel/More.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/src/mobile/components/chat-room/panel/VoicePanel.vue b/src/mobile/components/chat-room/panel/VoicePanel.vue new file mode 100644 index 0000000..caf02d4 --- /dev/null +++ b/src/mobile/components/chat-room/panel/VoicePanel.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/src/mobile/components/community/CommunityContent.vue b/src/mobile/components/community/CommunityContent.vue new file mode 100644 index 0000000..930a2a6 --- /dev/null +++ b/src/mobile/components/community/CommunityContent.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/src/mobile/components/community/CommunityTab.vue b/src/mobile/components/community/CommunityTab.vue new file mode 100644 index 0000000..4b57cc8 --- /dev/null +++ b/src/mobile/components/community/CommunityTab.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/src/mobile/components/my/MobileApplyList.vue b/src/mobile/components/my/MobileApplyList.vue new file mode 100644 index 0000000..a070cfc --- /dev/null +++ b/src/mobile/components/my/MobileApplyList.vue @@ -0,0 +1,386 @@ + + + + diff --git a/src/mobile/components/my/MyMessageItem.vue b/src/mobile/components/my/MyMessageItem.vue new file mode 100644 index 0000000..fc374bb --- /dev/null +++ b/src/mobile/components/my/MyMessageItem.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/src/mobile/components/my/PersonalInfo.vue b/src/mobile/components/my/PersonalInfo.vue new file mode 100644 index 0000000..e834431 --- /dev/null +++ b/src/mobile/components/my/PersonalInfo.vue @@ -0,0 +1,519 @@ + + + + diff --git a/src/mobile/components/my/Settings.vue b/src/mobile/components/my/Settings.vue new file mode 100644 index 0000000..e0fd506 --- /dev/null +++ b/src/mobile/components/my/Settings.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/src/mobile/components/my/ShareModal.vue b/src/mobile/components/my/ShareModal.vue new file mode 100644 index 0000000..617246e --- /dev/null +++ b/src/mobile/components/my/ShareModal.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/src/mobile/components/my/community.vue b/src/mobile/components/my/community.vue new file mode 100644 index 0000000..677f24b --- /dev/null +++ b/src/mobile/components/my/community.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/src/mobile/components/virtual-scroll/MeasuredItem.vue b/src/mobile/components/virtual-scroll/MeasuredItem.vue new file mode 100644 index 0000000..0936c7d --- /dev/null +++ b/src/mobile/components/virtual-scroll/MeasuredItem.vue @@ -0,0 +1,9 @@ + + + + + diff --git a/src/mobile/components/virtual-scroll/SmartVirtualList.vue b/src/mobile/components/virtual-scroll/SmartVirtualList.vue new file mode 100644 index 0000000..fb62d1f --- /dev/null +++ b/src/mobile/components/virtual-scroll/SmartVirtualList.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/src/mobile/layout/chat-room/ChatRoomLayout.vue b/src/mobile/layout/chat-room/ChatRoomLayout.vue new file mode 100644 index 0000000..5966cd7 --- /dev/null +++ b/src/mobile/layout/chat-room/ChatRoomLayout.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/src/mobile/layout/chat-room/NoticeLayout.vue b/src/mobile/layout/chat-room/NoticeLayout.vue new file mode 100644 index 0000000..74a4c30 --- /dev/null +++ b/src/mobile/layout/chat-room/NoticeLayout.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/src/mobile/layout/friends/FriendsLayout.vue b/src/mobile/layout/friends/FriendsLayout.vue new file mode 100644 index 0000000..8efa9f9 --- /dev/null +++ b/src/mobile/layout/friends/FriendsLayout.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/src/mobile/layout/index.vue b/src/mobile/layout/index.vue new file mode 100644 index 0000000..2ada02e --- /dev/null +++ b/src/mobile/layout/index.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/src/mobile/layout/my/MyLayout.vue b/src/mobile/layout/my/MyLayout.vue new file mode 100644 index 0000000..f96d986 --- /dev/null +++ b/src/mobile/layout/my/MyLayout.vue @@ -0,0 +1,143 @@ + + + diff --git a/src/mobile/layout/navBar/index.vue b/src/mobile/layout/navBar/index.vue new file mode 100644 index 0000000..1fbc4e7 --- /dev/null +++ b/src/mobile/layout/navBar/index.vue @@ -0,0 +1,32 @@ + + + + diff --git a/src/mobile/layout/tabBar/index.vue b/src/mobile/layout/tabBar/index.vue new file mode 100644 index 0000000..1524a39 --- /dev/null +++ b/src/mobile/layout/tabBar/index.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/src/mobile/login.vue b/src/mobile/login.vue new file mode 100644 index 0000000..497b7aa --- /dev/null +++ b/src/mobile/login.vue @@ -0,0 +1,747 @@ + + + + + diff --git a/src/mobile/views/AddGroupQRCode.vue b/src/mobile/views/AddGroupQRCode.vue new file mode 100644 index 0000000..166f4ef --- /dev/null +++ b/src/mobile/views/AddGroupQRCode.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/src/mobile/views/ConfirmQRLogin.vue b/src/mobile/views/ConfirmQRLogin.vue new file mode 100644 index 0000000..8c2882b --- /dev/null +++ b/src/mobile/views/ConfirmQRLogin.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/src/mobile/views/MobileForgetPassword.vue b/src/mobile/views/MobileForgetPassword.vue new file mode 100644 index 0000000..07d0679 --- /dev/null +++ b/src/mobile/views/MobileForgetPassword.vue @@ -0,0 +1,455 @@ + + + + + diff --git a/src/mobile/views/MobilePrivacyAgreement.vue b/src/mobile/views/MobilePrivacyAgreement.vue new file mode 100644 index 0000000..656accf --- /dev/null +++ b/src/mobile/views/MobilePrivacyAgreement.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/src/mobile/views/MobileServiceAgreement.vue b/src/mobile/views/MobileServiceAgreement.vue new file mode 100644 index 0000000..99f6216 --- /dev/null +++ b/src/mobile/views/MobileServiceAgreement.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/src/mobile/views/MyQRCode.vue b/src/mobile/views/MyQRCode.vue new file mode 100644 index 0000000..afd300c --- /dev/null +++ b/src/mobile/views/MyQRCode.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/src/mobile/views/Splashscreen.vue b/src/mobile/views/Splashscreen.vue new file mode 100644 index 0000000..fd4d435 --- /dev/null +++ b/src/mobile/views/Splashscreen.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/src/mobile/views/SyncData.vue b/src/mobile/views/SyncData.vue new file mode 100644 index 0000000..50c919a --- /dev/null +++ b/src/mobile/views/SyncData.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/src/mobile/views/chat-room/ChatSetting.vue b/src/mobile/views/chat-room/ChatSetting.vue new file mode 100644 index 0000000..6b133ed --- /dev/null +++ b/src/mobile/views/chat-room/ChatSetting.vue @@ -0,0 +1,716 @@ + + + + + diff --git a/src/mobile/views/chat-room/GroupChatMember.vue b/src/mobile/views/chat-room/GroupChatMember.vue new file mode 100644 index 0000000..99227af --- /dev/null +++ b/src/mobile/views/chat-room/GroupChatMember.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/src/mobile/views/chat-room/MediaViewer.vue b/src/mobile/views/chat-room/MediaViewer.vue new file mode 100644 index 0000000..b0d842f --- /dev/null +++ b/src/mobile/views/chat-room/MediaViewer.vue @@ -0,0 +1,92 @@ + + + diff --git a/src/mobile/views/chat-room/MobileChatMain.vue b/src/mobile/views/chat-room/MobileChatMain.vue new file mode 100644 index 0000000..62ff995 --- /dev/null +++ b/src/mobile/views/chat-room/MobileChatMain.vue @@ -0,0 +1,275 @@ + + + + + diff --git a/src/mobile/views/chat-room/MobileInviteGroupMember.vue b/src/mobile/views/chat-room/MobileInviteGroupMember.vue new file mode 100644 index 0000000..f1099f2 --- /dev/null +++ b/src/mobile/views/chat-room/MobileInviteGroupMember.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/src/mobile/views/chat-room/SearchChatContent.vue b/src/mobile/views/chat-room/SearchChatContent.vue new file mode 100644 index 0000000..a142ec6 --- /dev/null +++ b/src/mobile/views/chat-room/SearchChatContent.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/src/mobile/views/chat-room/notice/NoticeDetail.vue b/src/mobile/views/chat-room/notice/NoticeDetail.vue new file mode 100644 index 0000000..adf84d9 --- /dev/null +++ b/src/mobile/views/chat-room/notice/NoticeDetail.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/src/mobile/views/chat-room/notice/NoticeEdit.vue b/src/mobile/views/chat-room/notice/NoticeEdit.vue new file mode 100644 index 0000000..15a53b6 --- /dev/null +++ b/src/mobile/views/chat-room/notice/NoticeEdit.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/src/mobile/views/chat-room/notice/NoticeList.vue b/src/mobile/views/chat-room/notice/NoticeList.vue new file mode 100644 index 0000000..133fde7 --- /dev/null +++ b/src/mobile/views/chat-room/notice/NoticeList.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/src/mobile/views/community/DynamicDetailPage.vue b/src/mobile/views/community/DynamicDetailPage.vue new file mode 100644 index 0000000..b115f15 --- /dev/null +++ b/src/mobile/views/community/DynamicDetailPage.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/src/mobile/views/community/index.vue b/src/mobile/views/community/index.vue new file mode 100644 index 0000000..d82edfc --- /dev/null +++ b/src/mobile/views/community/index.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/src/mobile/views/friends/AddFriends.vue b/src/mobile/views/friends/AddFriends.vue new file mode 100644 index 0000000..fd70af8 --- /dev/null +++ b/src/mobile/views/friends/AddFriends.vue @@ -0,0 +1,476 @@ + + + + + diff --git a/src/mobile/views/friends/ConfirmAddFriend.vue b/src/mobile/views/friends/ConfirmAddFriend.vue new file mode 100644 index 0000000..15edccd --- /dev/null +++ b/src/mobile/views/friends/ConfirmAddFriend.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/src/mobile/views/friends/ConfirmAddGroup.vue b/src/mobile/views/friends/ConfirmAddGroup.vue new file mode 100644 index 0000000..6a98913 --- /dev/null +++ b/src/mobile/views/friends/ConfirmAddGroup.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/src/mobile/views/friends/FriendInfo.vue b/src/mobile/views/friends/FriendInfo.vue new file mode 100644 index 0000000..9329ae1 --- /dev/null +++ b/src/mobile/views/friends/FriendInfo.vue @@ -0,0 +1,301 @@ + + + diff --git a/src/mobile/views/friends/StartGroupChat.vue b/src/mobile/views/friends/StartGroupChat.vue new file mode 100644 index 0000000..451b10d --- /dev/null +++ b/src/mobile/views/friends/StartGroupChat.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/src/mobile/views/friends/index.vue b/src/mobile/views/friends/index.vue new file mode 100644 index 0000000..9ea14c0 --- /dev/null +++ b/src/mobile/views/friends/index.vue @@ -0,0 +1,415 @@ + + + diff --git a/src/mobile/views/message/index.vue b/src/mobile/views/message/index.vue new file mode 100644 index 0000000..7b2499b --- /dev/null +++ b/src/mobile/views/message/index.vue @@ -0,0 +1,700 @@ + + + + + diff --git a/src/mobile/views/my/AiAssistant.vue b/src/mobile/views/my/AiAssistant.vue new file mode 100644 index 0000000..a6df80c --- /dev/null +++ b/src/mobile/views/my/AiAssistant.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/src/mobile/views/my/EditBio.vue b/src/mobile/views/my/EditBio.vue new file mode 100644 index 0000000..8282e68 --- /dev/null +++ b/src/mobile/views/my/EditBio.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/mobile/views/my/EditBirthday.vue b/src/mobile/views/my/EditBirthday.vue new file mode 100644 index 0000000..d205707 --- /dev/null +++ b/src/mobile/views/my/EditBirthday.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/src/mobile/views/my/EditProfile.vue b/src/mobile/views/my/EditProfile.vue new file mode 100644 index 0000000..1dd3dfc --- /dev/null +++ b/src/mobile/views/my/EditProfile.vue @@ -0,0 +1,356 @@ + + + + + diff --git a/src/mobile/views/my/MobileQRCode.vue b/src/mobile/views/my/MobileQRCode.vue new file mode 100644 index 0000000..21ec091 --- /dev/null +++ b/src/mobile/views/my/MobileQRCode.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/src/mobile/views/my/MobileSettings.vue b/src/mobile/views/my/MobileSettings.vue new file mode 100644 index 0000000..16f325b --- /dev/null +++ b/src/mobile/views/my/MobileSettings.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/src/mobile/views/my/MyAlbum.vue b/src/mobile/views/my/MyAlbum.vue new file mode 100644 index 0000000..bb9cf45 --- /dev/null +++ b/src/mobile/views/my/MyAlbum.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/src/mobile/views/my/MyMessages.vue b/src/mobile/views/my/MyMessages.vue new file mode 100644 index 0000000..8ce5c8b --- /dev/null +++ b/src/mobile/views/my/MyMessages.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/src/mobile/views/my/PublishCommunity.vue b/src/mobile/views/my/PublishCommunity.vue new file mode 100644 index 0000000..71ce6d8 --- /dev/null +++ b/src/mobile/views/my/PublishCommunity.vue @@ -0,0 +1,371 @@ + + + + + diff --git a/src/mobile/views/my/Share.vue b/src/mobile/views/my/Share.vue new file mode 100644 index 0000000..8b80e99 --- /dev/null +++ b/src/mobile/views/my/Share.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/src/mobile/views/my/SimpleBio.vue b/src/mobile/views/my/SimpleBio.vue new file mode 100644 index 0000000..57a6655 --- /dev/null +++ b/src/mobile/views/my/SimpleBio.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/src/mobile/views/my/index.vue b/src/mobile/views/my/index.vue new file mode 100644 index 0000000..329b0a4 --- /dev/null +++ b/src/mobile/views/my/index.vue @@ -0,0 +1,240 @@ + + + diff --git a/src/mobile/views/rtcCall/index.vue b/src/mobile/views/rtcCall/index.vue new file mode 100644 index 0000000..b9e2470 --- /dev/null +++ b/src/mobile/views/rtcCall/index.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/src/plugins/dynamic/detail.vue b/src/plugins/dynamic/detail.vue new file mode 100644 index 0000000..d1ffbca --- /dev/null +++ b/src/plugins/dynamic/detail.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/src/plugins/dynamic/index.vue b/src/plugins/dynamic/index.vue new file mode 100644 index 0000000..4e04c80 --- /dev/null +++ b/src/plugins/dynamic/index.vue @@ -0,0 +1,594 @@ + + + + + diff --git a/src/plugins/robot/components/ApiKeyManagement.vue b/src/plugins/robot/components/ApiKeyManagement.vue new file mode 100644 index 0000000..98eb6db --- /dev/null +++ b/src/plugins/robot/components/ApiKeyManagement.vue @@ -0,0 +1,450 @@ + + + + + diff --git a/src/plugins/robot/components/ChatRoleManagement.vue b/src/plugins/robot/components/ChatRoleManagement.vue new file mode 100644 index 0000000..5b87d4e --- /dev/null +++ b/src/plugins/robot/components/ChatRoleManagement.vue @@ -0,0 +1,596 @@ + + + + + diff --git a/src/plugins/robot/components/ChatRoleSelector.vue b/src/plugins/robot/components/ChatRoleSelector.vue new file mode 100644 index 0000000..c478921 --- /dev/null +++ b/src/plugins/robot/components/ChatRoleSelector.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/src/plugins/robot/components/ModelManagement.vue b/src/plugins/robot/components/ModelManagement.vue new file mode 100644 index 0000000..bb8e085 --- /dev/null +++ b/src/plugins/robot/components/ModelManagement.vue @@ -0,0 +1,832 @@ + + + + + diff --git a/src/plugins/robot/index.vue b/src/plugins/robot/index.vue new file mode 100644 index 0000000..c1ee654 --- /dev/null +++ b/src/plugins/robot/index.vue @@ -0,0 +1,62 @@ + + diff --git a/src/plugins/robot/layout/Left.vue b/src/plugins/robot/layout/Left.vue new file mode 100644 index 0000000..ba98e83 --- /dev/null +++ b/src/plugins/robot/layout/Left.vue @@ -0,0 +1,724 @@ + + + + + diff --git a/src/plugins/robot/layout/Right.vue b/src/plugins/robot/layout/Right.vue new file mode 100644 index 0000000..21bef8b --- /dev/null +++ b/src/plugins/robot/layout/Right.vue @@ -0,0 +1,22 @@ + + diff --git a/src/plugins/robot/utils/markdown.ts b/src/plugins/robot/utils/markdown.ts new file mode 100644 index 0000000..27c2319 --- /dev/null +++ b/src/plugins/robot/utils/markdown.ts @@ -0,0 +1,76 @@ +import type { DefineComponent } from 'vue' +import { MarkdownCodeBlockNode, setCustomComponents } from 'markstream-vue' + +interface MarkdownCodeBlockNodeData { + type: 'code_block' + language: string + code: string + raw: string + diff?: boolean + originalCode?: string + updatedCode?: string +} + +type MarkdownCodeBlockNodeProps = { + node: MarkdownCodeBlockNodeData + loading?: boolean + stream?: boolean + darkTheme?: string + lightTheme?: string + isDark?: boolean + isShowPreview?: boolean + enableFontSizeControl?: boolean + minWidth?: string | number + maxWidth?: string | number + themes?: string[] + showHeader?: boolean + showCopyButton?: boolean + showExpandButton?: boolean + showPreviewButton?: boolean + showFontSizeButtons?: boolean + onCopy?: (...args: any[]) => void + onPreviewCode?: (...args: any[]) => void + [key: string]: unknown +} + +const MarkdownCodeBlockNodeComponent = MarkdownCodeBlockNode as unknown as DefineComponent + +export const ROBOT_MARKDOWN_CUSTOM_ID = 'robot-chat-markdown' + +let initialized = false + +const toolbarOverrides = { + isShowPreview: false, + showPreviewButton: false, + enableFontSizeControl: false, + showFontSizeButtons: false, + showExpandButton: true, + showCopyButton: true +} satisfies Partial + +const RobotMarkdownCodeBlockNode = defineComponent({ + name: 'RobotMarkdownCodeBlockNode', + inheritAttrs: false, + setup(_, { attrs, slots }) { + return () => + h( + MarkdownCodeBlockNodeComponent, + { + ...(attrs as Partial), + ...toolbarOverrides + } as MarkdownCodeBlockNodeProps, + slots + ) + } +}) + +// 配置默认的代码块组件和主题 +export function initMarkdownRenderer() { + if (initialized) return + + setCustomComponents(ROBOT_MARKDOWN_CUSTOM_ID, { + code_block: RobotMarkdownCodeBlockNode + }) + + initialized = true +} diff --git a/src/plugins/robot/views/Chat.vue b/src/plugins/robot/views/Chat.vue new file mode 100644 index 0000000..e095fc5 --- /dev/null +++ b/src/plugins/robot/views/Chat.vue @@ -0,0 +1,3478 @@ + + + + diff --git a/src/plugins/robot/views/ImageGeneration.vue b/src/plugins/robot/views/ImageGeneration.vue new file mode 100644 index 0000000..d7444c1 --- /dev/null +++ b/src/plugins/robot/views/ImageGeneration.vue @@ -0,0 +1,517 @@ + + + + + diff --git a/src/plugins/robot/views/VideoGeneration.vue b/src/plugins/robot/views/VideoGeneration.vue new file mode 100644 index 0000000..0819db5 --- /dev/null +++ b/src/plugins/robot/views/VideoGeneration.vue @@ -0,0 +1,520 @@ + + + + + diff --git a/src/plugins/robot/views/Welcome.vue b/src/plugins/robot/views/Welcome.vue new file mode 100644 index 0000000..790729a --- /dev/null +++ b/src/plugins/robot/views/Welcome.vue @@ -0,0 +1,288 @@ + + + + diff --git a/src/plugins/robot/views/chatSettings/config.tsx b/src/plugins/robot/views/chatSettings/config.tsx new file mode 100644 index 0000000..aedd3c4 --- /dev/null +++ b/src/plugins/robot/views/chatSettings/config.tsx @@ -0,0 +1,206 @@ +import { NFlex } from 'naive-ui' +import type { VNode } from 'vue' +import { MacOsKeyEnum, WinKeyEnum } from '@/enums' +import { isWindows } from '@/utils/PlatformConstants' +import pkg from '~/package.json' +import { Button, Input, InputNumber, Select, Slider, Switch } from './model.tsx' + +const key = computed(() => { + return `${isWindows() ? WinKeyEnum.CTRL : MacOsKeyEnum['⌘']}` +}) + +type ConfigItemType = 'system' | 'record' | 'identity' | 'cueWords' | 'APIAddress' | 'model' | 'clear' +type ChatConfig = { + [key in ConfigItemType]: { + title: string + description?: string + features: VNode + }[] +} + +/** chat 设置面板配置 */ +export const content: ChatConfig = { + system: [ + { + title: `当前版本:v${pkg.version}`, + description: '已是最新版本', + features: