Compare commits

...
17 Commits
Author SHA1 Message Date
PiEgg 8952c98351 🎉 Release: v2.5.0 2026-01-27 16:57:41 +08:00
PiEgg 5cabbbdb90 🐛 Fix: review changes 2026-01-27 16:56:35 +08:00
PiEggandGitHub 43447726ef Feature: add picgo cloud and config sync (#1382)
ISSUES CLOSED: #1381
2026-01-27 16:52:23 +08:00
PiEggandGitHub 678cb713a9 📦 Chore(multi-arch): add multi-arch build support (#1379) 2026-01-20 14:54:55 +08:00
PiEggandGitHub 32f501cadb 🔨 Refactor(config): multi-config handle by picgo-core (#1378) 2026-01-13 20:41:15 +08:00
PiEgg 4fb3f2d488 📝 Docs(faq): update faq 2026-01-12 21:18:36 +08:00
PiEgg 5eb3755fa2 📝 Docs(release): update 2.4.3 docs 2026-01-12 20:57:54 +08:00
PiEgg 2a716ecdbc 🎉 Release: v2.4.3 2026-01-12 20:11:52 +08:00
PiEgg 089884b10a 🐛 Fix(ui): some ui bugs 2026-01-12 20:10:56 +08:00
PiEggandGitHub 2ed1dd5abd Feature: add global url rewrite support (#1377)
ISSUES CLOSED: #1255, #1281
2026-01-12 18:41:17 +08:00
PiEggandGitHub c84d542557 Feature(upload): add batch url upload support (#1376)
ISSUES CLOSED: #1302
2026-01-12 09:29:01 +08:00
PiEggandGitHub dfe92d496b 🐛 Fix(gallery): fix picbed list visible status not sync with gallery (#1373)
ISSUES CLOSED: #1372
2026-01-08 17:16:56 +08:00
PiEgg 3a9ff25071 🎉 Release: v2.4.2 2026-01-07 16:13:28 +08:00
PiEgg 2c9a188dbb 📝 Docs(2.4.2): update changelog 2026-01-07 16:13:04 +08:00
PiEgg 525492bed1 🐛 Fix(tray): clamp image titles to two lines
Prevent long file names from stretching the tray item layout.
2026-01-07 15:45:11 +08:00
PiEggandGitHub 50881b071a 🔨 Refactor(db): rm config store (#1371) 2026-01-07 15:13:25 +08:00
PiEggandGitHub 2936b19008 Feature(notification): refactor notification and add notificationSound settings (#1370)
ISSUES CLOSED: #1229
2026-01-04 17:08:19 +08:00
131 changed files with 6525 additions and 3013 deletions
+2 -2
View File
@@ -22,7 +22,7 @@ body:
label: 前置阅读 | Pre-reading
description: 我已经自行查找、阅读以下内容(阅读了请打勾) | I have searched and read the following on my own (Please tick after reading)
options:
- label: "[文档/Doc](https://picgo.github.io/PicGo-Doc/)"
- label: "[文档/Doc](https://docs.picgo.app/gui/)"
required: true
- label: "[Issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+sort%3Aupdated-desc+is%3Aclosed)"
required: true
@@ -60,4 +60,4 @@ body:
最后,喜欢 PicGo 的话不妨给它点个 star~
如果可以的话,请我喝杯咖啡?首页有赞助二维码,谢谢你的支持!
Finally, if you like PicGo, give it a star~
Buy me a cup of coffee if you can? There is a sponsorship QR code on the homepage, thank you for your support!
Buy me a cup of coffee if you can? There is a sponsorship QR code on the homepage, thank you for your support!
+213 -83
View File
@@ -6,11 +6,6 @@ on:
- v*
workflow_dispatch:
inputs:
test_upload_dist:
description: "Test upload-dist.js script"
required: true
default: false
type: boolean
build_os:
description: "Build for specific OS: Windows, macOS, Linux, All"
required: true
@@ -21,97 +16,243 @@ on:
- macOS
- Linux
- All
test_upload_dist:
description: "Test upload-dist.js script"
required: true
default: false
type: boolean
test_upload_dist_to_dev:
description: "Test upload-dist.js script to dev folder"
required: true
default: false
type: boolean
skip_notarize:
description: "Skip Notarization (true/false)"
required: true
default: false
type: boolean
env:
NODE_VERSION: 22.x
jobs:
# parallel build jobs
build:
name: Build on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
# ============== macOS Builds ==============
build-macos:
name: Build macOS (${{ matrix.arch }})
if: github.event.inputs.build_os == 'macOS' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v')
runs-on: ${{ matrix.runner }}
strategy:
# if one job fails, do not stop other jobs
fail-fast: false
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
include:
- runner: macos-15-intel
target: dmg
arch: x64
- runner: macos-latest
target: dmg
arch: arm64
steps:
- name: Check out git repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Clean workspace on Windows
if: runner.os == 'Windows' && (github.event.inputs.build_os == 'Windows' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v'))
- name: Clean workspace
run: rm -rf dist dist_electron node_modules ~/.cache/electron-builder ~/.cache/electron
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build macOS App (${{ matrix.arch }})
run: pnpm run build && pnpm exec electron-builder --config electron-builder.config.ts --mac ${{ matrix.target }} --${{ matrix.arch }} --publish never
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
SKIP_NOTARIZE: ${{ inputs.skip_notarize }}
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: PicGo-macOS-${{ matrix.arch }}
path: dist/*.*
# ============== Windows Builds ==============
build-windows:
name: Build Windows (${{ matrix.arch }})
if: github.event.inputs.build_os == 'Windows' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v')
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- runner: windows-latest
arch: x64-ia32
build_arch: --x64 --ia32
target: nsis
- runner: windows-11-arm
arch: arm64
build_arch: --arm64
target: nsis
steps:
- name: Check out git repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Clean workspace
run: |
if (Test-Path dist) { Remove-Item -Recurse -Force dist }
if (Test-Path dist_electron) { Remove-Item -Recurse -Force dist_electron }
if (Test-Path node_modules) { Remove-Item -Recurse -Force node_modules }
if (Test-Path "$env:LOCALAPPDATA\electron-builder") {
Remove-Item "$env:LOCALAPPDATA\electron-builder" -Recurse -Force -ErrorAction SilentlyContinue
}
if (Test-Path "$env:LOCALAPPDATA\electron") {
Remove-Item "$env:LOCALAPPDATA\electron" -Recurse -Force -ErrorAction SilentlyContinue
}
- name: Clean workspace on macOS & Linux
if: runner.os == 'macOS' || runner.os == 'Linux' && (github.event.inputs.build_os == 'macOS' || github.event.inputs.build_os == 'Linux' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v'))
run: |
rm -rf dist dist_electron node_modules ~/.cache/electron-builder ~/.cache/electron
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Ubuntu Update with sudo
if: runner.os == 'Linux' && (github.event.inputs.build_os == 'Linux' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v'))
run: |
sudo apt-get update
sudo apt-get install -y libfuse2
- name: Build Windows x64 & ARM64 App
if: runner.os == 'Windows' && (github.event.inputs.build_os == 'Windows' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v'))
run: pnpm run build:win || true
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Build macOS x64 & ARM64 App
if: runner.os == 'macOS' && (github.event.inputs.build_os == 'macOS' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v'))
run: pnpm run build:mac || true
shell: bash
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
# macOS Code Signing
# p12 证书的 Base64 字符串
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
# p12 证书密码
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
# macOS Notarization (公证所需变量)
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
SKIP_NOTARIZE: ${{ inputs.skip_notarize }}
- name: Build Linux x64 & ARM64 App
if: runner.os == 'Linux' && (github.event.inputs.build_os == 'Linux' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v'))
run: pnpm run build:linux || true
shell: bash
- name: Build Windows App (${{ matrix.arch }})
run: pnpm run build && pnpm exec electron-builder --config electron-builder.config.ts --win ${{matrix.target}} ${{ matrix.build_arch }} --publish never
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: PicGo-${{ runner.os }}
name: PicGo-Windows-${{ matrix.arch }}
path: dist/*.*
- name: Upload to release.picgo.app
if: startsWith(github.ref, 'refs/tags/v') || github.event.inputs.test_upload_dist
# ============== Linux Builds ==============
build-linux:
name: Build Linux (${{ matrix.arch }})
if: github.event.inputs.build_os == 'Linux' || github.event.inputs.build_os == 'All' || startsWith(github.ref, 'refs/tags/v')
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
arch: x64
target: AppImage deb snap
- runner: ubuntu-24.04-arm
arch: arm64
target: AppImage deb
steps:
- name: Check out git repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Clean workspace
run: rm -rf dist dist_electron node_modules ~/.cache/electron-builder ~/.cache/electron
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Linux dependencies
run: |
pnpm run upload-dist
sudo apt-get update
sudo apt-get install -y libfuse2
- name: Build Linux App (${{ matrix.arch }})
run: pnpm run build && pnpm exec electron-builder --config electron-builder.config.ts --linux ${{matrix.target}} --${{ matrix.arch }} --publish never
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: PicGo-Linux-${{ matrix.arch }}
path: dist/*.*
# ============== Release ==============
release:
name: Merge & Release
needs: [build-macos, build-windows, build-linux]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check out git repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: List artifacts
run: ls -laR artifacts/
- name: Merge artifacts and yml files
run: node scripts/merge-artifacts.js
- name: List dist
run: ls -la dist/
- name: Upload to release.picgo.app
if: startsWith(github.ref, 'refs/tags/v') || github.event.inputs.test_upload_dist || github.event.inputs.test_upload_dist_to_dev
run: |
ARGS="--all"
if [[ "${{ github.event.inputs.test_upload_dist_to_dev }}" == "true" ]]; then
ARGS="$ARGS --dev"
echo "🚧 Test Upload Mode: ON"
fi
node scripts/upload-dist.js $ARGS
env:
PICGO_ENV_S3_SECRET_ID: ${{ secrets.PICGO_ENV_S3_SECRET_ID }}
PICGO_ENV_S3_SECRET_KEY: ${{ secrets.PICGO_ENV_S3_SECRET_KEY }}
@@ -119,17 +260,7 @@ jobs:
PICGO_ENV_S3_LEGACY_ACCOUNT_ID: ${{ secrets.PICGO_ENV_S3_LEGACY_ACCOUNT_ID }}
PICGO_ENV_S3_LEGACY_SECRET_ID: ${{ secrets.PICGO_ENV_S3_LEGACY_SECRET_ID }}
PICGO_ENV_S3_LEGACY_SECRET_KEY: ${{ secrets.PICGO_ENV_S3_LEGACY_SECRET_KEY }}
release:
name: Publish GitHub Release
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Publish GitHub Dev Release
if: github.event_name == 'workflow_dispatch'
uses: softprops/action-gh-release@v2
@@ -140,16 +271,16 @@ jobs:
draft: true
prerelease: false
files: |
!artifacts/**/*-unpacked/**
artifacts/**/*.exe
artifacts/**/*.dmg
artifacts/**/*.zip
artifacts/**/*.AppImage
artifacts/**/*.deb
artifacts/**/*.snap
artifacts/**/*.tar.gz
artifacts/**/*.yml
artifacts/**/*.blockmap
dist/*.exe
dist/*.dmg
dist/*.zip
dist/*.AppImage
dist/*.deb
dist/*.snap
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
- name: Publish GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
@@ -160,13 +291,12 @@ jobs:
draft: true
prerelease: false
files: |
!artifacts/**/*-unpacked/**
artifacts/**/*.exe
artifacts/**/*.dmg
artifacts/**/*.zip
artifacts/**/*.AppImage
artifacts/**/*.deb
artifacts/**/*.snap
artifacts/**/*.tar.gz
artifacts/**/*.yml
artifacts/**/*.blockmap
dist/*.exe
dist/*.dmg
dist/*.zip
dist/*.AppImage
dist/*.deb
dist/*.snap
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
+5 -1
View File
@@ -25,4 +25,8 @@ scripts/*.yml
/dist_electron
.serena/
dist/*
test.js
test.js
specs/
.cache/
openspec/
bug*
+1
View File
@@ -0,0 +1 @@
pnpm commitlint ${1}
+1
View File
@@ -0,0 +1 @@
pnpm check
+1
View File
@@ -0,0 +1 @@
pnpm test
+14 -1
View File
@@ -4,19 +4,28 @@
PicGo is an Electron + Vue 3 desktop client. Source lives in `src/`: `src/main` for main-process and IPC logic, `src/renderer` for Vue views, and `src/universal` for shared helpers (`types/`, `events/constants.ts`). `background.ts` wires Electron Builder. Static assets and locale YAML files stay in `public/` (add languages under `public/i18n/`), while `docs/` hosts user-facing guides. Automation scripts live in `scripts/`, and legacy tests sit under `test/unit` (Karma) and `test/e2e` (Spectron).
## Build, Test, and Development Commands
- `pnpm install` — install dependencies; `npm install` is unsupported.
- `pnpm install` — install dependencies; `npm install` is unsupported. Only run this when the user explicitly asks/coordinates it.
- Always add/remove dependencies with `pnpm` (never edit package.json versions by hand then install).
- `pnpm dev` — electron-vite dev server for main/preload/renderer.
- `pnpm build` — electron-vite build outputs to `dist/main`, `dist/preload`, `dist/renderer`; `pnpm preview` for preview mode.
- Packaging config lives in `electron-builder.yml` (read by electron-builder via package.json `build` field/extraResources); set `ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/` if downloads are slow.
- `pnpm lint` / `pnpm lint:fix` — run or auto-fix ESLint (Standard, TypeScript, Vue rules).
- `pnpm lint:dpdm` — fail fast on circular dependencies in `src/`.
- `pnpm check` — run `tsc` + `lint` (run once before finishing a task).
- Before completing a task, always run `pnpm check` and resolve any issues it reports.
- `pnpm gen-i18n` — regenerate typed locales after touching `public/i18n/*.yml`.
## Coding Style & Naming Conventions
Follow ESLint Standard defaults: two-space indentation, single quotes, trailing commas where allowed, and no stray semicolons. Author new modules in TypeScript. Keep renderer files browser-safe; route Node APIs through IPC helpers such as `src/main/events/picgoCoreIPC.ts`. Name Vue components in PascalCase (`UploadPanel.vue`) and use camelCase for utilities. Centralize IPC event names inside `src/universal/events/constants.ts`, and store enums/types under `src/universal/types/` so they stay reusable. Static assets are served from `public/` and resolved via `getStaticPath`/`getStaticFileUrl` (`src/universal/utils/staticPath.ts`); avoid using `__static` directly.
Static assets are served from `public/`. In the main process use `getStaticPath`/`getStaticFileUrl` (`src/universal/utils/staticPath.ts`). In the renderer, place assets under `public/` and resolve them via `import.meta.env.BASE_URL + filename` (helper: `src/renderer/utils/static.ts`); do not rely on `__static` in renderer code.
- Do not use `as any` under any circumstances; keep typings explicit and safe.
- Avoid `as any` in tests as well; build concrete typed stubs (e.g., `IpcMainInvokeEvent`) instead.
- Do not prefix method calls with `void` (e.g. use `store?.refreshPicBeds()` rather than `void store?.refreshPicBeds()`).
- If a renderer → main request mutates persisted config/state without using `saveConfig`, call `notifyAppConfigUpdated()` in main to inform renderers.
- Prefer enums over union types for discrete value sets (e.g., encryption methods). Avoid introducing new string literal union types.
- Renderer page/component styles should prefer Tailwind utility classes; avoid adding new Vue `<style>` blocks unless there's no reasonable Tailwind equivalent.
- New renderer ↔ main request/response APIs should be implemented via RPC routes (see `src/main/events/rpc/routes/system.ts`) with `RPCRouter` + `IRPCActionType` rather than adding ad-hoc IPC modules (e.g. `picgoCloudIPC`).
- For request/response semantics in renderer, prefer `invokeRPC` (backed by `ipcMain.handle(RPC_ACTIONS, ...)` in `src/main/events/rpc/index.ts`).
## Testing Guidelines
Place renderer unit specs in `test/unit/specs` with the `.spec.js` suffix; Karma picks them up via `require.context`. Run them with `npx karma start test/unit/karma.conf.js --single-run` and ensure new renderer folders are covered. Spectron e2e cases live in `test/e2e/specs`; build first (`pnpm build`), then run `npx mocha test/e2e/index.js` so Spectron can launch `dist/electron/main.js`. Document any test data, IPC stubs, or fixtures you add to keep suites reproducible.
@@ -26,6 +35,10 @@ Commits follow the PicGo conventional preset enforced by Husky (`pnpm lint:dpdm`
## Internationalization Tips
Add locales by creating `public/i18n/<locale>.yml`, exposing its `LANG_DISPLAY_LABEL`, and registering it in `src/universal/i18n/index.ts`. After editing `public/i18n/*.yml`, run `pnpm gen-i18n` to regenerate TS typings and keep them in sync.
- Any user-facing copy (UI text, error messages, warnings, prompts, tips, notifications, etc.) MUST use i18n keys. Do not hardcode strings in code.
- Renderer: use `$T('KEY')` from `src/renderer/i18n/index.ts`.
- Main process: use `T('KEY')` from `src/main/i18n/index.ts`.
- Add new keys to all locales under `public/i18n/` (at least `en.yml`, `zh-CN.yml`, `zh-TW.yml`) and run `pnpm gen-i18n`.
## Serena MCP & Context7 Tools
When starting work or if you hit issues, try checking MCP for Serena or Context7 tooling. If available, use those tools to navigate, edit, or fetch docs efficiently.
+60
View File
@@ -1,3 +1,63 @@
# :tada: 2.5.0 (2026-01-27)
### :sparkles: Features
* add picgo cloud and config sync ([#1382](https://github.com/Molunerfinn/PicGo/issues/1382)) ([4344772](https://github.com/Molunerfinn/PicGo/commit/4344772)), closes [#1381](https://github.com/Molunerfinn/PicGo/issues/1381)
### :bug: Bug Fixes
* review changes ([5cabbbd](https://github.com/Molunerfinn/PicGo/commit/5cabbbd))
### :package: Chore
* **multi-arch:** add multi-arch build support ([#1379](https://github.com/Molunerfinn/PicGo/issues/1379)) ([678cb71](https://github.com/Molunerfinn/PicGo/commit/678cb71))
### :pencil: Documentation
* **faq:** update faq ([4fb3f2d](https://github.com/Molunerfinn/PicGo/commit/4fb3f2d))
* **release:** update 2.4.3 docs ([5eb3755](https://github.com/Molunerfinn/PicGo/commit/5eb3755))
## :tada: 2.4.3 (2026-01-12)
### :sparkles: Features
* add global url rewrite support ([#1377](https://github.com/Molunerfinn/PicGo/issues/1377)) ([2ed1dd5](https://github.com/Molunerfinn/PicGo/commit/2ed1dd5)), closes [#1255](https://github.com/Molunerfinn/PicGo/issues/1255) [#1281](https://github.com/Molunerfinn/PicGo/issues/1281)
* **upload:** add batch url upload support ([#1376](https://github.com/Molunerfinn/PicGo/issues/1376)) ([c84d542](https://github.com/Molunerfinn/PicGo/commit/c84d542)), closes [#1302](https://github.com/Molunerfinn/PicGo/issues/1302)
### :bug: Bug Fixes
* **gallery:** fix picbed list visible status not sync with gallery ([#1373](https://github.com/Molunerfinn/PicGo/issues/1373)) ([dfe92d4](https://github.com/Molunerfinn/PicGo/commit/dfe92d4)), closes [#1372](https://github.com/Molunerfinn/PicGo/issues/1372)
* **ui:** some ui bugs ([089884b](https://github.com/Molunerfinn/PicGo/commit/089884b))
## :tada: 2.4.2 (2026-01-07)
### :sparkles: Features
* **notification:** refactor notification and add notificationSound settings ([#1370](https://github.com/Molunerfinn/PicGo/issues/1370)) ([2936b19](https://github.com/Molunerfinn/PicGo/commit/2936b19)), closes [#1229](https://github.com/Molunerfinn/PicGo/issues/1229)
### :bug: Bug Fixes
* **tray:** clamp image titles to two lines ([525492b](https://github.com/Molunerfinn/PicGo/commit/525492b))
### :pencil: Documentation
* **2.4.2:** update changelog ([2c9a188](https://github.com/Molunerfinn/PicGo/commit/2c9a188))
## :tada: 2.4.2-beta.0 (2025-12-31)
+4 -100
View File
@@ -1,8 +1,8 @@
## Frequently Asked Questions / 常见问题
> While using PicGo you may run into various issues. Many of them have already been asked and resolved, so please check the [documentation](https://picgo.github.io/PicGo-Doc/guide/getting-started.html#%E5%BF%AB%E9%80%9F%E4%B8%8A%E6%89%8B), this FAQ, and closed [issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+is%3Aclosed) first — you will likely find the answer there.
> While using PicGo you may run into various issues. Many of them have already been asked and resolved, so please check the [documentation](https://docs.picgo.app/gui/guide/getting-started), this FAQ, and closed [issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+is%3Aclosed) first — you will likely find the answer there.
>
> 在使用 PicGo 期间你会遇到很多问题,不过很多问题其实之前就有人提问过,也被解决,所以你可以先看看 [使用文档](https://picgo.github.io/PicGo-Doc/guide/getting-started.html#%E5%BF%AB%E9%80%9F%E4%B8%8A%E6%89%8B),这份 FAQ,以及那些被关闭的 [issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+is%3Aclosed),应该能找到答案。
> 在使用 PicGo 期间你会遇到很多问题,不过很多问题其实之前就有人提问过,也被解决,所以你可以先看看 [使用文档](https://docs.picgo.app/gui/guide/getting-started),这份 FAQ,以及那些被关闭的 [issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+is%3Aclosed),应该能找到答案。
## 1. Qiniu image host: upload succeeds but images dont show in Album, or the URL has no `http://` prefix / 七牛图床上传图片成功后,相册里无法显示或图片无`http://`前缀
@@ -148,106 +148,10 @@ If you run into upload issues with the Gitee image host, PicGo cannot help becau
## 12. On macOS, PicGo shows “App is damaged”, or it doesnt respond after installation / macOS系统安装完PicGo显示「文件已损坏」或者安装完打开没有反应
Because PicGo is not signed, it may be blocked by macOS Gatekeeper.
Please try the version >= v2.4.2, which has fixed this issue.
1. If you see “App is damaged” when opening after installation, do the following:
请尝试使用 v2.4.2 及以上版本,已经修复该问题。
Trust the developer (password required):
```
sudo spctl --master-disable
```
Then remove quarantine attributes from PicGo:
```
xattr -cr /Applications/PicGo.app
```
If you see the following message:
```sh
option -r not recognized
usage: xattr [-slz] file [file ...]
xattr -p [-slz] attr_name file [file ...]
xattr -w [-sz] attr_name attr_value file [file ...]
xattr -d [-s] attr_name file [file ...]
xattr -c [-s] file [file ...]
The first form lists the names of all xattrs on the given file(s).
The second form (-p) prints the value of the xattr attr_name.
The third form (-w) sets the value of the xattr attr_name to attr_value.
The fourth form (-d) deletes the xattr attr_name.
The fifth form (-c) deletes (clears) all xattrs.
options:
-h: print this help
-s: act on symbolic links themselves rather than their targets
-l: print long format (attr_name: attr_value)
-z: compress or decompress (if compressed) attribute value in zip format
```
Run:
```
sudo xattr -d com.apple.quarantine /Applications/PicGo.app/
```
2. If PicGo doesnt respond after installation, troubleshoot in this order:
1. PicGo wont automatically pop up a main window on macOS — its designed as a menu bar app. If you can see the PicGo icon in the menu bar, the installation succeeded; click it to open the menu bar window. See FAQ #7.
2. If youre on an Apple Silicon (M1) Mac and previously had the x64 build installed, then switched to the arm64 build and it doesnt respond, reboot your Mac.
因为 PicGo 没有签名,所以会被 macOS 的安全检查所拦下。
1. 安装后打开遇到「文件已损坏」的情况,请按如下方式操作:
信任开发者,会要求输入密码:
```
sudo spctl --master-disable
```
然后放行 PicGo :
```
xattr -cr /Applications/PicGo.app
```
然后就能正常打开。
如果提示以下内容
```sh
option -r not recognized
usage: xattr [-slz] file [file ...]
xattr -p [-slz] attr_name file [file ...]
xattr -w [-sz] attr_name attr_value file [file ...]
xattr -d [-s] attr_name file [file ...]
xattr -c [-s] file [file ...]
The first form lists the names of all xattrs on the given file(s).
The second form (-p) prints the value of the xattr attr_name.
The third form (-w) sets the value of the xattr attr_name to attr_value.
The fourth form (-d) deletes the xattr attr_name.
The fifth form (-c) deletes (clears) all xattrs.
options:
-h: print this help
-s: act on symbolic links themselves rather than their targets
-l: print long format (attr_name: attr_value)
-z: compress or decompress (if compressed) attribute value in zip format
```
执行命令
```
sudo xattr -d com.apple.quarantine /Applications/PicGo.app/
```
2. 如果安装打开后没有反应,请按下方顺序排查:
1. macOS安装好之后,PicGo 是不会弹出主窗口的,因为 PicGo 在 macOS 系统里设计是个顶部栏应用。注意看你顶部栏的图标,如果有 PicGo 的图标,说明安装成功了,点击图标即可打开顶部栏窗口。参考上述第七点。
2. 如果你是 M1 的系统,此前装过 PicGo 的 x64 版本,但是后来更新了 arm64 的版本发现打开后没反应,请重启电脑即可。
## 13. Are third-party plugins claiming to be “PicGo Official image host” trustworthy? / 所谓「PicGo 官方图床」的第三方插件是否可信
+4 -4
View File
@@ -52,7 +52,7 @@ PicGo supports mainstream Image hosts out of the box, and can be extended indefi
- **International / open platforms**: GitHub, SM.MS, Imgur
- **More options via plugins**: AWS S3, Cloudflare R2, MinIO, and more
> **Note**: PicGo itself will no longer add new third-party Image hosts by default. You can build Image host plugins yourself—see [PicGo-Core](https://picgo.github.io/PicGo-Core-Doc/).
> **Note**: PicGo itself will no longer add new third-party Image hosts by default. You can build Image host plugins yourself—see [PicGo-Core](https://docs.picgo.app/core/).
## ✨ Key Features
@@ -66,7 +66,7 @@ PicGo is built around a fast, low-friction image upload experience:
### 🚀 Fast uploads
- **Multiple ways to upload**: drag & drop, paste from clipboard, hotkeys, and even right-click context menu upload on macOS/Windows.
- **Global hotkey**: press `Command+Shift+P` (macOS) / `Ctrl+Shift+P` (Windows/Linux) to open the upload window without leaving your current app. The global key can be customized.
- **Global hotkey**: press `Command+Shift+U` (macOS) / `Ctrl+Shift+U` (Windows/Linux) to open the upload window without leaving your current app. The global key can be customized.
### 🧩 Powerful plugin ecosystem
- **Highly extensible**: plugins already exist for AWS S3, Cloudflare R2, MinIO, and many other Image hosts.
@@ -76,11 +76,11 @@ PicGo is built around a fast, low-friction image upload experience:
### 🛠 Developer-friendly
- **HTTP API**: upload via HTTP requests (v2.2.0+), making it easy to integrate with other tools.
- **Open source**: fully open-source and transparent.
- **Great documentation**: detailed docs help you get started quickly. For plugin development, see the [PicGo-Core docs](https://picgo.github.io/PicGo-Core-Doc/).
- **Great documentation**: detailed docs help you get started quickly. For plugin development, see the [PicGo-Core docs](https://docs.picgo.app/core/).
> Theres more to discover—development progress is tracked in [Projects](https://github.com/Molunerfinn/PicGo/projects).
If youre new to PicGo, start with the [User Guide](https://picgo.github.io/PicGo-Doc/guide/getting-started.html). If you run into issues, check the [FAQ](https://github.com/Molunerfinn/PicGo/blob/dev/FAQ.md) and closed [issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+is%3Aclosed).
If youre new to PicGo, start with the [User Guide](https://docs.picgo.app/gui/guide/getting-started). If you run into issues, check the [FAQ](https://github.com/Molunerfinn/PicGo/blob/dev/FAQ.md) and closed [issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+is%3Aclosed).
## Download & Install
+4 -5
View File
@@ -53,7 +53,7 @@ PicGo 原生支持主流图床平台,并可通过插件系统无限扩展:
- **国际/开源平台**GitHub、SM.MS、Imgur
- **更多支持**:通过插件支持 AWS S3、Cloudflare R2、MinIO 等第三方图床
> **注意**:PicGo 本体不再增加默认的第三方图床支持。你可以自行开发第三方图床插件。详见 [PicGo-Core](https://picgo.github.io/PicGo-Core-Doc/)。
> **注意**:PicGo 本体不再增加默认的第三方图床支持。你可以自行开发第三方图床插件。详见 [PicGo-Core](https://docs.picgo.app/core/)。
## ✨ 特色功能
@@ -67,7 +67,7 @@ PicGo 打造了全方位的上传体验,让“传图”这件事变得前所
### 🚀 极速上传体验
- **多维上传方式**:支持拖拽图片、剪贴板粘贴、快捷键上传,甚至在 macOS/Windows 上支持右键菜单直接上传。
- **全局快捷键**:默认 `Command+Shift+P` (macOS) / `Ctrl+Shift+P` (Windows/Linux) 即可唤起上传,无需离开当前窗口。 快捷键可自定义。
- **全局快捷键**:默认 `Command+Shift+U` (macOS) / `Ctrl+Shift+U` (Windows/Linux) 即可唤起上传,无需离开当前窗口。 快捷键可自定义。
### 🧩 强大的插件生态
- **高度可扩展**:PicGo 拥有丰富的插件系统,已有插件支持 AWS S3、Cloudflare R2、MinIO 等第三方图床。
@@ -77,12 +77,11 @@ PicGo 打造了全方位的上传体验,让“传图”这件事变得前所
### 🛠 开发者友好
- **HTTP API**:支持通过 HTTP 请求调用 PicGo 上传 (v2.2.0+),方便与其他工具集成。
- **开源透明**:代码完全开源,安全可靠。
- **丰富的文档**:详尽的开发文档助你快速上手。插件开发请参考 [PicGo-Core 文档](https://picgo.github.io/PicGo-Core-Doc/)。
- **丰富的文档**:详尽的开发文档助你快速上手。插件开发请参考 [PicGo-Core 文档](https://docs.picgo.app/core/)。
> 更多功能等你自己去发现,开发进度可以查看 [Projects](https://github.com/Molunerfinn/PicGo/projects)。
**如果第一次使用,请参考应用 [使用文档](https://picgo.github.io/PicGo-Doc/guide/getting-started.html)。遇到问题了还可以看看 [FAQ](https://github.com/Molunerfinn/PicGo/blob/dev/FAQ.md) 以及被关闭的 [issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+is%3Aclosed)。**
**如果第一次使用,请参考应用 [使用文档](https://docs.picgo.app/gui/guide/getting-started)。遇到问题了还可以看看 [FAQ](https://github.com/Molunerfinn/PicGo/blob/dev/FAQ.md) 以及被关闭的 [issues](https://github.com/Molunerfinn/PicGo/issues?q=is%3Aissue+is%3Aclosed)。**
## 下载安装
+48 -48
View File
@@ -1,66 +1,66 @@
# PicGo 2.4.0 Changelog
## Features
- Add filename display in the gallery (#1050).
- Add default placeholders when images/URLs cannot be shown in the gallery or tray window (#1050).
- Add filename display in the gallery (#1050)
- Add default placeholders when images/URLs cannot be shown in the gallery or tray window (#1050)
![image](https://user-images.githubusercontent.com/12621342/210806238-e77bc3b1-5e32-4c9f-af5c-278c5706450d.png)
- Add filename display in the macOS tray window (#1054).
- Allow multiple configs per uploader type and choose one for upload (thanks @STDSuperman, #1016).
- Add filename display in the macOS tray window (#1054)
- Allow multiple configs per uploader type and choose one for upload (thanks @STDSuperman, #1016)
![](https://user-images.githubusercontent.com/44311619/203093104-9537e08a-2ef0-450d-a59d-c470dbcdd6c8.png)
- Allow the uploader selection menu to pick a specific config entry for that uploader type.
- Allow the uploader selection menu to pick a specific config entry for that uploader type
![image](https://user-images.githubusercontent.com/12621342/210804413-4f78804f-a451-4ca5-93a3-63d461261b18.png)
- Add Dock icon visibility option (#1045).
- Add Dock icon visibility option (#1045)
<img width="787" alt="image" src="https://github.com/Molunerfinn/PicGo/assets/12621342/bb9492f1-6522-45ce-ae5d-c614901d8b06" />
- Add PicGo Repair Toolbox for self-diagnosis.
- Add PicGo Repair Toolbox for self-diagnosis
<img width="324" alt="image" src="https://github.com/Molunerfinn/PicGo/assets/12621342/98cf6e64-7313-4ebe-83d0-aa86e4514109" />
- Add option to encode/escape output URLs (#731).
- Add option to encode/escape output URLs (#731)
<img width="775" alt="image" src="https://github.com/Molunerfinn/PicGo/assets/12621342/6e0c13dd-3404-4b07-9c99-412321835b27" />
- Support drag-and-drop uploads for arbitrary file formats (#1052).
- Tencent COS: add Endpoint and Intelligent Compression settings (thanks @palmcivet @yc910920).
- Support drag-and-drop uploads for arbitrary file formats (#1052)
- Tencent COS: add Endpoint and Intelligent Compression settings (thanks @palmcivet @yc910920)
![image](https://github.com/Molunerfinn/PicGo/assets/12621342/6e623751-d330-47f8-89bc-986031452914)
- Add Markdown-rendered tips for uploader config (see tcyun uploader config).
- Add Markdown-rendered tips for uploader config (see tcyun uploader config)
![image](https://github.com/Molunerfinn/PicGo/assets/12621342/2f9b8734-717a-4f49-b397-1ddb9437bc3c)
- Show the active uploader config name in the upload screen.
- Show the active uploader config name in the upload screen
![image](https://github.com/Molunerfinn/PicGo/assets/12621342/52c58fff-4810-4b91-92d7-5ade512eaca8)
- Gallery toolbar: bulk replace image URL host (#875).
Note: select images first; you can filter by uploader; e.g., replace `https://www.a.com/...` with `https://www.b.com/...`.
- Gallery toolbar: bulk replace image URL host (#875)
Note: select images first; you can filter by uploader; e.g., replace `https://www.a.com/...` with `https://www.b.com/...`
![image](https://github.com/Molunerfinn/PicGo/assets/12621342/ee314dfc-7699-4ceb-8638-cafe7948bd5a)
- Add “Startup Mode”: Silent (default) / Main Window; Windows/Linux also support Mini Window (#915).
- Add “Startup Mode”: Silent (default) / Main Window; Windows/Linux also support Mini Window (#915)
<img width="577" alt="image" src="https://github.com/Molunerfinn/PicGo/assets/12621342/7e63bb5c-44b0-480e-824f-3c2edbed4fbb" />
- PicGo Server: support multipart form uploads (field `files`, #428, thanks @happy-game).
- PicGo Server: support multipart form uploads (field `files`, #428, thanks @happy-game)
<img width="951" alt="image" src="https://github.com/user-attachments/assets/14244f1d-60f5-487f-bde6-6d0e009645fb" />
## Bug Fixes
- Fix Windows context-menu config script generation (#1019).
- Fix custom link URL encoding (#1112).
- Fix potential logging dead loop (#1101).
- Fix drag-to-tray upload error (#1107).
- Fix filename encoding (#1121).
- Prevent errors on GitHub uploads with duplicate filenames.
- Fix beta.2 style issues.
- Fix slow filename display in the rename window (#1130).
- Fix “open config file” actually opening the log file (#1163).
- Fix plugin config dialog not reading/saving correctly.
- Fix inability to add new uploader configs (created as edit) (#1198, #1196).
- Fix macOS context menu disappearing (thanks @muwoo, #1179).
- Fix macOS tray window flashing after right-click (thanks @QThans, #1217).
- Fix uploader config page not scrollable when many fields (#1237).
- Fix Tencent COS URL encoding (#1265).
- Fix plugin list search not working (#1297).
- Fix clipboard filename losing “seconds” (#1293).
- Fix macOS tray copy-link failing on click (#1280, #1210).
- Fix auto-copy URL toggle not turning off (#1294, thanks @happy-game).
- Fix Wayland clipboard image upload issue (#1261, thanks @happy-game).
- Fix URL uploads with Chinese characters being encoded in filenames (#1339).
- Fix Windows context-menu config script generation (#1019)
- Fix custom link URL encoding (#1112)
- Fix potential logging dead loop (#1101)
- Fix drag-to-tray upload error (#1107)
- Fix filename encoding (#1121)
- Prevent errors on GitHub uploads with duplicate filenames
- Fix beta.2 style issues
- Fix slow filename display in the rename window (#1130)
- Fix “open config file” actually opening the log file (#1163)
- Fix plugin config dialog not reading/saving correctly
- Fix inability to add new uploader configs (created as edit) (#1198, #1196)
- Fix macOS context menu disappearing (thanks @muwoo, #1179)
- Fix macOS tray window flashing after right-click (thanks @QThans, #1217)
- Fix uploader config page not scrollable when many fields (#1237)
- Fix Tencent COS URL encoding (#1265)
- Fix plugin list search not working (#1297)
- Fix clipboard filename losing “seconds” (#1293)
- Fix macOS tray copy-link failing on click (#1280, #1210)
- Fix auto-copy URL toggle not turning off (#1294, thanks @happy-game)
- Fix Wayland clipboard image upload issue (#1261, thanks @happy-game)
- Fix URL uploads with Chinese characters being encoded in filenames (#1339)
## Other
- COS distribution paused due to malicious traffic charges.
- Upgrade to Vue3.
- Refactor parts of the code.
- Refactor parts of the code (again).
- Add more detailed hotkey logs (#1031).
- Known issue: some button styles were incorrect (fixed in later betas).
- Update README scoop installation section (thanks @wuhang2003).
- COS distribution paused due to malicious traffic charges
- Upgrade to Vue3
- Refactor parts of the code
- Refactor parts of the code (again)
- Add more detailed hotkey logs (#1031)
- Known issue: some button styles were incorrect (fixed in later betas)
- Update README scoop installation section (thanks @wuhang2003)
----------
@@ -87,7 +87,7 @@
- 新增 上传界面展示当前图床使用的配置名
![image](https://github.com/Molunerfinn/PicGo/assets/12621342/52c58fff-4810-4b91-92d7-5ade512eaca8)
- 新增 相册页工具栏,目前内置 `批量修改图片 URL HOST 的功能` 。参考 #875
注意:需要先选中指定的图片,然后会根据已选中的图片进行修改,你可以通过图床筛选功能只筛选出需要修改的图片
注意:需要先选中指定的图片,然后会根据已选中的图片进行修改,你可以通过图床筛选功能只筛选出需要修改的图片
例如,你有一批图片都是 `https://www.a.com/...` 打头的 URL,你想把 `www.a.com` 批量修改成 `www.b.com` ,就可以用这个功能
![image](https://github.com/Molunerfinn/PicGo/assets/12621342/ee314dfc-7699-4ceb-8638-cafe7948bd5a)
- 新增 `启动模式`,可以设置启动的时候是否要打开窗口。全平台支持 `静默启动`(默认值) & `打开主窗口`Windows 和 Linux 额外支持 `打开 Mini 窗口`。参考 #915
@@ -102,11 +102,11 @@
- 修复 日志写入可能存在死循环问题,参考 #1101
- 修复 拖拽文件到顶部栏图标报错问题,参考 #1107
- 修复 文件名 encode 问题。参考 #1121
- 修复 GitHub 重名文件上传不再报错
- 修复 GitHub 重名文件上传不再报错
- 修复 beta.2 版本部分样式问题
- 修复 重命名窗口某些情况下显示文件名过慢的问题,参考 #1130
- 修复 打开配置文件打开的是日志文件的 bug,参考 #1163
- 修复 插件配置弹窗打开后无法正确读取和保存配置的问题
- 修复 插件配置弹窗打开后无法正确读取和保存配置的问题
- 修复 无法新增图床配置的问题(新增变成了编辑)。参考 #1198#1196
- 修复 macOS 右键菜单消失问题。感谢 @muwoo 。参考 #1179
- 修复 macOS 顶部栏窗口右键之后一闪而过的问题。 感谢 @QThans。 参考 #1217
@@ -120,7 +120,7 @@
- 修复 直接通过 URL 上传图片的时候,带有汉字的 URL 上传后,文件名被 encode 的问题。 参考 #1339
## Other
- 由于 PicGo 存储的 COS 空间被恶意刷大量流量导致欠费,暂时停止 COS 渠道的 PicGo 分发
- 由于 PicGo 存储的 COS 空间被恶意刷大量流量导致欠费,暂时停止 COS 渠道的 PicGo 分发
- 更新至 Vue3
- 重构部分代码
- 重构部分代码
+8 -8
View File
@@ -2,23 +2,23 @@
## Features
- Add `showMenubarIcon` setting to control the visibility of the macOS menu bar icon. See #1222 for details.
- Add `showMenubarIcon` setting to control the visibility of the macOS menu bar icon. See #1222 for details
## Bug Fixes
- Fix: An issue where the macOS menu bar window could not read images from the clipboard. See https://github.com/Molunerfinn/PicGo/issues/1310 for details.
- Fix: An issue where the macOS Intel build could not be opened. Please take a look at #1363 and #1310 for details.
- Fix: An issue where the macOS menu bar window could not read images from the clipboard. See https://github.com/Molunerfinn/PicGo/issues/1310 for details
- Fix: An issue where the macOS Intel build could not be opened. Please take a look at #1363 and #1310 for details
## Other
- Upgraded Electron to v38 and migrated the underlying build framework to Electron-vite.
- Unified the main window UI on Windows/Linux to match the macOS style.
- Upgraded Electron to v38 and migrated the underlying build framework to Electron-vite
- Unified the main window UI on Windows/Linux to match the macOS style
<img width="821" height="472" alt="image" src="https://github.com/user-attachments/assets/dbafadeb-7a9f-40e5-bd87-d4012c9a3902" />
- Updated ARM64 builds for Windows & Linux, and added Linux deb packages.
- Updated ARM64 builds for Windows & Linux, and added Linux deb packages
----------
## Features
- 新增 `showMenubarIcon` 配置项,用于控制 macOS 顶部栏图标的显示与隐藏。参考 #1222
- 新增 `showMenubarIcon` 配置项,用于控制 macOS 顶部栏图标的显示与隐藏。参考 #1222
## Bug Fixes
- 修复 macOS 顶部栏窗口无法获取剪贴板图片的问题。参考 https://github.com/Molunerfinn/PicGo/issues/1310
@@ -28,4 +28,4 @@
- 更新 Electron 版本到 38 以及切换底层开发框架到 Electron-vite
- 更新 Windows\Linux 系统现在主窗口的样式跟 macOS 一致了
<img width="821" height="472" alt="image" src="https://github.com/user-attachments/assets/dbafadeb-7a9f-40e5-bd87-d4012c9a3902" />
- 更新 Windows & Linux 平台的 ARM64 架构构建产物;新增 Linux `deb` 构建产物
- 更新 Windows & Linux 平台的 ARM64 架构构建产物;新增 Linux `deb` 构建产物
+31
View File
@@ -0,0 +1,31 @@
# PicGo 2.4.2 Changelog
## Features
- New: macOS version with signature && notarization. No more manual handling
<img width="260" height="260" alt="image" src="https://github.com/user-attachments/assets/dc1b7114-1f10-40e3-b45e-e3b330712399" />
- Add duplicate button for image host config item. And add a double confirm dialog for `duplicate` and `delete` config item action
<img width="800" height="450" alt="image" src="https://github.com/user-attachments/assets/238881e3-e993-4cb7-9699-fd2f7441ce02" />
- Add: Refactor notifications and add `notificationSound` setting (#1370)
## Bug Fixes
- Fix: An issue that the app icon will be too big under macOS 26. See #1367 for more details
- Fix: Clamp tray image titles to two lines or the style will be broken
## Other
- Refactor: Remove config store (#1371)
----------
## Features
- 新增:macOS 版本签名+公证,现在不再需要手动放行,可以直接下载安装使用
<img width="260" height="260" alt="image" src="https://github.com/user-attachments/assets/dc1b7114-1f10-40e3-b45e-e3b330712399" />
- 新增:图床配置界面新增复制按钮,可以一键复制已有配置,方便进行多样化操作。同时复制和删除均增加二次确认弹窗
<img width="800" height="450" alt="image" src="https://github.com/user-attachments/assets/238881e3-e993-4cb7-9699-fd2f7441ce02" />
- 新增:重构通知并新增 `notificationSound` 设置(#1370
## Bug Fixes
- 修复:在 macOS 26 版本下 app 图标过大的问题。参考 #1367
- 修复:托盘图片标题最多显示两行否则样式会被破坏的问题
## Other
- 重构:移除 config store#1371
+17
View File
@@ -0,0 +1,17 @@
# PicGo 2.4.3 Changelog
## Features
- Add: Batch URL upload support (#1376). See #1302 for details
- Add: [Global URL rewrite](https://docs.picgo.app/core/guide/config.html#settings) support (#1377). See #1281 for details
- Refactor: Gallery image URL can not only rewrite the host but also the whole link. See #1255 for details
----------
## Features
- 新增:支持批量 URL 上传(#1376),参考 #1302
- 新增:支持[全局 URL 重写](https://docs.picgo.app/zh/core/guide/config.html#settings)#1377),参考 #1281
- 重构:相册页的图片 URL 可以批量修改,不仅仅只是修改 HOST,参考 #1255
## Bug Fixes
- 修复:相册页图床列表显示状态与相册不同步的问题(#1373),参考 #1372
- 修复:一些 UI 问题
+37 -35
View File
@@ -1,48 +1,50 @@
# gen_changelog.md
This guide describes how to generate a consolidated changelog for any PicGo release series (e.g., 2.4.0, 2.5.0) from the GitHub release notes of its betas and finals.
This guide describes how to generate a consolidated changelog for any PicGo release series (e.g., 2.4.0, 2.5.0) from the GitHub release notes of its betas and finals
## Source of truth
- Use GitHub release pages only (e.g., `https://github.com/Molunerfinn/PicGo/releases/tag/vX.Y.Z-beta.N` and `vX.Y.Z` if present). You can fetch these via MCP GitHub APIs (e.g., `get_release_by_tag`, `list_releases`) instead of manual browsing.
- Do **not** use `CHANGELOG.md` in the repo.
- Preserve all text and images exactly as written in the releases.
- Use GitHub release pages only (e.g., `https://github.com/Molunerfinn/PicGo/releases/tag/vX.Y.Z-beta.N` and `vX.Y.Z` if present). You can fetch these via MCP GitHub APIs (e.g., `get_release_by_tag`, `list_releases`) instead of manual browsing
- Do **not** use `CHANGELOG.md` in the repo
- Preserve all text and images exactly as written in the releases
- Exception: Remove holiday greetings (e.g., "Happy New Year!", "新年快乐!") from the generated changelog. These can stay in GitHub release notes but should not appear in `changelog/X.Y.Z.md`
## Structure
- Target file: `changelog/X.Y.Z.md` (replace with the series version).
- Three top-level sections: `## Features`, `## Bug Fixes`, `## Other`.
- Do **not** nest content under individual beta version headers; merge all items into these sections.
- Keep items in chronological order (early → late) within each section, mirroring the beta sequence.
- Keep inline issue references, thanks, and notes as-is.
- Remove download-link sections entirely.
- After the English sections, insert a line with `----------`, then add a full Chinese translation with the same structure/content (including images).
- Target file: `changelog/X.Y.Z.md` (replace with the series version)
- Three top-level sections: `## Features`, `## Bug Fixes`, `## Other`
- Do **not** nest content under individual beta version headers; merge all items into these sections
- Keep items in chronological order (early → late) within each section, mirroring the beta sequence
- Keep inline issue references, thanks, and notes as-is
- Remove download-link sections entirely
- After the English sections, insert a line with `----------`, then add a full Chinese translation with the same structure/content (including images)
## Formatting rules
- Markdown, plain ASCII.
- Bullet lists only; no numbered lists required.
- Indent images under their bullet with two spaces to keep association clear.
- Keep inline HTML image tags from the releases (e.g., `<img width="...">`) untouched.
- Keep line breaks and multi-line notes intact.
- Do not reword or summarize; copy content verbatim except for removing beta headers and download sections.
- Chinese translation must mirror the English bullets in order and content (keep images alongside the translated bullets).
- Markdown, plain ASCII
- Bullet lists only; no numbered lists required
- Indent images under their bullet with two spaces to keep association clear
- Keep inline HTML image tags from the releases (e.g., `<img width="...">`) untouched
- Keep line breaks and multi-line notes intact
- Do not reword or summarize; copy content verbatim except for removing beta headers and download sections
- If you need to add items from git commit logs (for the same release series), do not paste raw commit messages. Normalize them to the existing changelog style (e.g., use `Add:`, `Fix:`, `Refactor:` prefixes without emoji)
- Chinese translation must mirror the English bullets in order and content (keep images alongside the translated bullets)
## Regeneration steps
1) Collect all release bodies for the target series (e.g., `vX.Y.Z-beta.0…N` and, if present, `vX.Y.Z`) from GitHub releases.
1) Collect all release bodies for the target series (e.g., `vX.Y.Z-beta.0…N` and, if present, `vX.Y.Z`) from GitHub releases
2) For each release, copy bullets and images into the appropriate section:
- Features ↔ “Feature(s)” or “Features” blocks.
- Bug Fixes ↔ “Bug Fixes” blocks.
- Other ↔ “Other”, “Notice”, or misc notes that are not features/bugs.
3) Omit “国内可下载链接” (or any download links) entirely.
4) Drop beta subheadings; keep only section-level bullets in chronological order.
5) Ensure images remain adjacent to their bullets with indentation.
6) After the English sections are complete, insert `----------` on its own line.
7) Append the Chinese translation, preserving bullet order and images, under `## Features`, `## Bug Fixes`, `## Other` again (same section titles, just Chinese content; no “(Chinese)” suffix).
8) Save the result to `changelog/X.Y.Z.md`.
- Features ↔ “Feature(s)” or “Features” blocks
- Bug Fixes ↔ “Bug Fixes” blocks
- Other ↔ “Other”, “Notice”, or misc notes that are not features/bugs
3) Omit “国内可下载链接” (or any download links) entirely
4) Drop beta subheadings; keep only section-level bullets in chronological order
5) Ensure images remain adjacent to their bullets with indentation
6) After the English sections are complete, insert `----------` on its own line
7) Append the Chinese translation, preserving bullet order and images, under `## Features`, `## Bug Fixes`, `## Other` again (same section titles, just Chinese content; no “(Chinese)” suffix)
8) Save the result to `changelog/X.Y.Z.md`
## Quick checklist
- [ ] All features present with images kept.
- [ ] All bug fixes present.
- [ ] All “Other” notes present.
- [ ] No beta headers.
- [ ] No download links.
- [ ] Chronological ordering preserved.
- [ ] Chinese translation present with matching bullets/images after `----------`.
- [ ] All features present with images kept
- [ ] All bug fixes present
- [ ] All “Other” notes present
- [ ] No beta headers
- [ ] No download links
- [ ] Chronological ordering preserved
- [ ] Chinese translation present with matching bullets/images after `----------`
-216
View File
@@ -1,216 +0,0 @@
<template lang='pug'>
#app(v-cloak)
#header
.mask
img.logo(src="~icons/256x256.png", alt="PicGo")
h1.title PicGo
small(v-if="version") {{ version }}
h2.desc 图片上传+管理新体验
button.download(@click="goLink('https://github.com/Molunerfinn/picgo/releases')") 免费下载
button.download(@click="goLink('https://picgo.github.io/PicGo-Doc/guide/')") 查看文档
h3.desc
| 基于#[a(href="https://github.com/SimulatedGREG/electron-vue" target="_blank") electron-vue]开发
h3.desc
| 支持macOS,Windows,Linux
h3.desc
| 支持#[a(href="https://picgo.github.io/PicGo-Doc/guide/config.html#%E6%8F%92%E4%BB%B6%E8%AE%BE%E7%BD%AE%EF%BC%88v2-0%EF%BC%89" target="_blank") 插件系统]让PicGo更强大
#container.container-fluid
.row.ex-width
img.gallery.col-xs-10.col-xs-offset-1.col-md-offset-2.col-md-8(src="https://cdn.jsdelivr.net/gh/Molunerfinn/test/picgo-site/first.png")
.row.ex-width.display-list
.display-list__item(v-for="(item, index) in itemList" :key="index" :class="{ 'o-item': index % 2 !== 0 }")
.col-xs-10.col-xs-offset-1.col-md-7.col-md-offset-0
img(:src="item.url")
.col-xs-10.col-xs-offset-1.col-md-5.col-md-offset-0.display-list__content
.display-list__title {{ item.title }}
.display-list__desc {{ item.desc }}
.row.ex-width.info
.col-xs-10.col-xs-offset-1
| &copy;2017 - {{ year }} #[a(href="https://github.com/Molunerfinn" target="_blank") Molunerfinn]
</template>
<script>
export default {
name: 'HomePage',
data () {
return {
version: '',
year: new Date().getFullYear(),
itemList: [
{
url: 'https://cdn.jsdelivr.net/gh/Molunerfinn/test/picgo-site/second.png',
title: '精致设计',
desc: 'macOS系统下,支持拖拽至menubar图标实现上传。menubar app 窗口显示最新上传的5张图片以及剪贴板里的图片。点击图片自动将上传的链接复制到剪贴板。(Windows平台不支持)'
},
{
url: 'https://cdn.jsdelivr.net/gh/Molunerfinn/test/picgo-site/third.png',
title: 'Mini小窗',
desc: 'Windows以及Linux系统下提供一个mini悬浮窗用于用户拖拽上传,节约你宝贵的桌面空间。'
},
{
url: 'https://cdn.jsdelivr.net/gh/Molunerfinn/test/picgo-site/forth.png',
title: '便捷管理',
desc: '查看你的上传记录,重复使用更方便。支持点击图片大图查看。支持删除图片(仅本地记录),让界面更加干净。'
},
{
url: 'https://cdn.jsdelivr.net/gh/Molunerfinn/test/picgo-site/fifth.png',
title: '可选图床',
desc: '默认支持微博图床、七牛图床、腾讯云COS、又拍云、GitHub、SM.MS、阿里云OSS、Imgur。方便不同图床的上传需求。2.0版本开始更可以自己开发插件实现其他图床的上传需求。'
},
{
url: 'https://cdn.jsdelivr.net/gh/Molunerfinn/test/picgo-site/sixth.png',
title: '多样链接',
desc: '支持5种默认剪贴板链接格式,包括一种自定义格式,让你的文本编辑游刃有余。'
},
{
url: 'https://cdn.jsdelivr.net/gh/Molunerfinn/test/picgo-site/seventh.png',
title: '插件系统',
desc: '2.0版本开始支持插件系统,让PicGo发挥无限潜能,成为一个极致的效率工具。'
}
]
}
},
created () {
this.getVersion()
},
methods: {
goLink (link) {
window.open(link, '_blank')
},
async getVersion () {
const release = 'https://api.github.com/repos/Molunerfinn/PicGo/releases/latest'
const res = await this.$http.get(release)
this.version = res.data.name
}
}
}
</script>
<style lang='stylus'>
[v-cloak]
display none
*
box-sizing border-box
body,
html,
h1
margin 0
padding 0
font-family "Source Sans Pro","Helvetica Neue","PingFang SC","Hiragino Sans GB","Microsoft YaHei","微软雅黑",Arial,sans-serif
#app
position relative
.mask
position absolute
width 100%
height 100vh
top 0
left 0
background rgba(0,0,0, 0.7)
z-index -1
#header
height 100vh
width 100%
background-image url("https://cdn.jsdelivr.net/gh/Molunerfinn/test/picgo-site/bg.jpeg")
background-attachment fixed
background-size cover
background-position center
text-align center
padding 15vh
position relative
z-index 2
.logo
width 120px
.title
color #4BA2E2
font-size 36px
font-weight 300
margin 10px auto
text-align center
small
margin-left 10px
font-size 14px
.desc
font-weight 400
margin 20px auto 10px
color #ddd
a
text-decoration none
color #4BA2E2
.download
display inline-block
line-height 1
white-space nowrap
cursor pointer
background transparent
border 1px solid #d8dce5
color #ddd
-webkit-appearance none
text-align center
box-sizing border-box
outline none
margin 20px 12px
transition .1s
font-weight 500
user-select none
padding 12px 20px
font-size 14px
border-radius 20px
padding 12px 23px
transition .2s all ease-in-out
&:hover
background #ddd
color rgba(0,0,0, 0.7)
#container
position relative
text-align center
margin-top -10vh
z-index 3
.gallery
margin-bottom 60px
cursor pointer
transition all .2s ease-in-out
&:hover
transform scale(1.05)
.display-list
&__item
padding 48px
text-align left
background #2E2E2E
overflow hidden
&.o-item
background #fff
.display-list__desc
color #2E2E2E
img
width 100%
cursor pointer
transition all .2s ease-in-out
&:hover
transform scale(1.05)
&__content
padding-top 120px
&__title
color #4BA2E2
font-size 50px
&__desc
color #fff
margin-top 20px
.info
padding 48px 0
background #2E2E2E
color #fff
a
text-decoration none
color #fff
@media (max-width: 768px)
#header
padding 10vh
#container
.display-list
&__item
padding 24px 12px
&__content
padding-top 30px
&__title
font-size 25px
&__desc
margin-top 12px
</style>
-10
View File
@@ -1,10 +0,0 @@
import Vue from 'vue'
import App from './APP.vue'
import 'melody.css'
import axios from 'axios'
Vue.prototype.$http = axios
new Vue({
render: h => h(App)
}).$mount('#app')
-12
View File
@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>PicGo</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
+1 -1
View File
@@ -3,7 +3,7 @@ import dotenv from 'dotenv'
dotenv.config()
const shouldNotarize = process.env.SKIP_NOTARIZE !== 'true';
const shouldNotarize = process.env.SKIP_NOTARIZE !== 'true'
const config: Configuration = {
appId: 'com.molunerfinn.picgo',
+9 -3
View File
@@ -6,6 +6,7 @@ const tsParser = require('@typescript-eslint/parser')
const importPlugin = require('eslint-plugin-import')
const promisePlugin = require('eslint-plugin-promise')
const vuePlugin = require('eslint-plugin-vue')
const stylistic = require('@stylistic/eslint-plugin')
const isProduction = process.env.NODE_ENV === 'production'
const vueConfigs = vuePlugin.configs['flat/recommended'].map(config => ({
@@ -62,7 +63,8 @@ module.exports = [
'test/unit/coverage/**',
'test/unit/*.js',
'test/e2e/*.js',
'node_modules/**'
'node_modules/**',
'vitest.config.ts',
]
},
{
@@ -76,7 +78,8 @@ module.exports = [
},
plugins: {
import: importPlugin,
promise: promisePlugin
promise: promisePlugin,
'@stylistic': stylistic
},
settings: {
'import/resolver': {
@@ -98,7 +101,10 @@ module.exports = [
'no-debugger': isProduction ? 'error' : 'off',
'no-async-promise-executor': 'off',
'no-empty': ['error', { allowEmptyCatch: true }],
'no-unused-vars': 'off'
'no-unused-vars': 'off',
'@stylistic/indent': ['error', 2],
'@stylistic/semi': ['error', 'never'],
'no-unexpected-multiline': 'error'
}
},
...vueConfigs,
+17 -8
View File
@@ -1,6 +1,6 @@
{
"name": "picgo",
"version": "2.4.2-beta.0",
"version": "2.5.0",
"private": true,
"main": "dist_electron/main/index.js",
"description": "A powerful & simple image uploader for creators.",
@@ -9,13 +9,14 @@
"email": "marksz@teamsz.xyz"
},
"scripts": {
"build": "electron-vite build && electron-builder --config electron-builder.config.ts --publish never",
"build": "electron-vite build",
"build:win": "npm run build && electron-builder --config electron-builder.config.ts --win --publish never",
"build:mac": "npm run build && electron-builder --config electron-builder.config.ts --mac --publish never",
"build:linux": "npm run build && electron-builder --config electron-builder.config.ts --linux --publish never",
"build:local": "dotenv -e .env -- electron-vite build && electron-builder --config electron-builder.config.ts --publish never",
"lint": "eslint --ext .js,.jsx,.ts,.tsx,.vue src/",
"lint": "pnpm lint:dpdm && eslint --ext .js,.jsx,.ts,.tsx,.vue src/",
"tsc": "tsc --noEmit",
"vue-tsc": "vue-tsc --noEmit",
"bump": "bump-version",
"cz": "git-cz",
"dev": "electron-vite dev",
@@ -25,7 +26,11 @@
"postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps",
"upload-dist": "node ./scripts/upload-dist.js",
"lint:dpdm": "dpdm -T --tsconfig ./tsconfig.json --no-tree --no-warning --exit-code circular:1 src/background.ts"
"lint:dpdm": "dpdm -T --tsconfig ./tsconfig.json --no-tree --no-warning --exit-code circular:1 src/background.ts",
"check": "pnpm run tsc && pnpm run vue-tsc && pnpm run lint",
"test": "vitest run",
"prepare": "husky",
"commitlint": "commitlint --edit"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
@@ -34,13 +39,14 @@
"@picgo/video-duration": "^1.0.1",
"axios": "^0.19.0",
"clip-filepaths": "^0.3.0",
"comment-json": "^4.5.1",
"compare-versions": "^4.1.3",
"core-js": "^3.27.1",
"dayjs": "^1.11.19",
"element-plus": "^2.3.7",
"epipebomb": "^1.0.0",
"fs-extra": "^10.0.0",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"keycode": "^2.2.0",
"lodash": "^4.17.21",
"lodash-id": "^0.14.0",
@@ -49,7 +55,7 @@
"mime-types": "^3.0.2",
"mitt": "^3.0.1",
"multer": "^1.4.5-lts.1",
"picgo": "^1.6.4",
"picgo": "^2.0.0",
"qrcode.vue": "^3.3.3",
"semver": "^7.7.3",
"shell-path": "2.1.0",
@@ -68,6 +74,7 @@
"@eslint/js": "^9.39.1",
"@molunerfinn/vite-plugin-electron-renderer": "^0.14.7",
"@picgo/bump-version": "^2.0.0",
"@stylistic/eslint-plugin": "^5.6.1",
"@types/electron-devtools-installer": "^2.2.0",
"@types/fs-extra": "^9.0.13",
"@types/inquirer": "^6.5.0",
@@ -100,13 +107,15 @@
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-vue": "^10.6.2",
"globals": "^16.5.0",
"husky": "^3.1.0",
"husky": "^9.1.7",
"postcss": "^8.4.23",
"stylus": "^0.54.7",
"stylus-loader": "^3.0.2",
"tailwindcss": "^3.3.2",
"typescript": "^5.9.3",
"vite": "^7.2.6"
"vite": "^7.2.6",
"vitest": "^4.0.16",
"vue-tsc": "^3.2.3"
},
"commitlint": {
"extends": [
+511 -927
View File
File diff suppressed because it is too large Load Diff
+134 -39
View File
@@ -3,7 +3,7 @@ ABOUT: About
OPEN_MAIN_WINDOW: Open Main Window
CHOOSE_DEFAULT_PICBED: Choose Default Picbed
OPEN_UPDATE_HELPER: Open Update Helper
PRIVACY_AGREEMENT: Privacy Agreement
PRIVACY_TERMS_AGREEMENT: Privacy & Terms Agreement
RELOAD_APP: Reload App
UPLOAD_FAILED: Upload Failed
UPLOAD_SUCCEED: Upload Succeed
@@ -34,6 +34,75 @@ GALLERY: Gallery
PICBEDS_SETTINGS: Picbeds Settings
PICGO_SETTINGS: PicGo Settings
PLUGIN_SETTINGS: Plugins Settings
PICGO_CLOUD_TITLE: PicGo Cloud
PICGO_CLOUD_ERROR_TITLE: PicGo Cloud Error
PICGO_CLOUD_NOT_LOGGED_IN: Not logged in to PicGo Cloud.
PICGO_CLOUD_LOGIN: Log In
PICGO_CLOUD_LOGOUT: Log Out
PICGO_CLOUD_CANCEL_LOGIN: Cancel Login
PICGO_CLOUD_RETRY: Retry
PICGO_CLOUD_CONFIG_SYNC: Config Sync
PICGO_CLOUD_LOGIN_IN_PROGRESS: Login in progress. Please finish login in your browser.
PICGO_CLOUD_LOGGED_IN_AS: "Logged in as: ${user}"
PICGO_CLOUD_OPEN: Open PicGo Cloud
PICGO_CLOUD_LOGIN_TIMEOUT: Login timed out. Please try again.
PICGO_CLOUD_LOGIN_FAILED: Login failed. Please try again.
PICGO_CLOUD_AGREE_PREFIX: "I have read and agree to PicGo's "
PICGO_CLOUD_TERMS_OF_SERVICE: Terms of Service
PICGO_CLOUD_AGREE_AND: " and "
PICGO_CLOUD_PRIVACY_POLICY: Privacy Policy
PICGO_CLOUD_LOGIN_EXPIRED: Login expired. Please log in again.
PICGO_CLOUD_ENCRYPTION_MODE_LABEL: Encryption mode
PICGO_CLOUD_ENCRYPTION_MODE_AUTO: Auto
PICGO_CLOUD_ENCRYPTION_MODE_SERVER: Server-side encryption
PICGO_CLOUD_ENCRYPTION_MODE_E2E: End-to-end encryption
PICGO_CLOUD_ENCRYPTION_MODE_TIP_AUTO: "Auto: follow the cloud's last used encryption method (defaults to server-side)."
PICGO_CLOUD_ENCRYPTION_MODE_TIP_SERVER: "Server-side encryption: your config is encrypted on the server before being stored. No PIN required."
PICGO_CLOUD_ENCRYPTION_MODE_TIP_E2E: "End-to-end encryption: use your PIN to encrypt/decrypt data. PicGo does not store your PIN; losing it means data cannot be recovered."
PICGO_CLOUD_ENCRYPTION_MODE_TIP_DOC: Read full docs
PICGO_CLOUD_E2E_CHECKBOX_LABEL: Enable E2E encryption
PICGO_CLOUD_E2E_ENABLE_WARNING_TITLE: Enable E2E Encryption
PICGO_CLOUD_E2E_ENABLE_WARNING_MESSAGE: "E2E encryption requires you to keep your PIN safe. If you lose your PIN, the encrypted data cannot be recovered. PicGo does not store your PIN or any recovery data."
PICGO_CLOUD_REMOTE_E2E_AUTO_ENABLED: Remote config is E2E-encrypted. E2E has been enabled locally.
PICGO_CLOUD_E2E_PIN_SETUP_TITLE: Set up E2E PIN
PICGO_CLOUD_E2E_PIN_DECRYPT_TITLE: Enter E2E PIN
PICGO_CLOUD_E2E_PIN_RETRY_TITLE: "Incorrect PIN. Please try again (attempt ${retryCount})."
PICGO_CLOUD_E2E_PIN_PLACEHOLDER: PIN
PICGO_CLOUD_E2E_PIN_CONFIRM_PLACEHOLDER: Confirm PIN
PICGO_CLOUD_CONFIG_SYNC_SUCCESS: Config sync succeeded.
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_DETECTED: Conflict detected. Please resolve the conflicts.
PICGO_CLOUD_CONFIG_SYNC_FAILED: Config sync failed.
PICGO_CLOUD_CONFIG_SYNC_ABORTED: Config sync cancelled.
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_TITLE: Confirm switch encryption method?
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_BODY: "You are switching from \"${from}\" to \"${to}\".\n\nNote: Switching encryption modes will clear all your cloud history versions.\nThis is because older history versions cannot be decrypted or verified under the new mode.\nAfter switching, the system will immediately create a new backup as the starting point."
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CONFIRM: Confirm switch and clear history
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCEL: Cancel
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED: Encryption switch cancelled by user
PICGO_CLOUD_CONFIG_SYNC_FAILED_WITH_REASON: "Config sync failed: ${reason}"
PICGO_CLOUD_CONFIG_SYNC_PIN_MAX_RETRY: Too many incorrect PIN attempts. Please try again.
PICGO_CLOUD_CONFIG_SYNC_LOCAL_CONFIG_INVALID: Local config is invalid. Please check your config file.
PICGO_CLOUD_CONFIG_SYNC_IN_PROGRESS: Config sync is in progress.
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_PENDING: There is a pending conflict session. Please resolve conflicts first.
PICGO_CLOUD_CONFIG_SYNC_NO_CONFLICT_SESSION: No conflict session found.
PICGO_CLOUD_CONFIG_SYNC_RESOLUTION_INCOMPLETE: Please choose Local or Cloud for all conflict items.
PICGO_CLOUD_CONFIG_SYNC_STARTING: Config sync started...
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_TITLE: "Conflict Detected (${count})"
PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_LOCAL: Choose All Local
PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_CLOUD: Choose All Cloud
PICGO_CLOUD_CONFIG_SYNC_RESET_ALL: Reset All
PICGO_CLOUD_CONFIG_SYNC_LOCAL_VERSION: Local Version
PICGO_CLOUD_CONFIG_SYNC_CLOUD_VERSION: Cloud Version
PICGO_CLOUD_CONFIG_SYNC_ABORT: Abort
PICGO_CLOUD_CONFIG_SYNC_CONFIRM_AND_SYNC: Confirm & Sync
PICGO_CLOUD_CONFIG_SYNC_VALUE_UNDEFINED: (empty)
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_RESOLVED: Conflicts resolved.
PICGO_CLOUD_LAST_SYNC_TIME: "Last local sync time: ${time}"
PICGO_CLOUD_LAST_SYNC_TIME_NONE: None
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_TITLE: Restart Required?
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_MESSAGE: Some settings may require a restart to take effect. Restart now?
PICGO_CLOUD_CONFIG_SYNC_RESTART_NOW: Restart Now
PICGO_CLOUD_CONFIG_SYNC_RESTART_LATER: Later
INPUT_BOX_CONFIRM_MISMATCH: The two inputs do not match.
PICGO_SPONSOR_TEXT: PicGo is a free software, if you like it, please don't forget to buy me a cup of coffee.
ALIPAY: Alipay
WECHATPAY: Wechat Pay
@@ -68,6 +137,7 @@ SETTINGS_SET_LOG_FILE: Set Log File
SETTINGS_CLICK_TO_SET: Click to Set
SETTINGS_CLICK_TO_CHECK: Click to Check
SETTINGS_SET_SHORTCUT: Set Shortcut
SETTINGS_URL_REWRITE: URL Rewrite
SETTINGS_CUSTOM_LINK_FORMAT: Custom Link Format
SETTINGS_SET_PROXY_AND_MIRROR: Set Proxy and Mirror
SETTINGS_SET_SERVER: Set Server
@@ -80,6 +150,7 @@ SETTINGS_LAUNCH_ON_BOOT: Launch On Boot
SETTINGS_RENAME_BEFORE_UPLOAD: Rename Before Upload
SETTINGS_TIMESTAMP_RENAME: Timestamp Rename
SETTINGS_OPEN_UPLOAD_TIPS: Open Upload Tips
SETTINGS_NOTIFICATION_SOUND: Play Notification Sound
SETTINGS_MINI_WINDOW_ON_TOP: Mini Window On Top
SETTINGS_AUTO_COPY_URL_AFTER_UPLOAD: Auto Copy URL After Upload
SETTINGS_TIPS_PLACEHOLDER_URL: Use $url to represent url position
@@ -117,6 +188,46 @@ SETTINGS_USE_BUILTIN_CLIPBOARD_UPLOAD: Use Builtin Clipboard to Upload
SETTINGS_CHOOSE_LANGUAGE: Choose Language
UPLOADER_CONFIG_NAME: Configuration Name
BUILTIN_CLIPBOARD_TIPS: Use builtin clipboard function to upload instead of using scripts
# url rewrite
URL_REWRITE_HELP: Rewrites uploaded image URLs. Rules are evaluated in order; the first matched rule wins.
URL_REWRITE_ADD_RULE: Add Rule
URL_REWRITE_EDIT_RULE: Edit Rule
URL_REWRITE_EMPTY: No rules
URL_REWRITE_ORDER: Order
URL_REWRITE_MATCH: Match
URL_REWRITE_REPLACE: Replace
URL_REWRITE_FLAGS: Flags
URL_REWRITE_ENABLED: Enabled
URL_REWRITE_ACTIONS: Actions
URL_REWRITE_MOVE_UP: Up
URL_REWRITE_MOVE_DOWN: Down
URL_REWRITE_EDIT: Edit
URL_REWRITE_DELETE: Delete
URL_REWRITE_DELETE_CONFIRM: Delete this rule?
URL_REWRITE_MATCH_TIPS: Supports regex (JavaScript RegExp)
URL_REWRITE_MATCH_PLACEHOLDER: https://example.com/path
URL_REWRITE_REPLACE_TIPS: Replacement string (supports $1, $2, ...)
URL_REWRITE_REPLACE_PLACEHOLDER: https://example.org/newpath
URL_REWRITE_OPTIONS: Options
URL_REWRITE_RULE_ENABLED: Enable this rule
URL_REWRITE_FLAG_GLOBAL_LABEL: Global (g)
URL_REWRITE_FLAG_GLOBAL_DESC: Replace all occurrences, not just the first one
URL_REWRITE_FLAG_IGNORE_CASE_LABEL: Ignore case (i)
URL_REWRITE_FLAG_IGNORE_CASE_DESC: Case-insensitive matching (e.g. JPG equals jpg)
URL_REWRITE_MATCH_REQUIRED: Match is required
URL_REWRITE_REPLACE_REQUIRED: Replace is required
URL_REWRITE_INVALID_REGEX: Invalid regex
URL_REWRITE_PREVIEW_TITLE: Preview
URL_REWRITE_PREVIEW_TIPS: Enter a URL to see how the current rules rewrite it (matched in order; only the first match is applied)
URL_REWRITE_PREVIEW_PLACEHOLDER: https://example.com/path/to/image.png
URL_REWRITE_PREVIEW_RUN: Preview
URL_REWRITE_PREVIEW_OUTPUT: Output URL
URL_REWRITE_PREVIEW_INPUT_REQUIRED: Please enter a URL to preview
URL_REWRITE_PREVIEW_RULE_INVALID: Invalid rule
URL_REWRITE_PREVIEW_MATCHED_RULE: Matched rule
URL_REWRITE_PREVIEW_NO_MATCH: No rules matched
UPLOADER_CONFIG_PLACEHOLDER: Please Enter Configuration Name
SELECTED_SETTING_HINT: Selected
SETTINGS_ENCODE_OUTPUT_URL: Encode Output(or Copyed) URL
@@ -143,11 +254,20 @@ SHORTCUT_EDIT: Edit
SHORTCUT_CHANGE_UPLOAD: Change Upload Shortcut
# gallery-page
CHANGE_IMAGE_URL_HOST: Change Image URL HOST
NEW_IMAGE_URL_HOST: New Image URL HOST
SELECTED_IMAGE_URL_HOST: Selected Image URL HOST
CHANGE_IMAGE_URL_HOST_RESULT: The result of changing image URL HOST
CHANGE_IMAGE_URL_HOST_WARN: You must select at least one picture first
GALLERY_URL_REWRITE_TITLE: Rewrite Selected Image URLs
GALLERY_URL_REWRITE_RESULT_TITLE: Rewrite Image URL Result
GALLERY_URL_REWRITE_WARN_NO_SELECTION: You must select at least one picture first
GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES: Apply global URL rewrite rules
GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT: Global rules
GALLERY_URL_REWRITE_TEMP_RULE_TIPS: Optional. Leave empty to skip the temporary rule. Temporary rule has higher priority than global rules.
GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED: Match and Replace are required for the temporary rule
GALLERY_URL_REWRITE_NO_RULES_TO_APPLY: No global rules enabled and no temporary rule provided
GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: Save the temporary rule to global URL rewrite rules?
GALLERY_URL_REWRITE_APPLY_AND_SAVE: Apply and Save
GALLERY_URL_REWRITE_APPLY_ONLY: Apply Only
GALLERY_URL_REWRITE_NO_CHANGES: No URLs were changed
GALLERY_URL_REWRITE_EMPTY_RESULT_WARN: The rewrite result is empty; skipped
# tray-page
@@ -164,7 +284,7 @@ CUSTOM: Custom
CLIPBOARD_PICTURE: Clipboard
TIPS_DRAG_VALID_PICTURE_OR_URL: Drag valid picture or url to here
TIPS_INPUT_URL: Input URL
TIPS_HTTP_PREFIX: http:// or https://
TIPS_HTTP_PREFIX: Starts with http:// or https://. Multiple URLs supported (one per line)
TIPS_INPUT_VALID_URL: Input valid URL
# plugins
@@ -236,6 +356,9 @@ TOOLBOX_CHECK_CLIPBOARD_FILE_PATH_ERROR_TIPS: "Please create the folder yourself
TIPS_NOTICE: Tips
TIPS_WARNING: Warning
TIPS_ERROR: Error
TIPS_SKIPPED_INVALID_URLS: Skipped ${n} invalid URL(s), see logs for details
TIPS_TOO_MANY_URLS_CONFIRM: You are about to upload ${n} URLs at once. This may cause lag. It's recommended to upload in batches. Continue?
TIPS_NO_VALID_URLS: No valid URL found
TIPS_INSTALL_NODE_AND_RELOAD_PICGO: Please install Node.js and restart PicGo to continue
TIPS_PLUGIN_REMOVE_GALLERY_ITEM: Plugin is trying to remove some images from the album gallery, continue?
TIPS_PLUGIN_OVERWRITE_GALLERY: Plugin is trying to overwrite the album gallery, continue?
@@ -250,39 +373,11 @@ TIPS_CUSTOM_LINK_STYLE_MODIFIED_SUCCEED: Custom link style modified successfully
TIPS_FIND_NEW_VERSION: Find new version ${v}update many new features, do you want to download the latest version?
TIPS_DELETE_UPLOADER_CONFIG: Are you sure you want to delete this config?
TIPS_COPY_UPLOADER_CONFIG: Are you sure you want to copy this config?
TIPS_UPLOADER_CONFIG_NAME_EMPTY: Config name cannot be empty
TIPS_UPLOADER_CONFIG_NOT_FOUND: Config not found
TIPS_UPLOADER_CONFIG_CANNOT_DELETE_LAST: Cannot delete the last config
# privacy
PRIVACY: >
This software respects and protects the personal privacy of all users who use the service. In order to provide you with more accurate and better services, this software will use and collect some of your behavioral information in accordance with the provisions of this Privacy Policy. When you agree to the software service use agreement, you are deemed to have agreed to the entire content of this privacy policy. This privacy policy is an integral part of the software service use agreement, and it will not be used if you do not agree.
This Agreement will be updated periodically.
1. Scope of application
a) When you use this software, this software will record some information about your operation behavior of this software, including but not limited to the time-consuming, type, quantity and other information of your use of this software to upload files.
2. Use of Information
a) After obtaining your usage data, the software will upload it to the data analysis server so as to provide you with better services after analyzing the data.
3. Information disclosure
a) This software will not disclose your information to untrusted third parties.
b) In accordance with the relevant provisions of the law, or the requirements of administrative or judicial institutions, disclose to third parties or administrative or judicial institutions;
4. Information Security
a) This software does not collect your personal information, key information and other private information, and the collected information is only used for improving the software, optimizing the experience, and understanding the daily activities of the software.
PRIVACY: "Please read and agree to the Privacy Policy ${privacyUrl} and Terms of Service ${termsUrl} before using."
PRIVACY_TIPS: Please agree the privacy policy to upload
QUIT: Quit
+134 -39
View File
@@ -3,7 +3,7 @@ ABOUT: 关于
OPEN_MAIN_WINDOW: 打开主窗口
CHOOSE_DEFAULT_PICBED: 选择默认图床
OPEN_UPDATE_HELPER: 打开更新助手
PRIVACY_AGREEMENT: 隐私协议
PRIVACY_TERMS_AGREEMENT: 隐私与条款协议
RELOAD_APP: 重启应用
UPLOAD_SUCCEED: 上传成功
UPLOAD_FAILED: 上传失败
@@ -34,6 +34,75 @@ GALLERY: 相册
PICBEDS_SETTINGS: 图床设置
PICGO_SETTINGS: PicGo设置
PLUGIN_SETTINGS: 插件设置
PICGO_CLOUD_TITLE: PicGo Cloud
PICGO_CLOUD_ERROR_TITLE: PicGo Cloud 错误
PICGO_CLOUD_NOT_LOGGED_IN: 尚未登录 PicGo Cloud
PICGO_CLOUD_LOGIN: 登录
PICGO_CLOUD_LOGOUT: 退出登录
PICGO_CLOUD_CANCEL_LOGIN: 取消登录
PICGO_CLOUD_RETRY: 重试
PICGO_CLOUD_CONFIG_SYNC: 配置同步
PICGO_CLOUD_LOGIN_IN_PROGRESS: 登录进行中,请在浏览器完成登录。
PICGO_CLOUD_LOGGED_IN_AS: "已登录:${user}"
PICGO_CLOUD_OPEN: 打开 PicGo Cloud
PICGO_CLOUD_LOGIN_TIMEOUT: 登录超时,请重试。
PICGO_CLOUD_LOGIN_FAILED: 登录失败,请重试。
PICGO_CLOUD_AGREE_PREFIX: 我已阅读并同意 PicGo 的
PICGO_CLOUD_TERMS_OF_SERVICE: 服务条款
PICGO_CLOUD_AGREE_AND: 以及
PICGO_CLOUD_PRIVACY_POLICY: 隐私政策
PICGO_CLOUD_LOGIN_EXPIRED: 登录失效,请重新登录。
PICGO_CLOUD_ENCRYPTION_MODE_LABEL: 加密模式
PICGO_CLOUD_ENCRYPTION_MODE_AUTO: 自动
PICGO_CLOUD_ENCRYPTION_MODE_SERVER: 服务端加密
PICGO_CLOUD_ENCRYPTION_MODE_E2E: 端到端加密
PICGO_CLOUD_ENCRYPTION_MODE_TIP_AUTO: 自动:跟随云端上一次配置的加密方式(默认服务端加密)。
PICGO_CLOUD_ENCRYPTION_MODE_TIP_SERVER: 服务端加密:我们会在服务端对你的配置加密后再存储,不需要 PIN。
PICGO_CLOUD_ENCRYPTION_MODE_TIP_E2E: 端到端加密:需要你输入 PIN 来加密/解密数据。PicGo 不存储 PIN,丢失将无法找回数据。
PICGO_CLOUD_ENCRYPTION_MODE_TIP_DOC: 查看完整文档
PICGO_CLOUD_E2E_CHECKBOX_LABEL: 开启 E2E 加密
PICGO_CLOUD_E2E_ENABLE_WARNING_TITLE: 开启 E2E 加密
PICGO_CLOUD_E2E_ENABLE_WARNING_MESSAGE: E2E 加密需要你自行妥善保管 PIN。PIN 丢失将无法恢复加密数据。PicGo 不会存储 PIN 或任何恢复信息。
PICGO_CLOUD_REMOTE_E2E_AUTO_ENABLED: 检测到远端配置已开启 E2E 加密,已自动在本地开启 E2E。
PICGO_CLOUD_E2E_PIN_SETUP_TITLE: 设置 E2E PIN
PICGO_CLOUD_E2E_PIN_DECRYPT_TITLE: 输入 E2E PIN
PICGO_CLOUD_E2E_PIN_RETRY_TITLE: "PIN 错误,请重试(第 ${retryCount} 次)。"
PICGO_CLOUD_E2E_PIN_PLACEHOLDER: PIN
PICGO_CLOUD_E2E_PIN_CONFIRM_PLACEHOLDER: 确认 PIN
PICGO_CLOUD_CONFIG_SYNC_SUCCESS: 配置同步成功。
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_DETECTED: 检测到配置冲突,请选择解决方案。
PICGO_CLOUD_CONFIG_SYNC_FAILED: 配置同步失败。
PICGO_CLOUD_CONFIG_SYNC_ABORTED: 已取消配置同步。
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_TITLE: 确认切换加密方式吗?
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_BODY: "您正在从“${from}”切换为“${to}”。\n\n注意:切换加密模式将清空您所有的云端历史版本记录。\n这是因为旧的历史版本无法在新模式下被解密或验证。\n切换后,系统将立即为您创建一份新的备份作为起点。"
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CONFIRM: 确认切换并清空历史
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCEL: 取消
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED: 已取消切换加密方式
PICGO_CLOUD_CONFIG_SYNC_FAILED_WITH_REASON: "配置同步失败:${reason}"
PICGO_CLOUD_CONFIG_SYNC_PIN_MAX_RETRY: PIN 错误次数过多,请稍后重试。
PICGO_CLOUD_CONFIG_SYNC_LOCAL_CONFIG_INVALID: 本地配置文件格式错误,请检查配置文件。
PICGO_CLOUD_CONFIG_SYNC_IN_PROGRESS: 配置同步进行中…
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_PENDING: 存在未解决的冲突,请先处理。
PICGO_CLOUD_CONFIG_SYNC_NO_CONFLICT_SESSION: 未找到可处理的冲突会话。
PICGO_CLOUD_CONFIG_SYNC_RESOLUTION_INCOMPLETE: 请为所有冲突项选择本地或云端。
PICGO_CLOUD_CONFIG_SYNC_STARTING: 配置同步已开始…
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_TITLE: "冲突处理(${count} 项)"
PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_LOCAL: 全选本地
PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_CLOUD: 全选云端
PICGO_CLOUD_CONFIG_SYNC_RESET_ALL: 重置全部
PICGO_CLOUD_CONFIG_SYNC_LOCAL_VERSION: 本地版本
PICGO_CLOUD_CONFIG_SYNC_CLOUD_VERSION: 云端版本
PICGO_CLOUD_CONFIG_SYNC_ABORT: 放弃同步
PICGO_CLOUD_CONFIG_SYNC_CONFIRM_AND_SYNC: 确认并同步
PICGO_CLOUD_CONFIG_SYNC_VALUE_UNDEFINED: (空)
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_RESOLVED: 配置冲突已解决。
PICGO_CLOUD_LAST_SYNC_TIME: "上次本地同步时间:${time}"
PICGO_CLOUD_LAST_SYNC_TIME_NONE:
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_TITLE: 需要重启吗?
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_MESSAGE: 部分配置可能需要重启后生效,是否立即重启?
PICGO_CLOUD_CONFIG_SYNC_RESTART_NOW: 立即重启
PICGO_CLOUD_CONFIG_SYNC_RESTART_LATER: 稍后
INPUT_BOX_CONFIRM_MISMATCH: 两次输入不一致,请重新输入。
PICGO_SPONSOR_TEXT: PicGo是免费开源的软件,如果你喜欢它,对你有帮助,不妨请我喝杯咖啡?
ALIPAY: 支付宝
WECHATPAY: 微信支付
@@ -68,6 +137,7 @@ SETTINGS_SET_LOG_FILE: 设置日志文件
SETTINGS_CLICK_TO_SET: 点击设置
SETTINGS_CLICK_TO_CHECK: 点击检查
SETTINGS_SET_SHORTCUT: 设置快捷键
SETTINGS_URL_REWRITE: URL 重写
SETTINGS_CUSTOM_LINK_FORMAT: 自定义链接格式
SETTINGS_SET_PROXY_AND_MIRROR: 设置代理和镜像地址
SETTINGS_SET_SERVER: 设置Server
@@ -80,6 +150,7 @@ SETTINGS_LAUNCH_ON_BOOT: 开机自启
SETTINGS_RENAME_BEFORE_UPLOAD: 上传前重命名
SETTINGS_TIMESTAMP_RENAME: 时间戳重命名
SETTINGS_OPEN_UPLOAD_TIPS: 开启上传提示
SETTINGS_NOTIFICATION_SOUND: 开启通知提示音
SETTINGS_MINI_WINDOW_ON_TOP: Mini窗口置顶
SETTINGS_AUTO_COPY_URL_AFTER_UPLOAD: 上传后自动复制URL
SETTINGS_TIPS_PLACEHOLDER_URL: 用占位符 $url 来表示url的位置
@@ -117,6 +188,46 @@ SETTINGS_USE_BUILTIN_CLIPBOARD_UPLOAD: 使用内置剪贴板上传
SETTINGS_CHOOSE_LANGUAGE: 选择语言
BUILTIN_CLIPBOARD_TIPS: 使用内置剪贴板函数而不是调用脚本获取剪贴板图片
UPLOADER_CONFIG_NAME: 图床配置名
# url rewrite
URL_REWRITE_HELP: 用于重写上传后的图片 URL。规则按顺序匹配,命中的第一条匹配生效。
URL_REWRITE_ADD_RULE: 新增规则
URL_REWRITE_EDIT_RULE: 编辑规则
URL_REWRITE_EMPTY: 暂无规则
URL_REWRITE_ORDER: 顺序
URL_REWRITE_MATCH: 匹配
URL_REWRITE_REPLACE: 替换
URL_REWRITE_FLAGS: 标志
URL_REWRITE_ENABLED: 启用
URL_REWRITE_ACTIONS: 操作
URL_REWRITE_MOVE_UP: 上移
URL_REWRITE_MOVE_DOWN: 下移
URL_REWRITE_EDIT: 编辑
URL_REWRITE_DELETE: 删除
URL_REWRITE_DELETE_CONFIRM: 确定删除该规则?
URL_REWRITE_MATCH_TIPS: 支持正则(JavaScript RegExp
URL_REWRITE_MATCH_PLACEHOLDER: https://example.com/path
URL_REWRITE_REPLACE_TIPS: 替换内容(支持 $1、$2...
URL_REWRITE_REPLACE_PLACEHOLDER: https://example.org/newpath
URL_REWRITE_OPTIONS: 选项
URL_REWRITE_RULE_ENABLED: 启用该规则
URL_REWRITE_FLAG_GLOBAL_LABEL: 全局(g
URL_REWRITE_FLAG_GLOBAL_DESC: 替换所有找到的内容,而不仅仅是第一个
URL_REWRITE_FLAG_IGNORE_CASE_LABEL: 忽略大小写(i
URL_REWRITE_FLAG_IGNORE_CASE_DESC: 匹配时不区分字母大小写(例如 JPG 等同于 jpg)
URL_REWRITE_MATCH_REQUIRED: 匹配规则不能为空
URL_REWRITE_REPLACE_REQUIRED: 替换内容不能为空
URL_REWRITE_INVALID_REGEX: 正则表达式不合法
URL_REWRITE_PREVIEW_TITLE: 预览
URL_REWRITE_PREVIEW_TIPS: 输入一个 URL,查看当前规则的重写结果(按顺序匹配,仅第一条命中的规则生效)
URL_REWRITE_PREVIEW_PLACEHOLDER: https://example.com/path/to/image.png
URL_REWRITE_PREVIEW_RUN: 预览
URL_REWRITE_PREVIEW_OUTPUT: 输出 URL
URL_REWRITE_PREVIEW_INPUT_REQUIRED: 请输入要预览的 URL
URL_REWRITE_PREVIEW_RULE_INVALID: 规则无效
URL_REWRITE_PREVIEW_MATCHED_RULE: 命中规则
URL_REWRITE_PREVIEW_NO_MATCH: 没有规则匹配
UPLOADER_CONFIG_PLACEHOLDER: 请输入配置名称
SELECTED_SETTING_HINT: 已选中
SETTINGS_ENCODE_OUTPUT_URL: 输出(复制) URL 时进行转义
@@ -143,11 +254,20 @@ SHORTCUT_EDIT: 编辑
SHORTCUT_CHANGE_UPLOAD: 修改上传快捷键
# gallery-page
CHANGE_IMAGE_URL_HOST: 修改图片 URL HOST
NEW_IMAGE_URL_HOST: 新的图片 URL HOST
SELECTED_IMAGE_URL_HOST: 已选中的图片 URL HOST
CHANGE_IMAGE_URL_HOST_RESULT: 修改图片 URL HOST 结果
CHANGE_IMAGE_URL_HOST_WARN: 你必须先选中至少一张图片
GALLERY_URL_REWRITE_TITLE: 重写选中图片 URL
GALLERY_URL_REWRITE_RESULT_TITLE: 重写图片 URL 结果
GALLERY_URL_REWRITE_WARN_NO_SELECTION: 你必须先选中至少一张图片
GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES: 应用全局 URL 重写规则
GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT: 全局规则数量
GALLERY_URL_REWRITE_TEMP_RULE_TIPS: 可选,留空则不使用临时规则(临时规则优先级高于全局规则)。
GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED: 临时规则需要同时填写「匹配」和「替换」
GALLERY_URL_REWRITE_NO_RULES_TO_APPLY: 没有可用的全局规则,且未填写临时规则
GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: 是否将临时规则写入全局 URL 重写规则列表?
GALLERY_URL_REWRITE_APPLY_AND_SAVE: 应用并写入
GALLERY_URL_REWRITE_APPLY_ONLY: 仅应用
GALLERY_URL_REWRITE_NO_CHANGES: 没有任何 URL 被修改
GALLERY_URL_REWRITE_EMPTY_RESULT_WARN: 重写结果为空,已跳过
# tray-page
@@ -164,7 +284,7 @@ CUSTOM: 自定义
CLIPBOARD_PICTURE: 剪贴板图片
TIPS_DRAG_VALID_PICTURE_OR_URL: 请拖入合法的图片文件或者图片URL地址
TIPS_INPUT_URL: 请输入URL
TIPS_HTTP_PREFIX: http://或者https://开头
TIPS_HTTP_PREFIX: http:// 或者 https:// 开头,支持上传多条 URL(请换行输入)
TIPS_INPUT_VALID_URL: 请输入合法的URL
# plugins
@@ -236,6 +356,9 @@ TOOLBOX_CHECK_CLIPBOARD_FILE_PATH_ERROR_TIPS: 请自行创建文件夹:${path}
TIPS_NOTICE: 注意
TIPS_WARNING: 警告
TIPS_ERROR: 发生错误
TIPS_SKIPPED_INVALID_URLS: 已跳过 ${n} 条非法 URL,请查看日志了解详情
TIPS_TOO_MANY_URLS_CONFIRM: 你将一次上传 ${n} 条 URL,可能会引起卡顿,建议分批上传。是否继续?
TIPS_NO_VALID_URLS: 未检测到合法的 URL
TIPS_INSTALL_NODE_AND_RELOAD_PICGO: 请安装Node.js并重启PicGo再继续操作
TIPS_PLUGIN_REMOVE_GALLERY_ITEM: 有插件正在试图删除一些相册图片,是否继续
TIPS_PLUGIN_OVERWRITE_GALLERY: 有插件正在试图覆盖相册列表,是否继续
@@ -250,39 +373,11 @@ TIPS_CUSTOM_LINK_STYLE_MODIFIED_SUCCEED: 自定义链接格式已经修改成功
TIPS_FIND_NEW_VERSION: 发现新版本${v},更新了很多功能,是否去下载最新的版本?
TIPS_DELETE_UPLOADER_CONFIG: 是否要删除这个配置?
TIPS_COPY_UPLOADER_CONFIG: 是否要复制这个配置?
TIPS_UPLOADER_CONFIG_NAME_EMPTY: 配置名称不能为空
TIPS_UPLOADER_CONFIG_NOT_FOUND: 未找到该配置
TIPS_UPLOADER_CONFIG_CANNOT_DELETE_LAST: 无法删除最后一个配置
# privacy
PRIVACY: >
本软件尊重并保护所有使用服务用户的个人隐私权。为了给您提供更准确、更优质的服务,本软件会按照本隐私权政策的规定使用和收集您的一些行为信息。您在同意本软件服务使用协议之时,即视为您已经同意本隐私权政策全部内容。本隐私权政策属于本软件服务使用协议不可分割的一部分,如果不同意将无法使用。本协议会定期更新。
1.适用范围
a)在您使用本软件时,本软件会记录的您对本软件的一些操作行为信息,包括但不限于您使用本软件进行文件上传的耗时、类型、数量等信息。
2.信息的使用
a)在获得您的使用数据之后,本软件会将其上传至数据分析服务器,以便分析数据后,提供给您更好的服务。
3.信息披露
a)本软件不会将您的信息披露给不受信任的第三方。
b)根据法律的有关规定,或者行政或司法机构的要求,向第三方或者行政、司法机构披露;
4.信息安全
a)本软件不会收集您的个人信息、密钥信息等隐私信息,所收集的信息仅仅作为改善软件、优化体验、了解软件日活等用途。
PRIVACY: "使用前请阅读并同意隐私政策 ${privacyUrl} 与服务条款 ${termsUrl},同意后方可使用。"
PRIVACY_TIPS: 请同意隐私协议,否则无法上传。
QUIT: 退出
+134 -39
View File
@@ -3,7 +3,7 @@ ABOUT: 關於
OPEN_MAIN_WINDOW: 打開主視窗
CHOOSE_DEFAULT_PICBED: 選擇預設圖床
OPEN_UPDATE_HELPER: 開啟更新助手
PRIVACY_AGREEMENT: 隱私協議
PRIVACY_TERMS_AGREEMENT: 隱私與條款協議
RELOAD_APP: 重啟程式
UPLOAD_SUCCEED: 上傳成功
UPLOAD_FAILED: 上傳失敗
@@ -34,6 +34,75 @@ GALLERY: 相簿
PICBEDS_SETTINGS: 圖床設定
PICGO_SETTINGS: PicGo設定
PLUGIN_SETTINGS: 插件設定
PICGO_CLOUD_TITLE: PicGo Cloud
PICGO_CLOUD_ERROR_TITLE: PicGo Cloud 錯誤
PICGO_CLOUD_NOT_LOGGED_IN: 尚未登入 PicGo Cloud
PICGO_CLOUD_LOGIN: 登入
PICGO_CLOUD_LOGOUT: 登出
PICGO_CLOUD_CANCEL_LOGIN: 取消登入
PICGO_CLOUD_RETRY: 重試
PICGO_CLOUD_CONFIG_SYNC: 配置同步
PICGO_CLOUD_LOGIN_IN_PROGRESS: 登入進行中,請在瀏覽器完成登入。
PICGO_CLOUD_LOGGED_IN_AS: "已登入:${user}"
PICGO_CLOUD_OPEN: 打開 PicGo Cloud
PICGO_CLOUD_LOGIN_TIMEOUT: 登入逾時,請重試。
PICGO_CLOUD_LOGIN_FAILED: 登入失敗,請重試。
PICGO_CLOUD_AGREE_PREFIX: 我已閱讀並同意 PicGo 的
PICGO_CLOUD_TERMS_OF_SERVICE: 服務條款
PICGO_CLOUD_AGREE_AND: 以及
PICGO_CLOUD_PRIVACY_POLICY: 隱私政策
PICGO_CLOUD_LOGIN_EXPIRED: 登入失效,請重新登入。
PICGO_CLOUD_ENCRYPTION_MODE_LABEL: 加密模式
PICGO_CLOUD_ENCRYPTION_MODE_AUTO: 自動
PICGO_CLOUD_ENCRYPTION_MODE_SERVER: 服務端加密
PICGO_CLOUD_ENCRYPTION_MODE_E2E: 端到端加密
PICGO_CLOUD_ENCRYPTION_MODE_TIP_AUTO: 自動:跟隨雲端上一次配置的加密方式(預設服務端加密)。
PICGO_CLOUD_ENCRYPTION_MODE_TIP_SERVER: 服務端加密:我們會在服務端對你的配置加密後再儲存,不需要 PIN。
PICGO_CLOUD_ENCRYPTION_MODE_TIP_E2E: 端到端加密:需要你輸入 PIN 來加密/解密資料。PicGo 不儲存 PIN,遺失將無法找回資料。
PICGO_CLOUD_ENCRYPTION_MODE_TIP_DOC: 查看完整文檔
PICGO_CLOUD_E2E_CHECKBOX_LABEL: 開啟 E2E 加密
PICGO_CLOUD_E2E_ENABLE_WARNING_TITLE: 開啟 E2E 加密
PICGO_CLOUD_E2E_ENABLE_WARNING_MESSAGE: E2E 加密需要你自行妥善保管 PIN。PIN 遺失將無法恢復加密資料。PicGo 不會儲存 PIN 或任何恢復資訊。
PICGO_CLOUD_REMOTE_E2E_AUTO_ENABLED: 偵測到遠端配置已開啟 E2E 加密,已自動在本地開啟 E2E。
PICGO_CLOUD_E2E_PIN_SETUP_TITLE: 設定 E2E PIN
PICGO_CLOUD_E2E_PIN_DECRYPT_TITLE: 輸入 E2E PIN
PICGO_CLOUD_E2E_PIN_RETRY_TITLE: "PIN 錯誤,請重試(第 ${retryCount} 次)。"
PICGO_CLOUD_E2E_PIN_PLACEHOLDER: PIN
PICGO_CLOUD_E2E_PIN_CONFIRM_PLACEHOLDER: 確認 PIN
PICGO_CLOUD_CONFIG_SYNC_SUCCESS: 配置同步成功。
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_DETECTED: 偵測到配置衝突,請選擇解決方案。
PICGO_CLOUD_CONFIG_SYNC_FAILED: 配置同步失敗。
PICGO_CLOUD_CONFIG_SYNC_ABORTED: 已取消配置同步。
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_TITLE: 確認切換加密方式嗎?
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_BODY: "您正在從「${from}」切換為「${to}」。\n\n注意:切換加密模式將清空您所有的雲端歷史版本記錄。\n這是因為舊的歷史版本無法在新模式下被解密或驗證。\n切換後,系統將立即為您建立一份新的備份作為起點。"
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CONFIRM: 確認切換並清空歷史
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCEL: 取消
PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED: 已取消切換加密方式
PICGO_CLOUD_CONFIG_SYNC_FAILED_WITH_REASON: "配置同步失敗:${reason}"
PICGO_CLOUD_CONFIG_SYNC_PIN_MAX_RETRY: PIN 錯誤次數過多,請稍後重試。
PICGO_CLOUD_CONFIG_SYNC_LOCAL_CONFIG_INVALID: 本地配置檔格式錯誤,請檢查配置檔。
PICGO_CLOUD_CONFIG_SYNC_IN_PROGRESS: 配置同步進行中…
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_PENDING: 存在未解決的衝突,請先處理。
PICGO_CLOUD_CONFIG_SYNC_NO_CONFLICT_SESSION: 未找到可處理的衝突工作階段。
PICGO_CLOUD_CONFIG_SYNC_RESOLUTION_INCOMPLETE: 請為所有衝突項選擇本地或雲端。
PICGO_CLOUD_CONFIG_SYNC_STARTING: 配置同步已開始…
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_TITLE: "衝突處理(${count} 項)"
PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_LOCAL: 全選本地
PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_CLOUD: 全選雲端
PICGO_CLOUD_CONFIG_SYNC_RESET_ALL: 重置全部
PICGO_CLOUD_CONFIG_SYNC_LOCAL_VERSION: 本地版本
PICGO_CLOUD_CONFIG_SYNC_CLOUD_VERSION: 雲端版本
PICGO_CLOUD_CONFIG_SYNC_ABORT: 放棄同步
PICGO_CLOUD_CONFIG_SYNC_CONFIRM_AND_SYNC: 確認並同步
PICGO_CLOUD_CONFIG_SYNC_VALUE_UNDEFINED: (空)
PICGO_CLOUD_CONFIG_SYNC_CONFLICT_RESOLVED: 配置衝突已解決。
PICGO_CLOUD_LAST_SYNC_TIME: "上次本地同步時間:${time}"
PICGO_CLOUD_LAST_SYNC_TIME_NONE:
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_TITLE: 需要重啟嗎?
PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_MESSAGE: 部分配置可能需要重啟後生效,是否立即重啟?
PICGO_CLOUD_CONFIG_SYNC_RESTART_NOW: 立即重啟
PICGO_CLOUD_CONFIG_SYNC_RESTART_LATER: 稍後
INPUT_BOX_CONFIRM_MISMATCH: 兩次輸入不一致,請重新輸入。
PICGO_SPONSOR_TEXT: PicGo是開放原始碼的軟體,如果你喜歡它,對你有幫助,不妨請我喝杯咖啡?
ALIPAY: 支付寶
WECHATPAY: 微信支付
@@ -68,6 +137,7 @@ SETTINGS_SET_LOG_FILE: 設定記錄檔案
SETTINGS_CLICK_TO_SET: 點擊設定
SETTINGS_CLICK_TO_CHECK: 點擊檢查
SETTINGS_SET_SHORTCUT: 設定快捷鍵
SETTINGS_URL_REWRITE: URL 重寫
SETTINGS_CUSTOM_LINK_FORMAT: 自訂連結格式
SETTINGS_SET_PROXY_AND_MIRROR: 設定PROXY和鏡像地址
SETTINGS_SET_SERVER: 設定Server
@@ -80,6 +150,7 @@ SETTINGS_LAUNCH_ON_BOOT: 開機時啟動
SETTINGS_RENAME_BEFORE_UPLOAD: 上傳前重新命名
SETTINGS_TIMESTAMP_RENAME: 以時間戳命名
SETTINGS_OPEN_UPLOAD_TIPS: 開啟上傳提示
SETTINGS_NOTIFICATION_SOUND: 開啟通知提示音
SETTINGS_MINI_WINDOW_ON_TOP: Mini視窗置頂
SETTINGS_AUTO_COPY_URL_AFTER_UPLOAD: 上傳後自動複製URL
SETTINGS_TIPS_PLACEHOLDER_URL: 用佔位符 $url 來表示URL的位置
@@ -117,6 +188,46 @@ SETTINGS_USE_BUILTIN_CLIPBOARD_UPLOAD: 使用內建剪貼簿上傳
SETTINGS_CHOOSE_LANGUAGE: 選擇語言
BUILTIN_CLIPBOARD_TIPS: 使用內建剪貼簿函數而不是調用腳本取得剪貼簿內的照片
UPLOADER_CONFIG_NAME: 圖床配置名
# url rewrite
URL_REWRITE_HELP: 用於重寫上傳後的圖片 URL。規則按順序匹配,命中的第一條匹配生效。
URL_REWRITE_ADD_RULE: 新增規則
URL_REWRITE_EDIT_RULE: 編輯規則
URL_REWRITE_EMPTY: 暫無規則
URL_REWRITE_ORDER: 順序
URL_REWRITE_MATCH: 匹配
URL_REWRITE_REPLACE: 替換
URL_REWRITE_FLAGS: 標誌
URL_REWRITE_ENABLED: 啟用
URL_REWRITE_ACTIONS: 操作
URL_REWRITE_MOVE_UP: 上移
URL_REWRITE_MOVE_DOWN: 下移
URL_REWRITE_EDIT: 編輯
URL_REWRITE_DELETE: 刪除
URL_REWRITE_DELETE_CONFIRM: 確定刪除該規則?
URL_REWRITE_MATCH_TIPS: 支援正則(JavaScript RegExp
URL_REWRITE_MATCH_PLACEHOLDER: https://example.com/path
URL_REWRITE_REPLACE_TIPS: 替換內容(支援 $1、$2...
URL_REWRITE_REPLACE_PLACEHOLDER: https://example.org/newpath
URL_REWRITE_OPTIONS: 選項
URL_REWRITE_RULE_ENABLED: 啟用該規則
URL_REWRITE_FLAG_GLOBAL_LABEL: 全域(g
URL_REWRITE_FLAG_GLOBAL_DESC: 替換所有找到的內容,而不僅僅是第一個
URL_REWRITE_FLAG_IGNORE_CASE_LABEL: 忽略大小寫(i
URL_REWRITE_FLAG_IGNORE_CASE_DESC: 匹配時不區分字母大小寫(例如 JPG 等同於 jpg)
URL_REWRITE_MATCH_REQUIRED: 匹配規則不能為空
URL_REWRITE_REPLACE_REQUIRED: 替換內容不能為空
URL_REWRITE_INVALID_REGEX: 正則表達式不合法
URL_REWRITE_PREVIEW_TITLE: 預覽
URL_REWRITE_PREVIEW_TIPS: 輸入一個 URL,查看目前規則的重寫結果(按順序匹配,僅第一條命中的規則生效)
URL_REWRITE_PREVIEW_PLACEHOLDER: https://example.com/path/to/image.png
URL_REWRITE_PREVIEW_RUN: 預覽
URL_REWRITE_PREVIEW_OUTPUT: 輸出 URL
URL_REWRITE_PREVIEW_INPUT_REQUIRED: 請輸入要預覽的 URL
URL_REWRITE_PREVIEW_RULE_INVALID: 規則無效
URL_REWRITE_PREVIEW_MATCHED_RULE: 命中規則
URL_REWRITE_PREVIEW_NO_MATCH: 沒有規則匹配
UPLOADER_CONFIG_PLACEHOLDER: 請輸入配置名稱
SELECTED_SETTING_HINT: 已選中
SETTINGS_ENCODE_OUTPUT_URL: 輸出(複製) URL 時進行轉義
@@ -143,11 +254,20 @@ SHORTCUT_EDIT: 編輯
SHORTCUT_CHANGE_UPLOAD: 修改上傳快捷鍵
# gallery-page
CHANGE_IMAGE_URL_HOST: 修改圖片 URL HOST
NEW_IMAGE_URL_HOST: 新的圖片 URL HOST
SELECTED_IMAGE_URL_HOST: 已選中的圖片 URL HOST
CHANGE_IMAGE_URL_HOST_RESULT: 修改圖片 URL HOST 結果
CHANGE_IMAGE_URL_HOST_WARN: 你必須先選中至少一張圖片
GALLERY_URL_REWRITE_TITLE: 重寫選中圖片 URL
GALLERY_URL_REWRITE_RESULT_TITLE: 重寫圖片 URL 結果
GALLERY_URL_REWRITE_WARN_NO_SELECTION: 你必須先選中至少一張圖片
GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES: 套用全域 URL 重寫規則
GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT: 全域規則數量
GALLERY_URL_REWRITE_TEMP_RULE_TIPS: 可選,留空則不使用臨時規則(臨時規則優先級高於全域規則)。
GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED: 臨時規則需要同時填寫「匹配」和「替換」
GALLERY_URL_REWRITE_NO_RULES_TO_APPLY: 沒有可用的全域規則,且未填寫臨時規則
GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT: 是否將臨時規則寫入全域 URL 重寫規則列表?
GALLERY_URL_REWRITE_APPLY_AND_SAVE: 套用並寫入
GALLERY_URL_REWRITE_APPLY_ONLY: 僅套用
GALLERY_URL_REWRITE_NO_CHANGES: 沒有任何 URL 被修改
GALLERY_URL_REWRITE_EMPTY_RESULT_WARN: 重寫結果為空,已跳過
# tray-page
@@ -164,7 +284,7 @@ CUSTOM: 自訂
CLIPBOARD_PICTURE: 剪貼簿圖片
TIPS_DRAG_VALID_PICTURE_OR_URL: 請拖入合法的圖片檔案或者圖片URL地址
TIPS_INPUT_URL: 請輸入URL
TIPS_HTTP_PREFIX: http://或者https://開頭
TIPS_HTTP_PREFIX: http:// 或者 https:// 開頭,支援上傳多條 URL(請換行輸入)
TIPS_INPUT_VALID_URL: 請輸入合法的URL
# plugins
@@ -236,6 +356,9 @@ TOOLBOX_CHECK_CLIPBOARD_FILE_PATH_ERROR_TIPS: 請自行創建文件夾:${path}
TIPS_NOTICE: 注意
TIPS_WARNING: 警告
TIPS_ERROR: 發生錯誤
TIPS_SKIPPED_INVALID_URLS: 已跳過 ${n} 條非法 URL,請查看日誌了解詳情
TIPS_TOO_MANY_URLS_CONFIRM: 你將一次上傳 ${n} 條 URL,可能會引起卡頓,建議分批上傳。是否繼續?
TIPS_NO_VALID_URLS: 未偵測到合法的 URL
TIPS_INSTALL_NODE_AND_RELOAD_PICGO: 請安裝Node.js並重新啟動PicGo再繼續操作
TIPS_PLUGIN_REMOVE_GALLERY_ITEM: 有插件正在試圖刪除一些相簿圖片,是否繼續?
TIPS_PLUGIN_OVERWRITE_GALLERY: 有插件正在試圖覆蓋相簿列表,是否繼續?
@@ -250,39 +373,11 @@ TIPS_CUSTOM_LINK_STYLE_MODIFIED_SUCCEED: 自訂連結格式已經修改成功
TIPS_FIND_NEW_VERSION: 發現新版本${v},更新了很多功能,是否去下載最新的版本?
TIPS_DELETE_UPLOADER_CONFIG: 是否要刪除這個配置?
TIPS_COPY_UPLOADER_CONFIG: 是否要複製這個配置?
TIPS_UPLOADER_CONFIG_NAME_EMPTY: 配置名稱不能為空
TIPS_UPLOADER_CONFIG_NOT_FOUND: 未找到該配置
TIPS_UPLOADER_CONFIG_CANNOT_DELETE_LAST: 無法刪除最後一個配置
# privacy
PRIVACY: >
本軟體尊重並保護所有使用服務用戶的個人隱私權。為了給您提供更準確、更優質的服務,本軟體會按照本隱私權政策的規定使用和收集您的一些行為信息。您在同意本軟體服務使用協議之時,即視為您已經同意本隱私權政策全部內容。本隱私權政策屬於本軟體服務使用協議不可分割的一部分,如果不同意將無法使用。本協議會定期更新。
1.適用範圍
a)在您使用本軟體時,本軟體會記錄的您對本軟體的一些操作行為信息,包括但不限於您使用本軟體進行文件上傳的耗時、類型、數量等信息。
2.信息的使用
a)在獲得您的使用數據之後,本軟體會將其上傳至數據分析服務器,以便分析數據後,提供給您更好的服務。
3.信息披露
a)本軟體不會將您的信息披露給不受信任的第三方。
b)根據法律的有關規定,或者行政或司法機構的要求,向第三方或者行政、司法機構披露;
4.信息安全
a)本軟體不會收集您的個人信息、密鑰信息等隱私信息,所收集的信息僅僅作為改善軟體、優化體驗、了解軟體日活等用途。
PRIVACY: "使用前請閱讀並同意隱私政策 ${privacyUrl} 與服務條款 ${termsUrl},同意後方可使用。"
PRIVACY_TIPS: 請同意隱私協議,否則無法上傳。
QUIT: 退出
+3 -3
View File
@@ -16,15 +16,15 @@ const macos = [
const linux = {
AppImage: [
{ label: '64 bit', arch: '-x64', ext: '.AppImage' },
{ label: '64 bit', arch: '-amd64', ext: '.AppImage' },
{ label: 'ARM64', arch: '-arm64', ext: '.AppImage' }
],
Deb: [
{ label: '64 bit', arch: '-x64', ext: '.deb' },
{ label: '64 bit', arch: '-amd64', ext: '.deb' },
{ label: 'ARM64', arch: '-arm64', ext: '.deb' }
],
Snap: [
{ label: '64 bit', arch: '-x64', ext: '.snap' }
{ label: '64 bit', arch: '-amd64', ext: '.snap' }
]
}
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable @stylistic/indent */
const yaml = require('js-yaml')
const path = require('path')
const fs = require('fs')
+197
View File
@@ -0,0 +1,197 @@
/**
* Merge artifacts from different platforms and architectures
* Also merge latest*.yml files for electron-updater
*/
const fs = require('fs')
const path = require('path')
const yaml = require('js-yaml')
const ARTIFACTS_DIR = path.join(__dirname, '../artifacts')
const DIST_DIR = path.join(__dirname, '../dist')
// yml 文件分组规则
const YML_MERGE_RULES = {
// macOS: 合并 x64 和 arm64 的 latest-mac.yml
'latest-mac.yml': ['latest-mac.yml'],
// Windows: 合并所有架构的 latest.yml
'latest.yml': ['latest.yml'],
// Linux x64: latest-linux.yml
'latest-linux.yml': ['latest-linux.yml'],
// Linux arm64: latest-linux-arm64.yml
'latest-linux-arm64.yml': ['latest-linux-arm64.yml']
}
/**
* 递归查找指定文件名的所有文件
*/
function findFiles(dir, filename) {
const results = []
if (!fs.existsSync(dir)) {
return results
}
const items = fs.readdirSync(dir)
for (const item of items) {
const fullPath = path.join(dir, item)
const stat = fs.statSync(fullPath)
if (stat.isDirectory()) {
results.push(...findFiles(fullPath, filename))
} else if (item === filename) {
results.push(fullPath)
}
}
return results
}
/**
* 合并多个 yml 文件
*/
function mergeYmlFiles(files) {
if (files.length === 0) return null
if (files.length === 1) {
return yaml.load(fs.readFileSync(files[0], 'utf8'))
}
const contents = files.map(f => yaml.load(fs.readFileSync(f, 'utf8')))
// 以第一个为基准,合并 files 数组
const merged = {
version: contents[0].version,
files: [],
releaseDate: contents[0].releaseDate
}
for (const content of contents) {
if (content.files && Array.isArray(content.files)) {
merged.files.push(...content.files)
}
}
// 去重(根据 sha512
const seen = new Set()
merged.files = merged.files.filter(file => {
const key = file.sha512
if (seen.has(key)) return false
seen.add(key)
return true
})
// 设置 path/sha512/size 为第一个文件(electron-updater 兼容性)
if (merged.files.length > 0) {
merged.path = merged.files[0].url
merged.sha512 = merged.files[0].sha512
merged.size = merged.files[0].size
}
return merged
}
/**
* 复制所有构建产物到 dist 目录
*/
function copyArtifacts() {
console.log('📁 Copying all artifacts to dist...\n')
if (!fs.existsSync(ARTIFACTS_DIR)) {
console.log('⚠️ No artifacts directory found')
return
}
const platformDirs = fs.readdirSync(ARTIFACTS_DIR)
for (const platformDir of platformDirs) {
const platformPath = path.join(ARTIFACTS_DIR, platformDir)
const stat = fs.statSync(platformPath)
if (!stat.isDirectory()) continue
console.log(`📦 Processing ${platformDir}...`)
const files = fs.readdirSync(platformPath)
for (const file of files) {
const srcPath = path.join(platformPath, file)
const destPath = path.join(DIST_DIR, file)
const fileStat = fs.statSync(srcPath)
// 跳过目录和 yml 文件(yml 文件会单独处理合并)
if (fileStat.isDirectory()) continue
if (file.endsWith('.yml')) continue
// 如果目标文件已存在且大小相同,跳过
if (fs.existsSync(destPath)) {
const destStat = fs.statSync(destPath)
if (destStat.size === fileStat.size) {
console.log(` ⏭️ Skipped (exists): ${file}`)
continue
}
}
fs.copyFileSync(srcPath, destPath)
console.log(` ✅ Copied: ${file}`)
}
}
}
/**
* 合并 yml 文件
*/
function mergeYmlFilesFromArtifacts() {
console.log('\n🔀 Merging yml files...\n')
for (const [outputName, sourceNames] of Object.entries(YML_MERGE_RULES)) {
const allFiles = []
for (const sourceName of sourceNames) {
const files = findFiles(ARTIFACTS_DIR, sourceName)
allFiles.push(...files)
}
if (allFiles.length === 0) {
console.log(`⏭️ No ${outputName} found, skipping...`)
continue
}
console.log(`📄 Found ${allFiles.length} ${outputName} file(s):`)
allFiles.forEach(f => console.log(` - ${path.relative(ARTIFACTS_DIR, f)}`))
const merged = mergeYmlFiles(allFiles)
if (merged) {
const outputPath = path.join(DIST_DIR, outputName)
fs.writeFileSync(outputPath, yaml.dump(merged, { lineWidth: -1 }))
console.log(`✅ Merged -> ${outputName}`)
if (merged.files) {
console.log(` Files: ${merged.files.map(f => f.url).join(', ')}`)
}
console.log('')
}
}
}
async function main() {
console.log('🚀 Starting artifact merge process...\n')
// 确保 dist 目录存在
if (!fs.existsSync(DIST_DIR)) {
fs.mkdirSync(DIST_DIR, { recursive: true })
}
// 1. 复制所有构建产物
copyArtifacts()
// 2. 合并 yml 文件
mergeYmlFilesFromArtifacts()
console.log('🎉 Artifact merge completed!')
}
main().catch(err => {
console.error('❌ Error:', err)
process.exit(1)
})
+118 -147
View File
@@ -2,8 +2,6 @@
// upload version file to cos
require('dotenv').config()
// const crypto = require('crypto')
// const axios = require('axios').default
const fs = require('fs')
const pkg = require('../package.json')
const configList = require('./config')
@@ -12,15 +10,13 @@ const path = require('path')
const distPath = path.join(__dirname, '../dist')
const S3Client = require('@aws-sdk/client-s3').S3Client
const Upload = require('@aws-sdk/lib-storage').Upload
// const BUCKET = 'picgo-1251750343'
// const COS_SECRET_ID = process.env.PICGO_ENV_COS_SECRET_ID
// const COS_SECRET_KEY = process.env.PICGO_ENV_COS_SECRET_KEY
const uploadToDev = process.argv.includes('--dev')
const S3_BUCKET = 'release'
const S3_LEGACY_BUCKET = 'picgo'
// const AREA = 'ap-chengdu'
const VERSION = pkg.version
const FILE_PATH = `${VERSION}/`
const DEV_DIST_PREFIX = 'dev/'
const FILE_PATH = uploadToDev ? `${DEV_DIST_PREFIX}${VERSION}/` : `${VERSION}/`
const S3_SECRET_ID = process.env.PICGO_ENV_S3_SECRET_ID
const S3_SECRET_KEY = process.env.PICGO_ENV_S3_SECRET_KEY
const S3_ACCOUNT_ID = process.env.PICGO_ENV_S3_ACCOUNT_ID
@@ -49,155 +45,130 @@ const S3LegacyOptions = {
region: 'auto'
}
// https://cloud.tencent.com/document/product/436/7778#signature
// /**
// * @param {string} fileName
// * @returns
// */
// const generateSignature = (fileName, folder = FILE_PATH) => {
// const secretKey = COS_SECRET_ID
// // const area = AREA
// const bucket = BUCKET
// const path = folder
// const today = Math.floor(new Date().getTime() / 1000)
// const tomorrow = today + 86400
// const signTime = `${today};${tomorrow}`
// const signKey = crypto.createHmac('sha1', secretKey).update(signTime).digest('hex')
// const httpString = `put\n/${path}${fileName}\n\nhost=${bucket}.cos.accelerate.myqcloud.com\n`
// const sha1edHttpString = crypto.createHash('sha1').update(httpString).digest('hex')
// const stringToSign = `sha1\n${signTime}\n${sha1edHttpString}\n`
// const signature = crypto.createHmac('sha1', signKey).update(stringToSign).digest('hex')
// return {
// signature,
// signTime
// }
// }
// /**
// *
// * @param {string} fileName
// * @param {Buffer} fileBuffer
// * @param {{ signature: string, signTime: string }} signature
// * @returns
// */
// const getReqOptions = (fileName, fileBuffer, signature, folder = FILE_PATH) => {
// return {
// method: 'PUT',
// url: `http://${BUCKET}.cos.accelerate.myqcloud.com/${encodeURI(folder)}${encodeURI(fileName)}`,
// headers: {
// Host: `${BUCKET}.cos.accelerate.myqcloud.com`,
// Authorization: `q-sign-algorithm=sha1&q-ak=${COS_SECRET_KEY}&q-sign-time=${signature.signTime}&q-key-time=${signature.signTime}&q-header-list=host&q-url-param-list=&q-signature=${signature.signature}`,
// contentType: mime.lookup(fileName),
// useAgent: `PicGo;${pkg.version};null;null`
// },
// maxContentLength: Infinity,
// maxBodyLength: Infinity,
// data: fileBuffer,
// resolveWithFullResponse: true
// }
// }
/**
* 检查是否使用 --all 参数(上传所有平台)
*/
function shouldUploadAll() {
return process.argv.includes('--all')
}
/**
* a backup for version file
* 获取要上传的配置列表
*/
// const uploadVersionFile = async () => {
// try {
// const platform = process.platform
// if (configList[platform]) {
// let versionFileHasUploaded = false
// for (const [, config] of configList[platform].entries()) {
// const versionFilePath = path.join(distPath, config['version-file'])
// let versionFileName = config['version-file']
// if (VERSION.toLocaleLowerCase().includes('beta')) {
// versionFileName = versionFileName.replace('.yml', '.beta.yml')
// }
// // upload version file
// if (!versionFileHasUploaded) {
// const signature = generateSignature(versionFileName, '')
// const reqOptions = getReqOptions(versionFileName, fs.readFileSync(versionFilePath), signature, '')
// console.log('[PicGo Version File] Uploading...', versionFileName)
// await axios.request(reqOptions)
// versionFileHasUploaded = true
// }
// }
// } else {
// console.warn('platform not supported!', platform)
// }
// } catch (e) {
// console.error(e)
// }
// }
function getUploadConfigs() {
if (shouldUploadAll()) {
// 合并所有平台的配置
return [
...configList.darwin,
...configList.win32,
...configList.linux
]
}
// 原有逻辑:根据当前平台
const platform = process.platform
return configList[platform] || []
}
/**
* 上传单个文件到 S3
*/
async function uploadFileToS3(client, bucket, key, filePath, contentType = 'application/octet-stream') {
const upload = new Upload({
client,
params: {
Bucket: bucket,
Key: key,
Body: fs.createReadStream(filePath),
ContentType: contentType
}
})
upload.on('httpUploadProgress', progress => {
const percent = progress.total ? Math.round((progress.loaded / progress.total) * 100) : 0
process.stdout.write(`\r Progress: ${progress.loaded}/${progress.total || '?'} (${percent}%)`)
})
await upload.done()
console.log('') // 换行
}
const uploadDist = async () => {
try {
const platform = process.platform
if (configList[platform]) {
const uploadedVersionFiles = new Set()
for (const [index, config] of configList[platform].entries()) {
const fileName = `${config.appNameWithPrefix}-${VERSION}-${config.arch}.${config.ext}`
const filePath = path.join(distPath, fileName)
const versionFilePath = path.join(distPath, config['version-file'])
let versionFileName = config['version-file']
if (VERSION.toLocaleLowerCase().includes('beta')) {
versionFileName = versionFileName.replace('.yml', '.beta.yml')
}
console.log('[PicGo Dist] Preparing to upload', fileName)
const client = new S3Client(S3Options)
if (fs.existsSync(filePath)) {
const uploadDistToS3 = new Upload({
client,
params: {
Bucket: S3_BUCKET,
Key: `${FILE_PATH}${fileName}`,
Body: fs.createReadStream(filePath),
ContentType: 'application/octet-stream'
}
})
// upload dist file
console.log('[PicGo Dist] Uploading...', fileName, `${index + 1}/${configList[platform].length}`)
uploadDistToS3.on('httpUploadProgress', progress => {
console.log(`[PicGo Dist] Uploading... ${progress.loaded}/${progress.total}`)
})
await uploadDistToS3.done()
} else {
console.warn('[PicGo Dist] File not found:', fileName)
}
const configs = getUploadConfigs()
// upload version file
if (!uploadedVersionFiles.has(versionFileName) && fs.existsSync(versionFilePath)) {
const uploadVersionFileToS3 = new Upload({
client,
params: {
Bucket: S3_BUCKET,
Key: `${versionFileName}`,
Body: fs.createReadStream(versionFilePath),
ContentType: mime.lookup(versionFileName)
}
})
// upload to legacy bucket as well
// will be deprecated in 2.5.0
const legacyClient = new S3Client(S3LegacyOptions)
const uploadVersionFileToLegacyS3 = new Upload({
client: legacyClient,
params: {
Bucket: S3_LEGACY_BUCKET,
Key: `${versionFileName}`,
Body: fs.createReadStream(versionFilePath),
ContentType: mime.lookup(versionFileName)
}
})
console.log('[PicGo Version File] Uploading...', versionFileName)
await uploadVersionFileToS3.done()
await uploadVersionFileToLegacyS3.done()
uploadedVersionFiles.add(versionFileName)
console.log('[PicGo Version File] Upload successfully')
}
}
} else {
console.warn('platform not supported!', platform)
if (configs.length === 0) {
console.warn('[PicGo] No upload config found!')
return
}
console.log(`[PicGo] Upload mode: ${shouldUploadAll() ? 'ALL PLATFORMS' : process.platform}`)
console.log(`[PicGo] Version: ${VERSION}`)
console.log(`[PicGo] Total files to upload: ${configs.length}\n`)
const uploadedVersionFiles = new Set()
const client = new S3Client(S3Options)
const legacyClient = new S3Client(S3LegacyOptions)
for (const [index, config] of configs.entries()) {
const fileName = `${config.appNameWithPrefix}-${VERSION}-${config.arch}.${config.ext}`
const filePath = path.join(distPath, fileName)
let versionFileName = config['version-file']
console.log(`[${index + 1}/${configs.length}] Processing ${fileName}`)
// 上传构建产物
if (fs.existsSync(filePath)) {
console.log(` Uploading to S3: ${FILE_PATH}${fileName}`)
await uploadFileToS3(client, S3_BUCKET, `${FILE_PATH}${fileName}`, filePath)
console.log(` ✅ Uploaded: ${fileName}`)
} else {
console.warn(` ⚠️ File not found: ${fileName}`)
}
let versionFilePath = path.join(distPath, versionFileName)
// Beta 版本使用不同的 yml 文件名
if (VERSION.toLowerCase().includes('beta') && fs.existsSync(versionFilePath)) {
versionFileName = versionFileName.replace('.yml', '.beta.yml')
const betaVersionFilePath = path.join(distPath, versionFileName)
// change to beta version file path
fs.renameSync(versionFilePath, betaVersionFilePath)
versionFilePath = betaVersionFilePath
}
// 上传版本文件(每个 yml 只上传一次)
if (!uploadedVersionFiles.has(versionFileName) && fs.existsSync(versionFilePath)) {
console.log(` Uploading version file: ${versionFileName}`)
const versionFileNameFinal = uploadToDev ? `${DEV_DIST_PREFIX}${versionFileName}` : versionFileName
// 上传到主 bucket
await uploadFileToS3(
client,
S3_BUCKET,
versionFileNameFinal,
versionFilePath,
mime.lookup(versionFileName) || 'text/yaml'
)
// 上传到 legacy bucket
await uploadFileToS3(
legacyClient,
S3_LEGACY_BUCKET,
versionFileNameFinal,
versionFilePath,
mime.lookup(versionFileName) || 'text/yaml'
)
uploadedVersionFiles.add(versionFileName)
console.log(` ✅ Version file uploaded: ${versionFileName}`)
}
console.log('')
}
console.log('[PicGo] 🎉 All uploads completed!')
} catch (e) {
console.error(e)
console.error('[PicGo] ❌ Upload error:', e)
process.exit(1)
}
}
@@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { IpcMainInvokeEvent } from 'electron'
import os from 'node:os'
import path from 'node:path'
import fs from 'fs-extra'
import { IRPCActionType } from '~/universal/types/enum'
import { IPicGoCloudConfigSyncToastType } from '#/types/cloudConfigSync'
type OnAskEncryptionSwitch = (context: { from: string; to: string }) => Promise<boolean>
type SyncImplementation = (askSwitch?: OnAskEncryptionSwitch) => Promise<{ status: string; message?: string }>
const showMessageBoxMock = vi.fn()
const showInputBoxMock = vi.fn()
const getUserInfoMock = vi.fn()
const getConfigMock = vi.fn()
const saveConfigMock = vi.fn()
const i18nTranslateMock = vi.fn((key: string) => key)
let baseDir = ''
let syncImplementation: SyncImplementation | null = null
const createInvokeEvent = (): IpcMainInvokeEvent => {
const event = {
sender: {
send: vi.fn()
}
}
return event as unknown as IpcMainInvokeEvent
}
vi.mock('@core/picgo', () => {
return {
default: {
get baseDir () {
return baseDir
},
getConfig: getConfigMock,
saveConfig: saveConfigMock,
cloud: {
getUserInfo: getUserInfoMock,
login: vi.fn(),
logout: vi.fn(),
disposeLoginFlow: vi.fn()
},
i18n: {
translate: i18nTranslateMock
}
}
}
})
vi.mock('apis/gui', () => {
return {
default: {
getInstance: () => ({
showInputBox: showInputBoxMock,
showMessageBox: showMessageBoxMock
})
}
}
})
vi.mock('apis/core/picgo/logger', () => {
return {
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}
}
})
vi.mock('picgo', () => {
const SyncStatus = {
SUCCESS: 'success',
CONFLICT: 'conflict',
FAILED: 'failed'
}
const EncryptionMethod = {
AUTO: 'auto',
SSE: 'sse',
E2EE: 'e2ee'
}
const E2EAskPinReason = {
SETUP: 'setup',
DECRYPT: 'decrypt',
RETRY: 'retry'
}
const ConflictType = {
CONFLICT: 'conflict'
}
class ConfigSyncManager {
private readonly onAskEncryptionSwitch?: OnAskEncryptionSwitch
constructor (_ctx: unknown, options: { onAskEncryptionSwitch?: OnAskEncryptionSwitch } = {}) {
this.onAskEncryptionSwitch = options.onAskEncryptionSwitch
}
async sync (): Promise<{ status: string; message?: string }> {
if (syncImplementation) {
return syncImplementation(this.onAskEncryptionSwitch)
}
return { status: SyncStatus.SUCCESS }
}
}
return {
ConfigSyncManager,
ConflictType,
E2EAskPinReason,
EncryptionMethod,
SyncStatus
}
})
describe('config sync encryption switch confirmation (main)', () => {
beforeEach(async () => {
vi.clearAllMocks()
syncImplementation = null
getConfigMock.mockReturnValue(undefined)
getUserInfoMock.mockResolvedValue({ user: 'tester' })
i18nTranslateMock.mockImplementation((key: string) => key)
baseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picgo-gui-config-sync-'))
})
afterEach(async () => {
if (baseDir) {
await fs.remove(baseDir)
}
})
it('prompts for confirmation and proceeds on confirm', async () => {
showMessageBoxMock.mockResolvedValue({ result: 0, checkboxChecked: false })
syncImplementation = async (askSwitch) => {
if (askSwitch) {
const confirmed = await askSwitch({ from: 'e2ee', to: 'sse' })
if (!confirmed) {
return { status: 'failed', message: i18nTranslateMock('CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED') }
}
}
return { status: 'success', message: 'ok' }
}
const { cloudRouter } = await import('../../main/events/rpc/routes/cloud')
const handler = cloudRouter.routes().get(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_START)
const res = await handler?.([], createInvokeEvent())
expect(showMessageBoxMock).toHaveBeenCalledTimes(1)
expect(showMessageBoxMock).toHaveBeenCalledWith({
title: 'PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_TITLE',
message: 'PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_BODY',
type: 'warning',
buttons: [
'PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CONFIRM',
'PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCEL'
]
})
expect(res?.success).toBe(true)
expect(res?.data.toastType).toBe(IPicGoCloudConfigSyncToastType.SUCCESS)
expect(res?.data.message).toBe('PICGO_CLOUD_CONFIG_SYNC_SUCCESS')
})
it('maps encryption-switch cancel to warning', async () => {
showMessageBoxMock.mockResolvedValue({ result: 1, checkboxChecked: false })
syncImplementation = async (askSwitch) => {
if (askSwitch) {
const confirmed = await askSwitch({ from: 'sse', to: 'e2ee' })
if (!confirmed) {
return { status: 'failed', message: i18nTranslateMock('CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED') }
}
}
return { status: 'success', message: 'ok' }
}
const { cloudRouter } = await import('../../main/events/rpc/routes/cloud')
const handler = cloudRouter.routes().get(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_START)
const res = await handler?.([], createInvokeEvent())
expect(showMessageBoxMock).toHaveBeenCalledTimes(1)
expect(res?.success).toBe(true)
expect(res?.data.toastType).toBe(IPicGoCloudConfigSyncToastType.WARNING)
expect(res?.data.message).toBe('PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED')
})
})
+309
View File
@@ -0,0 +1,309 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import fs from 'fs-extra'
import os from 'node:os'
import path from 'node:path'
type ServerConfig = {
port: number | string
host: string
enable: boolean
}
type HonoContextLike = {
req: {
raw: Request
formData: () => Promise<FormData>
}
json: (data: unknown, status?: number) => Response
}
type UploadHandler = (c: HonoContextLike) => Promise<Response>
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null
}
const createJsonContext = (req: Request): HonoContextLike => {
return {
req: {
raw: req,
formData: async () => new FormData()
},
json: (data: unknown, status: number = 200) => {
return new Response(JSON.stringify(data), {
status,
headers: {
'content-type': 'application/json'
}
})
}
}
}
const readJson = async (res: Response): Promise<any> => {
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return text
}
}
let serverConfig: ServerConfig | undefined
let registeredUploadHandler: UploadHandler | undefined
let formImageDir: string
const getConfigMock = vi.fn((key?: string) => {
if (key === 'settings.server') return serverConfig
return undefined
})
const saveConfigMock = vi.fn((patch: unknown) => {
if (!isRecord(patch)) return
const next = patch['settings.server']
if (isRecord(next) && 'port' in next && 'host' in next && 'enable' in next) {
serverConfig = next as unknown as ServerConfig
}
})
const registerPostMock = vi.fn((routePath: string, handler: unknown, isInternal?: boolean) => {
if (routePath === '/upload' && typeof handler === 'function') {
registeredUploadHandler = handler as unknown as UploadHandler
}
return { routePath, isInternal }
})
const listenMock = vi.fn()
const shutdownMock = vi.fn()
const loggerMock = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}
const getAvailableWindowMock = vi.fn()
const uploadClipboardFilesMock = vi.fn()
const uploadSelectedFilesMock = vi.fn()
const dbPathDirMock = vi.fn(() => path.join(os.tmpdir(), 'picgo-gui-store'))
const getFormImageFolderPathMock = vi.fn(() => formImageDir)
const cleanupFormUploaderFilesMock = vi.fn((fileInfoList?: unknown) => {
if (!Array.isArray(fileInfoList)) return
for (const item of fileInfoList) {
if (typeof item === 'string') {
try {
fs.removeSync(item)
} catch {
// ignore
}
}
}
})
vi.mock('@core/picgo', () => {
return {
default: {
getConfig: getConfigMock,
saveConfig: saveConfigMock,
server: {
registerPost: registerPostMock,
listen: listenMock,
shutdown: shutdownMock
}
}
}
})
vi.mock('@core/picgo/logger', () => {
return { default: loggerMock }
})
vi.mock('apis/app/window/windowManager', () => {
return {
default: {
getAvailableWindow: getAvailableWindowMock
}
}
})
vi.mock('apis/app/uploader/apis', () => {
return {
uploadClipboardFiles: uploadClipboardFilesMock,
uploadSelectedFiles: uploadSelectedFilesMock
}
})
vi.mock('apis/core/datastore/dbChecker', () => {
return {
dbPathDir: dbPathDirMock,
getFormImageFolderPath: getFormImageFolderPathMock
}
})
vi.mock('~/main/utils/cleanupFormUploaderFiles', () => {
return {
cleanupFormUploaderFiles: cleanupFormUploaderFilesMock
}
})
describe('main/server (GUI adapter to picgo-core)', () => {
beforeEach(async () => {
serverConfig = undefined
registeredUploadHandler = undefined
formImageDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picgo-gui-form-'))
vi.clearAllMocks()
vi.resetModules()
})
afterEach(async () => {
await fs.remove(formImageDir)
})
it('backfills default settings.server when missing', async () => {
serverConfig = undefined
await import('../../main/server')
expect(saveConfigMock).toHaveBeenCalledWith({
'settings.server': {
port: 36677,
host: '127.0.0.1',
enable: true
}
})
expect(serverConfig).toEqual({
port: 36677,
host: '127.0.0.1',
enable: true
})
})
it('does not listen when settings.server.enable is false', async () => {
serverConfig = { port: 36677, host: '127.0.0.1', enable: false }
const mod = await import('../../main/server')
const server = mod.default
server.startup()
expect(registerPostMock).not.toHaveBeenCalled()
expect(listenMock).not.toHaveBeenCalled()
})
it('registers internal /upload override and delegates listen/shutdown to picgo.server', async () => {
serverConfig = { port: '36677', host: '127.0.0.1', enable: true }
listenMock.mockResolvedValue(36677)
const mod = await import('../../main/server')
const server = mod.default
server.startup()
expect(registerPostMock).toHaveBeenCalledTimes(1)
expect(registerPostMock).toHaveBeenCalledWith('/upload', expect.any(Function), true)
expect(listenMock).toHaveBeenCalledWith(36677, '127.0.0.1')
server.shutdown()
expect(shutdownMock).toHaveBeenCalledTimes(1)
})
it('implements GUI-compatible /upload JSON semantics with core-style status codes', async () => {
serverConfig = { port: 36677, host: '127.0.0.1', enable: true }
uploadClipboardFilesMock.mockResolvedValue('https://a.example/clipboard.png')
uploadSelectedFilesMock.mockResolvedValue(['https://a.example/a.png'])
getAvailableWindowMock.mockReturnValue({ webContents: {} })
const mod = await import('../../main/server')
const server = mod.default
server.startup()
expect(registeredUploadHandler).toBeDefined()
const handler = registeredUploadHandler!
const resEmpty = await handler(createJsonContext(new Request('http://127.0.0.1/upload', { method: 'POST' })))
expect(resEmpty.status).toBe(200)
expect(await readJson(resEmpty)).toEqual({ success: true, result: ['https://a.example/clipboard.png'] })
const resObj = await handler(createJsonContext(new Request('http://127.0.0.1/upload', { method: 'POST', body: '{}' })))
expect(resObj.status).toBe(200)
expect(await readJson(resObj)).toEqual({ success: true, result: ['https://a.example/clipboard.png'] })
const resEmptyList = await handler(createJsonContext(new Request('http://127.0.0.1/upload', {
method: 'POST',
body: JSON.stringify({ list: [] })
})))
expect(resEmptyList.status).toBe(200)
expect(await readJson(resEmptyList)).toEqual({ success: true, result: ['https://a.example/clipboard.png'] })
const resList = await handler(createJsonContext(new Request('http://127.0.0.1/upload', {
method: 'POST',
body: JSON.stringify({ list: ['/a.png'] })
})))
expect(resList.status).toBe(200)
expect(await readJson(resList)).toEqual({ success: true, result: ['https://a.example/a.png'] })
const resInvalid = await handler(createJsonContext(new Request('http://127.0.0.1/upload', { method: 'POST', body: '{' })))
expect(resInvalid.status).toBe(400)
expect(await readJson(resInvalid)).toMatchObject({ success: false })
})
it('writes multipart files to fixed temp folder and always cleans up (success/failure)', async () => {
serverConfig = { port: 36677, host: '127.0.0.1', enable: true }
getAvailableWindowMock.mockReturnValue({ webContents: {} })
const mod = await import('../../main/server')
const server = mod.default
server.startup()
const handler = registeredUploadHandler!
const makeMultipartContext = async (): Promise<{ ctx: HonoContextLike; expectedPath: string }> => {
const fd = new FormData()
fd.append('files', new Blob([Buffer.from('hello')], { type: 'image/png' }), 'a.png')
const req = new Request('http://127.0.0.1/upload', {
method: 'POST',
headers: {
'content-type': 'multipart/form-data'
}
})
const expectedPath = path.join(formImageDir, 'a.png')
return {
ctx: {
req: {
raw: req,
formData: async () => fd
},
json: (data: unknown, status: number = 200) => new Response(JSON.stringify(data), { status })
},
expectedPath
}
}
// success case
uploadSelectedFilesMock.mockImplementation(async (_webContents: unknown, list: Array<{ path: string }>) => {
for (const item of list) {
expect(await fs.pathExists(item.path)).toBe(true)
}
return ['https://a.example/form.png']
})
const { ctx: ctxSuccess, expectedPath: pathSuccess } = await makeMultipartContext()
const resSuccess = await handler(ctxSuccess)
expect(resSuccess.status).toBe(200)
expect(await readJson(resSuccess)).toEqual({ success: true, result: ['https://a.example/form.png'] })
expect(await fs.pathExists(pathSuccess)).toBe(false)
// failure case still cleans up
uploadSelectedFilesMock.mockRejectedValueOnce(new Error('fail'))
const { ctx: ctxFail, expectedPath: pathFail } = await makeMultipartContext()
const resFail = await handler(ctxFail)
expect(resFail.status).toBe(500)
expect(await readJson(resFail)).toMatchObject({ success: false })
expect(await fs.pathExists(pathFail)).toBe(false)
})
})
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import type { IConfig } from 'picgo'
import { getConfig, getPicBeds } from '@/utils/dataSender'
import { store, storeKey, type IStore } from '@/store'
vi.mock('@/utils/dataSender', () => {
return {
getConfig: vi.fn(),
getPicBeds: vi.fn(),
saveConfig: vi.fn()
}
})
const buildStore = (): IStore => {
const app = createApp({})
store.install(app)
return app._context.provides[storeKey as symbol] as IStore
}
describe('renderer/store appConfig', () => {
const getConfigMock = vi.mocked(getConfig)
const getPicBedsMock = vi.mocked(getPicBeds)
beforeEach(() => {
vi.clearAllMocks()
})
it('refreshAppConfig updates appConfig and defaultPicBed', async () => {
const config: IConfig = {
picBed: {
uploader: 'github',
current: 'smms'
},
picgoPlugins: {}
}
getConfigMock.mockResolvedValue(config)
const storeInstance = buildStore()
await storeInstance.refreshAppConfig()
expect(storeInstance.state.appConfig).toStrictEqual(config)
expect(storeInstance.state.defaultPicBed).toBe('github')
})
it('refreshPicBeds updates picBeds', async () => {
const picBeds: IPicBedType[] = [
{ type: 'smms', name: 'SM.MS', visible: true },
{ type: 'github', name: 'GitHub', visible: true }
]
getPicBedsMock.mockResolvedValue(picBeds)
const storeInstance = buildStore()
await storeInstance.refreshPicBeds()
expect(storeInstance.state.picBeds).toEqual(picBeds)
})
})
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { saveConfig } from '@/utils/dataSender'
import { PICGO_SAVE_CONFIG } from '#/events/constants'
import { ipcRenderer } from 'electron'
vi.mock('electron', () => {
return {
ipcRenderer: {
invoke: vi.fn(),
on: vi.fn(),
once: vi.fn(),
send: vi.fn(),
removeListener: vi.fn()
}
}
})
describe('renderer/utils/dataSender', () => {
const ipcRendererMock = vi.mocked(ipcRenderer)
beforeEach(() => {
vi.clearAllMocks()
ipcRendererMock.invoke.mockResolvedValue(true)
})
it('invokes save config IPC after saveConfig', async () => {
await saveConfig('settings.language', 'en')
expect(ipcRendererMock.invoke).toHaveBeenCalledWith(PICGO_SAVE_CONFIG, {
'settings.language': 'en'
})
})
})
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import { extractHttpUrlsFromText, parseNewlineSeparatedUrls } from '../../../universal/utils/common'
describe('universal/utils/common', () => {
describe('parseNewlineSeparatedUrls', () => {
it('parses newline-separated urls, trims lines, ignores empty lines, and de-duplicates', () => {
const input = [
'https://a.example/1.png',
'',
' https://a.example/2.png ',
'https://a.example/1.png',
'not-a-url'
].join('\n')
const { urls, invalidLines } = parseNewlineSeparatedUrls(input)
expect(urls).toEqual([
'https://a.example/1.png',
'https://a.example/2.png'
])
expect(invalidLines).toEqual(['not-a-url'])
})
it('ignores uri-list comment lines when source is uri-list', () => {
const input = [
'# comment',
'https://a.example/1.png',
'# another comment',
'https://a.example/2.png'
].join('\n')
const { urls, invalidLines } = parseNewlineSeparatedUrls(input, { source: 'uri-list' })
expect(urls).toEqual([
'https://a.example/1.png',
'https://a.example/2.png'
])
expect(invalidLines).toEqual([])
})
it('supports NUL-separated drag payloads by normalizing to newlines', () => {
const input = `https://a.example/1.png\u0000https://a.example/2.png`
const { urls, invalidLines } = parseNewlineSeparatedUrls(input)
expect(urls).toEqual([
'https://a.example/1.png',
'https://a.example/2.png'
])
expect(invalidLines).toEqual([])
})
it('splits concatenated urls in a single line as a fallback', () => {
const input = 'https://a.example/1.pnghttps://b.example/2.webphttps://c.example/3.png'
const { urls, invalidLines } = parseNewlineSeparatedUrls(input)
expect(urls).toEqual([
'https://a.example/1.png',
'https://b.example/2.webp',
'https://c.example/3.png'
])
expect(invalidLines).toEqual([])
})
it('does not split embedded urls in query parameters', () => {
const input = 'https://example.com/?url=https://a.example/1.png'
const { urls, invalidLines } = parseNewlineSeparatedUrls(input)
expect(urls).toEqual(['https://example.com/?url=https://a.example/1.png'])
expect(invalidLines).toEqual([])
})
})
describe('extractHttpUrlsFromText', () => {
it('extracts urls from mixed text, strips trailing punctuation, and de-duplicates', () => {
const input = [
'hello',
'https://a.example/1.png)',
'https://b.example/2.webp]',
'https://a.example/1.png'
].join(' ')
expect(extractHttpUrlsFromText(input)).toEqual([
'https://a.example/1.png',
'https://b.example/2.webp'
])
})
it('returns empty array when no urls are present', () => {
expect(extractHttpUrlsFromText('no urls here')).toEqual([])
})
})
})
+1 -1
View File
@@ -116,7 +116,7 @@ class RemoteNoticeHandler {
body: action.data?.content || '',
clickToCopy: !!action.data?.copyToClipboard,
copyContent: action.data?.copyToClipboard || '',
clickFn () {
callback () {
if (action.data?.url) {
shell.openExternal(action.data.url)
}
@@ -4,7 +4,6 @@ import {
} from 'electron'
import logger from '@core/picgo/logger'
import GuiApi from '../../gui'
import db from '~/main/apis/core/datastore'
import { TOGGLE_SHORTKEY_MODIFIED_MODE } from '#/events/constants'
import shortKeyService from './shortKeyService'
import picgo from '@core/picgo'
@@ -25,7 +24,7 @@ class ShortKeyHandler {
}
private initBuiltInShortKey () {
const commands = db.get('settings.shortKey') as IShortKeyConfigs
const commands = picgo.getConfig<IShortKeyConfigs>('settings.shortKey') || {}
Object.keys(commands)
.filter(item => item.includes('picgo:'))
.forEach(command => {
@@ -57,8 +56,8 @@ class ShortKeyHandler {
const commands = plugin.commands(picgo) as IPluginShortKeyConfig[]
for (const cmd of commands) {
const command = `${item}:${cmd.name}`
if (db.has(`settings.shortKey[${command}]`)) {
const commandConfig = db.get(`settings.shortKey.${command}`) as IShortKeyConfig
const commandConfig = picgo.getConfig<IShortKeyConfig | undefined>(`settings.shortKey.${command}`)
if (commandConfig) {
// if disabled, don't register #534
if (commandConfig.enable) {
this.registerShortKey(commandConfig, command, cmd.handle, false)
@@ -168,8 +167,8 @@ class ShortKeyHandler {
const commands = plugin.commands(picgo) as IPluginShortKeyConfig[]
for (const cmd of commands) {
const command = `${pluginName}:${cmd.name}`
if (db.has(`settings.shortKey[${command}]`)) {
const commandConfig = db.get(`settings.shortKey[${command}]`) as IShortKeyConfig
const commandConfig = picgo.getConfig<IShortKeyConfig | undefined>(`settings.shortKey.${command}`)
if (commandConfig) {
this.registerShortKey(commandConfig, command, cmd.handle, false)
} else {
this.registerShortKey(cmd, command, cmd.handle, true)
@@ -179,7 +178,7 @@ class ShortKeyHandler {
}
unregisterPluginShortKey (pluginName: string) {
const commands = db.get('settings.shortKey') as IShortKeyConfigs
const commands = picgo.getConfig<IShortKeyConfigs>('settings.shortKey') || {}
const keyList = Object.keys(commands)
.filter(command => command.includes(pluginName))
.map(command => {
@@ -191,7 +190,7 @@ class ShortKeyHandler {
keyList.forEach(item => {
globalShortcut.unregister(item.key)
shortKeyService.unregisterCommand(item.command)
db.unset('settings.shortKey', item.command)
picgo.removeConfig('settings.shortKey', item.command)
})
}
}
+20 -21
View File
@@ -4,16 +4,16 @@ import {
Tray,
dialog,
clipboard,
Notification,
nativeTheme
} from 'electron'
import uploader from 'apis/app/uploader'
import db, { GalleryDB } from '~/main/apis/core/datastore'
import picgo from '@core/picgo'
import { GalleryDB } from '~/main/apis/core/datastore'
import windowManager from 'apis/app/window/windowManager'
import { IWindowList } from '#/types/enum'
import { IPasteStyle, IWindowList } from '#/types/enum'
import pasteTemplate from '~/main/utils/pasteTemplate'
import pkg from 'root/package.json'
import { ensureFilePath, getClipboardFilePathList, handleCopyUrl } from '~/main/utils/common'
import { ensureFilePath, getClipboardFilePathList, handleCopyUrl, showNotification } from '~/main/utils/common'
import { privacyManager } from '~/main/utils/privacyManager'
import { T } from '~/main/i18n'
import { isMacOSVersionGreaterThanOrEqualTo } from '~/main/utils/getMacOSVersion'
@@ -59,14 +59,14 @@ export function createContextMenu () {
{
label: T('OPEN_UPDATE_HELPER'),
type: 'checkbox',
checked: db.get('settings.showUpdateTip'),
checked: picgo.getConfig<boolean>('settings.showUpdateTip') !== false,
click () {
const value = db.get('settings.showUpdateTip')
db.set('settings.showUpdateTip', !value)
const value = picgo.getConfig<boolean>('settings.showUpdateTip') !== false
picgo.saveConfig({ 'settings.showUpdateTip': !value })
}
},
{
label: T('PRIVACY_AGREEMENT'),
label: T('PRIVACY_TERMS_AGREEMENT'),
click () {
privacyManager.show(false)
}
@@ -108,10 +108,10 @@ export function createContextMenu () {
{
label: T('OPEN_UPDATE_HELPER'),
type: 'checkbox',
checked: db.get('settings.showUpdateTip'),
checked: picgo.getConfig<boolean>('settings.showUpdateTip') !== false,
click () {
const value = db.get('settings.showUpdateTip')
db.set('settings.showUpdateTip', !value)
const value = picgo.getConfig<boolean>('settings.showUpdateTip') !== false
picgo.saveConfig({ 'settings.showUpdateTip': !value })
}
},
{
@@ -217,7 +217,7 @@ export function createTray () {
// drop-files only be supported in macOS
// so the tray window must be available
tray.on('drop-files', async (event, files: string[]) => {
const pasteStyle = db.get('settings.pasteStyle') || 'markdown'
const pasteStyle = picgo.getConfig<IPasteStyle>('settings.pasteStyle') || 'markdown'
const trayWindow = windowManager.get(IWindowList.TRAY_WINDOW)!
const imgs = await uploader
.setWebContents(trayWindow.webContents)
@@ -226,15 +226,14 @@ export function createTray () {
const pasteText: string[] = []
for (let i = 0; i < imgs.length; i++) {
pasteText.push(
pasteTemplate(pasteStyle, imgs[i], db.get('settings.customLink'))
pasteTemplate(pasteStyle, imgs[i], picgo.getConfig<string>('settings.customLink'))
)
const notification = new Notification({
title: T('UPLOAD_SUCCEED'),
body: imgs[i].imgUrl!
// icon: files[i]
})
setTimeout(() => {
notification.show()
showNotification({
title: T('UPLOAD_SUCCEED'),
body: imgs[i].imgUrl!
// icon: files[i]
})
}, i * 100)
await GalleryDB.getInstance().insert(imgs[i])
}
@@ -273,7 +272,7 @@ export function handleMenubarIcon (visible?: boolean) {
}
return
}
const shouldShow = visible !== undefined ? visible : (db.get('settings.showMenubarIcon') !== false)
const shouldShow = visible !== undefined ? visible : (picgo.getConfig<boolean>('settings.showMenubarIcon') !== false)
if (shouldShow) {
if (!tray) {
createTray()
@@ -285,7 +284,7 @@ export function handleMenubarIcon (visible?: boolean) {
export function handleDockIcon () {
if (isMacOS) {
if (db.get('settings.showDockIcon') !== false) {
if (picgo.getConfig<boolean>('settings.showDockIcon') !== false) {
app.dock?.show()
app.dock?.setMenu(createContextMenu())
} else {
+20 -23
View File
@@ -1,19 +1,19 @@
import {
Notification,
WebContents
} from 'electron'
import windowManager from 'apis/app/window/windowManager'
import { IRPCActionType, IWindowList } from '#/types/enum'
import { IPasteStyle, IRPCActionType, IWindowList } from '#/types/enum'
import uploader from '.'
import pasteTemplate from '~/main/utils/pasteTemplate'
import db, { GalleryDB } from '~/main/apis/core/datastore'
import { handleCopyUrl, handleUrlEncodeWithSetting } from '~/main/utils/common'
import { GalleryDB } from '~/main/apis/core/datastore'
import { handleCopyUrl, handleUrlEncodeWithSetting, showNotification } from '~/main/utils/common'
import { T } from '~/main/i18n/index'
import logger from '@core/picgo/logger'
import picgo from '@core/picgo'
// import dayjs from 'dayjs'
const handleClipboardUploading = async (): Promise<false | ImgInfo[]> => {
const useBuiltinClipboard = !!db.get('settings.useBuiltinClipboard')
const useBuiltinClipboard = !!picgo.getConfig<boolean>('settings.useBuiltinClipboard')
const win = windowManager.getAvailableWindow()
if (useBuiltinClipboard) {
return await uploader.setWebContents(win!.webContents).uploadWithBuildInClipboard()
@@ -27,15 +27,14 @@ export const uploadClipboardFiles = async (): Promise<string> => {
if (img !== false) {
if (img.length > 0) {
const trayWindow = windowManager.get(IWindowList.TRAY_WINDOW)
const pasteStyle = db.get('settings.pasteStyle') || 'markdown'
handleCopyUrl(pasteTemplate(pasteStyle, img[0], db.get('settings.customLink')))
const notification = new Notification({
title: T('UPLOAD_SUCCEED'),
body: img[0].imgUrl!
// icon: img[0].imgUrl
})
const pasteStyle = picgo.getConfig<IPasteStyle>('settings.pasteStyle') || 'markdown'
handleCopyUrl(pasteTemplate(pasteStyle, img[0], picgo.getConfig<string>('settings.customLink')))
setTimeout(() => {
notification.show()
showNotification({
title: T('UPLOAD_SUCCEED'),
body: img[0].imgUrl!
// icon: img[0].imgUrl
})
}, 100)
await GalleryDB.getInstance().insert(img[0])
// trayWindow just be created in mac/windows, not in linux
@@ -46,11 +45,10 @@ export const uploadClipboardFiles = async (): Promise<string> => {
}
return handleUrlEncodeWithSetting(img[0].imgUrl as string)
} else {
const notification = new Notification({
showNotification({
title: T('UPLOAD_FAILED'),
body: T('TIPS_UPLOAD_NOT_PICTURES')
})
notification.show()
return ''
}
} else {
@@ -63,17 +61,16 @@ export const uploadSelectedFiles = async (webContents: WebContents, files: IFile
const imgs = await uploader.setWebContents(webContents).upload(input)
const result = []
if (imgs !== false) {
const pasteStyle = db.get('settings.pasteStyle') || 'markdown'
const pasteStyle = picgo.getConfig<IPasteStyle>('settings.pasteStyle') || 'markdown'
const pasteText: string[] = []
for (let i = 0; i < imgs.length; i++) {
pasteText.push(pasteTemplate(pasteStyle, imgs[i], db.get('settings.customLink')))
const notification = new Notification({
title: T('UPLOAD_SUCCEED'),
body: imgs[i].imgUrl!
// icon: files[i].path
})
pasteText.push(pasteTemplate(pasteStyle, imgs[i], picgo.getConfig<string>('settings.customLink')))
setTimeout(() => {
notification.show()
showNotification({
title: T('UPLOAD_SUCCEED'),
body: imgs[i].imgUrl!
// icon: files[i].path
})
}, i * 100)
await GalleryDB.getInstance().insert(imgs[i])
result.push(handleUrlEncodeWithSetting(imgs[i].imgUrl!))
+8 -13
View File
@@ -1,5 +1,4 @@
import {
Notification,
BrowserWindow,
ipcMain,
WebContents,
@@ -7,7 +6,6 @@ import {
} from 'electron'
import dayjs from 'dayjs'
import picgo from '@core/picgo'
import db from '~/main/apis/core/datastore'
import windowManager from 'apis/app/window/windowManager'
import { IWindowList } from '#/types/enum'
import util from 'util'
@@ -21,7 +19,6 @@ import path from 'path'
import { privacyManager } from '~/main/utils/privacyManager'
import writeFile from 'write-file-atomic'
import { CLIPBOARD_IMAGE_FOLDER } from '~/universal/utils/static'
import { cleanupFormUploaderFiles } from '~/main/utils/cleanupFormUploaderFiles'
import { IpcMainEvent } from 'electron/main'
import { dataReportManager } from '~/main/utils/dataReport'
@@ -48,27 +45,26 @@ class Uploader {
}
init () {
picgo.on('notification', (message: Electron.NotificationConstructorOptions | undefined) => {
const notification = new Notification(message)
notification.show()
picgo.on('notification', (message: IShowNotificationOption | undefined) => {
if (!message) return
showNotification(message)
})
picgo.on('uploadProgress', (progress: any) => {
this.webContents?.send('uploadProgress', progress)
})
picgo.on('beforeTransform', () => {
if (db.get('settings.uploadNotification')) {
const notification = new Notification({
if (picgo.getConfig<boolean>('settings.uploadNotification')) {
showNotification({
title: T('UPLOAD_PROGRESS'),
body: T('UPLOADING')
})
notification.show()
}
})
picgo.helper.beforeUploadPlugins.register('renameFn', {
handle: async (ctx: IPicGo) => {
const rename = db.get('settings.rename')
const autoRename = db.get('settings.autoRename')
const rename = picgo.getConfig<boolean>('settings.rename')
const autoRename = picgo.getConfig<boolean>('settings.autoRename')
if (autoRename || rename) {
await Promise.all(ctx.output.map(async (item, index) => {
let name: undefined | string | null
@@ -146,7 +142,7 @@ class Uploader {
fromClipboard: !img,
duration: Date.now() - startTime,
outputList: output
});
})
}
return output.filter(item => item.imgUrl)
} else {
@@ -164,7 +160,6 @@ class Uploader {
return false
} finally {
ipcMain.removeAllListeners(GET_RENAME_FILE_NAME)
cleanupFormUploaderFiles(img)
}
}
}
+5 -5
View File
@@ -9,12 +9,12 @@ import {
import { IStartupMode, IWindowList } from '#/types/enum'
import bus from '@core/bus'
import { CREATE_APP_MENU } from '@core/bus/constants'
import db from '~/main/apis/core/datastore'
import { TOGGLE_SHORTKEY_MODIFIED_MODE } from '#/events/constants'
import { app } from 'electron'
import { T } from '~/main/i18n'
import { isLinux } from '~/universal/utils/common'
import { isLinux, isWindows } from '~/universal/utils/common'
import { getStaticPath } from '#/utils/staticPath'
import picgo from '@core/picgo'
// import { URLSearchParams } from 'url'
const windowList = new Map<IWindowList, IWindowListItem>()
@@ -37,7 +37,7 @@ const handleWindowParams = (windowURL: string) => {
}
export const isWindowShouldShowOnStartup = (currentWindow: IWindowList) => {
const startupMode = db.get('settings.startupMode') || (isLinux ? IStartupMode.SHOW_MINI_WINDOW : IStartupMode.HIDE)
const startupMode = picgo.getConfig<IStartupMode | undefined>('settings.startupMode') || (isLinux ? IStartupMode.SHOW_MINI_WINDOW : isWindows ? IStartupMode.SHOW_MAIN_WINDOW : IStartupMode.HIDE)
switch (currentWindow) {
case IWindowList.MINI_WINDOW: {
return startupMode === IStartupMode.SHOW_MINI_WINDOW
@@ -82,7 +82,7 @@ windowList.set(IWindowList.SETTING_WINDOW, {
isValid: true,
multiple: false,
options () {
const showDockIcon = db.get('settings.showDockIcon') !== false
const showDockIcon = picgo.getConfig<boolean>('settings.showDockIcon') !== false
const options: IBrowserWindowOptions = {
height: 450,
width: 800,
@@ -142,7 +142,7 @@ windowList.set(IWindowList.MINI_WINDOW, {
}
}
if (db.get('settings.miniWindowOnTop')) {
if (picgo.getConfig<boolean>('settings.miniWindowOnTop')) {
obj.alwaysOnTop = true
}
return obj
+6 -77
View File
@@ -1,85 +1,14 @@
import path from 'path'
import fs from 'fs-extra'
import { dbPathChecker, dbPathDir, getGalleryDBPath } from './dbChecker'
import { DBStore, JSONStore } from '@picgo/store'
import { T } from '~/main/i18n'
import { DBStore } from '@picgo/store'
import { getGalleryDBPath } from './dbChecker'
const STORE_PATH = dbPathDir()
const DB_PATH: string = getGalleryDBPath().dbPath
fs.ensureDirSync(path.dirname(DB_PATH))
if (!fs.pathExistsSync(STORE_PATH)) {
fs.mkdirpSync(STORE_PATH)
}
const CONFIG_PATH: string = dbPathChecker()
export const DB_PATH: string = getGalleryDBPath().dbPath
class ConfigStore {
private db: JSONStore
constructor () {
this.db = new JSONStore(CONFIG_PATH)
if (!this.db.has('picBed')) {
this.db.set('picBed', {
current: 'smms', // deprecated
uploader: 'smms',
smms: {
token: ''
}
})
}
if (!this.db.has('settings.shortKey')) {
this.db.set('settings.shortKey[picgo:upload]', {
enable: true,
key: 'CommandOrControl+Shift+P',
name: 'upload',
label: T('QUICK_UPLOAD')
})
}
this.read()
}
flush () {
this.db = new JSONStore(CONFIG_PATH)
}
read () {
this.db.read()
return this.db
}
get (key = ''): any {
if (key === '') {
return this.db.read()
}
return this.db.get(key)
}
set (key: string, value: any): void {
return this.db.set(key, value)
}
has (key: string) {
return this.db.has(key)
}
unset (key: string, value: any): boolean {
return this.db.unset(key, value)
}
getConfigPath () {
return CONFIG_PATH
}
}
const db = new ConfigStore()
export default db
// v2.3.0 add gallery db
class GalleryDB {
private static instance: DBStore
private constructor () {
console.log('init gallery db')
}
private constructor () {}
public static getInstance (): DBStore {
if (!GalleryDB.instance) {
+15 -12
View File
@@ -1,8 +1,7 @@
import { dbChecker, dbPathChecker } from 'apis/core/datastore/dbChecker'
import { shell } from 'electron'
import pkg from 'root/package.json'
import { PicGo } from 'picgo'
import db from 'apis/core/datastore'
import debounce from 'lodash/debounce'
const CONFIG_PATH = dbPathChecker()
@@ -17,18 +16,22 @@ picgo.saveConfig({
global.PICGO_GUI_VERSION = pkg.version
picgo.GUI_VERSION = global.PICGO_GUI_VERSION
const originPicGoSaveConfig = picgo.saveConfig.bind(picgo)
function flushDB () {
db.flush()
picgo.openUrl = (url: string) => {
return shell.openExternal(url)
}
const debounced = debounce(flushDB, 1000)
// const originPicGoSaveConfig = picgo.saveConfig.bind(picgo)
picgo.saveConfig = (config: IStringKeyMap) => {
originPicGoSaveConfig(config)
// flush electron's db
debounced()
}
// function flushDB () {
// db.flush()
// }
// const debounced = debounce(flushDB, 1000)
// picgo.saveConfig = (config: IStringKeyMap) => {
// originPicGoSaveConfig(config)
// // flush electron's db
// debounced()
// }
export default picgo
+12 -17
View File
@@ -1,14 +1,14 @@
import {
dialog,
BrowserWindow,
Notification,
ipcMain
} from 'electron'
import db, { GalleryDB } from 'apis/core/datastore'
import picgo from '@core/picgo'
import { GalleryDB } from 'apis/core/datastore'
import { dbPathChecker, defaultConfigPath, getGalleryDBPath } from 'apis/core/datastore/dbChecker'
import uploader from 'apis/app/uploader'
import pasteTemplate from '~/main/utils/pasteTemplate'
import { handleCopyUrl } from '~/main/utils/common'
import { handleCopyUrl, showNotification as showMainNotification } from '~/main/utils/common'
import {
getWindowId,
getSettingWindowId
@@ -18,7 +18,7 @@ import {
} from '~/universal/events/constants'
import { DBStore } from '@picgo/store'
import { T } from '~/main/i18n'
import { IRPCActionType } from '~/universal/types/enum'
import { IPasteStyle, IRPCActionType } from '~/universal/types/enum'
class GuiApi implements IGuiApi {
private static instance: GuiApi
@@ -77,17 +77,16 @@ class GuiApi implements IGuiApi {
const webContents = this.getWebContentsByWindowId(this.windowId)
const imgs = await uploader.setWebContents(webContents!).upload(input)
if (imgs !== false) {
const pasteStyle = db.get('settings.pasteStyle') || 'markdown'
const pasteStyle = picgo.getConfig<IPasteStyle>('settings.pasteStyle') || 'markdown'
const pasteText: string[] = []
for (let i = 0; i < imgs.length; i++) {
pasteText.push(pasteTemplate(pasteStyle, imgs[i], db.get('settings.customLink')))
const notification = new Notification({
title: T('UPLOAD_SUCCEED'),
body: imgs[i].imgUrl as string
// icon: imgs[i].imgUrl
})
pasteText.push(pasteTemplate(pasteStyle, imgs[i], picgo.getConfig<string>('settings.customLink')))
setTimeout(() => {
notification.show()
showMainNotification({
title: T('UPLOAD_SUCCEED'),
body: imgs[i].imgUrl as string
// icon: imgs[i].imgUrl
})
}, i * 100)
await GalleryDB.getInstance().insert(imgs[i])
}
@@ -103,11 +102,7 @@ class GuiApi implements IGuiApi {
title: '',
body: ''
}) {
const notification = new Notification({
title: options.title,
body: options.body
})
notification.show()
showMainNotification(options)
}
showMessageBox (options: IShowMessageBoxOption = {
+23 -20
View File
@@ -2,15 +2,16 @@ import {
app,
ipcMain,
shell,
Notification,
IpcMainEvent,
BrowserWindow
} from 'electron'
import windowManager from 'apis/app/window/windowManager'
import { IRPCActionType, IWindowList } from '#/types/enum'
import { IPasteStyle, IRPCActionType, IWindowList } from '#/types/enum'
import uploader from 'apis/app/uploader'
import pasteTemplate from '~/main/utils/pasteTemplate'
import db, { GalleryDB } from '~/main/apis/core/datastore'
import picgo from '@core/picgo'
import logger from '@core/picgo/logger'
import { GalleryDB } from '~/main/apis/core/datastore'
import server from '~/main/server'
import getPicBeds from '~/main/utils/getPicBeds'
import shortKeyHandler from 'apis/app/shortKey/shortKeyHandler'
@@ -27,14 +28,15 @@ import {
OPEN_URL,
SHOW_PLUGIN_PAGE_MENU,
SET_MINI_WINDOW_POS,
GET_PICBEDS
GET_PICBEDS,
LOG_INVALID_URL_LINES
} from '#/events/constants'
import {
uploadClipboardFiles,
uploadSelectedFiles
} from '~/main/apis/app/uploader/apis'
import picgoCoreIPC from './picgoCoreIPC'
import { handleCopyUrl } from '~/main/utils/common'
import { handleCopyUrl, showNotification } from '~/main/utils/common'
import { buildMainPageMenu, buildMiniPageMenu, buildPluginPageMenu, buildPicBedListMenu } from './remotes/menu'
import path from 'path'
import { T } from '~/main/i18n'
@@ -49,15 +51,14 @@ export default {
// macOS use builtin clipboard is OK
const img = await uploader.setWebContents(trayWindow.webContents).uploadWithBuildInClipboard()
if (img !== false) {
const pasteStyle = db.get('settings.pasteStyle') || 'markdown'
handleCopyUrl(pasteTemplate(pasteStyle, img[0], db.get('settings.customLink')))
const notification = new Notification({
const pasteStyle = picgo.getConfig<IPasteStyle>('settings.pasteStyle') || 'markdown'
handleCopyUrl(pasteTemplate(pasteStyle, img[0], picgo.getConfig<string>('settings.customLink')))
showNotification({
title: T('UPLOAD_SUCCEED'),
body: img[0].imgUrl!
// icon: file[0]
// icon: img[0].imgUrl
})
notification.show()
await GalleryDB.getInstance().insert(img[0])
trayWindow.webContents.send('clipboardFiles', [])
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
@@ -80,43 +81,38 @@ export default {
const result = shortKeyHandler.updateShortKey(item, oldKey, from)
evt.sender.send('updateShortKeyResponse', result)
if (result) {
const notification = new Notification({
showNotification({
title: T('OPERATION_SUCCEED'),
body: T('TIPS_SHORTCUT_MODIFIED_SUCCEED')
})
notification.show()
} else {
const notification = new Notification({
showNotification({
title: T('OPERATION_FAILED'),
body: T('TIPS_SHORTCUT_MODIFIED_CONFLICT')
})
notification.show()
}
})
ipcMain.on('bindOrUnbindShortKey', (evt: IpcMainEvent, item: IShortKeyConfig, from: string) => {
const result = shortKeyHandler.bindOrUnbindShortKey(item, from)
if (result) {
const notification = new Notification({
showNotification({
title: T('OPERATION_SUCCEED'),
body: T('TIPS_SHORTCUT_MODIFIED_SUCCEED')
})
notification.show()
} else {
const notification = new Notification({
showNotification({
title: T('OPERATION_FAILED'),
body: T('TIPS_SHORTCUT_MODIFIED_CONFLICT')
})
notification.show()
}
})
ipcMain.on('updateCustomLink', () => {
const notification = new Notification({
showNotification({
title: T('OPERATION_SUCCEED'),
body: T('TIPS_CUSTOM_LINK_STYLE_MODIFIED_SUCCEED')
})
notification.show()
})
ipcMain.on('autoStart', (evt: IpcMainEvent, val: boolean) => {
@@ -136,7 +132,7 @@ export default {
const miniWindow = windowManager.get(IWindowList.MINI_WINDOW)!
const settingWindow = windowManager.get(IWindowList.SETTING_WINDOW)!
if (db.get('settings.miniWindowOnTop')) {
if (picgo.getConfig<boolean>('settings.miniWindowOnTop')) {
miniWindow.setAlwaysOnTop(true)
}
@@ -222,6 +218,13 @@ export default {
const window = BrowserWindow.getFocusedWindow()
window?.setBounds(pos)
})
ipcMain.on(LOG_INVALID_URL_LINES, (_evt: IpcMainEvent, lines: string[]) => {
if (!Array.isArray(lines) || !lines.length) return
lines.forEach((line, index) => {
logger.warn(`[Batch URL Upload] invalid url line #${index + 1}: ${line}`)
})
})
},
dispose () {}
}
+4 -1
View File
@@ -38,6 +38,7 @@ import { GalleryDB } from 'apis/core/datastore'
import { IObject, IFilter } from '@picgo/store/dist/types'
import pasteTemplate from '../utils/pasteTemplate'
import { i18nManager, T } from '~/main/i18n'
import { notifyAppConfigUpdated } from '~/main/utils/appConfigNotifier'
import { rpcServer } from './rpc'
const STORE_PATH = path.dirname(dbPathChecker())
@@ -257,8 +258,10 @@ const handleRemoveFiles = () => {
}
const handlePicGoSaveConfig = () => {
ipcMain.on(PICGO_SAVE_CONFIG, (event: IpcMainEvent, data: IObj) => {
ipcMain.handle(PICGO_SAVE_CONFIG, (_event, data: IObj) => {
picgo.saveConfig(data)
notifyAppConfigUpdated()
return true
})
}
+2 -2
View File
@@ -49,7 +49,7 @@ const buildMiniPageMenu = () => {
}
},
{
label: T('PRIVACY_AGREEMENT'),
label: T('PRIVACY_TERMS_AGREEMENT'),
click () {
privacyManager.show(false)
}
@@ -110,7 +110,7 @@ const buildMainPageMenu = (win: BrowserWindow) => {
}
},
{
label: T('PRIVACY_AGREEMENT'),
label: T('PRIVACY_TERMS_AGREEMENT'),
click () {
privacyManager.show(false)
}
+5 -2
View File
@@ -4,7 +4,6 @@ import { Menu } from 'electron'
import getPicBeds from '~/main/utils/getPicBeds'
import picgo from '@core/picgo'
import { T } from '~/main/i18n'
import { changeCurrentUploader } from '~/main/utils/handleUploaderConfig'
export const buildPicBedListMenu = () => {
const picBeds = getPicBeds()
@@ -34,7 +33,11 @@ export const buildPicBedListMenu = () => {
type: 'checkbox',
checked: config._id === defaultId && (item.type === currentPicBed),
click: function () {
changeCurrentUploader(item.type, config, config._id)
try {
picgo.uploaderConfig.use(item.type, config._configName)
} catch (e) {
picgo.log.error(e instanceof Error ? e : new Error(String(e)))
}
if (windowManager.has(IWindowList.SETTING_WINDOW)) {
windowManager.get(IWindowList.SETTING_WINDOW)!.webContents.send('syncPicBed')
}
+22 -3
View File
@@ -1,4 +1,4 @@
import { ipcMain, IpcMainEvent } from 'electron'
import { ipcMain, IpcMainEvent, IpcMainInvokeEvent } from 'electron'
import { IRPCActionType } from '~/universal/types/enum'
import { RPC_ACTIONS } from '#/events/constants'
import { configRouter } from './routes/config'
@@ -6,11 +6,13 @@ import { versionRouter } from './routes/version'
import { toolboxRouter } from './routes/toolbox'
import { systemRouter } from './routes/system'
import { galleryToolboxRouter } from './routes/galleryToolbox'
import { cloudRouter } from './routes/cloud'
import { fail, isIRPCResult, ok } from './utils'
class RPCServer implements IRPCServer {
private routes: IRPCRoutes = new Map()
private rpcEventHandler = async (event: IpcMainEvent, action: IRPCActionType, args: any[], callbackId: string) => {
private rpcEventHandler = async (event: IpcMainEvent, action: IRPCActionType, args: any[], callbackId?: string) => {
try {
const handler = this.routes.get(action)
if (!handler) {
@@ -23,11 +25,25 @@ class RPCServer implements IRPCServer {
}
}
private rpcInvokeHandler = async (_event: IpcMainInvokeEvent, action: IRPCActionType, args: any[] = []) => {
const handler = this.routes.get(action)
if (!handler) {
return fail(new Error(`RPC action not supported: ${action}`))
}
try {
const res = await handler(args, _event)
// For invoke-based RPC, normalize the return value to IRPCResult.
return isIRPCResult(res) ? res : ok(res)
} catch (e) {
return fail(e)
}
}
/**
* if sendback data is null, then it means that the action is not supported or error occurs
* if there is no callbackId, then do not send back
*/
private sendBack (event: IpcMainEvent, action: IRPCActionType, data: any, callbackId: string) {
private sendBack (event: IpcMainEvent, action: IRPCActionType, data: any, callbackId?: string) {
if (callbackId) {
event.sender.send(RPC_ACTIONS, data, action, callbackId)
}
@@ -35,6 +51,7 @@ class RPCServer implements IRPCServer {
start () {
ipcMain.on(RPC_ACTIONS, this.rpcEventHandler)
ipcMain.handle(RPC_ACTIONS, this.rpcInvokeHandler)
}
use (routes: IRPCRoutes) {
@@ -45,6 +62,7 @@ class RPCServer implements IRPCServer {
stop () {
ipcMain.off(RPC_ACTIONS, this.rpcEventHandler)
ipcMain.removeHandler(RPC_ACTIONS)
}
}
@@ -55,6 +73,7 @@ rpcServer.use(versionRouter.routes())
rpcServer.use(toolboxRouter.routes())
rpcServer.use(systemRouter.routes())
rpcServer.use(galleryToolboxRouter.routes())
rpcServer.use(cloudRouter.routes())
export {
rpcServer
+559
View File
@@ -0,0 +1,559 @@
import { IRPCActionType } from '~/universal/types/enum'
import { RPCRouter } from '../router'
import picgo from '@core/picgo'
import type { IPicGoCloudUserInfo } from '#/types/cloud'
import { T } from '~/main/i18n'
import { fail, ok } from '../utils'
import GuiApi from 'apis/gui'
import fs from 'fs-extra'
import { parse } from 'comment-json'
import { cloneDeep, isPlainObject, set, unset } from 'lodash'
import path from 'path'
import logger from 'apis/core/picgo/logger'
import {
ConfigSyncManager,
ConflictType,
E2EAskPinReason,
EncryptionMethod,
SyncStatus,
type IDiffNode,
type IConfig
} from 'picgo'
import {
IPicGoCloudConfigSyncConflictChoice,
IPicGoCloudConfigSyncRunStatus,
IPicGoCloudConfigSyncSessionStatus,
IPicGoCloudConfigSyncToastType,
IPicGoCloudEncryptionMethod,
type IPicGoCloudConfigSyncConflictItem,
type IPicGoCloudConfigSyncResolution,
type IPicGoCloudConfigSyncRunResult,
type IPicGoCloudConfigSyncState
} from '#/types/cloudConfigSync'
const cloudRouter = new RPCRouter()
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000
const USER_ABORTED_CODE = 'PICGO_CLOUD_CONFIG_SYNC_ABORTED'
/**
* Config sync session state MUST live in the main process (memory only) so the UI can re-hydrate
* after window hide/show without losing an in-progress/conflict state.
*/
let configSyncSessionStatus: IPicGoCloudConfigSyncSessionStatus = IPicGoCloudConfigSyncSessionStatus.IDLE
let configSyncConflictDiffTree: IDiffNode | null = null
let configSyncConflictItems: IPicGoCloudConfigSyncConflictItem[] = []
let configSyncManager: ConfigSyncManager | null = null
const clearConfigSyncSession = (): void => {
configSyncSessionStatus = IPicGoCloudConfigSyncSessionStatus.IDLE
configSyncConflictDiffTree = null
configSyncConflictItems = []
}
const logConfigSyncOutcome = (
stage: 'sync' | 'applyResolvedConfig',
status: SyncStatus,
message?: string,
meta: { conflictCount?: number } = {}
): void => {
const prefix = `[PicGo Cloud][config-sync][${stage}]`
if (status === SyncStatus.SUCCESS) {
logger.info(`${prefix} success`)
return
}
if (status === SyncStatus.CONFLICT) {
const count = typeof meta.conflictCount === 'number' ? meta.conflictCount : 0
logger.warn(`${prefix} conflict`, `count=${count}`)
return
}
if (message === USER_ABORTED_CODE || message === 'Invalid PIN input') {
logger.warn(`${prefix} aborted`)
return
}
logger.error(`${prefix} failed`, message || '')
}
const getLocalEncryptionMethod = (): IPicGoCloudEncryptionMethod | undefined => {
const value = picgo.getConfig<unknown>('settings.picgoCloud.encryptionMethod')
if (
value === IPicGoCloudEncryptionMethod.AUTO
|| value === IPicGoCloudEncryptionMethod.SSE
|| value === IPicGoCloudEncryptionMethod.E2EE
) {
return value
}
return undefined
}
const toSyncEncryptionMethod = (method?: IPicGoCloudEncryptionMethod): EncryptionMethod | undefined => {
if (method === IPicGoCloudEncryptionMethod.AUTO) return EncryptionMethod.AUTO
if (method === IPicGoCloudEncryptionMethod.SSE) return EncryptionMethod.SSE
if (method === IPicGoCloudEncryptionMethod.E2EE) return EncryptionMethod.E2EE
return undefined
}
const getSnapshotUpdatedAt = async (): Promise<string | undefined> => {
try {
const snapshotPath = path.join(picgo.baseDir, 'config.snapshot.json')
if (!(await fs.pathExists(snapshotPath))) return undefined
const content = await fs.readFile(snapshotPath, 'utf8')
const parsed: unknown = parse(content)
if (!isPlainObject(parsed)) return undefined
const updatedAt = (parsed as { updatedAt?: unknown }).updatedAt
return typeof updatedAt === 'string' && updatedAt ? updatedAt : undefined
} catch {
return undefined
}
}
const buildConfigSyncState = async (): Promise<IPicGoCloudConfigSyncState> => {
return {
sessionStatus: configSyncSessionStatus,
encryptionMethod: getLocalEncryptionMethod(),
lastSyncedAt: await getSnapshotUpdatedAt(),
conflicts: configSyncSessionStatus === IPicGoCloudConfigSyncSessionStatus.CONFLICT ? configSyncConflictItems : undefined
}
}
const readLocalConfigWithComments = async (): Promise<IConfig> => {
if (!(await fs.pathExists(picgo.configPath))) {
return picgo.getConfig<IConfig>()
}
const content = await fs.readFile(picgo.configPath, 'utf8')
const parsed: unknown = parse(content)
if (!isPlainObject(parsed)) {
throw new Error(T('PICGO_CLOUD_CONFIG_SYNC_LOCAL_CONFIG_INVALID'))
}
return parsed as IConfig
}
const extractConflictItems = (diffTree: IDiffNode): IPicGoCloudConfigSyncConflictItem[] => {
const items: IPicGoCloudConfigSyncConflictItem[] = []
const walk = (node: IDiffNode, pathSegments: string[]) => {
const nextSegments = node.key === 'root' ? pathSegments : [...pathSegments, node.key]
if (node.status === ConflictType.CONFLICT) {
// If the conflict is an object-level aggregation, surface leaf conflicts instead.
if (node.children && node.children.length > 0) {
node.children.forEach(child => walk(child, nextSegments))
return
}
items.push({
path: nextSegments.join('.'),
localValue: node.localValue,
remoteValue: node.remoteValue
})
return
}
if (node.children && node.children.length > 0) {
node.children.forEach(child => walk(child, nextSegments))
}
}
walk(diffTree, [])
return items
}
const localizeConfigSyncResult = (status: SyncStatus, message: string | undefined): { message: string, toastType: IPicGoCloudConfigSyncToastType } => {
if (status === SyncStatus.SUCCESS) {
return {
message: T('PICGO_CLOUD_CONFIG_SYNC_SUCCESS'),
toastType: IPicGoCloudConfigSyncToastType.SUCCESS
}
}
if (status === SyncStatus.CONFLICT) {
return {
message: T('PICGO_CLOUD_CONFIG_SYNC_CONFLICT_DETECTED'),
toastType: IPicGoCloudConfigSyncToastType.INFO
}
}
const raw = message || T('PICGO_CLOUD_CONFIG_SYNC_FAILED')
const isEncryptionSwitchCancelled = message === picgo.i18n.translate('CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED')
if (raw === USER_ABORTED_CODE || raw === 'Invalid PIN input' || isEncryptionSwitchCancelled) {
return {
message: isEncryptionSwitchCancelled
? T('PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCELLED')
: T('PICGO_CLOUD_CONFIG_SYNC_ABORTED'),
toastType: IPicGoCloudConfigSyncToastType.WARNING
}
}
if (raw === 'Maximum retry attempts exceeded') {
return {
message: T('PICGO_CLOUD_CONFIG_SYNC_PIN_MAX_RETRY'),
toastType: IPicGoCloudConfigSyncToastType.ERROR
}
}
return {
message: T('PICGO_CLOUD_CONFIG_SYNC_FAILED_WITH_REASON', { reason: raw }),
toastType: IPicGoCloudConfigSyncToastType.ERROR
}
}
const getEncryptionMethodLabel = (method: EncryptionMethod): string => {
if (method === EncryptionMethod.E2EE) return T('PICGO_CLOUD_ENCRYPTION_MODE_E2E')
return T('PICGO_CLOUD_ENCRYPTION_MODE_SERVER')
}
const getConfigSyncManager = (): ConfigSyncManager => {
if (configSyncManager) return configSyncManager
const guiApi = GuiApi.getInstance()
configSyncManager = new ConfigSyncManager(picgo, {
onAskPin: async (reason: E2EAskPinReason, retryCount: number) => {
const inputOptions: IShowInputBoxOption = {
title: (() => {
if (reason === E2EAskPinReason.SETUP) return T('PICGO_CLOUD_E2E_PIN_SETUP_TITLE')
if (reason === E2EAskPinReason.DECRYPT) return T('PICGO_CLOUD_E2E_PIN_DECRYPT_TITLE')
return T('PICGO_CLOUD_E2E_PIN_RETRY_TITLE', { retryCount })
})(),
placeholder: T('PICGO_CLOUD_E2E_PIN_PLACEHOLDER'),
inputType: 'password',
width: 520,
confirm: reason === E2EAskPinReason.SETUP
? { placeholder: T('PICGO_CLOUD_E2E_PIN_CONFIRM_PLACEHOLDER') }
: undefined
}
const value = await guiApi.showInputBox(inputOptions)
if (!value) {
// Throw a sentinel code so we can treat it as a user-aborted flow in the GUI.
throw new Error(USER_ABORTED_CODE)
}
return value
},
onAskEncryptionSwitch: async ({ from, to }: { from: EncryptionMethod, to: EncryptionMethod }): Promise<boolean> => {
const title = T('PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_TITLE')
const message = T('PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_BODY', {
from: getEncryptionMethodLabel(from),
to: getEncryptionMethodLabel(to)
})
const res = await guiApi.showMessageBox({
title,
message,
type: 'warning',
buttons: [
T('PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CONFIRM'),
T('PICGO_CLOUD_CONFIG_SYNC_ENCRYPTION_SWITCH_CANCEL')
]
})
return res.result === 0
}
})
return configSyncManager
}
const buildResolvedConfig = async (resolution: IPicGoCloudConfigSyncResolution): Promise<IConfig> => {
const base = await readLocalConfigWithComments()
for (const item of configSyncConflictItems) {
const choice = resolution[item.path]
if (choice === IPicGoCloudConfigSyncConflictChoice.CLOUD) {
if (item.remoteValue === undefined) {
unset(base, item.path)
} else {
set(base, item.path, cloneDeep(item.remoteValue))
}
}
}
return base
}
const getUserInfo = async (): Promise<IPicGoCloudUserInfo | null> => {
return await picgo.cloud.getUserInfo()
}
const loginWithTimeout = async (): Promise<void> => {
const loginPromise = picgo.cloud.login()
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeoutId = setTimeout(() => {
picgo.cloud.disposeLoginFlow()
reject(new Error(T('PICGO_CLOUD_LOGIN_TIMEOUT')))
}, LOGIN_TIMEOUT_MS)
})
try {
await Promise.race([loginPromise, timeoutPromise])
} finally {
if (timeoutId) clearTimeout(timeoutId)
// Avoid unhandled rejection when timeout disposes the core login flow.
loginPromise.catch(() => {})
}
}
cloudRouter
.add(IRPCActionType.PICGO_CLOUD_GET_USER_INFO, async () => {
try {
const userInfo = await getUserInfo()
return ok(userInfo)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_LOGIN, async () => {
try {
await loginWithTimeout()
const userInfo = await getUserInfo()
if (!userInfo) {
return fail(T('PICGO_CLOUD_LOGIN_FAILED'))
}
return ok(userInfo)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_LOGOUT, async () => {
try {
picgo.cloud.logout()
clearConfigSyncSession()
return ok(true)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_DISPOSE_LOGIN_FLOW, async () => {
try {
picgo.cloud.disposeLoginFlow()
return ok(true)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_GET_STATE, async () => {
try {
return ok(await buildConfigSyncState())
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_SET_E2E_PREFERENCE, async (args) => {
try {
const [mode] = args as [IPicGoCloudEncryptionMethod]
if (mode === IPicGoCloudEncryptionMethod.AUTO) {
picgo.saveConfig({
'settings.picgoCloud.encryptionMethod': IPicGoCloudEncryptionMethod.AUTO
})
} else if (mode === IPicGoCloudEncryptionMethod.SSE) {
picgo.saveConfig({
'settings.picgoCloud.encryptionMethod': IPicGoCloudEncryptionMethod.SSE
})
} else {
picgo.saveConfig({
'settings.picgoCloud.encryptionMethod': IPicGoCloudEncryptionMethod.E2EE
})
}
return ok(getLocalEncryptionMethod())
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_ABORT, async () => {
try {
clearConfigSyncSession()
return ok(await buildConfigSyncState())
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_START, async () => {
const fallbackState = await buildConfigSyncState()
if (configSyncSessionStatus === IPicGoCloudConfigSyncSessionStatus.SYNCING) {
logger.info('[PicGo Cloud][config-sync][sync] already in progress')
const runRes: IPicGoCloudConfigSyncRunResult = {
status: IPicGoCloudConfigSyncRunStatus.FAILED,
message: T('PICGO_CLOUD_CONFIG_SYNC_IN_PROGRESS'),
toastType: IPicGoCloudConfigSyncToastType.INFO,
state: fallbackState
}
return ok(runRes)
}
if (configSyncSessionStatus === IPicGoCloudConfigSyncSessionStatus.CONFLICT) {
logger.info('[PicGo Cloud][config-sync][sync] pending conflict session')
const runRes: IPicGoCloudConfigSyncRunResult = {
status: IPicGoCloudConfigSyncRunStatus.CONFLICT,
message: T('PICGO_CLOUD_CONFIG_SYNC_CONFLICT_PENDING'),
toastType: IPicGoCloudConfigSyncToastType.INFO,
state: fallbackState
}
return ok(runRes)
}
configSyncSessionStatus = IPicGoCloudConfigSyncSessionStatus.SYNCING
try {
const userInfo = await getUserInfo()
if (!userInfo) {
logger.warn('[PicGo Cloud][config-sync][sync] login expired')
clearConfigSyncSession()
const runRes: IPicGoCloudConfigSyncRunResult = {
status: IPicGoCloudConfigSyncRunStatus.FAILED,
message: T('PICGO_CLOUD_LOGIN_EXPIRED'),
toastType: IPicGoCloudConfigSyncToastType.WARNING,
authInvalidated: true,
state: await buildConfigSyncState()
}
return ok(runRes)
}
const manager = getConfigSyncManager()
const encryptionMethod = toSyncEncryptionMethod(getLocalEncryptionMethod())
const res = encryptionMethod
? await manager.sync({ encryptionMethod })
: await manager.sync()
if (res.status === SyncStatus.CONFLICT && res.diffTree) {
configSyncSessionStatus = IPicGoCloudConfigSyncSessionStatus.CONFLICT
configSyncConflictDiffTree = res.diffTree
configSyncConflictItems = extractConflictItems(res.diffTree)
logConfigSyncOutcome('sync', res.status, res.message, { conflictCount: configSyncConflictItems.length })
} else {
clearConfigSyncSession()
logConfigSyncOutcome('sync', res.status, res.message)
}
const localized = localizeConfigSyncResult(res.status, res.message)
const runStatus = res.status === SyncStatus.SUCCESS
? IPicGoCloudConfigSyncRunStatus.SUCCESS
: res.status === SyncStatus.CONFLICT
? IPicGoCloudConfigSyncRunStatus.CONFLICT
: IPicGoCloudConfigSyncRunStatus.FAILED
const runRes: IPicGoCloudConfigSyncRunResult = {
status: runStatus,
message: localized.message,
toastType: localized.toastType,
shouldShowRestartPrompt: res.status === SyncStatus.SUCCESS,
state: await buildConfigSyncState()
}
return ok(runRes)
} catch (e) {
logger.error('[PicGo Cloud][config-sync][sync] error', e)
clearConfigSyncSession()
const localized = localizeConfigSyncResult(SyncStatus.FAILED, e instanceof Error ? e.message : String(e))
const runRes: IPicGoCloudConfigSyncRunResult = {
status: IPicGoCloudConfigSyncRunStatus.FAILED,
message: localized.message,
toastType: localized.toastType,
state: await buildConfigSyncState()
}
return ok(runRes)
}
})
.add(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_APPLY_RESOLUTION, async (args) => {
try {
const [resolution] = args as [IPicGoCloudConfigSyncResolution]
if (configSyncSessionStatus !== IPicGoCloudConfigSyncSessionStatus.CONFLICT || !configSyncConflictDiffTree) {
logger.warn('[PicGo Cloud][config-sync][applyResolvedConfig] no conflict session')
const runRes: IPicGoCloudConfigSyncRunResult = {
status: IPicGoCloudConfigSyncRunStatus.FAILED,
message: T('PICGO_CLOUD_CONFIG_SYNC_NO_CONFLICT_SESSION'),
toastType: IPicGoCloudConfigSyncToastType.ERROR,
state: await buildConfigSyncState()
}
return ok(runRes)
}
const expectedPaths = new Set(configSyncConflictItems.map(item => item.path))
const providedPaths = new Set(Object.keys(resolution))
for (const path of expectedPaths) {
if (!providedPaths.has(path)) {
logger.warn('[PicGo Cloud][config-sync][applyResolvedConfig] resolution incomplete')
const runRes: IPicGoCloudConfigSyncRunResult = {
status: IPicGoCloudConfigSyncRunStatus.FAILED,
message: T('PICGO_CLOUD_CONFIG_SYNC_RESOLUTION_INCOMPLETE'),
toastType: IPicGoCloudConfigSyncToastType.ERROR,
state: await buildConfigSyncState()
}
return ok(runRes)
}
}
const userInfo = await getUserInfo()
if (!userInfo) {
logger.warn('[PicGo Cloud][config-sync][applyResolvedConfig] login expired')
clearConfigSyncSession()
const runRes: IPicGoCloudConfigSyncRunResult = {
status: IPicGoCloudConfigSyncRunStatus.FAILED,
message: T('PICGO_CLOUD_LOGIN_EXPIRED'),
toastType: IPicGoCloudConfigSyncToastType.WARNING,
authInvalidated: true,
state: await buildConfigSyncState()
}
return ok(runRes)
}
configSyncSessionStatus = IPicGoCloudConfigSyncSessionStatus.SYNCING
const resolvedConfig = await buildResolvedConfig(resolution)
const encryptionMethod = getLocalEncryptionMethod()
const manager = getConfigSyncManager()
const applyRes = await manager.applyResolvedConfig(
resolvedConfig,
encryptionMethod === IPicGoCloudEncryptionMethod.E2EE
? { useE2E: true }
: encryptionMethod === IPicGoCloudEncryptionMethod.SSE
? { useE2E: false }
: {}
)
if (applyRes.status === SyncStatus.SUCCESS) {
clearConfigSyncSession()
} else {
// Keep conflict session so the user can retry.
configSyncSessionStatus = IPicGoCloudConfigSyncSessionStatus.CONFLICT
}
logConfigSyncOutcome('applyResolvedConfig', applyRes.status, applyRes.message, { conflictCount: configSyncConflictItems.length })
const localized = localizeConfigSyncResult(applyRes.status, applyRes.message)
const runStatus = applyRes.status === SyncStatus.SUCCESS
? IPicGoCloudConfigSyncRunStatus.SUCCESS
: applyRes.status === SyncStatus.CONFLICT
? IPicGoCloudConfigSyncRunStatus.CONFLICT
: IPicGoCloudConfigSyncRunStatus.FAILED
const runRes: IPicGoCloudConfigSyncRunResult = {
status: runStatus,
message: localized.message,
toastType: localized.toastType,
shouldShowRestartPrompt: applyRes.status === SyncStatus.SUCCESS,
state: await buildConfigSyncState()
}
return ok(runRes)
} catch (e) {
logger.error('[PicGo Cloud][config-sync][applyResolvedConfig] error', e)
// Keep the conflict session so user can retry from UI.
configSyncSessionStatus = IPicGoCloudConfigSyncSessionStatus.CONFLICT
const localized = localizeConfigSyncResult(SyncStatus.FAILED, e instanceof Error ? e.message : String(e))
const runRes: IPicGoCloudConfigSyncRunResult = {
status: IPicGoCloudConfigSyncRunStatus.FAILED,
message: localized.message,
toastType: localized.toastType,
state: await buildConfigSyncState()
}
return ok(runRes)
}
})
export {
cloudRouter
}
+79 -16
View File
@@ -1,34 +1,97 @@
import { IRPCActionType } from '~/universal/types/enum'
import { RPCRouter } from '../router'
import { copyUploaderConfig, deleteUploaderConfig, getUploaderConfigList, selectUploaderConfig, updateUploaderConfig } from '~/main/utils/handleUploaderConfig'
import picgo from '@core/picgo'
import { T } from '~/main/i18n'
import { fail, ok } from '../utils'
import { notifyAppConfigUpdated } from '~/main/utils/appConfigNotifier'
const configRouter = new RPCRouter()
configRouter
.add(IRPCActionType.GET_PICBED_CONFIG_LIST, async (args) => {
const [type] = args as IGetUploaderConfigListArgs
const config = getUploaderConfigList(type)
return config
try {
const [type] = args as IGetUploaderConfigListArgs
const configList = picgo.uploaderConfig.getConfigList(type)
const activeConfig = picgo.uploaderConfig.getActiveConfig(type)
return ok({
configList,
defaultId: activeConfig?._id ?? ''
})
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.DELETE_PICBED_CONFIG, async (args) => {
const [type, id] = args as IDeleteUploaderConfigArgs
const config = deleteUploaderConfig(type, id)
return config
try {
const [type, configName] = args as IDeleteUploaderConfigArgs
const existing = picgo.uploaderConfig.getConfigList(type)
if (existing.length <= 1) {
throw new Error(T('TIPS_UPLOADER_CONFIG_CANNOT_DELETE_LAST'))
}
picgo.uploaderConfig.remove(type, configName)
const configList = picgo.uploaderConfig.getConfigList(type)
const activeConfig = picgo.uploaderConfig.getActiveConfig(type)
notifyAppConfigUpdated()
return ok({
configList,
defaultId: activeConfig?._id ?? ''
})
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.COPY_UPLOADER_CONFIG, async (args) => {
const [type, id] = args as ICopyUploaderConfigArgs
const config = copyUploaderConfig(type, id)
return config
try {
const [type, configName, newConfigName] = args as ICopyUploaderConfigArgs
picgo.uploaderConfig.copy(type, configName, newConfigName)
const configList = picgo.uploaderConfig.getConfigList(type)
const activeConfig = picgo.uploaderConfig.getActiveConfig(type)
notifyAppConfigUpdated()
return ok({
configList,
defaultId: activeConfig?._id ?? ''
})
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.SELECT_UPLOADER, async (args) => {
const [type, id] = args as ISelectUploaderConfigArgs
selectUploaderConfig(type, id)
return true
try {
const [type, configName] = args as ISelectUploaderConfigArgs
const activeConfig = picgo.uploaderConfig.use(type, configName)
notifyAppConfigUpdated()
return ok(activeConfig._id)
} catch (e) {
return fail(e)
}
})
.add(IRPCActionType.UPDATE_UPLOADER_CONFIG, async (args) => {
const [type, id, config] = args as IUpdateUploaderConfigArgs
updateUploaderConfig(type, id, config)
return true
try {
const [type, configId, config] = args as IUpdateUploaderConfigArgs
const configName = typeof config._configName === 'string' ? config._configName : ''
if (configId && !configName) {
throw new Error(T('TIPS_UPLOADER_CONFIG_NAME_EMPTY'))
}
let oldConfigName = ''
if (configId) {
const configList = picgo.uploaderConfig.getConfigList(type)
const existConfig = configList.find(item => item._id === configId)
if (!existConfig) {
throw new Error(T('TIPS_UPLOADER_CONFIG_NOT_FOUND'))
}
oldConfigName = existConfig._configName
}
if (oldConfigName && oldConfigName !== configName) {
picgo.uploaderConfig.rename(type, oldConfigName, configName)
}
picgo.uploaderConfig.createOrUpdate(type, configName, config)
notifyAppConfigUpdated()
return ok(true)
} catch (e) {
return fail(e)
}
})
export {
@@ -1,77 +0,0 @@
import { logger } from '@picgo/i18n'
import { IPicGo } from 'picgo'
import { T } from '~/main/i18n'
import { getHost, removeProtocolAndSuffix, replaceHost } from '~/main/utils/common'
export const galleryMenu = () => {
return [{
label: T('CHANGE_IMAGE_URL_HOST'),
async handle (ctx: IPicGo, guiApi: IGuiApi, selectedList: ImgInfo[] = []) {
const hostList = [...new Set(selectedList.map((item) => {
return getHost(item.imgUrl)
}).filter(Boolean) as string[])]
if (hostList.length === 0) {
guiApi.showNotification({
title: T('CHANGE_IMAGE_URL_HOST'),
body: T('CHANGE_IMAGE_URL_HOST_WARN')
})
logger.warn(T('CHANGE_IMAGE_URL_HOST_WARN'))
return
}
const config: IPicGoPluginConfig[] = [
{
alias: T('SELECTED_IMAGE_URL_HOST'),
name: 'selectedHost',
type: 'checkbox',
choices: hostList.map(item => ({
name: item,
value: item,
checked: true
})),
required: true
},
{
alias: T('NEW_IMAGE_URL_HOST'),
name: 'newHost',
type: 'input',
message: 'www.example.com',
default: '',
required: true
}
]
const options: IPicGoPluginShowConfigDialogOption = {
title: T('CHANGE_IMAGE_URL_HOST'),
config
}
const res = await guiApi.showConfigDialog<{
selectedHost: string[]
newHost: string
}>(options)
if (res) {
const selectedHost = res.selectedHost
const newHost = removeProtocolAndSuffix(res.newHost)
const changedList = selectedList.map((item) => {
try {
const url = new URL(item.imgUrl || '')
const host = url.host
if (selectedHost.includes(host)) {
item.imgUrl = replaceHost(item.imgUrl!, host, newHost)
return item
} else {
return false
}
} catch (e: any) {
ctx.log.error(e)
return false
}
}).filter(Boolean) as ImgInfo[]
const updateRes = await guiApi.galleryDB.updateMany(changedList)
guiApi.showNotification({
title: T('CHANGE_IMAGE_URL_HOST_RESULT'),
body: `${T('SUCCESS')}: ${updateRes.success} ${T('FAILED')}: ${updateRes.total - updateRes.success}}`
})
}
}
}]
}
@@ -0,0 +1,230 @@
import { logger } from '@picgo/i18n'
import { PicGoUtils, type IPicGo } from 'picgo'
import { T } from '~/main/i18n'
interface IUrlRewriteRule {
match: string
replace: string
enable?: boolean
global?: boolean
ignoreCase?: boolean
}
interface IUrlRewriteDialogResult {
applyGlobalRules?: boolean
match?: string
replace?: string
global?: boolean
ignoreCase?: boolean
}
function normalizeRules (value: unknown): IUrlRewriteRule[] {
if (!Array.isArray(value)) return []
return value.map(item => {
const raw = (item ?? {}) as Partial<Record<keyof IUrlRewriteRule, unknown>>
return {
match: String(raw.match ?? ''),
replace: String(raw.replace ?? ''),
enable: raw.enable === false ? false : true,
global: raw.global === true,
ignoreCase: raw.ignoreCase === true
}
}).filter(rule => rule.match.length > 0)
}
function buildFlags (rule: Pick<IUrlRewriteRule, 'global' | 'ignoreCase'>): string {
return `${rule.global ? 'g' : ''}${rule.ignoreCase ? 'i' : ''}`
}
function validateRuleOrThrow (rule: IUrlRewriteRule) {
if (!rule.match.trim() || !rule.replace.trim()) {
throw new Error(T('GALLERY_URL_REWRITE_TEMP_RULE_REQUIRED'))
}
try {
new RegExp(rule.match, buildFlags(rule))
} catch (error) {
const message = `Invalid URL rewrite regex pattern "${rule.match}": ${error instanceof Error ? error.message : String(error)}`
logger.error(message)
throw new Error(message)
}
}
function applyFirstMatchRewrite (ctx: IPicGo, imgItem: ImgInfo, rules: IUrlRewriteRule[]): ImgInfo {
const imgInfo = {
imgUrl: imgItem.imgUrl,
originImgUrl: imgItem.originImgUrl
}
PicGoUtils.applyUrlRewriteToImgInfo(imgInfo, rules, {
log: {
error: (...args: Parameters<IPicGo['log']['error']>) => ctx.log.error(...args),
warn: () => ctx.log.warn(T('GALLERY_URL_REWRITE_EMPTY_RESULT_WARN'))
}
})
if (imgInfo.imgUrl === '') return imgItem
return imgInfo
}
export const galleryMenu = () => {
return [{
label: T('GALLERY_URL_REWRITE_TITLE'),
async handle (ctx: IPicGo, guiApi: IGuiApi, selectedList: ImgInfo[] = []) {
if (!selectedList.length) {
guiApi.showNotification({
title: T('GALLERY_URL_REWRITE_TITLE'),
body: T('GALLERY_URL_REWRITE_WARN_NO_SELECTION')
})
logger.warn(T('GALLERY_URL_REWRITE_WARN_NO_SELECTION'))
return
}
const globalRules = normalizeRules(ctx.getConfig('settings.urlRewrite.rules'))
const config: IPicGoPluginConfig[] = [
{
alias: T('GALLERY_URL_REWRITE_APPLY_GLOBAL_RULES'),
name: 'applyGlobalRules',
type: 'confirm',
default: globalRules.length > 0,
required: false,
confirmText: T('SETTINGS_OPEN'),
cancelText: T('SETTINGS_CLOSE'),
tips: `${T('GALLERY_URL_REWRITE_GLOBAL_RULES_COUNT')}: ${globalRules.length}`
},
{
alias: T('URL_REWRITE_MATCH'),
name: 'match',
type: 'input',
message: T('URL_REWRITE_MATCH_PLACEHOLDER'),
default: '',
required: false,
tips: `${T('GALLERY_URL_REWRITE_TEMP_RULE_TIPS')}\n\n${T('URL_REWRITE_MATCH_TIPS')}`
},
{
alias: T('URL_REWRITE_REPLACE'),
name: 'replace',
type: 'input',
message: T('URL_REWRITE_REPLACE_PLACEHOLDER'),
default: '',
required: false,
tips: T('URL_REWRITE_REPLACE_TIPS')
},
{
alias: T('URL_REWRITE_FLAG_GLOBAL_LABEL'),
name: 'global',
type: 'confirm',
default: false,
required: false,
confirmText: 'g',
cancelText: '-',
tips: T('URL_REWRITE_FLAG_GLOBAL_DESC')
},
{
alias: T('URL_REWRITE_FLAG_IGNORE_CASE_LABEL'),
name: 'ignoreCase',
type: 'confirm',
default: false,
required: false,
confirmText: 'i',
cancelText: '-',
tips: T('URL_REWRITE_FLAG_IGNORE_CASE_DESC')
}
]
const options: IPicGoPluginShowConfigDialogOption = {
title: T('GALLERY_URL_REWRITE_TITLE'),
config
}
const res = await guiApi.showConfigDialog<IUrlRewriteDialogResult>(options)
if (!res) return
const applyGlobalRules = res.applyGlobalRules === true
const tempMatch = String(res.match ?? '').trim()
const tempReplace = String(res.replace ?? '').trim()
const hasTempRuleInput = tempMatch.length > 0 || tempReplace.length > 0
let tempRule: IUrlRewriteRule | null = null
if (hasTempRuleInput) {
tempRule = {
match: tempMatch,
replace: tempReplace,
enable: true,
global: res.global === true,
ignoreCase: res.ignoreCase === true
}
}
if (tempRule) {
try {
validateRuleOrThrow(tempRule)
} catch (e: any) {
guiApi.showNotification({
title: T('GALLERY_URL_REWRITE_TITLE'),
body: e.message
})
return
}
}
if (!applyGlobalRules && !tempRule) {
guiApi.showNotification({
title: T('GALLERY_URL_REWRITE_TITLE'),
body: T('GALLERY_URL_REWRITE_NO_RULES_TO_APPLY')
})
return
}
let shouldSaveTempRule = false
if (tempRule) {
const saveRes = await guiApi.showMessageBox({
title: T('GALLERY_URL_REWRITE_TITLE'),
message: T('GALLERY_URL_REWRITE_SAVE_TEMP_RULE_PROMPT'),
type: 'info',
buttons: [
T('GALLERY_URL_REWRITE_APPLY_AND_SAVE'),
T('GALLERY_URL_REWRITE_APPLY_ONLY'),
T('CANCEL')
]
})
if (saveRes.result === 2) return
shouldSaveTempRule = saveRes.result === 0
}
const rulesToApply: IUrlRewriteRule[] = [
...(tempRule ? [tempRule] : []),
...(applyGlobalRules ? globalRules : [])
]
const changedList = selectedList.map((item) => {
const current = item.imgUrl || ''
if (!current) return false
const next = applyFirstMatchRewrite(ctx, item, rulesToApply)
if (next.imgUrl === current) return false
return {
id: item.id,
imgUrl: next.imgUrl,
originImgUrl: next.originImgUrl
} as ImgInfo
}).filter(Boolean) as ImgInfo[]
if (changedList.length === 0) {
guiApi.showNotification({
title: T('GALLERY_URL_REWRITE_RESULT_TITLE'),
body: T('GALLERY_URL_REWRITE_NO_CHANGES')
})
return
}
if (shouldSaveTempRule && tempRule) {
const nextGlobalRules = [...globalRules, tempRule]
ctx.saveConfig({
'settings.urlRewrite.rules': nextGlobalRules
})
}
const updateRes = await guiApi.galleryDB.updateMany(changedList)
guiApi.showNotification({
title: T('GALLERY_URL_REWRITE_RESULT_TITLE'),
body: `${T('SUCCESS')}: ${updateRes.success} ${T('FAILED')}: ${updateRes.total - updateRes.success}`
})
}
}]
}
@@ -1,6 +1,6 @@
import { galleryMenu as changeHostGalleryMenu } from './changeHost'
import { galleryMenu as changeURLGalleryMenu } from './changeURL'
export const builtInGalleryToolboxMenu = () => {
const menuList = [...changeHostGalleryMenu()]
const menuList = [...changeURLGalleryMenu()]
return menuList
}
+20
View File
@@ -3,6 +3,8 @@ import { RPCRouter } from '../router'
import { app, clipboard, shell } from 'electron'
import windowManager from '~/main/apis/app/window/windowManager'
import { handleMenubarIcon } from '~/main/apis/app/system'
import { PICGO_NOTIFICATION_CLICKED } from '~/universal/events/constants'
import { showNotification } from '~/main/utils/common'
const systemRouter = new RPCRouter()
@@ -35,6 +37,24 @@ systemRouter
const [visible] = args as IShowMenubarIconArgs
handleMenubarIcon(visible)
})
.add(IRPCActionType.SHOW_NOTIFICATION, async (args, event) => {
const [title, body, id] = args as IShowNotificationArgs
const options: IPrivateShowNotificationOption = {
title,
body
}
if (id) {
options.callback = () => {
if (!event.sender.isDestroyed()) {
event.sender.send(PICGO_NOTIFICATION_CLICKED, id)
}
}
}
showNotification(options)
})
export {
systemRouter
@@ -11,7 +11,7 @@ const sendToolboxRes = sendToolboxResWithType(IToolboxItemType.HAS_PROBLEM_WITH_
const defaultClipboardImagePath = path.join(defaultConfigPath, CLIPBOARD_IMAGE_FOLDER)
export const checkClipboardUploadMap: IToolboxCheckerMap<
IToolboxItemType.HAS_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD
IToolboxItemType.HAS_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD
> = {
[IToolboxItemType.HAS_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD]: async (event) => {
sendToolboxRes(event, {
@@ -51,7 +51,7 @@ IToolboxItemType.HAS_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD
}
export const fixClipboardUploadMap: IToolboxFixMap<
IToolboxItemType.HAS_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD
IToolboxItemType.HAS_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD
> = {
[IToolboxItemType.HAS_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD]: async () => {
const configFilePath = dbPathChecker()
@@ -1,16 +1,15 @@
import fs from 'fs-extra'
import { IpcMainEvent } from 'electron'
import { IToolboxItemCheckStatus, IToolboxItemType } from '~/universal/types/enum'
import { sendToolboxResWithType } from './utils'
import { dbPathChecker } from '~/main/apis/core/datastore/dbChecker'
import { GalleryDB, DB_PATH } from '~/main/apis/core/datastore'
import { dbPathChecker, getGalleryDBPath } from '~/main/apis/core/datastore/dbChecker'
import { GalleryDB } from '~/main/apis/core/datastore'
import path from 'path'
import { T } from '~/main/i18n'
export const checkFileMap: IToolboxCheckerMap<
IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_GALLERY_FILE_BROKEN
> = {
[IToolboxItemType.IS_CONFIG_FILE_BROKEN]: async (event: IpcMainEvent) => {
[IToolboxItemType.IS_CONFIG_FILE_BROKEN]: async (event) => {
const sendToolboxRes = sendToolboxResWithType(IToolboxItemType.IS_CONFIG_FILE_BROKEN)
sendToolboxRes(event, {
status: IToolboxItemCheckStatus.LOADING
@@ -40,20 +39,21 @@ IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_GALLERY_FILE_BROKEN
sendToolboxRes(event, {
status: IToolboxItemCheckStatus.LOADING
})
const { dbPath } = getGalleryDBPath()
const galleryDB = GalleryDB.getInstance()
if (galleryDB.errorList.length === 0) {
sendToolboxRes(event, {
status: IToolboxItemCheckStatus.SUCCESS,
msg: T('TOOLBOX_CHECK_GALLERY_FILE_PATH_TIPS', {
path: DB_PATH
path: dbPath
}),
value: path.dirname(DB_PATH)
value: path.dirname(dbPath)
})
} else {
sendToolboxRes(event, {
status: IToolboxItemCheckStatus.ERROR,
msg: T('TOOLBOX_CHECK_GALLERY_FILE_BROKEN_TIPS'),
value: path.dirname(DB_PATH)
value: path.dirname(dbPath)
})
}
}
@@ -75,7 +75,7 @@ IToolboxItemType.IS_CONFIG_FILE_BROKEN | IToolboxItemType.IS_GALLERY_FILE_BROKEN
},
[IToolboxItemType.IS_GALLERY_FILE_BROKEN]: async () => {
try {
fs.unlinkSync(DB_PATH)
fs.unlinkSync(getGalleryDBPath().dbPath)
} catch (e) {
// do nothing
}
@@ -25,7 +25,7 @@ const getProxy = (proxyStr: string): AxiosRequestConfig['proxy'] | false => {
const sendToolboxRes = sendToolboxResWithType(IToolboxItemType.HAS_PROBLEM_WITH_PROXY)
export const checkProxyMap: IToolboxCheckerMap<
IToolboxItemType.HAS_PROBLEM_WITH_PROXY
IToolboxItemType.HAS_PROBLEM_WITH_PROXY
> = {
[IToolboxItemType.HAS_PROBLEM_WITH_PROXY]: async (event) => {
sendToolboxRes(event, {
+2 -2
View File
@@ -1,7 +1,7 @@
import { IpcMainEvent } from 'electron'
import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron'
import { IRPCActionType, IToolboxItemType } from '~/universal/types/enum'
export const sendToolboxResWithType = (type: IToolboxItemType) => (event: IpcMainEvent, res?: Omit<IToolboxCheckRes, 'type'>) => {
export const sendToolboxResWithType = (type: IToolboxItemType) => (event: IpcMainEvent | IpcMainInvokeEvent, res?: Omit<IToolboxCheckRes, 'type'>) => {
return event.sender.send(IRPCActionType.TOOLBOX_CHECK_RES, {
...res,
type
+25
View File
@@ -0,0 +1,25 @@
const errorToMessage = (e: unknown): string => {
if (e instanceof Error) return e.message
return String(e)
}
export const ok = <T>(data: T): IRPCResult<T> => ({
success: true,
data
})
export const fail = <T>(e: unknown): IRPCResult<T> => ({
success: false,
error: errorToMessage(e)
})
export const isIRPCResult = (value: unknown): value is IRPCResult<any> => {
if (!value || typeof value !== 'object') return false
const maybe = value as { success?: unknown, data?: unknown, error?: unknown }
if (typeof maybe.success !== 'boolean') return false
// success=true MUST have data; success=false MUST have error.
if (maybe.success) return 'data' in maybe
return typeof maybe.error === 'string'
}
+2 -2
View File
@@ -10,8 +10,8 @@ class I18nManager {
private builtinI18nFolder = getStaticPath('i18n')
private outerI18nFolder = ''
private localesMap: Map<string, ILocales> = new Map()
private currentLanguage: string = 'zh-CN'
readonly defaultLanguage: string = 'zh-CN'
private currentLanguage: string = 'en'
readonly defaultLanguage: string = 'en'
private i18nFileList: II18nItem[] = builtinI18nList
setOuterI18nFolder (folder: string) {
+11 -10
View File
@@ -2,8 +2,7 @@ import './errorHandler'
import {
app,
globalShortcut,
protocol,
Notification
protocol
} from 'electron'
import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'
import beforeOpen from '~/main/utils/beforeOpen'
@@ -26,7 +25,7 @@ import server from '~/main/server/index'
import updateChecker from '~/main/utils/updateChecker'
import shortKeyHandler from 'apis/app/shortKey/shortKeyHandler'
import { getUploadFiles } from '~/main/utils/handleArgv'
import db, { GalleryDB } from '~/main/apis/core/datastore'
import { GalleryDB } from '~/main/apis/core/datastore'
import bus from '@core/bus'
import logger from 'apis/core/picgo/logger'
import picgo from 'apis/core/picgo'
@@ -35,6 +34,7 @@ import { initI18n } from '~/main/utils/handleI18n'
import { remoteNoticeHandler } from 'apis/app/remoteNotice'
import { isMacOS } from '../utils/getMacOSVersion'
import { isWindowShouldShowOnStartup } from '../apis/app/window/windowList'
import { showNotification } from '../utils/common'
import { initStaticPath, isDev } from '../utils/env'
const isDevelopment = isDev
@@ -65,8 +65,8 @@ class LifeCycle {
initI18n()
ipcList.listen()
busEventList.listen()
updateShortKeyFromVersion212(db, db.get('settings.shortKey'))
await migrateGalleryFromVersion230(db, GalleryDB.getInstance(), picgo)
updateShortKeyFromVersion212(picgo)
await migrateGalleryFromVersion230(GalleryDB.getInstance(), picgo)
}
private onReady () {
@@ -98,7 +98,7 @@ class LifeCycle {
}
handleMenubarIcon()
handleDockIcon()
db.set('needReload', false)
picgo.saveConfig({ needReload: false })
updateChecker()
// 不需要阻塞
process.nextTick(() => {
@@ -112,8 +112,9 @@ class LifeCycle {
if (global.notificationList && global.notificationList?.length > 0) {
while (global.notificationList?.length) {
const option = global.notificationList.pop()
const notice = new Notification(option!)
notice.show()
if (option) {
showNotification(option)
}
}
}
await remoteNoticeHandler.init()
@@ -146,13 +147,13 @@ class LifeCycle {
// click dock to open setting window
if (isMacOS) {
handleDockIcon()
if (db.get('settings.showDockIcon') !== false) {
if (picgo.getConfig<boolean>('settings.showDockIcon') !== false) {
windowManager.get(IWindowList.SETTING_WINDOW)?.show()
}
}
})
app.setLoginItemSettings({
openAtLogin: db.get('settings.autoStart') || false
openAtLogin: picgo.getConfig<boolean>('settings.autoStart') || false
})
if (process.platform === 'win32') {
app.setAppUserModelId('com.molunerfinn.picgo')
+20 -15
View File
@@ -1,47 +1,52 @@
import { DBStore } from '@picgo/store'
import ConfigStore from '~/main/apis/core/datastore'
import path from 'path'
import fse from 'fs-extra'
import { PicGo as PicGoCore } from 'picgo'
import { T } from '~/main/i18n'
import { SHORTKEY_COMMAND_UPLOAD } from 'apis/core/bus/constants'
// from v2.1.2
const updateShortKeyFromVersion212 = (db: typeof ConfigStore, shortKeyConfig: IShortKeyConfigs | IOldShortKeyConfigs) => {
const updateShortKeyFromVersion212 = (picgo: PicGoCore) => {
const shortKeyConfig = picgo.getConfig<IShortKeyConfigs | IOldShortKeyConfigs | undefined>('settings.shortKey')
// #557 极端情况可能会出现配置不存在,需要重新写入
if (shortKeyConfig === undefined) {
const defaultShortKeyConfig = {
enable: true,
key: 'CommandOrControl+Shift+P',
key: 'CommandOrControl+Shift+U',
name: 'upload',
label: T('QUICK_UPLOAD')
}
db.set('settings.shortKey[picgo:upload]', defaultShortKeyConfig)
picgo.saveConfig({
[`settings.shortKey[${SHORTKEY_COMMAND_UPLOAD}]`]: defaultShortKeyConfig
})
return true
}
if (shortKeyConfig.upload) {
// @ts-ignore
shortKeyConfig[SHORTKEY_COMMAND_UPLOAD] = {
if (typeof (shortKeyConfig as IOldShortKeyConfigs).upload === 'string') {
const oldKey = (shortKeyConfig as IOldShortKeyConfigs).upload
const nextConfig = Object.fromEntries(
Object.entries(shortKeyConfig).filter(([key]) => key !== 'upload')
) as IShortKeyConfigs
nextConfig[SHORTKEY_COMMAND_UPLOAD] = {
enable: true,
key: shortKeyConfig.upload,
key: oldKey,
name: 'upload',
label: T('QUICK_UPLOAD')
}
// @ts-ignore
delete shortKeyConfig.upload
db.set('settings.shortKey', shortKeyConfig)
picgo.saveConfig({
'settings.shortKey': nextConfig
})
return true
}
return false
}
const migrateGalleryFromVersion230 = async (configDB: typeof ConfigStore, galleryDB: DBStore, picgo: PicGoCore) => {
const originGallery: ImgInfo[] = picgo.getConfig('uploaded')
const migrateGalleryFromVersion230 = async (galleryDB: DBStore, picgo: PicGoCore) => {
const originGallery = picgo.getConfig<ImgInfo[] | undefined>('uploaded')
// if hasMigrate, we don't need to migrate
const hasMigrate: boolean = configDB.get('__migrateUploaded')
const hasMigrate = picgo.getConfig<boolean | undefined>('__migrateUploaded') === true
if (hasMigrate) {
return
}
const configPath = configDB.getConfigPath()
const configPath = picgo.configPath
const configBakPath = path.join(path.dirname(configPath), 'config.bak.json')
// migrate gallery from config to gallery db
if (originGallery && Array.isArray(originGallery) && originGallery?.length > 0) {
+134
View File
@@ -0,0 +1,134 @@
import logger from '@core/picgo/logger'
import windowManager from 'apis/app/window/windowManager'
import { uploadClipboardFiles, uploadSelectedFiles } from 'apis/app/uploader/apis'
import path from 'path'
import { dbPathDir, getFormImageFolderPath } from 'apis/core/datastore/dbChecker'
import fs from 'fs-extra'
import { cleanupFormUploaderFiles } from '~/main/utils/cleanupFormUploaderFiles'
import {
buildError,
buildSuccess,
getFormDataFileName,
isFileLike,
isRecord,
type HonoContextLike
} from './utils'
const STORE_PATH = dbPathDir()
const LOG_PATH = path.join(STORE_PATH, 'picgo.log')
const errorMessage = `upload error. see ${LOG_PATH} for more detail.`
const handleClipboardUpload = async (c: HonoContextLike): Promise<Response> => {
try {
logger.info('[PicGo Server] upload clipboard file')
const res = await uploadClipboardFiles()
if (res) {
return c.json(buildSuccess([res]))
}
return c.json(buildError(errorMessage), 500)
} catch (e: unknown) {
logger.error('[PicGo Server] upload clipboard error', e)
return c.json(buildError(errorMessage), 500)
}
}
const handleListUpload = async (c: HonoContextLike, list: string[]): Promise<Response> => {
try {
logger.info('[PicGo Server] upload files in list')
const win = windowManager.getAvailableWindow()
if (!win) {
return c.json(buildError(errorMessage), 500)
}
const pathList = list.map(item => ({ path: item }))
const res = await uploadSelectedFiles(win.webContents, pathList)
if (res.length) {
return c.json(buildSuccess(res))
}
return c.json(buildError(errorMessage), 500)
} catch (e: unknown) {
logger.error('[PicGo Server] upload list error', e)
return c.json(buildError(errorMessage), 500)
}
}
export const uploadHandler = async (c: HonoContextLike): Promise<Response> => {
const contentType = c.req.raw.headers.get('content-type') || ''
if (contentType.includes('multipart/form-data')) {
const tempFiles: string[] = []
try {
const formData = await c.req.formData()
const files = formData.getAll('files') as unknown[]
if (files.length === 0) {
return c.json(buildError('No files found in form-data: files'), 400)
}
// Pre-validate to avoid leaking already-written temp files.
const fileLikes: Array<Parameters<typeof getFormDataFileName>[0]> = []
for (const file of files) {
if (!isFileLike(file)) {
return c.json(buildError('Invalid form-data: files must be file(s)'), 400)
}
fileLikes.push(file)
}
const formImagesPath = getFormImageFolderPath()
await fs.ensureDir(formImagesPath)
for (const file of fileLikes) {
const fileName = getFormDataFileName(file)
const safeName = path.basename(fileName)
const filePath = path.join(formImagesPath, safeName)
const buffer = Buffer.from(await file.arrayBuffer())
await fs.writeFile(filePath, buffer)
tempFiles.push(filePath)
}
return await handleListUpload(c, tempFiles)
} catch (e: unknown) {
logger.error('[PicGo Server] process form upload error', e)
return c.json(buildError(errorMessage), 500)
} finally {
cleanupFormUploaderFiles(tempFiles)
}
}
const bodyText = await c.req.raw.text().catch(() => '')
// No request body -> upload from clipboard.
if (bodyText.trim() === '') {
return await handleClipboardUpload(c)
}
let body: unknown
try {
body = JSON.parse(bodyText)
} catch {
return c.json(buildError('Invalid JSON body'), 400)
}
// GUI compatibility: JSON without list (or empty list) -> upload from clipboard.
if (!isRecord(body) || !('list' in body)) {
return await handleClipboardUpload(c)
}
const listValue = body.list
if (listValue === undefined) {
return await handleClipboardUpload(c)
}
if (!Array.isArray(listValue)) {
return c.json(buildError('Invalid request body: { list: string[] } required'), 400)
}
if (listValue.length === 0) {
return await handleClipboardUpload(c)
}
if (!listValue.every((item) => typeof item === 'string')) {
return c.json(buildError('Invalid request body: { list: string[] } required'), 400)
}
return await handleListUpload(c, listValue)
}
+28 -102
View File
@@ -1,34 +1,12 @@
import http from 'http'
import routers from './routerManager'
import {
handleResponse,
ensureHTTPLink
} from './utils'
import picgo from '@core/picgo'
import logger from '@core/picgo/logger'
import axios from 'axios'
import formUploader from './middlewares/formUploader'
import { uploadHandler } from './handler'
class Server {
private httpServer: http.Server
private config: IServerConfig
private hasRegisteredUploadOverride = false
constructor () {
let config = picgo.getConfig<IServerConfig>('settings.server')
const result = this.checkIfConfigIsValid(config)
if (result) {
this.config = config
} else {
config = {
port: 36677,
host: '127.0.0.1',
enable: true
}
this.config = config
picgo.saveConfig({
'settings.server': config
})
}
this.httpServer = http.createServer(this.handleRequest)
this.config = this.ensureConfig()
}
private checkIfConfigIsValid (config: IObj | undefined) {
@@ -39,98 +17,46 @@ class Server {
}
}
private handleRequest = (request: http.IncomingMessage, response: http.ServerResponse) => {
if (request.method === 'OPTIONS') {
handleResponse({
response
})
return
private ensureConfig (): IServerConfig {
let config = picgo.getConfig<IServerConfig>('settings.server')
if (this.checkIfConfigIsValid(config)) {
return config
}
if (request.method === 'POST') {
if (!routers.getHandler(request.url!)) {
logger.warn(`[PicGo Server] don't support [${request.url}] url`)
handleResponse({
response,
statusCode: 404,
body: {
success: false
}
})
} else if (formUploader.isFileUpload(request)) {
formUploader.handleFileUpload(request, response)
} else {
let body: string = ''
let postObj: IObj
request.on('data', chunk => {
body += chunk
})
request.on('end', () => {
try {
postObj = (body === '') ? {} : JSON.parse(body)
} catch (err: any) {
logger.error('[PicGo Server]', err)
return handleResponse({
response,
body: {
success: false,
message: 'Not sending data in JSON format'
}
})
}
logger.info('[PicGo Server] get the request', body)
const handler = routers.getHandler(request.url!)
handler!({
...postObj,
response
})
})
}
} else {
logger.warn(`[PicGo Server] don't support [${request.method}] method`)
response.statusCode = 404
response.end()
config = {
port: 36677,
host: '127.0.0.1',
enable: true
}
picgo.saveConfig({
'settings.server': config
})
return config
}
// port as string is a bug
private listen = (port: number | string) => {
logger.info(`[PicGo Server] is listening at ${port}`)
if (typeof port === 'string') {
port = parseInt(port, 10)
}
this.httpServer.listen(port, this.config.host).on('error', async (err: ErrnoException) => {
if (err.errno === 'EADDRINUSE') {
try {
// make sure the system has a PicGo Server instance
await axios.post(ensureHTTPLink(`${this.config.host}:${port}/heartbeat`))
this.shutdown(true)
} catch (e) {
logger.warn(`[PicGo Server] ${port} is busy, trying with port ${(port as number) + 1}`)
// fix a bug: not write an increase number to config file
// to solve the auto number problem
this.listen((port as number) + 1)
}
}
})
private ensureUploadOverrideRegistered () {
if (this.hasRegisteredUploadOverride) return
// @ts-expect-error override internal handler
picgo.server.registerPost('/upload', uploadHandler, true)
this.hasRegisteredUploadOverride = true
}
startup () {
console.log('startup', this.config.enable)
this.config = this.ensureConfig()
if (this.config.enable) {
this.listen(this.config.port)
this.ensureUploadOverrideRegistered()
// let core resolve config defaults when possible, but preserve GUI config semantics.
const port = typeof this.config.port === 'string' ? parseInt(this.config.port, 10) : this.config.port
picgo.server.listen(port, this.config.host)
}
}
shutdown (hasStarted?: boolean) {
this.httpServer.close()
if (!hasStarted) {
logger.info('[PicGo Server] shutdown')
}
shutdown () {
picgo.server.shutdown()
logger.info('[PicGo Server] shutdown')
}
restart () {
this.config = picgo.getConfig('settings.server')
this.shutdown()
this.startup()
}
-125
View File
@@ -1,125 +0,0 @@
import http from 'http'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import { dbPathDir } from 'apis/core/datastore/dbChecker'
import logger from '@core/picgo/logger'
import { handleResponse } from '../utils'
import routers from '../routerManager'
import { FORM_IMAGE_FOLDER } from '~/universal/utils/static'
// Multer 错误类型定义
export interface MulterError extends Error {
code: string
field?: string
storageErrors?: any[]
}
// Multer 中间件适配器类型
export type MulterMiddleware = (
req: http.IncomingMessage,
res: http.ServerResponse,
callback: (error?: MulterError) => void
) => void;
// 扩展 multer 函数的返回类型
declare module 'multer' {
interface Multer {
array(fieldname: string, maxCount?: number): MulterMiddleware;
single(fieldname: string): MulterMiddleware;
fields(fields: { name: string; maxCount?: number }[]): MulterMiddleware;
none(): MulterMiddleware;
}
}
class FormUploader {
private upload!: multer.Multer
private formImagesPath!: string
constructor () {
this.initializeStorage()
this.setupMulter()
}
private initializeStorage () {
const STORE_PATH = dbPathDir()
this.formImagesPath = path.join(STORE_PATH, FORM_IMAGE_FOLDER)
if (!fs.existsSync(this.formImagesPath)) {
fs.mkdirSync(this.formImagesPath, { recursive: true })
}
}
private setupMulter () {
const storage = multer.diskStorage({
destination: (req: http.IncomingMessage, file: Express.Multer.File, cb: (error: Error | null, destination: string) => void) => {
cb(null, this.formImagesPath)
},
filename: (req: http.IncomingMessage, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
cb(null, file.originalname || Date.now() + path.extname(file.originalname))
}
})
this.upload = multer({
storage
})
}
/**
* 处理文件上传的中间件
*/
public handleFileUpload = (request: http.IncomingMessage, response: http.ServerResponse): void => {
logger.info('[PicGo Server] handling file upload')
this.upload.array('files')(request, response, async (err?: MulterError) => {
if (err) {
logger.error('[PicGo Server] file upload error', err)
return handleResponse({
response,
body: {
success: false,
message: 'File upload failed'
}
})
}
try {
const files = request.files
if (!files || files.length === 0) {
return handleResponse({
response,
body: {
success: false,
message: 'No files were uploaded'
}
})
}
const filePaths = files.map((file) => file.path)
logger.info('[PicGo Server] files uploaded: ' + filePaths.join(', '))
const handler = routers.getHandler(request.url!)
handler!({
list: filePaths,
response
})
} catch (err: any) {
logger.error('[PicGo Server] process upload files error', err)
handleResponse({
response,
body: {
success: false,
message: 'Failed to process uploaded files'
}
})
}
})
}
/**
* 检查请求是否为文件上传
*/
public isFileUpload (request: http.IncomingMessage): boolean {
return !!(request.headers['content-type'] && request.headers['content-type'].includes('multipart/form-data'))
}
}
export default new FormUploader()
-21
View File
@@ -1,21 +0,0 @@
class Router {
private router = new Map<string, routeHandler>()
get (url: string, callback: routeHandler): void {
this.router.set(url, callback)
}
post (url: string, callback: routeHandler): void {
this.router.set(url, callback)
}
getHandler (url: string) {
if (this.router.has(url)) {
return this.router.get(url)
} else {
return null
}
}
}
export default new Router()
-100
View File
@@ -1,100 +0,0 @@
import router from './router'
import {
handleResponse
} from './utils'
import logger from '@core/picgo/logger'
import windowManager from 'apis/app/window/windowManager'
import { uploadSelectedFiles, uploadClipboardFiles } from 'apis/app/uploader/apis'
import path from 'path'
import { dbPathDir } from 'apis/core/datastore/dbChecker'
const STORE_PATH = dbPathDir()
const LOG_PATH = path.join(STORE_PATH, 'picgo.log')
const errorMessage = `upload error. see ${LOG_PATH} for more detail.`
router.post('/upload', async ({
response,
list = []
} : {
response: IHttpResponse,
list?: string[]
}): Promise<void> => {
try {
if (list.length === 0) {
// upload with clipboard
logger.info('[PicGo Server] upload clipboard file')
const res = await uploadClipboardFiles()
logger.info('[PicGo Server] upload result:', res)
if (res) {
handleResponse({
response,
body: {
success: true,
result: [res]
}
})
} else {
handleResponse({
response,
body: {
success: false,
message: errorMessage
}
})
}
} else {
logger.info('[PicGo Server] upload files in list')
// upload with files
const pathList = list.map(item => {
return {
path: item
}
})
const win = windowManager.getAvailableWindow()
const res = await uploadSelectedFiles(win.webContents, pathList)
logger.info('[PicGo Server] upload result', res.join(' ; '))
if (res.length) {
handleResponse({
response,
body: {
success: true,
result: res
}
})
} else {
handleResponse({
response,
body: {
success: false,
message: errorMessage
}
})
}
}
} catch (err: any) {
logger.error(err)
handleResponse({
response,
body: {
success: false,
message: errorMessage
}
})
}
})
router.post('/heartbeat', async ({
response
} : {
response: IHttpResponse,
}) => {
handleResponse({
response,
body: {
success: true,
result: 'alive'
}
})
})
export default router
+44 -29
View File
@@ -1,33 +1,48 @@
import logger from '@core/picgo/logger'
import { randomUUID } from 'node:crypto'
export const handleResponse = ({
response,
statusCode = 200,
header = {
'Content-Type': 'application/json',
'access-control-allow-headers': '*',
'access-control-allow-methods': 'POST, GET, OPTIONS',
'access-control-allow-origin': '*'
},
body = {
success: false
}
} : {
response: IHttpResponse,
statusCode?: number,
header?: IObj,
body?: any
}) => {
if (body?.success === false) {
logger.warn('[PicGo Server] upload failed, see picgo.log for more detail ↑')
}
response.writeHead(statusCode, header)
response.write(JSON.stringify(body))
response.end()
export type UploadResponseBody = {
success: boolean
result: string[]
message?: string
}
export const ensureHTTPLink = (url: string): string => {
return url.startsWith('http')
? url
: `http://${url}`
export type HonoContextLike = {
req: {
raw: Request
formData: () => Promise<FormData>
}
json: (data: unknown, status?: number) => Response
}
type FormDataFileLike = {
name?: string
arrayBuffer: () => Promise<ArrayBuffer>
}
export const isFileLike = (value: unknown): value is FormDataFileLike => {
if (typeof value !== 'object' || value === null) return false
if (!('arrayBuffer' in value)) return false
return typeof (value as { arrayBuffer?: unknown }).arrayBuffer === 'function'
}
export const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null
}
export const buildSuccess = (result: string[]): UploadResponseBody => ({
success: true,
result
})
export const buildError = (message: string): UploadResponseBody => ({
success: false,
result: [],
message
})
export const getFormDataFileName = (value: FormDataFileLike): string => {
const name = value.name
if (typeof name === 'string' && name.trim() !== '') return name
return `${randomUUID()}.png`
}
+16
View File
@@ -0,0 +1,16 @@
import { APP_CONFIG_UPDATED } from '#/events/constants'
import { IWindowList } from '#/types/enum'
import windowManager from 'apis/app/window/windowManager'
const TARGET_WINDOWS: IWindowList[] = [
IWindowList.SETTING_WINDOW,
IWindowList.TRAY_WINDOW,
IWindowList.MINI_WINDOW
]
export const notifyAppConfigUpdated = (): void => {
TARGET_WINDOWS.forEach((windowType) => {
if (!windowManager.has(windowType)) return
windowManager.get(windowType)?.webContents.send(APP_CONFIG_UPDATED)
})
}
+28 -17
View File
@@ -1,12 +1,13 @@
import fs from 'fs-extra'
import db from '~/main/apis/core/datastore'
import logger from '@core/picgo/logger'
import { clipboard, Notification, dialog } from 'electron'
import { handleUrlEncode } from '~/universal/utils/common'
import { readClipboardFilePaths } from 'clip-filepaths'
import crypto from 'node:crypto'
import picgo from '@core/picgo'
export const handleCopyUrl = (str: string): void => {
if (db.get('settings.autoCopyUrl') !== false) {
if (picgo.getConfig<boolean>('settings.autoCopyUrl') !== false) {
clipboard.writeText(str)
}
}
@@ -18,21 +19,31 @@ export const handleCopyUrl = (str: string): void => {
export const showNotification = (options: IPrivateShowNotificationOption = {
title: '',
body: '',
text: '',
clickToCopy: false,
copyContent: '',
clickFn: () => {}
callback: () => {}
}) => {
if (options.text) {
logger.info('[PicGo Notification]', options.text)
clipboard.writeText(options.text)
}
const title = options.title || ''
const body = options.body || options.text || ''
const silent = picgo.getConfig('settings.notificationSound') === false
const notification = new Notification({
title: options.title,
body: options.body
title,
body,
silent
// icon: options.icon || undefined
})
const handleClick = () => {
if (options.clickToCopy) {
clipboard.writeText(options.copyContent || options.body)
clipboard.writeText(options.copyContent || body)
}
if (options.clickFn) {
options.clickFn()
if (options.callback) {
options.callback()
}
}
notification.once('click', handleClick)
@@ -83,18 +94,18 @@ export const calcUploadProcessDurationRange = (duration: number) => {
export const calcUploadBigFileSizeRange = (fileSizeMB: number) => {
if (fileSizeMB < 10) {
// 3.2 -> 3, 3.6 -> 4
const result = Math.round(fileSizeMB);
return result === 0 && fileSizeMB > 0 ? 1 : result;
const result = Math.round(fileSizeMB)
return result === 0 && fileSizeMB > 0 ? 1 : result
}
else if (fileSizeMB < 100) {
// 13 -> 1.3 -> 1 -> 10
// 17 -> 1.7 -> 2 -> 20
return Math.round(fileSizeMB / 10) * 10;
return Math.round(fileSizeMB / 10) * 10
}
else {
// 135 -> 1.35 -> 1 -> 100
// 160 -> 1.60 -> 2 -> 200
return Math.round(fileSizeMB / 100) * 100;
return Math.round(fileSizeMB / 100) * 100
}
}
@@ -102,18 +113,18 @@ export const calcUploadBigFileSizeRange = (fileSizeMB: number) => {
export const calcVideoDurationRange = (durationSec: number) => {
if (durationSec < 10) {
// 3.2 -> 3, 3.6 -> 4
const result = Math.round(durationSec);
return result === 0 && durationSec > 0 ? 1 : result;
const result = Math.round(durationSec)
return result === 0 && durationSec > 0 ? 1 : result
}
else if (durationSec < 100) {
// 13 -> 1.3 -> 1 -> 10
// 17 -> 1.7 -> 2 -> 20
return Math.round(durationSec / 10) * 10;
return Math.round(durationSec / 10) * 10
}
else {
// 135 -> 1.35 -> 1 -> 100
// 160 -> 1.60 -> 2 -> 200
return Math.round(durationSec / 100) * 100;
return Math.round(durationSec / 100) * 100
}
}
@@ -143,7 +154,7 @@ export const getClipboardFilePathList = (): string[] => {
}
export const handleUrlEncodeWithSetting = (url: string) => {
if (db.get('settings.encodeOutputURL') === true) {
if (picgo.getConfig<boolean>('settings.encodeOutputURL') === true) {
url = handleUrlEncode(url)
}
return url
+46 -46
View File
@@ -1,12 +1,12 @@
import { ipcMain, IpcMainEvent, type WebContents } from "electron";
import { deviceIdManager } from "./deviceId";
import { REGISTER_DEVICE_ID, TALKING_DATA_EVENT } from "~/universal/events/constants";
import type { IImgInfo } from "picgo";
import { calcUploadBigFileSizeRange, calcUploadProcessDurationRange, calcVideoDurationRange } from "./common";
import { app } from "electron/main";
import db from "~/main/apis/core/datastore";
import { getVideoDuration } from "@picgo/video-duration";
import { MB, SECOND } from "./constants";
import { ipcMain, IpcMainEvent, type WebContents } from "electron"
import { deviceIdManager } from "./deviceId"
import { REGISTER_DEVICE_ID, TALKING_DATA_EVENT } from "~/universal/events/constants"
import type { IImgInfo } from "picgo"
import { calcUploadBigFileSizeRange, calcUploadProcessDurationRange, calcVideoDurationRange } from "./common"
import { app } from "electron/main"
import picgo from "@core/picgo"
import { getVideoDuration } from "@picgo/video-duration"
import { MB, SECOND } from "./constants"
export interface IReportUploadDataOptions {
fromClipboard: boolean;
@@ -15,26 +15,26 @@ export interface IReportUploadDataOptions {
}
class DataReportManager {
private deviceId: string | null = null;
private hasRegisterDeviceID: boolean = false;
private deviceId: string | null = null
private hasRegisterDeviceID: boolean = false
constructor () {
this.init()
this.handleRegisterDeviceID()
}
private handleRegisterDeviceID() {
ipcMain.once(REGISTER_DEVICE_ID, (_evt: IpcMainEvent) => {
this.hasRegisterDeviceID = true;
console.log('Device ID registered');
});
this.hasRegisterDeviceID = true
console.log('Device ID registered')
})
}
private async init () {
if (this.deviceId) return;
if (this.deviceId) return
this.deviceId = await deviceIdManager.getId()
}
public async reportUploadData(webContents: WebContents, options: IReportUploadDataOptions) {
await this.init();
await this.registerDeviceID(webContents);
const { fromClipboard, duration, outputList } = options;
await this.init()
await this.registerDeviceID(webContents)
const { fromClipboard, duration, outputList } = options
const fileList = outputList.map(item => {
return {
fileName: item.fileName,
@@ -50,56 +50,56 @@ class DataReportManager {
by: fromClipboard ? 'clipboard' : 'files', // 上传剪贴板图片还是选择的文文件
count: fileList.length, // 上传的数量
duration: calcUploadProcessDurationRange(duration || 0), // 上传耗时
type: db.get('picBed.uploader') || db.get('picBed.current') || 'smms',
type: picgo.getConfig<string>('picBed.uploader') || picgo.getConfig<string>('picBed.current') || 'smms',
}
}
this.reportDataToWebContents(webContents, uploadEventData);
this.reportDataToWebContents(webContents, uploadEventData)
fileList.forEach(async file => {
if (file?.mimeType?.startsWith('video/') && file.filePath) {
const metadata = await getVideoDuration(file.filePath);
const sizeMB = metadata.size / MB;
const durationSec = (metadata.duration / SECOND) || 0;
const metadata = await getVideoDuration(file.filePath)
const sizeMB = metadata.size / MB
const durationSec = (metadata.duration / SECOND) || 0
const videoEventData: ITalkingDataOptions = {
EventId: 'upload_video',
Label: '',
MapKv: {
type: db.get('picBed.uploader') || db.get('picBed.current') || 'smms',
type: picgo.getConfig<string>('picBed.uploader') || picgo.getConfig<string>('picBed.current') || 'smms',
mimeType: file.mimeType,
sizeRange: calcUploadBigFileSizeRange(sizeMB),
durationRange: calcVideoDurationRange(durationSec),
}
};
this.reportDataToWebContents(webContents, videoEventData);
}
this.reportDataToWebContents(webContents, videoEventData)
} else if (file?.mimeType?.startsWith('image/') || fromClipboard) {
const imageEventData: ITalkingDataOptions = {
EventId: 'upload_image',
Label: '',
MapKv: {
type: db.get('picBed.uploader') || db.get('picBed.current') || 'smms',
type: picgo.getConfig<string>('picBed.uploader') || picgo.getConfig<string>('picBed.current') || 'smms',
mimeType: file.mimeType || 'image/png',
sizeRange: calcUploadBigFileSizeRange(file.size / MB)
}
};
this.reportDataToWebContents(webContents, imageEventData);
}
this.reportDataToWebContents(webContents, imageEventData)
} else {
const otherEventData: ITalkingDataOptions = {
EventId: 'upload_file',
Label: '',
MapKv: {
type: db.get('picBed.uploader') || db.get('picBed.current') || 'smms',
type: picgo.getConfig<string>('picBed.uploader') || picgo.getConfig<string>('picBed.current') || 'smms',
mimeType: file.mimeType || 'UNKNOWN',
sizeRange: calcUploadBigFileSizeRange(file.size / MB)
}
};
this.reportDataToWebContents(webContents, otherEventData);
}
this.reportDataToWebContents(webContents, otherEventData)
}
});
})
}
public async registerDeviceID(webContents: WebContents) {
if (this.hasRegisterDeviceID) return;
await this.init();
webContents.send(REGISTER_DEVICE_ID, this.deviceId);
if (this.hasRegisterDeviceID) return
await this.init()
webContents.send(REGISTER_DEVICE_ID, this.deviceId)
}
private reportDataToWebContents(webContents: WebContents, data: ITalkingDataOptions) {
@@ -111,22 +111,22 @@ class DataReportManager {
version: app.getVersion(),
area: this.getAreaFromTimezone()
}
});
})
}
private getAreaFromTimezone() {
try {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (!timeZone) return 'UNKNOWN';
try {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
if (!timeZone) return 'UNKNOWN'
if (timeZone === 'Asia/Shanghai') return 'CN';
if (timeZone === 'Asia/Shanghai') return 'CN'
const region = timeZone.split('/')[0]; // Asia, America, Europe
return region;
} catch (e) {
return 'UNKNOWN';
const region = timeZone.split('/')[0] // Asia, America, Europe
return region
} catch (e) {
return 'UNKNOWN'
}
}
}
}
export const dataReportManager = new DataReportManager()
+2 -2
View File
@@ -1,6 +1,6 @@
import db from '~/main/apis/core/datastore'
import picgo from '@core/picgo'
import { i18nManager } from '~/main/i18n'
export const initI18n = () => {
const currentLanguage = db.get('settings.language') || 'zh-CN'
const currentLanguage = picgo.getConfig<string>('settings.language') || 'en'
i18nManager.setCurrentLanguage(currentLanguage)
}
+10 -5
View File
@@ -1,16 +1,16 @@
import db from '~/main/apis/core/datastore'
import picgo from '@core/picgo'
import { showMessageBox } from '~/main/utils/common'
import { T } from '~/main/i18n'
class PrivacyManager {
async check () {
if (db.get('settings.privacyEnsure') !== true) {
if (picgo.getConfig<string | boolean>('settings.privacyEnsure') !== '20260127') {
const res = await this.show(true)
// cancel
if (res.result === 1) {
return false
} else {
db.set('settings.privacyEnsure', true)
picgo.saveConfig({ 'settings.privacyEnsure': '20260127' })
return true
}
}
@@ -18,11 +18,16 @@ class PrivacyManager {
}
async show (showCancel = true) {
const privacyUrl = 'https://picgo.app/privacy/'
const termsUrl = 'https://picgo.app/terms/'
const res = await showMessageBox({
type: 'info',
buttons: showCancel ? ['Yes', 'No'] : ['Yes'],
title: T('PRIVACY_AGREEMENT'),
message: T('PRIVACY')
title: T('PRIVACY_TERMS_AGREEMENT'),
message: T('PRIVACY', {
privacyUrl,
termsUrl
})
})
return res
}
+6 -6
View File
@@ -1,5 +1,5 @@
import { dialog, shell } from 'electron'
import db from '~/main/apis/core/datastore'
import picgo from '@core/picgo'
import pkg from 'root/package.json'
import { lt } from 'semver'
import { T } from '~/main/i18n'
@@ -10,13 +10,13 @@ const version = pkg.version
const downloadUrl = 'https://github.com/Molunerfinn/PicGo/releases/latest'
const checkVersion = async () => {
let showTip = db.get('settings.showUpdateTip')
let showTip = picgo.getConfig<boolean | undefined>('settings.showUpdateTip')
if (showTip === undefined) {
db.set('settings.showUpdateTip', true)
picgo.saveConfig({ 'settings.showUpdateTip': true })
showTip = true
}
if (showTip) {
const isCheckBetaUpdate = db.get('settings.checkBetaUpdate') !== false
const isCheckBetaUpdate = picgo.getConfig<boolean>('settings.checkBetaUpdate') !== false
const res: string = await getLatestVersion(isCheckBetaUpdate)
if (res !== '') {
const latest = res
@@ -35,7 +35,7 @@ const checkVersion = async () => {
if (res.response === 0) { // if selected yes
shell.openExternal(downloadUrl)
}
db.set('settings.showUpdateTip', !res.checkboxChecked)
picgo.saveConfig({ 'settings.showUpdateTip': !res.checkboxChecked })
})
}
} else {
@@ -50,7 +50,7 @@ const checkVersion = async () => {
const compareVersion2Update = (current: string, latest: string) => {
try {
if (latest.includes('beta')) {
const isCheckBetaUpdate = db.get('settings.checkBetaUpdate') !== false
const isCheckBetaUpdate = picgo.getConfig<boolean>('settings.checkBetaUpdate') !== false
if (!isCheckBetaUpdate) {
return false
}
+12 -7
View File
@@ -7,22 +7,27 @@
<script lang="ts" setup>
import { useStore } from '@/hooks/useStore'
import { onBeforeMount, onMounted, onUnmounted } from 'vue'
import { getConfig } from './utils/dataSender'
import type { IConfig } from 'picgo'
import bus from './utils/bus'
import { FORCE_UPDATE } from '~/universal/events/constants'
import { APP_CONFIG_UPDATED, FORCE_UPDATE } from '~/universal/events/constants'
import { useATagClick } from './hooks/useATagClick'
import { useIPCOn } from './hooks/useIPC'
useATagClick()
const store = useStore()
const handleAppConfigUpdated = () => {
store?.refreshAppConfig()
store?.refreshPicBeds()
}
onBeforeMount(async () => {
const config = await getConfig<IConfig>()
if (config) {
store?.setDefaultPicBed(config?.picBed?.uploader || config?.picBed?.current || 'smms')
}
if (!store) return
await store.refreshAppConfig()
await store.refreshPicBeds()
})
useIPCOn(APP_CONFIG_UPDATED, handleAppConfigUpdated)
onMounted(() => {
bus.on(FORCE_UPDATE, () => {
store?.updateForceUpdateTime()
+6 -1
View File
@@ -32,6 +32,7 @@ import { getConfig } from '@/utils/dataSender'
import { useRoute } from 'vue-router'
import BaseConfigForm from './form/BaseConfigForm.vue'
import { useConfigForm } from '@/hooks/useConfigForm'
import { useStore } from '@/hooks/useStore'
interface IProps {
config: any[]
@@ -46,6 +47,7 @@ const $form = ref<IFormInstance>()
const configList = ref<IPicGoPluginConfig[]>([])
const formModel = reactive<IStringKeyMap>({})
const isUploader = props.type === 'uploader'
const store = useStore()
async function validate (): Promise<IStringKeyMap | false> {
const res = await $form.value?.validate() || false
@@ -81,7 +83,10 @@ async function getCurConfigFormData () {
const configId = $route.params.configId
const configType = getConfigType()
if (isUploader) {
const curTypeConfigList = await getConfig<IStringKeyMap[]>(`uploader.${props.id}.configList`) || []
const cachedList = store?.state.appConfig?.uploader?.[props.id]?.configList
const curTypeConfigList = Array.isArray(cachedList)
? cachedList
: await getConfig<IStringKeyMap[]>(`uploader.${props.id}.configList`) || []
return curTypeConfigList.find(i => i._id === configId) || {}
} else {
const config = await getConfig<IStringKeyMap>(configType)
@@ -3,6 +3,7 @@
<ConfirmDialog
v-model="visible"
:title="title"
:width="width"
@confirm="handleConfirm"
@cancel="handleCancel"
@close="handleCancel"
@@ -31,6 +32,7 @@ const configList = ref<IPicGoPluginConfig[]>([])
const formModel = reactive<IStringKeyMap>({})
const $form = ref<IFormInstance>()
const title = ref('')
const width = ref(500)
const handleConfigForm = useConfigForm()
@@ -39,6 +41,7 @@ useIPCOn(IRPCActionType.OPEN_CONFIG_DIALOG, (event, options: IPicGoPluginShowCon
visible.value = true
configList.value = handleConfigForm(options.config, formModel)
title.value = options.title
width.value = options.width || 500
})
const handleConfirm = async () => {
const res = await $form.value?.validate() || false
@@ -2,6 +2,8 @@
<ElDialog
v-model="visible"
:title="title"
:width="width + 'px'"
:append-to-body="true"
@close="handleClose"
>
<slot name="body" />
@@ -39,10 +41,12 @@ interface IProps {
title: string
confirmButtonText?: string
cancelButtonText?: string
width?: number
}
const props = withDefaults(defineProps<IProps>(), {
confirmButtonText: $T('CONFIRM'),
cancelButtonText: $T('CANCEL')
cancelButtonText: $T('CANCEL'),
width: 500
})
const $emit = defineEmits(['confirm', 'cancel', 'close'])
@@ -2,12 +2,31 @@
<el-dialog
v-model="showInputBoxVisible"
:title="inputBoxOptions.title || $T('INPUT')"
:modal-append-to-body="false"
:append-to-body="true"
:width="inputBoxOptions.width + 'px'"
>
<el-input
v-model="inputBoxValue"
:placeholder="inputBoxOptions.placeholder"
:type="inputBoxOptions.inputType || 'text'"
:show-password="inputBoxOptions.inputType === 'password'"
:rows="inputBoxOptions.inputType === 'textarea' ? 6 : undefined"
:class="{ 'input-box__textarea': inputBoxOptions.inputType === 'textarea' }"
/>
<el-input
v-if="inputBoxOptions.hasConfirm"
v-model="inputBoxConfirmValue"
class="mt-[12px]"
:placeholder="inputBoxOptions.confirmPlaceholder"
:type="inputBoxOptions.inputType || 'text'"
:show-password="inputBoxOptions.inputType === 'password'"
/>
<div
v-if="confirmError"
class="mt-[8px] text-[12px] text-[#f56c6c] leading-[18px]"
>
{{ confirmError }}
</div>
<template #footer>
<el-button
round
@@ -26,7 +45,7 @@
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, reactive, onBeforeUnmount, onBeforeMount } from 'vue'
import { ref, reactive, onBeforeUnmount, onBeforeMount, watch } from 'vue'
import { ipcRenderer, IpcRendererEvent } from 'electron'
import {
SHOW_INPUT_BOX,
@@ -34,11 +53,18 @@ import {
} from '~/universal/events/constants'
import $bus from '@/utils/bus'
import { sendToMain } from '@/utils/dataSender'
import { T as $T } from '@/i18n/index'
const inputBoxValue = ref('')
const inputBoxConfirmValue = ref('')
const confirmError = ref('')
const showInputBoxVisible = ref(false)
const inputBoxOptions = reactive({
title: '',
placeholder: ''
placeholder: '',
inputType: 'text' as 'text' | 'textarea' | 'password',
width: 500,
hasConfirm: false,
confirmPlaceholder: ''
})
onBeforeMount(() => {
@@ -52,8 +78,14 @@ function ipcEventHandler (evt: IpcRendererEvent, options: IShowInputBoxOption) {
function initInputBoxValue (options: IShowInputBoxOption) {
inputBoxValue.value = options.value || ''
inputBoxConfirmValue.value = options.confirm?.value || ''
inputBoxOptions.title = options.title || ''
inputBoxOptions.placeholder = options.placeholder || ''
inputBoxOptions.inputType = options.inputType || 'text'
inputBoxOptions.width = options.width || 400
inputBoxOptions.hasConfirm = !!options.confirm
inputBoxOptions.confirmPlaceholder = options.confirm?.placeholder || ''
confirmError.value = ''
showInputBoxVisible.value = true
}
@@ -65,11 +97,19 @@ function handleInputBoxCancel () {
}
function handleInputBoxConfirm () {
if (inputBoxOptions.hasConfirm && inputBoxValue.value !== inputBoxConfirmValue.value) {
confirmError.value = $T('INPUT_BOX_CONFIRM_MISMATCH')
return
}
showInputBoxVisible.value = false
sendToMain(SHOW_INPUT_BOX, inputBoxValue.value)
$bus.emit(SHOW_INPUT_BOX_RESPONSE, inputBoxValue.value)
}
watch([inputBoxValue, inputBoxConfirmValue], () => {
confirmError.value = ''
})
onBeforeUnmount(() => {
ipcRenderer.removeListener(SHOW_INPUT_BOX, ipcEventHandler)
$bus.off(SHOW_INPUT_BOX)
@@ -82,4 +122,8 @@ export default {
}
</script>
<style lang='stylus'>
.input-box__textarea
.el-textarea__inner
resize vertical
max-height 240px
</style>
@@ -89,6 +89,7 @@ import { ref } from 'vue'
import { marked } from 'marked'
import type { FormInstance } from 'element-plus'
import { useVModel } from '@/hooks/useVModel'
import { QuestionFilled } from '@element-plus/icons-vue'
const $form = ref<FormInstance>()
@@ -117,7 +118,6 @@ async function validate (): Promise<IStringKeyMap | false> {
resolve(form.value)
} else {
resolve(false)
return false
}
})
})
@@ -0,0 +1,412 @@
<template>
<el-dialog
v-model="visible"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
class="picgo-cloud-config-sync-conflict-dialog"
width="80%"
top="6vh"
append-to-body
lock-scroll
header-class="!px-[20px] !pt-[18px] !pb-[14px] !m-0 border-b border-slate-100"
body-class="!p-0 overflow-hidden"
footer-class="!px-[20px] !py-[16px] border-t border-slate-100"
>
<template #header>
<div class="flex items-start justify-between gap-[16px]">
<div class="min-w-0">
<div class="flex items-center gap-[8px]">
<el-icon>
<WarningFilled class="text-[20px] text-orange-500" />
</el-icon>
<div class="text-[20px] font-semibold text-slate-800 leading-[22px]">
{{ $T('PICGO_CLOUD_CONFIG_SYNC_CONFLICT_TITLE', { count: conflicts.length }) }}
</div>
</div>
</div>
<div class="flex items-center gap-[10px] flex-shrink-0">
<el-button
size="small"
round
@click="handleResetAll"
>
<el-icon class="mr-1">
<RefreshLeft />
</el-icon>
{{ $T('PICGO_CLOUD_CONFIG_SYNC_RESET_ALL') }}
</el-button>
<el-button-group>
<el-button
size="small"
round
@click="handleChooseAllLocal"
>
{{ $T('PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_LOCAL') }}
</el-button>
<el-button
size="small"
round
@click="handleChooseAllCloud"
>
{{ $T('PICGO_CLOUD_CONFIG_SYNC_CHOOSE_ALL_CLOUD') }}
</el-button>
</el-button-group>
</div>
</div>
<div class="mt-[4px] text-[12px] text-slate-500 leading-[18px]">
{{ $T('PICGO_CLOUD_CONFIG_SYNC_CONFLICT_DETECTED') }}
</div>
</template>
<!-- Conflict List -->
<div>
<div class="h-full overflow-y-auto px-[20px] pt-[16px] pb-[24px] picgo-cloud-conflict-list">
<div
v-for="item in conflicts"
:key="item.path"
class="mb-[16px] last:mb-0 rounded-[12px] border-2 bg-white shadow-[0_1px_6px_rgba(15,23,42,0.10)] transition-colors duration-200 overflow-hidden"
:class="getCardBorderClass(item.path)"
>
<!-- Card Header -->
<div class="bg-slate-100/80 px-[16px] py-[10px] border-b border-slate-200 flex justify-between items-center gap-[12px]">
<div class="min-w-0 flex items-center gap-2">
<el-icon class="text-slate-500">
<Operation />
</el-icon>
<span class="font-mono font-medium text-[12px] text-slate-700 leading-[16px] break-all">
{{ item.path }}
</span>
</div>
<div
v-if="selections[item.path]"
class="px-[10px] py-[4px] rounded-full text-[12px] font-medium flex items-center gap-1 shrink-0 border"
:class="selections[item.path] === IPicGoCloudConfigSyncConflictChoice.LOCAL
? 'bg-blue-100 text-blue-700 border-blue-200'
: 'bg-purple-100 text-purple-700 border-purple-200'"
>
<el-icon class="text-[14px]">
<Check />
</el-icon>
{{ selections[item.path] === IPicGoCloudConfigSyncConflictChoice.LOCAL
? $T('PICGO_CLOUD_CONFIG_SYNC_LOCAL_VERSION')
: $T('PICGO_CLOUD_CONFIG_SYNC_CLOUD_VERSION') }}
</div>
</div>
<!-- Card Content -->
<div>
<div class="grid grid-cols-[1fr_auto_1fr] gap-[12px] items-stretch overflow-x-auto p-[16px]">
<!-- Local Option -->
<div
class="relative cursor-pointer rounded-[8px] p-[16px] border-2 transition-all duration-200 group flex flex-col"
:class="localBoxClass(item.path)"
@click="setChoice(item.path, IPicGoCloudConfigSyncConflictChoice.LOCAL)"
>
<div
class="flex items-center gap-[8px] mb-[8px] font-semibold text-[14px]"
:class="selections[item.path] === IPicGoCloudConfigSyncConflictChoice.LOCAL ? 'text-blue-600' : 'text-gray-600'"
>
<el-icon class="text-[16px]">
<Monitor />
</el-icon>
<span>{{ $T('PICGO_CLOUD_CONFIG_SYNC_LOCAL_VERSION') }}</span>
</div>
<div class="font-mono text-[13px] text-slate-800 break-words leading-relaxed whitespace-pre-wrap">
{{ formatValue(item.localValue) }}
</div>
<div
v-if="selections[item.path] === IPicGoCloudConfigSyncConflictChoice.LOCAL"
class="absolute top-[12px] right-[12px] text-blue-500"
>
<el-icon class="text-[20px]">
<Check />
</el-icon>
</div>
</div>
<!-- Middle Indicator -->
<div class="flex flex-col items-center justify-center w-[40px]">
<el-icon
v-if="selections[item.path] === IPicGoCloudConfigSyncConflictChoice.LOCAL"
class="text-[24px] text-blue-500"
>
<ArrowLeft />
</el-icon>
<el-icon
v-else-if="selections[item.path] === IPicGoCloudConfigSyncConflictChoice.CLOUD"
class="text-[24px] text-purple-500"
>
<ArrowRight />
</el-icon>
<div
v-else
class="w-[2px] h-full bg-gray-100 rounded-full my-2"
/>
</div>
<!-- Cloud Option -->
<div
class="relative cursor-pointer rounded-[8px] p-[16px] border-2 transition-all duration-200 group flex flex-col"
:class="cloudBoxClass(item.path)"
@click="setChoice(item.path, IPicGoCloudConfigSyncConflictChoice.CLOUD)"
>
<div
class="flex items-center gap-[8px] mb-[8px] font-semibold text-[14px]"
:class="selections[item.path] === IPicGoCloudConfigSyncConflictChoice.CLOUD ? 'text-purple-600' : 'text-gray-600'"
>
<el-icon class="text-[16px]">
<Cloudy />
</el-icon>
<span>{{ $T('PICGO_CLOUD_CONFIG_SYNC_CLOUD_VERSION') }}</span>
</div>
<div class="font-mono text-[13px] text-slate-800 break-words leading-relaxed whitespace-pre-wrap">
{{ formatValue(item.remoteValue) }}
</div>
<div
v-if="selections[item.path] === IPicGoCloudConfigSyncConflictChoice.CLOUD"
class="absolute top-[12px] right-[12px] text-purple-500"
>
<el-icon class="text-[20px]">
<Check />
</el-icon>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex items-center justify-between">
<div class="text-[13px] text-gray-500 mr-[20px] text-left">
<span
v-if="remainingCount > 0"
>
{{ $T('PICGO_CLOUD_CONFIG_SYNC_CONFLICT_PENDING') }}
<span class="font-bold text-orange-500 ml-1">{{ remainingCount }}</span>
</span>
<span
v-else
class="text-green-600 flex items-center gap-1"
>
<el-icon class="text-[14px]">
<SuccessFilled />
</el-icon>
{{ $T('PICGO_CLOUD_CONFIG_SYNC_CONFLICT_RESOLVED') }}
</span>
</div>
<div class="flex items-center gap-[12px]">
<el-button
round
plain
:disabled="confirmLoading"
@click="handleAbort"
>
{{ $T('PICGO_CLOUD_CONFIG_SYNC_ABORT') }}
</el-button>
<el-button
type="primary"
round
:loading="confirmLoading"
:disabled="isConfirmDisabled || confirmLoading"
@click="handleConfirm"
>
{{ $T('PICGO_CLOUD_CONFIG_SYNC_CONFIRM_AND_SYNC') }}
</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, reactive, watch } from 'vue'
import { T as $T } from '@/i18n/index'
import {
IPicGoCloudConfigSyncConflictChoice,
type IPicGoCloudConfigSyncConflictItem,
type IPicGoCloudConfigSyncResolution
} from '#/types/cloudConfigSync'
import {
ArrowLeft,
ArrowRight,
Check,
Cloudy,
Monitor,
Operation,
RefreshLeft,
SuccessFilled,
WarningFilled
} from '@element-plus/icons-vue'
const props = defineProps<{
modelValue: boolean
conflicts: IPicGoCloudConfigSyncConflictItem[]
confirmLoading: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'abort'): void
(e: 'confirm', resolution: IPicGoCloudConfigSyncResolution): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
type IScrollLockRecord = {
el: HTMLElement
previousOverflow: string
}
const scrollLockRecords: IScrollLockRecord[] = []
const lockPageScroll = () => {
if (typeof document === 'undefined') return
if (scrollLockRecords.length > 0) return
const targets: Array<HTMLElement | null> = [
document.documentElement,
document.body,
document.getElementById('main-page'),
document.querySelector<HTMLElement>('.main-wrapper')
]
targets.forEach((el) => {
if (!el) return
scrollLockRecords.push({
el,
previousOverflow: el.style.overflow
})
el.style.overflow = 'hidden'
})
}
const unlockPageScroll = () => {
if (scrollLockRecords.length === 0) return
scrollLockRecords.forEach(({ el, previousOverflow }) => {
el.style.overflow = previousOverflow
})
scrollLockRecords.length = 0
}
watch(visible, (nextVisible) => {
if (nextVisible) {
lockPageScroll()
return
}
unlockPageScroll()
}, { immediate: true })
onBeforeUnmount(() => {
unlockPageScroll()
})
const selections = reactive<Record<string, IPicGoCloudConfigSyncConflictChoice | undefined>>({})
watch(() => props.conflicts, (next) => {
// New conflict set: reset selections.
Object.keys(selections).forEach((key) => {
delete selections[key]
})
next.forEach((item) => {
selections[item.path] = undefined
})
}, { immediate: true })
const isConfirmDisabled = computed(() => {
if (!props.conflicts.length) return true
return props.conflicts.some(item => !selections[item.path])
})
const remainingCount = computed(() => {
return props.conflicts.filter(item => !selections[item.path]).length
})
const setChoice = (path: string, choice: IPicGoCloudConfigSyncConflictChoice) => {
selections[path] = choice
}
const getCardBorderClass = (path: string) => {
const choice = selections[path]
if (!choice) return 'border-slate-300'
return choice === IPicGoCloudConfigSyncConflictChoice.LOCAL ? 'border-blue-300' : 'border-purple-300'
}
const localBoxClass = (path: string) => {
const selected = selections[path] === IPicGoCloudConfigSyncConflictChoice.LOCAL
return selected
? 'bg-blue-50 border-blue-500 ring-1 ring-blue-500'
: 'bg-white border-gray-200 hover:border-blue-300 hover:bg-gray-100'
}
const cloudBoxClass = (path: string) => {
const selected = selections[path] === IPicGoCloudConfigSyncConflictChoice.CLOUD
return selected
? 'bg-purple-50 border-purple-500 ring-1 ring-purple-500'
: 'bg-white border-gray-200 hover:border-purple-300 hover:bg-gray-100'
}
const handleChooseAllLocal = () => {
props.conflicts.forEach(item => {
selections[item.path] = IPicGoCloudConfigSyncConflictChoice.LOCAL
})
}
const handleChooseAllCloud = () => {
props.conflicts.forEach(item => {
selections[item.path] = IPicGoCloudConfigSyncConflictChoice.CLOUD
})
}
const handleResetAll = () => {
props.conflicts.forEach(item => {
selections[item.path] = undefined
})
}
const handleAbort = () => {
emit('abort')
visible.value = false
}
const handleConfirm = () => {
const resolution: IPicGoCloudConfigSyncResolution = {}
props.conflicts.forEach(item => {
const choice = selections[item.path]
if (choice) {
resolution[item.path] = choice
}
})
emit('confirm', resolution)
}
const formatValue = (value: unknown): string => {
if (value === undefined) return $T('PICGO_CLOUD_CONFIG_SYNC_VALUE_UNDEFINED')
try {
return JSON.stringify(value, null, 2) ?? String(value)
} catch (e) {
return String(value)
}
}
</script>
<style scoped>
.picgo-cloud-conflict-list::-webkit-scrollbar {
width: 6px;
}
.picgo-cloud-conflict-list::-webkit-scrollbar-track {
background: transparent;
}
.picgo-cloud-conflict-list::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 3px;
}
.picgo-cloud-conflict-list::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.25);
}
</style>
@@ -7,7 +7,6 @@
<el-tooltip
class="item"
effect="dark"
:open="true"
placement="right"
>
<template #content>
@@ -34,7 +33,7 @@
import { T as $T } from '@/i18n'
import { saveConfig } from '@/utils/dataSender'
import { QuestionFilled } from '@element-plus/icons-vue'
import { showNotification } from '@/utils/common'
import { showNotification } from '@/utils/notification'
import { useVModel } from '@/hooks/useVModel'
interface IProps {
@@ -50,11 +49,14 @@ const emit = defineEmits(['update:modelValue', 'change'])
const value = useVModel(props, 'modelValue')
const handleChange = (value: ISwitchValueType) => {
saveConfig(`settings.${props.settingProps}`, value)
const handleChange = async (value: ISwitchValueType) => {
await saveConfig(`settings.${props.settingProps}`, value)
emit('update:modelValue', value)
emit('change', value)
showNotification(props.label, $T('TIPS_SET_SUCCEED'))
showNotification({
title: props.label,
body: $T('TIPS_SET_SUCCEED')
})
}
</script>
+17 -14
View File
@@ -77,6 +77,12 @@
</el-menu-item>
</template>
</el-sub-menu>
<el-menu-item :index="routerConfig.PICGO_CLOUD_PAGE">
<el-icon>
<Cloudy />
</el-icon>
<span>PicGo Cloud</span>
</el-menu-item>
<el-menu-item :index="routerConfig.SETTING_PAGE">
<el-icon>
<Setting />
@@ -205,6 +211,7 @@ import {
UploadFilled,
PictureFilled,
Menu,
Cloudy,
Share,
InfoFilled,
Minus,
@@ -213,7 +220,7 @@ import {
} from '@element-plus/icons-vue'
import { ElMessage as $message } from 'element-plus'
import { T as $T } from '@/i18n/index'
import { ref, onBeforeUnmount, Ref, onBeforeMount, watch, nextTick, reactive } from 'vue'
import { ref, onBeforeUnmount, Ref, onBeforeMount, watch, nextTick, reactive, computed } from 'vue'
import { onBeforeRouteUpdate, useRouter } from 'vue-router'
import QrcodeVue from 'qrcode.vue'
import pick from 'lodash/pick'
@@ -221,7 +228,6 @@ import pkg from 'root/package.json'
import * as config from '@/router/config'
import {
ipcRenderer,
IpcRendererEvent,
clipboard
} from 'electron'
import InputBoxDialog from '@/components/dialog/InputBoxDialog.vue'
@@ -230,18 +236,19 @@ import {
CLOSE_WINDOW,
SHOW_MAIN_PAGE_MENU,
SHOW_MAIN_PAGE_QRCODE,
SHOW_MAIN_PAGE_DONATION,
GET_PICBEDS
SHOW_MAIN_PAGE_DONATION
} from '~/universal/events/constants'
import { getConfig, sendToMain } from '@/utils/dataSender'
import { useOS } from '@/hooks/useOS'
import { useStore } from '@/hooks/useStore'
const version = ref(process.env.NODE_ENV === 'production' ? pkg.version : 'Dev')
const routerConfig = reactive(config)
const defaultActive = ref(routerConfig.UPLOAD_PAGE)
const visible = ref(false)
const os = useOS()
const $router = useRouter()
const picBed: Ref<IPicBedType[]> = ref([])
const store = useStore()
const picBed = computed(() => store?.state.picBeds ?? [])
const qrcodeVisible = ref(false)
const picBedConfigString = ref('')
const choosedPicBedForQRCode: Ref<string[]> = ref([])
@@ -249,8 +256,7 @@ const choosedPicBedForQRCode: Ref<string[]> = ref([])
const keepAlivePages = $router.getRoutes().filter(item => item.meta.keepAlive).map(item => item.name as string)
onBeforeMount(() => {
sendToMain(GET_PICBEDS)
ipcRenderer.on(GET_PICBEDS, getPicBeds)
store?.refreshPicBeds()
handleGetPicPeds()
ipcRenderer.on(SHOW_MAIN_PAGE_QRCODE, () => {
qrcodeVisible.value = true
@@ -263,7 +269,7 @@ onBeforeMount(() => {
watch(() => choosedPicBedForQRCode, (val) => {
if (val.value.length > 0) {
nextTick(async () => {
const picBedConfig = await getConfig('picBed')
const picBedConfig = store?.state.appConfig?.picBed ?? await getConfig('picBed')
const config = pick(picBedConfig, ...choosedPicBedForQRCode.value)
picBedConfigString.value = JSON.stringify(config)
})
@@ -271,7 +277,7 @@ watch(() => choosedPicBedForQRCode, (val) => {
}, { deep: true })
const handleGetPicPeds = () => {
sendToMain(GET_PICBEDS)
store?.refreshPicBeds()
}
const handleSelect = (index: string) => {
@@ -325,10 +331,6 @@ function handleCopyPicBedConfig () {
$message.success($T('COPY_PICBED_CONFIG_SUCCEED'))
}
function getPicBeds (event: IpcRendererEvent, picBeds: IPicBedType[]) {
picBed.value = picBeds
}
onBeforeRouteUpdate(async (to) => {
if (to.params.type) {
defaultActive.value = `${routerConfig.UPLOADER_CONFIG_PAGE}-${to.params.type}`
@@ -338,7 +340,8 @@ onBeforeRouteUpdate(async (to) => {
})
onBeforeUnmount(() => {
ipcRenderer.removeListener(GET_PICBEDS, getPicBeds)
ipcRenderer.removeAllListeners(SHOW_MAIN_PAGE_QRCODE)
ipcRenderer.removeAllListeners(SHOW_MAIN_PAGE_DONATION)
})
</script>
+1 -2
View File
@@ -12,7 +12,7 @@ import { dragMixin } from '@/utils/mixin'
import { initTalkingData } from './utils/analytics'
import db from './utils/db'
import { i18nManager, T } from './i18n/index'
import { getConfig, saveConfig, sendToMain, triggerRPC } from '@/utils/dataSender'
import { getConfig, saveConfig, sendToMain } from '@/utils/dataSender'
import { store } from '@/store'
import vue3PhotoPreview from 'vue3-photo-preview'
import 'vue3-photo-preview/dist/index.css'
@@ -37,7 +37,6 @@ app.config.globalProperties.$http = axios
app.config.globalProperties.$T = T
app.config.globalProperties.$i18n = i18nManager
app.config.globalProperties.getConfig = getConfig
app.config.globalProperties.triggerRPC = triggerRPC
app.config.globalProperties.saveConfig = saveConfig
app.config.globalProperties.sendToMain = sendToMain
+49 -36
View File
@@ -31,7 +31,7 @@
:placeholder="$T('CHOOSE_SHOWED_PICBED')"
>
<el-option
v-for="item in picBed"
v-for="item in visiblePicBedList"
:key="item.type"
:label="item.name"
:value="item.type"
@@ -187,22 +187,23 @@
</template>
<script lang="ts" setup>
import type { IResult } from '@picgo/store/dist/types'
import { PASTE_TEXT, GET_PICBEDS } from '#/events/constants'
import { PASTE_TEXT } from '#/events/constants'
import { CheckboxValueType, ElMessageBox } from 'element-plus'
import { Close, CaretBottom, Document, Edit, Delete, CaretTop } from '@element-plus/icons-vue'
import {
ipcRenderer,
clipboard,
IpcRendererEvent
clipboard
} from 'electron'
import { computed, nextTick, onActivated, onBeforeUnmount, onBeforeMount, reactive, ref, watch } from 'vue'
import { getConfig, saveConfig, sendRPC, sendToMain } from '@/utils/dataSender'
import { saveConfig, sendRPC, sendToMain } from '@/utils/dataSender'
import { onBeforeRouteUpdate } from 'vue-router'
import { T as $T } from '@/i18n/index'
import $$db from '@/utils/db'
import GalleryToolbar from './components/gallery/GalleryToolbar.vue'
import { IRPCActionType } from '~/universal/types/enum'
import { getRawData } from '@/utils/common'
import { showNotification } from '@/utils/notification'
import { useStore } from '@/hooks/useStore'
const images = ref<ImgInfo[]>([])
const dialogVisible = ref(false)
const imgInfo = reactive({
@@ -228,7 +229,9 @@ const pasteStyleMap = {
UBB: 'UBB',
Custom: 'Custom'
}
const picBed = ref<IPicBedType[]>([])
const store = useStore()
const picBed = computed(() => store?.state.picBeds ?? [])
const visiblePicBedList = computed(() => picBed.value.filter(item => item.visible))
onBeforeRouteUpdate((to, from) => {
if (from.name === 'gallery') {
clearSelectedList()
@@ -244,8 +247,8 @@ onBeforeMount(async () => {
updateGallery()
})
})
sendToMain(GET_PICBEDS)
ipcRenderer.on(GET_PICBEDS, getPicBeds)
store?.refreshPicBeds()
store?.refreshAppConfig()
updateGallery()
document.addEventListener('keydown', handleDetectShiftKey)
@@ -277,10 +280,6 @@ const isAllSelected = computed(() => {
}
})
function getPicBeds (event: IpcRendererEvent, picBeds: IPicBedType[]) {
picBed.value = picBeds
}
function getGallery (): IGalleryItem[] {
if (searchText.value || selectedPicBed.value.length > 0) {
return images.value
@@ -322,6 +321,12 @@ watch(() => filterList, () => {
clearSelectedList()
})
watch(picBed, (list) => {
if (selectedPicBed.value.length === 0) return
const visibleTypes = new Set(list.filter(item => item.visible).map(item => item.type))
selectedPicBed.value = selectedPicBed.value.filter(type => visibleTypes.has(type))
})
function handleChooseImage (val: CheckboxValueType, index: number) {
if (val === true) {
handleBarActive.value = true
@@ -375,10 +380,10 @@ async function copy (item: ImgInfo) {
// sometimes will cause lagging
// icon: item.url || item.imgUrl
}
const myNotification = new Notification(obj.title, obj)
myNotification.onclick = () => {
return true
}
showNotification({
title: obj.title,
body: obj.body
})
}
function remove (id?: string) {
@@ -395,10 +400,10 @@ function remove (id?: string) {
title: $T('OPERATION_SUCCEED'),
body: ''
}
const myNotification = new Notification(obj.title, obj)
myNotification.onclick = () => {
return true
}
showNotification({
title: obj.title,
body: obj.body
})
updateGallery()
}).catch((e) => {
console.log(e)
@@ -422,10 +427,10 @@ async function confirmModify () {
body: imgInfo.imgUrl
// icon: this.imgInfo.imgUrl
}
const myNotification = new Notification(obj.title, obj)
myNotification.onclick = () => {
return true
}
showNotification({
title: obj.title,
body: obj.body
})
dialogVisible.value = false
updateGallery()
}
@@ -478,10 +483,10 @@ function multiRemove () {
body: ''
}
sendToMain('removeFiles', files)
const myNotification = new Notification(obj.title, obj)
myNotification.onclick = () => {
return true
}
showNotification({
title: obj.title,
body: obj.body
})
updateGallery()
}).catch(() => {
return true
@@ -509,11 +514,11 @@ async function multiCopy () {
title: $T('BATCH_COPY_LINK_SUCCEED'),
body: copyString.join('\n')
}
const myNotification = new Notification(obj.title, obj)
clipboard.writeText(copyString.join('\n'))
myNotification.onclick = () => {
return true
}
showNotification({
title: obj.title,
body: obj.body
})
}
}
@@ -522,17 +527,25 @@ function toggleHandleBar () {
}
async function handlePasteStyleChange (val: string) {
saveConfig('settings.pasteStyle', val)
await saveConfig('settings.pasteStyle', val)
pasteStyle.value = val
}
onBeforeUnmount(() => {
ipcRenderer.removeAllListeners('updateGallery')
ipcRenderer.removeListener(GET_PICBEDS, getPicBeds)
})
onActivated(async () => {
pasteStyle.value = (await getConfig('settings.pasteStyle')) || 'markdown'
const applyAppConfig = () => {
const settings = store?.state.appConfig?.settings ?? {}
pasteStyle.value = settings.pasteStyle || 'markdown'
}
watch(() => store?.state.appConfig, () => {
applyAppConfig()
}, { immediate: true })
onActivated(() => {
applyAppConfig()
})
</script>
+66 -29
View File
@@ -31,15 +31,16 @@
// import mixin from '@/utils/mixin'
// import { Component, Vue, Watch } from 'vue-property-decorator'
import { T as $T } from '@/i18n/index'
import { ElMessage as $message } from 'element-plus'
import { showNotification } from '@/utils/notification'
import {
ipcRenderer,
IpcRendererEvent
} from 'electron'
import { onBeforeUnmount, onBeforeMount, ref, watch } from 'vue'
import { SHOW_MINI_PAGE_MENU, SET_MINI_WINDOW_POS } from '~/universal/events/constants'
import { LOG_INVALID_URL_LINES, SHOW_MINI_PAGE_MENU, SET_MINI_WINDOW_POS } from '~/universal/events/constants'
import {
isUrl
isUrl,
parseNewlineSeparatedUrls
} from '~/universal/utils/common'
import { sendToMain } from '@/utils/dataSender'
import { getFilePath } from '@/utils/common'
@@ -84,42 +85,78 @@ watch(progress, (val) => {
}
})
function onDrop (e: DragEvent) {
async function onDrop (e: DragEvent) {
dragover.value = false
const items = e.dataTransfer?.items!
const files = e.dataTransfer?.files!
// send files first
if (files?.length) {
ipcSendFiles(e.dataTransfer?.files!)
} else {
if (items.length === 2 && items[0].type === 'text/uri-list') {
handleURLDrag(items, e.dataTransfer!)
} else if (items[0].type === 'text/plain') {
const str = e.dataTransfer!.getData(items[0].type)
if (isUrl(str)) {
sendToMain('uploadChoosedFiles', [{ path: str }])
} else {
$message.error($T('TIPS_DRAG_VALID_PICTURE_OR_URL'))
}
}
return
}
const dataTransfer = e.dataTransfer
if (!dataTransfer) return
const uriList = dataTransfer.getData('text/uri-list')
if (uriList) {
await handleUriListDrop(uriList, dataTransfer.getData('text/html'))
return
}
const plainText = dataTransfer.getData('text/plain')
if (plainText) {
await handlePlainTextDrop(plainText)
return
}
showNotification({
title: $T('TIPS_ERROR'),
body: $T('TIPS_DRAG_VALID_PICTURE_OR_URL')
})
}
function handleURLDrag (items: DataTransferItemList, dataTransfer: DataTransfer) {
// text/html
// Use this data to get a more precise URL
const urlString = dataTransfer.getData(items[1].type)
const urlMatch = urlString.match(/<img.*src="(.*?)"/)
if (urlMatch) {
sendToMain('uploadChoosedFiles', [
{
path: urlMatch[1]
}
])
} else {
$message.error($T('TIPS_DRAG_VALID_PICTURE_OR_URL'))
async function uploadUrls (urls: string[], invalidLines: string[]) {
if (invalidLines.length) {
sendToMain(LOG_INVALID_URL_LINES, invalidLines)
showNotification({
title: $T('TIPS_WARNING'),
body: $T('TIPS_SKIPPED_INVALID_URLS', { n: invalidLines.length })
})
}
sendToMain('uploadChoosedFiles', urls.map((url) => ({ path: url })))
}
async function handlePlainTextDrop (plainText: string) {
const { urls, invalidLines } = parseNewlineSeparatedUrls(plainText, { source: 'plain' })
if (!urls.length) {
showNotification({
title: $T('TIPS_ERROR'),
body: $T('TIPS_DRAG_VALID_PICTURE_OR_URL')
})
return
}
await uploadUrls(urls, invalidLines)
}
async function handleUriListDrop (uriListText: string, urlString: string) {
const { urls, invalidLines } = parseNewlineSeparatedUrls(uriListText, { source: 'uri-list' })
if (urls.length) {
await uploadUrls(urls, invalidLines)
return
}
const urlMatch = urlString.match(/<img.*src="(.*?)"/)
if (urlMatch && isUrl(urlMatch[1])) {
await uploadUrls([urlMatch[1]], invalidLines)
return
}
showNotification({
title: $T('TIPS_ERROR'),
body: $T('TIPS_DRAG_VALID_PICTURE_OR_URL')
})
}
function openUploadWindow () {
+583
View File
@@ -0,0 +1,583 @@
<template>
<div id="picgo-cloud-page">
<div class="view-title">
{{ $T('PICGO_CLOUD_TITLE') }}
</div>
<el-row>
<el-col
:span="20"
:offset="2"
>
<div
v-loading="isUserInfoLoading"
element-loading-background="rgba(0, 0, 0, 0.6)"
class="mt-[16px] rounded-[8px] border border-[rgba(255,255,255,0.06)] bg-[rgba(130,130,130,0.12)] p-[16px]"
>
<el-alert
v-if="errorMessage"
class="!mb-[12px]"
type="error"
show-icon
:title="$T('PICGO_CLOUD_ERROR_TITLE')"
:description="errorMessage"
/>
<template v-if="isLoginInProgress">
<div class="text-[12px] text-[#bbb] leading-[18px] mb-[12px]">
{{ $T('PICGO_CLOUD_LOGIN_IN_PROGRESS') }}
</div>
</template>
<template v-if="userInfo">
<div class="flex items-center gap-[12px] mb-[12px]">
<div class="text-[16px] font-medium text-[#ddd] leading-[22px]">
{{ $T('PICGO_CLOUD_LOGGED_IN_AS', { user: userInfo.user }) }}
</div>
<el-button
size="small"
@click="handleOpenCloud"
>
{{ $T('PICGO_CLOUD_OPEN') }}
</el-button>
<el-button
type="danger"
size="small"
@click="handleLogout"
>
{{ $T('PICGO_CLOUD_LOGOUT') }}
</el-button>
</div>
<div class="flex items-center gap-[12px] flex-wrap">
<el-button
type="primary"
:loading="isConfigSyncRunning"
:disabled="isConfigSyncBusy"
@click="handleConfigSyncStart"
>
{{ $T('PICGO_CLOUD_CONFIG_SYNC') }}
</el-button>
<div class="flex items-center gap-[8px] text-[#bbb]">
<span class="text-[12px] text-[#bbb] leading-[18px] shrink-0 whitespace-nowrap">
{{ $T('PICGO_CLOUD_ENCRYPTION_MODE_LABEL') }}
</span>
<el-select
v-model="encryptionMethodValue"
size="small"
class="w-[180px] shrink-0"
:disabled="isEncryptionModeDisabled"
>
<el-option
:label="$T('PICGO_CLOUD_ENCRYPTION_MODE_AUTO')"
:value="IPicGoCloudEncryptionMethod.AUTO"
/>
<el-option
:label="$T('PICGO_CLOUD_ENCRYPTION_MODE_SERVER')"
:value="IPicGoCloudEncryptionMethod.SSE"
/>
<el-option
:label="$T('PICGO_CLOUD_ENCRYPTION_MODE_E2E')"
:value="IPicGoCloudEncryptionMethod.E2EE"
/>
</el-select>
<el-tooltip
effect="dark"
placement="top"
:enterable="true"
>
<template #content>
<div class="text-[12px] leading-[18px] max-w-[320px]">
<div>{{ $T('PICGO_CLOUD_ENCRYPTION_MODE_TIP_AUTO') }}</div>
<div>{{ $T('PICGO_CLOUD_ENCRYPTION_MODE_TIP_SERVER') }}</div>
<div>{{ $T('PICGO_CLOUD_ENCRYPTION_MODE_TIP_E2E') }}</div>
<div class="mt-[6px]">
<el-link
type="primary"
:underline="false"
@click.stop.prevent="handleOpenDocs"
>
{{ $T('PICGO_CLOUD_ENCRYPTION_MODE_TIP_DOC') }}
</el-link>
</div>
</div>
</template>
<el-icon class="cursor-pointer text-[#999] hover:text-blue">
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
</div>
<div class="mt-[8px] text-[12px] text-[#999] leading-[18px]">
{{ $T('PICGO_CLOUD_LAST_SYNC_TIME', { time: lastSyncedAtText || $T('PICGO_CLOUD_LAST_SYNC_TIME_NONE') }) }}
</div>
</template>
<template v-else>
<div class="text-[12px] text-[#bbb] leading-[18px] mb-[12px]">
{{ $T('PICGO_CLOUD_NOT_LOGGED_IN') }}
</div>
<div class="flex gap-[8px]">
<el-button
type="primary"
:loading="isLoginInProgress"
:disabled="isLoginInProgress || !hasAgreedToTermsAndPrivacy"
@click="handleLogin"
>
{{ $T('PICGO_CLOUD_LOGIN') }}
</el-button>
<el-button
v-if="isLoginInProgress"
@click="handleDisposeLoginFlow"
>
{{ $T('PICGO_CLOUD_CANCEL_LOGIN') }}
</el-button>
</div>
<el-checkbox
v-model="hasAgreedToTermsAndPrivacy"
:disabled="isLoginInProgress"
class="mt-[8px]"
>
<span class="text-[12px] text-[#bbb] leading-[18px]">
{{ $T('PICGO_CLOUD_AGREE_PREFIX') }}
<el-link
type="primary"
:underline="false"
@click.stop.prevent="handleOpenTerms"
>
{{ $T('PICGO_CLOUD_TERMS_OF_SERVICE') }}
</el-link>
{{ $T('PICGO_CLOUD_AGREE_AND') }}
<el-link
type="primary"
:underline="false"
@click.stop.prevent="handleOpenPrivacy"
>
{{ $T('PICGO_CLOUD_PRIVACY_POLICY') }}
</el-link>
</span>
</el-checkbox>
</template>
</div>
</el-col>
</el-row>
<ConfigSyncConflictDialog
v-model="isConflictDialogVisible"
:conflicts="configSyncConflicts"
:confirm-loading="isApplyResolutionLoading"
@abort="handleAbortConfigSync"
@confirm="handleConfirmConfigSyncResolution"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
import { T as $T } from '@/i18n/index'
import { useStore } from '@/hooks/useStore'
import type { IPicGoCloudUserInfo } from '#/types/cloud'
import { IPicGoCloudLoginStatus, IPicGoCloudRequestStatus } from '@/store'
import { invokeRPC } from '@/utils/dataSender'
import { IRPCActionType } from '~/universal/types/enum'
import { openURL } from '@/utils/common'
import { ElMessage, ElMessageBox } from 'element-plus'
import ConfigSyncConflictDialog from '@/components/picgoCloud/ConfigSyncConflictDialog.vue'
import dayjs from 'dayjs'
import { QuestionFilled } from '@element-plus/icons-vue'
import {
IPicGoCloudConfigSyncSessionStatus,
IPicGoCloudConfigSyncToastType,
IPicGoCloudEncryptionMethod,
type IPicGoCloudConfigSyncRunResult,
type IPicGoCloudConfigSyncState,
type IPicGoCloudConfigSyncResolution
} from '#/types/cloudConfigSync'
const store = useStore()
const userInfo = computed(() => store?.state.picgoCloud.userInfo)
const userInfoStatus = computed(() => store?.state.picgoCloud.userInfoStatus ?? IPicGoCloudRequestStatus.IDLE)
const userInfoError = computed(() => store?.state.picgoCloud.userInfoError)
const loginStatus = computed(() => store?.state.picgoCloud.loginStatus ?? IPicGoCloudLoginStatus.IDLE)
const loginError = computed(() => store?.state.picgoCloud.loginError)
const hasAgreedToTermsAndPrivacy = computed({
get: () => store?.state.picgoCloud.hasAgreedToTermsAndPrivacy ?? false,
set: (value: boolean) => {
store?.setPicGoCloudHasAgreedToTermsAndPrivacy(value)
}
})
const TERMS_URL = 'https://picgo.app/terms/'
const PRIVACY_URL = 'https://picgo.app/privacy/'
const DOC_URL = 'https://picgo.app/blog/2026/picgo-configuration-sync-release/'
const CLOUD_URL = 'https://cloud.picgo.app'
const isUserInfoLoading = computed(() => userInfoStatus.value === IPicGoCloudRequestStatus.LOADING)
const isLoginInProgress = computed(() => loginStatus.value === IPicGoCloudLoginStatus.IN_PROGRESS)
const errorMessage = computed(() => loginError.value || userInfoError.value)
const configSyncState = ref<IPicGoCloudConfigSyncState>({
sessionStatus: IPicGoCloudConfigSyncSessionStatus.IDLE,
encryptionMethod: undefined
})
const isConfigSyncStateLoading = ref(false)
const isE2EPreferenceUpdating = ref(false)
const isApplyResolutionLoading = ref(false)
const isConflictDialogVisible = ref(false)
const configSyncSessionStatus = computed(() => configSyncState.value.sessionStatus)
const isConfigSyncRunning = computed(() => configSyncSessionStatus.value === IPicGoCloudConfigSyncSessionStatus.SYNCING)
const isConfigSyncBusy = computed(() => configSyncSessionStatus.value !== IPicGoCloudConfigSyncSessionStatus.IDLE)
const configSyncConflicts = computed(() => configSyncState.value.conflicts ?? [])
const lastSyncedAtText = computed<string | undefined>(() => {
const raw = configSyncState.value.lastSyncedAt
if (!raw) return undefined
const date = dayjs(raw)
if (!date.isValid()) return undefined
return date.format('YYYY-MM-DD HH:mm:ss')
})
const isEncryptionModeDisabled = computed(() => {
if (isConfigSyncBusy.value) return true
return isConfigSyncStateLoading.value || isE2EPreferenceUpdating.value
})
const encryptionMethodValue = computed<IPicGoCloudEncryptionMethod>({
get: () => configSyncState.value.encryptionMethod ?? IPicGoCloudEncryptionMethod.AUTO,
set: (value: IPicGoCloudEncryptionMethod) => {
handleSetEncryptionMethod(value)
}
})
const handleOpenTerms = () => {
openURL(TERMS_URL)
}
const handleOpenPrivacy = () => {
openURL(PRIVACY_URL)
}
const handleOpenDocs = () => {
openURL(DOC_URL)
}
const handleOpenCloud = () => {
openURL(CLOUD_URL)
}
const refreshAppStateAfterSync = async () => {
if (!store) return
await store.refreshAppConfig()
await store.refreshPicBeds()
}
onBeforeMount(() => {
// First entry: only fetch when store is empty (undefined). Subsequent page entries read store.
if (!store) return
if (store.state.picgoCloud.userInfo !== undefined) {
if (store.state.picgoCloud.userInfo) {
loadConfigSyncState()
}
return
}
loadUserInfoAndMaybeHydrateCloudState()
})
const loadUserInfoAndMaybeHydrateCloudState = async () => {
await loadUserInfo()
if (store?.state.picgoCloud.userInfo) {
await loadConfigSyncState()
}
}
const loadUserInfo = async () => {
if (!store) return
store.setPicGoCloudUserInfoStatus(IPicGoCloudRequestStatus.LOADING)
store.setPicGoCloudUserInfoError(null)
const res = await invokeRPC<IPicGoCloudUserInfo | null>(IRPCActionType.PICGO_CLOUD_GET_USER_INFO)
if (!res.success) {
store.setPicGoCloudUserInfoStatus(IPicGoCloudRequestStatus.ERROR)
store.setPicGoCloudUserInfoError(res.error)
return
}
store.setPicGoCloudUserInfo(res.data)
store.setPicGoCloudUserInfoStatus(IPicGoCloudRequestStatus.IDLE)
}
const applyConfigSyncState = (state: IPicGoCloudConfigSyncState) => {
configSyncState.value = state
if (state.sessionStatus === IPicGoCloudConfigSyncSessionStatus.CONFLICT) {
isConflictDialogVisible.value = true
}
}
const loadConfigSyncState = async () => {
isConfigSyncStateLoading.value = true
const res = await invokeRPC<IPicGoCloudConfigSyncState>(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_GET_STATE)
isConfigSyncStateLoading.value = false
if (!res.success) {
ElMessage.error(res.error)
return
}
applyConfigSyncState(res.data)
}
const showConfigSyncToast = (toastType: IPicGoCloudConfigSyncToastType, message: string) => {
if (toastType === IPicGoCloudConfigSyncToastType.SUCCESS) {
ElMessage.success(message)
return
}
if (toastType === IPicGoCloudConfigSyncToastType.WARNING) {
ElMessage.warning(message)
return
}
if (toastType === IPicGoCloudConfigSyncToastType.ERROR) {
ElMessage.error(message)
return
}
ElMessage.info(message)
}
const promptRestartIfNeeded = async () => {
try {
await ElMessageBox.confirm(
$T('PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_MESSAGE'),
$T('PICGO_CLOUD_CONFIG_SYNC_RESTART_PROMPT_TITLE'),
{
type: 'warning',
confirmButtonText: $T('PICGO_CLOUD_CONFIG_SYNC_RESTART_NOW'),
cancelButtonText: $T('PICGO_CLOUD_CONFIG_SYNC_RESTART_LATER'),
closeOnClickModal: false,
closeOnPressEscape: false
}
)
} catch {
return
}
const res = await invokeRPC<void>(IRPCActionType.RELOAD_APP)
if (!res.success) {
ElMessage.error(res.error)
}
}
const handleConfigSyncStart = async () => {
if (isConfigSyncBusy.value) return
// Optimistically switch to SYNCING for immediate loading feedback.
// The main process still owns the source-of-truth session state.
configSyncState.value = {
...configSyncState.value,
sessionStatus: IPicGoCloudConfigSyncSessionStatus.SYNCING,
conflicts: undefined
}
ElMessage.info($T('PICGO_CLOUD_CONFIG_SYNC_STARTING'))
const res = await invokeRPC<IPicGoCloudConfigSyncRunResult>(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_START)
if (!res.success) {
ElMessage.error(res.error)
await loadConfigSyncState()
return
}
const runRes = res.data
applyConfigSyncState(runRes.state)
showConfigSyncToast(runRes.toastType, runRes.message)
if (runRes.authInvalidated && store) {
store.setPicGoCloudUserInfo(null)
store.setPicGoCloudUserInfoError(null)
store.setPicGoCloudUserInfoStatus(IPicGoCloudRequestStatus.IDLE)
}
if (runRes.shouldShowRestartPrompt) {
await refreshAppStateAfterSync()
await promptRestartIfNeeded()
}
}
const handleAbortConfigSync = async () => {
const res = await invokeRPC<IPicGoCloudConfigSyncState>(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_ABORT)
if (!res.success) {
ElMessage.error(res.error)
return
}
applyConfigSyncState(res.data)
isConflictDialogVisible.value = false
ElMessage.warning($T('PICGO_CLOUD_CONFIG_SYNC_ABORTED'))
}
const handleConfirmConfigSyncResolution = async (resolution: IPicGoCloudConfigSyncResolution) => {
isApplyResolutionLoading.value = true
const res = await invokeRPC<IPicGoCloudConfigSyncRunResult>(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_APPLY_RESOLUTION, resolution)
isApplyResolutionLoading.value = false
if (!res.success) {
ElMessage.error(res.error)
return
}
const runRes = res.data
applyConfigSyncState(runRes.state)
showConfigSyncToast(runRes.toastType, runRes.message)
if (runRes.authInvalidated && store) {
isConflictDialogVisible.value = false
store.setPicGoCloudUserInfo(null)
store.setPicGoCloudUserInfoError(null)
store.setPicGoCloudUserInfoStatus(IPicGoCloudRequestStatus.IDLE)
return
}
if (runRes.shouldShowRestartPrompt) {
isConflictDialogVisible.value = false
await refreshAppStateAfterSync()
await promptRestartIfNeeded()
}
}
const handleSetEncryptionMethod = async (nextMode: IPicGoCloudEncryptionMethod) => {
if (isE2EPreferenceUpdating.value) return
if (isConfigSyncBusy.value) return
const currentMode = encryptionMethodValue.value
if (nextMode === currentMode) return
const previousEncryptionMethod = configSyncState.value.encryptionMethod
// Only turning on E2E needs a warning confirmation.
// If the user cancels, we persist "server-side encryption" (`encryptionMethod='sse'`) per product requirement,
// rather than leaving it at AUTO.
let modeToPersist = nextMode
if (nextMode === IPicGoCloudEncryptionMethod.E2EE) {
try {
await ElMessageBox.confirm(
$T('PICGO_CLOUD_E2E_ENABLE_WARNING_MESSAGE'),
$T('PICGO_CLOUD_E2E_ENABLE_WARNING_TITLE'),
{
type: 'warning',
confirmButtonText: $T('CONFIRM'),
cancelButtonText: $T('CANCEL'),
closeOnClickModal: false,
closeOnPressEscape: false
}
)
} catch {
modeToPersist = IPicGoCloudEncryptionMethod.SSE
}
}
// Optimistically update local UI state so the dropdown reflects the user's selection immediately.
configSyncState.value = {
...configSyncState.value,
encryptionMethod: modeToPersist
}
isE2EPreferenceUpdating.value = true
const res = await invokeRPC<IPicGoCloudEncryptionMethod | undefined>(
IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_SET_E2E_PREFERENCE,
modeToPersist
)
isE2EPreferenceUpdating.value = false
if (!res.success) {
// Restore previous selection on failure.
configSyncState.value = {
...configSyncState.value,
encryptionMethod: previousEncryptionMethod
}
ElMessage.error(res.error)
return
}
configSyncState.value = {
...configSyncState.value,
encryptionMethod: res.data
}
}
const handleLogin = async () => {
if (!store) return
if (!hasAgreedToTermsAndPrivacy.value) return
store.setPicGoCloudLoginStatus(IPicGoCloudLoginStatus.IN_PROGRESS)
store.setPicGoCloudLoginError(null)
const res = await invokeRPC<IPicGoCloudUserInfo>(IRPCActionType.PICGO_CLOUD_LOGIN)
if (!res.success) {
store.setPicGoCloudLoginError(res.error)
store.setPicGoCloudLoginStatus(IPicGoCloudLoginStatus.IDLE)
return
}
store.setPicGoCloudUserInfo(res.data)
store.setPicGoCloudUserInfoStatus(IPicGoCloudRequestStatus.IDLE)
store.setPicGoCloudUserInfoError(null)
store.setPicGoCloudLoginStatus(IPicGoCloudLoginStatus.IDLE)
await loadConfigSyncState()
}
const handleDisposeLoginFlow = async () => {
if (!store) return
const res = await invokeRPC<boolean>(IRPCActionType.PICGO_CLOUD_DISPOSE_LOGIN_FLOW)
if (!res.success) {
store.setPicGoCloudLoginError(res.error)
}
store.setPicGoCloudLoginStatus(IPicGoCloudLoginStatus.IDLE)
}
const handleLogout = async () => {
if (!store) return
const res = await invokeRPC<boolean>(IRPCActionType.PICGO_CLOUD_LOGOUT)
if (!res.success) {
store.setPicGoCloudLoginError(res.error)
return
}
store.setPicGoCloudUserInfo(null)
store.setPicGoCloudLoginError(null)
// Clear config-sync related UI state after logout.
configSyncState.value = {
sessionStatus: IPicGoCloudConfigSyncSessionStatus.IDLE,
encryptionMethod: undefined
}
isConflictDialogVisible.value = false
}
const pollTimer = ref<number | null>(null)
const stopPolling = () => {
if (pollTimer.value === null) return
window.clearInterval(pollTimer.value)
pollTimer.value = null
}
watch(isConfigSyncRunning, (running) => {
if (!running) {
stopPolling()
return
}
if (pollTimer.value !== null) return
pollTimer.value = window.setInterval(() => {
loadConfigSyncState()
}, 1500)
})
onBeforeUnmount(() => {
stopPolling()
})
</script>
<script lang="ts">
export default {
name: 'PicGoCloudPage'
}
</script>
+42 -31
View File
@@ -49,14 +49,14 @@ import { Reading } from '@element-plus/icons-vue'
import { IConfig } from 'picgo'
import { T as $T } from '@/i18n/index'
import { enforceNumber, isLinux } from '~/universal/utils/common'
import { onBeforeMount, reactive, ref } from 'vue'
import { getConfig } from '@/utils/dataSender'
import { computed, onBeforeMount, reactive, ref, watch, type DeepReadonly } from 'vue'
import ButtonAreaSettings from './components/settings/buttonArea/ButtonAreaSettings.vue'
import SwitchAreaSettings from './components/settings/switchArea/SwitchAreaSettings.vue'
import CustomAreaSettings from './components/settings/customArea/CustomAreaSettings.vue'
import SelectAreaSettings from './components/settings/selectArea/SelectAreaSettings.vue'
import { openURL } from '@/utils/common'
import { IStartupMode } from '#/types/enum'
import { useStore } from '@/hooks/useStore'
const form = reactive<ISettingForm>({
showUpdateTip: false,
@@ -65,12 +65,13 @@ const form = reactive<ISettingForm>({
rename: false,
autoRename: false,
uploadNotification: false,
notificationSound: true,
miniWindowOnTop: false,
logLevel: ['all'],
autoCopyUrl: true,
checkBetaUpdate: true,
useBuiltinClipboard: false,
language: 'zh-CN',
language: 'en',
logFileSizeLimit: 10,
encodeOutputURL: true,
showDockIcon: true,
@@ -87,40 +88,50 @@ const form = reactive<ISettingForm>({
})
const proxy = ref('')
const store = useStore()
const appConfig = computed(() => store?.state.appConfig ?? null)
onBeforeMount(() => {
initData()
store?.refreshAppConfig()
})
async function initData () {
const config = (await getConfig<IConfig>())!
if (config !== undefined) {
const settings = config.settings || {}
const picBed = config.picBed
form.showUpdateTip = settings.showUpdateTip || false
form.autoStart = settings.autoStart || false
form.rename = settings.rename || false
form.autoRename = settings.autoRename || false
form.uploadNotification = settings.uploadNotification || false
form.miniWindowOnTop = settings.miniWindowOnTop || false
form.logLevel = initLogLevel(settings.logLevel || [])
form.autoCopyUrl = settings.autoCopyUrl === undefined ? true : settings.autoCopyUrl
form.checkBetaUpdate = settings.checkBetaUpdate === undefined ? true : settings.checkBetaUpdate
form.useBuiltinClipboard = settings.useBuiltinClipboard === undefined ? false : settings.useBuiltinClipboard
form.language = settings.language ?? 'zh-CN'
form.encodeOutputURL = settings.encodeOutputURL === undefined ? false : settings.encodeOutputURL
form.customLink = settings.customLink || '$url'
form.npmProxy = settings.npmProxy || ''
form.npmRegistry = settings.npmRegistry || ''
proxy.value = picBed.proxy || ''
form.server = settings.server
form.logFileSizeLimit = enforceNumber(settings.logFileSizeLimit) || 10
form.showDockIcon = settings.showDockIcon === undefined ? true : settings.showDockIcon
form.showMenubarIcon = settings.showMenubarIcon === undefined ? true : settings.showMenubarIcon
form.startupMode = settings.startupMode || (isLinux ? IStartupMode.SHOW_MINI_WINDOW : IStartupMode.HIDE)
const applyAppConfig = (config: DeepReadonly<IConfig> | null) => {
if (!config) return
const settings = config.settings || {}
const picBed = config.picBed
form.showUpdateTip = settings.showUpdateTip || false
form.autoStart = settings.autoStart || false
form.rename = settings.rename || false
form.autoRename = settings.autoRename || false
form.uploadNotification = settings.uploadNotification || false
form.notificationSound = settings.notificationSound === undefined ? true : settings.notificationSound
form.miniWindowOnTop = settings.miniWindowOnTop || false
form.logLevel = initLogLevel(settings.logLevel ? [...settings.logLevel] : [])
form.autoCopyUrl = settings.autoCopyUrl === undefined ? true : settings.autoCopyUrl
form.checkBetaUpdate = settings.checkBetaUpdate === undefined ? true : settings.checkBetaUpdate
form.useBuiltinClipboard = settings.useBuiltinClipboard === undefined ? false : settings.useBuiltinClipboard
form.language = settings.language ?? 'en'
form.encodeOutputURL = settings.encodeOutputURL === undefined ? false : settings.encodeOutputURL
form.customLink = settings.customLink || '$url'
form.npmProxy = settings.npmProxy || ''
form.npmRegistry = settings.npmRegistry || ''
proxy.value = picBed.proxy || ''
const server = settings.server ?? {}
form.server = {
port: enforceNumber(server.port ?? 36677) || 36677,
host: server.host || '127.0.0.1',
enable: server.enable ?? true
}
form.logFileSizeLimit = enforceNumber(settings.logFileSizeLimit ?? 10) || 10
form.showDockIcon = settings.showDockIcon === undefined ? true : settings.showDockIcon
form.showMenubarIcon = settings.showMenubarIcon === undefined ? true : settings.showMenubarIcon
form.startupMode = settings.startupMode || (isLinux ? IStartupMode.SHOW_MINI_WINDOW : IStartupMode.HIDE)
}
watch(appConfig, (config) => {
applyAppConfig(config)
}, { immediate: true })
function initLogLevel (logLevel: string | string[]) {
if (!Array.isArray(logLevel)) {
if (logLevel && logLevel.length > 0) {
@@ -133,7 +144,7 @@ function initLogLevel (logLevel: string | string[]) {
}
function goConfigPage () {
openURL('https://picgo.github.io/PicGo-Doc/guide/config.html#picgo设置')
openURL('https://docs.picgo.app/gui/guide/config#picgo-setting')
}
</script>
+7 -9
View File
@@ -214,6 +214,7 @@ import {
} from '#/events/constants'
import { computed, ref, onBeforeMount, onBeforeUnmount, watch } from 'vue'
import { getConfig, saveConfig, sendRPC, sendToMain } from '@/utils/dataSender'
import { showNotification } from '@/utils/notification'
import { ElMessageBox } from 'element-plus'
import axios from 'axios'
import { getRendererStaticFileUrl } from '@/utils/static'
@@ -405,12 +406,11 @@ async function handleReload () {
needReload: true
})
needReload.value = true
const successNotification = new Notification($T('PLUGIN_UPDATE_SUCCEED'), {
body: $T('TIPS_NEED_RELOAD')
showNotification({
title: $T('PLUGIN_UPDATE_SUCCEED'),
body: $T('TIPS_NEED_RELOAD'),
callback: reloadApp
})
successNotification.onclick = () => {
reloadApp()
}
}
function cleanSearch () {
@@ -437,12 +437,10 @@ async function handleConfirmConfig () {
})
break
}
const successNotification = new Notification($T('SETTINGS_RESULT'), {
showNotification({
title: $T('SETTINGS_RESULT'),
body: $T('TIPS_SET_SUCCEED')
})
successNotification.onclick = () => {
return true
}
dialogVisible.value = false
getPluginList()
}
+8 -3
View File
@@ -96,8 +96,8 @@
</template>
<script lang="ts" setup>
import { useIPC } from '@/hooks/useIPC'
import { sendRPC, triggerRPC } from '@/utils/dataSender'
import { ElMessageBox } from 'element-plus'
import { sendRPC, invokeRPC } from '@/utils/dataSender'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, reactive, ref } from 'vue'
import { IToolboxItemType, IToolboxItemCheckStatus, IRPCActionType } from '~/universal/types/enum'
import { T as $T } from '@/i18n'
@@ -194,7 +194,12 @@ const handleFix = async () => {
const status = fixList[key as IToolboxItemType].status
return status === IToolboxItemCheckStatus.ERROR && !fixList[key as IToolboxItemType].hasNoFixMethod
}).map(async key => {
return triggerRPC<IToolboxCheckRes>(IRPCActionType.TOOLBOX_CHECK_FIX, key as IToolboxItemType)
const res = await invokeRPC<IToolboxCheckRes>(IRPCActionType.TOOLBOX_CHECK_FIX, key as IToolboxItemType)
if (!res.success) {
ElMessage.warning(res.error)
return null
}
return res.data
}))
fixRes.filter(item => item !== null).forEach(item => {
+11 -5
View File
@@ -71,6 +71,7 @@ import { PASTE_TEXT, OPEN_WINDOW } from '#/events/constants'
import { IWindowList } from '#/types/enum'
import { sendToMain } from '@/utils/dataSender'
import { getRawData } from '@/utils/common'
import { showNotification } from '@/utils/notification'
import { IpcRendererEvent } from 'electron/renderer'
const files = ref<IResult<ImgInfo>[]>([])
@@ -95,10 +96,10 @@ async function getData () {
async function copyTheLink (item: ImgInfo) {
notification.body = item.imgUrl!
await ipcRenderer.invoke(PASTE_TEXT, getRawData(item))
const myNotification = new Notification(notification.title, notification)
myNotification.onclick = () => {
return true
}
showNotification({
title: notification.title,
body: notification.body
})
}
// function calcHeight (width: number, height: number): number {
@@ -238,9 +239,14 @@ body::-webkit-scrollbar
cursor not-allowed
&__title
text-align center
width 100%
overflow hidden
text-overflow ellipsis
white-space nowrap
white-space normal
word-break break-all
display -webkit-box
-webkit-box-orient vertical
-webkit-line-clamp 2
color #ddd
font-size 14px
margin-top 4px
+133 -67
View File
@@ -103,31 +103,50 @@
import { T as $T } from '@/i18n'
import $bus from '@/utils/bus'
import { getFilePath } from '@/utils/common'
import { getConfig, saveConfig, sendToMain } from '@/utils/dataSender'
import { saveConfig, sendToMain } from '@/utils/dataSender'
import { CaretBottom, UploadFilled } from '@element-plus/icons-vue'
import {
IpcRendererEvent,
ipcRenderer
} from 'electron'
import { ElMessage as $message } from 'element-plus'
import { onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage as $message, ElMessageBox } from 'element-plus'
import { computed, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
import {
GET_PICBEDS,
LOG_INVALID_URL_LINES,
SHOW_INPUT_BOX,
SHOW_INPUT_BOX_RESPONSE,
SHOW_UPLOAD_PAGE_MENU
} from '~/universal/events/constants'
import {
isUrl
extractHttpUrlsFromText,
isUrl,
parseNewlineSeparatedUrls
} from '~/universal/utils/common'
import { useStore } from '@/hooks/useStore'
const dragover = ref(false)
const progress = ref(0)
const showProgress = ref(false)
const showError = ref(false)
const pasteStyle = ref('')
const picBed = ref<IPicBedType[]>([])
const picBedName = ref('')
const configName = ref('')
const store = useStore()
const currentPicBedType = computed(() => {
const config = store?.state.appConfig
return config?.picBed?.uploader || config?.picBed?.current || store?.state.defaultPicBed || 'smms'
})
const picBedName = computed(() => {
const currentType = currentPicBedType.value
const list = store?.state.picBeds ?? []
const match = list.find(item => item.type === currentType)
return match?.name || currentType
})
const configName = computed(() => {
const configEntry = store?.state.appConfig?.picBed?.[currentPicBedType.value] as IStringKeyMap | undefined
if (configEntry && typeof configEntry._configName === 'string') {
return configEntry._configName
}
return 'Default'
})
const $confirm = ElMessageBox.confirm
onBeforeMount(() => {
ipcRenderer.on('uploadProgress', (event: IpcRendererEvent, _progress: number) => {
if (_progress !== -1) {
@@ -138,13 +157,11 @@ onBeforeMount(() => {
showError.value = true
}
})
getPasteStyle()
getDefaultPicBed()
store?.refreshAppConfig()
store?.refreshPicBeds()
ipcRenderer.on('syncPicBed', () => {
getDefaultPicBed()
store?.refreshAppConfig()
})
sendToMain(GET_PICBEDS)
ipcRenderer.on(GET_PICBEDS, getPicBeds)
$bus.on(SHOW_INPUT_BOX_RESPONSE, handleInputBoxValue)
})
@@ -166,45 +183,90 @@ onBeforeUnmount(() => {
$bus.off(SHOW_INPUT_BOX_RESPONSE)
ipcRenderer.removeAllListeners('uploadProgress')
ipcRenderer.removeAllListeners('syncPicBed')
ipcRenderer.removeListener(GET_PICBEDS, getPicBeds)
})
function onDrop (e: DragEvent) {
async function onDrop (e: DragEvent) {
dragover.value = false
const items = e.dataTransfer?.items!
const files = e.dataTransfer?.files!
// send files first
if (files?.length) {
ipcSendFiles(e.dataTransfer?.files!)
} else {
if (items.length === 2 && items[0].type === 'text/uri-list') {
handleURLDrag(items, e.dataTransfer!)
} else if (items[0].type === 'text/plain') {
const str = e.dataTransfer!.getData(items[0].type)
if (isUrl(str)) {
sendToMain('uploadChoosedFiles', [{ path: str }])
} else {
$message.error($T('TIPS_DRAG_VALID_PICTURE_OR_URL'))
return
}
const dataTransfer = e.dataTransfer
if (!dataTransfer) return
const uriList = dataTransfer.getData('text/uri-list')
if (uriList) {
await handleUriListDrop(uriList, dataTransfer.getData('text/html'))
return
}
const plainText = dataTransfer.getData('text/plain')
if (plainText) {
await handlePlainTextDrop(plainText)
return
}
$message.error($T('TIPS_DRAG_VALID_PICTURE_OR_URL'))
}
async function confirmLargeUrlBatch (count: number, onCancel?: () => void): Promise<boolean> {
if (count <= 10) return true
try {
await $confirm(
$T('TIPS_TOO_MANY_URLS_CONFIRM', { n: count }),
$T('TIPS_WARNING'),
{
type: 'warning',
confirmButtonText: $T('CONFIRM'),
cancelButtonText: $T('CANCEL')
}
}
)
return true
} catch (e) {
onCancel?.()
return false
}
}
function handleURLDrag (items: DataTransferItemList, dataTransfer: DataTransfer) {
// text/html
// Use this data to get a more precise URL
const urlString = dataTransfer.getData(items[1].type)
const urlMatch = urlString.match(/<img.*src="(.*?)"/)
if (urlMatch) {
sendToMain('uploadChoosedFiles', [
{
path: urlMatch[1]
}
])
} else {
$message.error($T('TIPS_DRAG_VALID_PICTURE_OR_URL'))
async function uploadUrls (urls: string[], invalidLines: string[], onCancel?: () => void) {
if (invalidLines.length) {
sendToMain(LOG_INVALID_URL_LINES, invalidLines)
$message.warning($T('TIPS_SKIPPED_INVALID_URLS', { n: invalidLines.length }))
}
const canUpload = await confirmLargeUrlBatch(urls.length, onCancel)
if (!canUpload) return
sendToMain('uploadChoosedFiles', urls.map((url) => ({ path: url })))
}
async function handlePlainTextDrop (plainText: string) {
const { urls, invalidLines } = parseNewlineSeparatedUrls(plainText, { source: 'plain' })
if (!urls.length) {
$message.error($T('TIPS_DRAG_VALID_PICTURE_OR_URL'))
return
}
await uploadUrls(urls, invalidLines)
}
async function handleUriListDrop (uriListText: string, urlString: string) {
const { urls, invalidLines } = parseNewlineSeparatedUrls(uriListText, { source: 'uri-list' })
if (urls.length) {
await uploadUrls(urls, invalidLines)
return
}
const urlMatch = urlString.match(/<img.*src="(.*?)"/)
if (urlMatch && isUrl(urlMatch[1])) {
await uploadUrls([urlMatch[1]], invalidLines)
return
}
$message.error($T('TIPS_DRAG_VALID_PICTURE_OR_URL'))
}
function openUploadWindow () {
@@ -230,11 +292,16 @@ function ipcSendFiles (files: FileList) {
sendToMain('uploadChoosedFiles', sendFiles)
}
async function getPasteStyle () {
pasteStyle.value = await getConfig('settings.pasteStyle') || 'markdown'
const applyAppConfig = () => {
const settings = store?.state.appConfig?.settings ?? {}
pasteStyle.value = settings.pasteStyle || 'markdown'
}
function handlePasteStyleChange (val: string | number | boolean) {
watch(() => store?.state.appConfig, () => {
applyAppConfig()
}, { immediate: true })
function handlePasteStyleChange (val: string | number | boolean | undefined) {
saveConfig({
'settings.pasteStyle': val
})
@@ -244,40 +311,39 @@ function uploadClipboardFiles () {
sendToMain('uploadClipboardFilesFromUploadPage')
}
async function uploadURLFiles () {
const str = await navigator.clipboard.readText()
function openUrlInputBox (value: string) {
$bus.emit(SHOW_INPUT_BOX, {
value: isUrl(str) ? str : '',
value,
title: $T('TIPS_INPUT_URL'),
placeholder: $T('TIPS_HTTP_PREFIX')
placeholder: $T('TIPS_HTTP_PREFIX'),
inputType: 'textarea'
})
}
function handleInputBoxValue (val: string) {
async function uploadURLFiles () {
let str = ''
try {
str = await navigator.clipboard.readText()
} catch (e) {}
const urls = extractHttpUrlsFromText(str)
openUrlInputBox(urls.join('\n'))
}
async function handleInputBoxValue (val: string) {
if (val === '') return
if (isUrl(val)) {
sendToMain('uploadChoosedFiles', [{
path: val
}])
} else {
$message.error($T('TIPS_INPUT_VALID_URL'))
}
}
async function getDefaultPicBed () {
const currentPicBed = await getConfig<string>('picBed.current')
const currentConfigName = await getConfig<string>(`picBed.${currentPicBed}._configName`) || 'Default'
picBed.value.forEach(item => {
if (item.type === currentPicBed) {
picBedName.value = item.name
const { urls, invalidLines } = parseNewlineSeparatedUrls(val, { source: 'plain' })
if (!urls.length) {
if (invalidLines.length) {
sendToMain(LOG_INVALID_URL_LINES, invalidLines)
$message.error($T('TIPS_SKIPPED_INVALID_URLS', { n: invalidLines.length }))
return
}
})
configName.value = currentConfigName
}
$message.error($T('TIPS_NO_VALID_URLS'))
return
}
function getPicBeds (event: IpcRendererEvent, picBeds: IPicBedType[]) {
picBed.value = picBeds
getDefaultPicBed()
await uploadUrls(urls, invalidLines, () => openUrlInputBox(val))
}
async function handleChangePicBed () {
+76 -31
View File
@@ -22,7 +22,7 @@
>
<div
:class="`config-item ${defaultConfigId === item._id ? 'selected' : ''}`"
@click="() => selectItem(item._id)"
@click="() => selectItem(item._configName)"
>
<div class="config-name">
{{ item._configName }}
@@ -39,20 +39,20 @@
<div class="operation-container">
<el-icon
class="el-icon-edit"
@click="openEditPage(item._id)"
@click.stop="openEditPage(item._id)"
>
<Edit />
</el-icon>
<el-icon
class="el-icon-copy"
@click.stop="() => copyConfig(item._id)"
@click.stop="() => copyConfig(item._configName)"
>
<DocumentCopy />
</el-icon>
<el-icon
class="el-icon-delete"
:class="curConfigList.length <= 1 ? 'disabled' : ''"
@click.stop="() => deleteConfig(item._id)"
@click.stop="() => deleteConfig(item._configName)"
>
<Delete />
</el-icon>
@@ -96,27 +96,41 @@
</template>
<script lang="ts" setup>
import { Delete, DocumentCopy, Edit, Plus } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
import { saveConfig, triggerRPC } from '@/utils/dataSender'
import { ElMessage, ElMessageBox } from 'element-plus'
import { saveConfig, invokeRPC } from '@/utils/dataSender'
import { showNotification } from '@/utils/notification'
import dayjs from 'dayjs'
import { IRPCActionType } from '~/universal/types/enum'
import { T as $T } from '@/i18n/index'
import { useRouter, useRoute, onBeforeRouteUpdate } from 'vue-router'
import { onBeforeMount, ref } from 'vue'
import { computed, onBeforeMount, ref, watch } from 'vue'
import { PICBEDS_PAGE, UPLOADER_CONFIG_PAGE } from '@/router/config'
import { useStore } from '@/hooks/useStore'
const $router = useRouter()
const $route = useRoute()
const type = ref('')
const curConfigList = ref<IStringKeyMap[]>([])
const curConfigList = ref<IUploaderConfigListItem[]>([])
const defaultConfigId = ref('')
const store = useStore()
const appConfig = computed(() => store?.state.appConfig ?? null)
const $confirm = ElMessageBox.confirm
const $prompt = ElMessageBox.prompt
async function selectItem (id: string) {
await triggerRPC<void>(IRPCActionType.SELECT_UPLOADER, type.value, id)
defaultConfigId.value = id
type MessageBoxAction = 'confirm' | 'cancel' | 'close'
interface PromptBoxState {
inputValue?: string
confirmButtonLoading?: boolean
}
async function selectItem (configName: string) {
const res = await invokeRPC<string>(IRPCActionType.SELECT_UPLOADER, type.value, configName)
if (!res.success) {
ElMessage.warning(res.error)
return
}
defaultConfigId.value = res.data
}
onBeforeRouteUpdate((to, from, next) => {
@@ -130,12 +144,22 @@ onBeforeRouteUpdate((to, from, next) => {
onBeforeMount(() => {
type.value = $route.params.type as string
getCurrentConfigList()
store?.refreshAppConfig()
})
watch(appConfig, () => {
if (!type.value) return
getCurrentConfigList()
})
async function getCurrentConfigList () {
const configList = await triggerRPC<IUploaderConfigItem>(IRPCActionType.GET_PICBED_CONFIG_LIST, type.value)
curConfigList.value = configList?.configList ?? []
defaultConfigId.value = configList?.defaultId ?? ''
const configList = await invokeRPC<IUploaderConfigItem>(IRPCActionType.GET_PICBED_CONFIG_LIST, type.value)
if (!configList.success) {
ElMessage.warning(configList.error)
return
}
curConfigList.value = configList.data?.configList ?? []
defaultConfigId.value = configList.data?.defaultId ?? ''
}
function openEditPage (configId: string) {
@@ -155,7 +179,7 @@ function formatTime (time: number): string {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
}
function deleteConfig (id: string) {
function deleteConfig (configName: string) {
if (curConfigList.value.length <= 1) {
return
}
@@ -164,26 +188,49 @@ function deleteConfig (id: string) {
cancelButtonText: $T('CANCEL'),
type: 'warning'
}).then(async () => {
const res = await triggerRPC<IUploaderConfigItem | undefined>(IRPCActionType.DELETE_PICBED_CONFIG, type.value, id)
if (!res) return
curConfigList.value = res.configList
defaultConfigId.value = res.defaultId
const res = await invokeRPC<IUploaderConfigItem>(IRPCActionType.DELETE_PICBED_CONFIG, type.value, configName)
if (!res.success) {
ElMessage.warning(res.error)
return
}
curConfigList.value = res.data.configList
defaultConfigId.value = res.data.defaultId
}).catch((e) => {
console.log(e)
return true
})
}
function copyConfig (id: string) {
$confirm($T('TIPS_COPY_UPLOADER_CONFIG'), $T('TIPS_NOTICE'), {
function copyConfig (configName: string) {
$prompt($T('TIPS_COPY_UPLOADER_CONFIG'), $T('TIPS_NOTICE'), {
confirmButtonText: $T('CONFIRM'),
cancelButtonText: $T('CANCEL'),
type: 'warning'
}).then(async () => {
const res = await triggerRPC<IUploaderConfigItem | undefined>(IRPCActionType.COPY_UPLOADER_CONFIG, type.value, id)
if (!res) return
curConfigList.value = res.configList
defaultConfigId.value = res.defaultId
inputValue: `${configName} - Copy`,
inputPlaceholder: $T('UPLOADER_CONFIG_PLACEHOLDER'),
beforeClose: async (action: MessageBoxAction, instance: PromptBoxState, done: () => void) => {
if (action !== 'confirm') {
done()
return
}
const newConfigName = String(instance.inputValue ?? '').trim()
if (!newConfigName) {
ElMessage.warning($T('TIPS_UPLOADER_CONFIG_NAME_EMPTY'))
return
}
instance.confirmButtonLoading = true
const res = await invokeRPC<IUploaderConfigItem>(IRPCActionType.COPY_UPLOADER_CONFIG, type.value, configName, newConfigName)
instance.confirmButtonLoading = false
if (!res.success) {
ElMessage.warning(res.error)
return
}
curConfigList.value = res.data.configList
defaultConfigId.value = res.data.defaultId
done()
}
}).catch((e) => {
console.log(e)
return true
@@ -207,12 +254,10 @@ function setDefaultPicBed (type: string) {
})
store?.setDefaultPicBed(type)
const successNotification = new Notification($T('SETTINGS_DEFAULT_PICBED'), {
showNotification({
title: $T('SETTINGS_DEFAULT_PICBED'),
body: $T('TIPS_SET_SUCCEED')
})
successNotification.onclick = () => {
return true
}
}
</script>
<script lang="ts">
+577
View File
@@ -0,0 +1,577 @@
<template>
<div id="url-rewrite-page">
<div class="view-title">
{{ $T('SETTINGS_URL_REWRITE') }}
</div>
<el-row
class="url-rewrite-list"
justify="center"
>
<el-col
:span="20"
:offset="2"
>
<div class="flex mb-[12px] justify-between align-middle">
<div class="text-[12px] text-[#bbb] leading-[18px]">
{{ $T('URL_REWRITE_HELP') }}
</div>
<el-button
type="primary"
size="small"
@click="openAddDialog"
>
<el-icon class="mr-[4px]">
<Plus />
</el-icon>
{{ $T('URL_REWRITE_ADD_RULE') }}
</el-button>
</div>
<el-table
class="url-rewrite-table-border"
:data="rules"
size="small"
header-cell-class-name="url-rewrite-table-border"
cell-class-name="url-rewrite-table-border"
:empty-text="$T('URL_REWRITE_EMPTY')"
>
<el-table-column
width="50px"
:label="$T('URL_REWRITE_ORDER')"
>
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column
min-width="300px"
:label="$T('URL_REWRITE_MATCH')"
>
<template #default="scope">
<span class="font-mono text-[12px] break-all">
{{ scope.row.match }}
</span>
</template>
</el-table-column>
<el-table-column
width="80px"
:label="$T('URL_REWRITE_FLAGS')"
>
<template #default="scope">
<el-tag
v-if="scope.row.global"
size="small"
>
G
</el-tag>
<el-tag
v-if="scope.row.ignoreCase"
size="small"
class="ml-[4px]"
>
I
</el-tag>
<span v-if="!scope.row.global && !scope.row.ignoreCase">-</span>
</template>
</el-table-column>
<el-table-column
width="70px"
:label="$T('URL_REWRITE_ENABLED')"
>
<template #default="scope">
<el-switch
v-model="scope.row.enable"
@change="handleToggleEnable(scope.$index, $event as boolean)"
/>
</template>
</el-table-column>
<el-table-column
width="220px"
:label="$T('URL_REWRITE_ACTIONS')"
>
<template #default="scope">
<el-row class="mb-[2px]">
<el-button
size="small"
type="text"
:disabled="scope.$index === 0"
@click="moveRule(scope.$index, scope.$index - 1)"
>
<el-icon class="mr-[2px]">
<ArrowUp />
</el-icon>
{{ $T('URL_REWRITE_MOVE_UP') }}
</el-button>
<el-button
size="small"
type="text"
:disabled="scope.$index === rules.length - 1"
@click="moveRule(scope.$index, scope.$index + 1)"
>
<el-icon class="mr-[2px]">
<ArrowDown />
</el-icon>
{{ $T('URL_REWRITE_MOVE_DOWN') }}
</el-button>
</el-row>
<el-row>
<el-button
size="small"
type="text"
@click="openEditDialog(scope.row, scope.$index)"
>
<el-icon class="mr-[2px]">
<Edit />
</el-icon>
{{ $T('URL_REWRITE_EDIT') }}
</el-button>
<el-button
class="danger"
size="small"
type="text"
@click="confirmDelete(scope.$index)"
>
<el-icon class="mr-[2px]">
<Delete />
</el-icon>
{{ $T('URL_REWRITE_DELETE') }}
</el-button>
</el-row>
</template>
</el-table-column>
</el-table>
<div class="mt-[16px] rounded-[8px] url-rewrite-panel p-[12px]">
<div class="text-[14px] mb-[8px] text-[#bbb] font-bold">
{{ $T('URL_REWRITE_PREVIEW_TITLE') }}
</div>
<div class="text-[12px] text-[#bbb] leading-[18px] mb-[10px]">
{{ $T('URL_REWRITE_PREVIEW_TIPS') }}
</div>
<el-row
class="mb-[10px]"
:gutter="10"
>
<el-col :span="18">
<el-input
v-model="previewInputUrl"
:placeholder="$T('URL_REWRITE_PREVIEW_PLACEHOLDER')"
size="small"
/>
</el-col>
<el-col :span="6">
<el-button
type="primary"
size="small"
class="w-full"
@click="runPreview"
>
{{ $T('URL_REWRITE_PREVIEW_RUN') }}
</el-button>
</el-col>
</el-row>
<el-alert
v-if="previewStatus === 'error'"
:title="previewMessage"
type="error"
show-icon
:closable="false"
/>
<el-alert
v-else-if="previewStatus === 'matched'"
:title="previewMessage"
type="success"
show-icon
:closable="false"
/>
<el-alert
v-else-if="previewStatus === 'noMatch'"
:title="previewMessage"
type="info"
show-icon
:closable="false"
/>
<div
v-if="previewStatus !== 'idle'"
class="mt-[10px]"
>
<div class="text-[12px] text-[#bbb] mb-[6px]">
{{ $T('URL_REWRITE_PREVIEW_OUTPUT') }}
</div>
<div class="rounded-[6px] url-rewrite-mono-box p-[10px] font-mono text-[12px] break-all text-[#bbb]">
{{ previewOutputUrl }}
</div>
</div>
</div>
</el-col>
</el-row>
<el-dialog
v-model="editDialogVisible"
:title="editDialogTitle"
width="500px"
:append-to-body="true"
>
<el-form
label-position="top"
label-width="80px"
size="small"
>
<el-form-item
:label="$T('URL_REWRITE_MATCH')"
>
<div class="flex flex-col gap-[6px] w-full">
<div class="text-[12px] text-[#bbb] leading-[18px]">
{{ $T('URL_REWRITE_MATCH_TIPS') }}
</div>
<el-input
v-model="ruleForm.match"
class="align-center"
:autofocus="true"
:placeholder="$T('URL_REWRITE_MATCH_PLACEHOLDER')"
/>
</div>
</el-form-item>
<el-form-item
:label="$T('URL_REWRITE_REPLACE')"
>
<div class="flex flex-col gap-[6px] w-full">
<div class="text-[12px] text-[#bbb] leading-[18px]">
{{ $T('URL_REWRITE_REPLACE_TIPS') }}
</div>
<el-input
v-model="ruleForm.replace"
class="align-center"
:placeholder="$T('URL_REWRITE_REPLACE_PLACEHOLDER')"
/>
</div>
</el-form-item>
<el-form-item
:label="$T('URL_REWRITE_OPTIONS')"
>
<div class="flex flex-col gap-[10px] w-full">
<el-row
justify="space-between"
align="middle"
>
<div class="text-[13px]">
{{ $T('URL_REWRITE_RULE_ENABLED') }}
</div>
<el-switch v-model="ruleForm.enable" />
</el-row>
<div class="grid grid-cols-2 gap-[12px]">
<div class="rounded-[6px] url-rewrite-option-card p-[10px]">
<el-checkbox
v-model="ruleForm.global"
>
{{ $T('URL_REWRITE_FLAG_GLOBAL_LABEL') }}
</el-checkbox>
<div class="text-[12px] text-[#bbb] leading-[18px] mt-[6px]">
{{ $T('URL_REWRITE_FLAG_GLOBAL_DESC') }}
</div>
</div>
<div class="rounded-[6px] url-rewrite-option-card p-[10px]">
<el-checkbox
v-model="ruleForm.ignoreCase"
>
{{ $T('URL_REWRITE_FLAG_IGNORE_CASE_LABEL') }}
</el-checkbox>
<div class="text-[12px] text-[#bbb] leading-[18px] mt-[6px]">
{{ $T('URL_REWRITE_FLAG_IGNORE_CASE_DESC') }}
</div>
</div>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button
round
@click="cancelEditDialog"
>
{{ $T('CANCEL') }}
</el-button>
<el-button
type="primary"
round
@click="confirmEditDialog"
>
{{ $T('CONFIRM') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ArrowDown, ArrowUp, Delete, Edit, Plus } from '@element-plus/icons-vue'
import { ElMessage as $message, ElMessageBox } from 'element-plus'
import { computed, onBeforeMount, reactive, ref } from 'vue'
import { getConfig, saveConfig } from '@/utils/dataSender'
import { T as $T } from '@/i18n'
interface IUrlRewriteRule {
match: string
replace: string
enable: boolean
global: boolean
ignoreCase: boolean
}
const $confirm = ElMessageBox.confirm
const rules = ref<IUrlRewriteRule[]>([])
const editDialogVisible = ref(false)
const editDialogMode = ref<'add' | 'edit'>('add')
const editRuleIndex = ref(-1)
const previewInputUrl = ref('')
const previewOutputUrl = ref('')
const previewStatus = ref<'idle' | 'matched' | 'noMatch' | 'error'>('idle')
const previewMessage = ref('')
const ruleForm = reactive<IUrlRewriteRule>({
match: '',
replace: '',
enable: true,
global: false,
ignoreCase: false
})
const editDialogTitle = computed(() => {
return editDialogMode.value === 'add' ? $T('URL_REWRITE_ADD_RULE') : $T('URL_REWRITE_EDIT_RULE')
})
onBeforeMount(async () => {
await initRules()
})
async function initRules () {
const configRules = await getConfig<unknown>('settings.urlRewrite.rules')
rules.value = normalizeRules(configRules)
}
function normalizeRules (value: unknown): IUrlRewriteRule[] {
if (!Array.isArray(value)) return []
return value.map(item => {
const raw = (item ?? {}) as Partial<Record<keyof IUrlRewriteRule, unknown>>
return {
match: String(raw.match ?? ''),
replace: String(raw.replace ?? ''),
enable: raw.enable === false ? false : true,
global: raw.global === true,
ignoreCase: raw.ignoreCase === true
}
})
}
async function persistRules () {
try {
await saveConfig('settings.urlRewrite.rules', rules.value)
} catch (e) {
$message.error($T('OPERATION_FAILED'))
throw e
}
}
async function handleToggleEnable (index: number, value: boolean) {
if (!rules.value[index]) return
rules.value[index].enable = value
await persistRules()
}
async function moveRule (fromIndex: number, toIndex: number) {
if (fromIndex === toIndex) return
if (toIndex < 0 || toIndex >= rules.value.length) return
const next = [...rules.value]
const [item] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, item)
rules.value = next
await persistRules()
}
async function confirmDelete (index: number) {
if (!rules.value[index]) return
try {
await $confirm($T('URL_REWRITE_DELETE_CONFIRM'), $T('TIPS_WARNING'), {
type: 'warning',
confirmButtonText: $T('CONFIRM'),
cancelButtonText: $T('CANCEL')
})
} catch {
return
}
rules.value.splice(index, 1)
await persistRules()
}
function openAddDialog () {
editDialogMode.value = 'add'
editRuleIndex.value = -1
ruleForm.match = ''
ruleForm.replace = ''
ruleForm.enable = true
ruleForm.global = false
ruleForm.ignoreCase = false
editDialogVisible.value = true
}
function openEditDialog (rule: IUrlRewriteRule, index: number) {
editDialogMode.value = 'edit'
editRuleIndex.value = index
ruleForm.match = rule.match
ruleForm.replace = rule.replace
ruleForm.enable = rule.enable
ruleForm.global = rule.global
ruleForm.ignoreCase = rule.ignoreCase
editDialogVisible.value = true
}
function cancelEditDialog () {
editDialogVisible.value = false
}
function buildRuleFlags (rule: Pick<IUrlRewriteRule, 'global' | 'ignoreCase'>) {
return `${rule.global ? 'g' : ''}${rule.ignoreCase ? 'i' : ''}`
}
function validateRuleOrThrow (rule: IUrlRewriteRule) {
if (!rule.match.trim()) {
throw new Error($T('URL_REWRITE_MATCH_REQUIRED'))
}
if (!rule.replace.trim()) {
throw new Error($T('URL_REWRITE_REPLACE_REQUIRED'))
}
new RegExp(rule.match, buildRuleFlags(rule))
}
function runPreview () {
previewStatus.value = 'idle'
previewMessage.value = ''
previewOutputUrl.value = previewInputUrl.value
const url = previewInputUrl.value
if (!url) {
previewStatus.value = 'error'
previewMessage.value = $T('URL_REWRITE_PREVIEW_INPUT_REQUIRED')
return
}
for (const [index, rule] of rules.value.entries()) {
if (rule.enable === false) continue
let regexp: RegExp
try {
regexp = new RegExp(rule.match, buildRuleFlags(rule))
} catch (e) {
previewStatus.value = 'error'
previewMessage.value = `${$T('URL_REWRITE_PREVIEW_RULE_INVALID')} #${index + 1}: ${(e as Error).message}`
return
}
const matched = regexp.test(url)
regexp.lastIndex = 0
if (!matched) continue
const output = url.replace(regexp, rule.replace)
previewOutputUrl.value = output
previewStatus.value = 'matched'
previewMessage.value = `${$T('URL_REWRITE_PREVIEW_MATCHED_RULE')} #${index + 1}`
return
}
previewStatus.value = 'noMatch'
previewMessage.value = $T('URL_REWRITE_PREVIEW_NO_MATCH')
}
async function confirmEditDialog () {
const nextRule: IUrlRewriteRule = {
match: ruleForm.match,
replace: ruleForm.replace,
enable: ruleForm.enable,
global: ruleForm.global,
ignoreCase: ruleForm.ignoreCase
}
try {
validateRuleOrThrow(nextRule)
} catch (e) {
$message.error((e as Error).message || $T('URL_REWRITE_INVALID_REGEX'))
return
}
if (editDialogMode.value === 'add') {
rules.value.push(nextRule)
} else if (editRuleIndex.value >= 0 && rules.value[editRuleIndex.value]) {
rules.value.splice(editRuleIndex.value, 1, nextRule)
}
await persistRules()
editDialogVisible.value = false
$message.success($T('TIPS_SET_SUCCEED'))
}
</script>
<script lang="ts">
export default {
name: 'UrlRewritePage'
}
</script>
<style lang='stylus'>
#url-rewrite-page
.url-rewrite-list
height 360px
box-sizing border-box
overflow-y auto
overflow-x hidden
width 100%
.url-rewrite-panel
background rgba(130, 130, 130, .12)
border 1px solid darken(#eee, 50%)
.url-rewrite-option-card
background rgba(130, 130, 130, .12)
border 1px solid rgba(255, 255, 255, .06)
.url-rewrite-mono-box
background rgba(130, 130, 130, .12)
border 1px solid rgba(255, 255, 255, .06)
.url-rewrite-table-border
border-color darken(#eee, 50%)
.el-checkbox__label
color #aaa
.el-table
background-color: transparent
color #ddd
&::before
background-color darken(#eee, 50%)
thead
color #bbb
th,tr
background-color: transparent
&__body
tr.el-table__row--striped
td
background transparent
&--enable-row-hover
.el-table__body
tr:hover
&>td
background #333
.el-button+.el-button
margin-left 4px
.el-button
&.danger
color: #F56C6C
</style>
@@ -14,6 +14,11 @@
:button-label="$T('SETTINGS_CLICK_TO_SET')"
@click="goShortCutPage"
/>
<ButtonFormItem
:label="$T('SETTINGS_URL_REWRITE')"
:button-label="$T('SETTINGS_CLICK_TO_SET')"
@click="goUrlRewritePage"
/>
<ButtonFormItem
:label="$T('SETTINGS_CUSTOM_LINK_FORMAT')"
:button-label="$T('SETTINGS_CLICK_TO_SET')"
@@ -72,7 +77,7 @@ import { reactive, ref } from 'vue'
import { PICGO_OPEN_FILE } from '~/universal/events/constants'
import LogSettingDialog from './LogSettingDialog.vue'
import { useRouter } from 'vue-router'
import { SHORTKEY_PAGE } from '@/router/config'
import { SHORTKEY_PAGE, URL_REWRITE_PAGE } from '@/router/config'
import { useVModel } from '@/hooks/useVModel'
const $router = useRouter()
@@ -103,6 +108,12 @@ function goShortCutPage () {
})
}
function goUrlRewritePage () {
$router.push({
name: URL_REWRITE_PAGE
})
}
</script>
<script lang="ts">
export default {
@@ -40,7 +40,7 @@ import pkg from 'root/package.json'
import { compare } from 'compare-versions'
import { IRPCActionType } from '~/universal/types/enum'
import { useVModel } from '@/hooks/useVModel'
import { triggerRPC } from '@/utils/dataSender'
import { invokeRPC } from '@/utils/dataSender'
import { openURL } from '@/utils/common'
interface IProps {
@@ -75,12 +75,12 @@ watch(() => dialogVisible.value, (value) => {
})
async function checkUpdate () {
const version = await triggerRPC<string>(IRPCActionType.GET_LATEST_VERSION, checkBetaUpdate.value)
if (version) {
latestVersion.value = version
} else {
latestVersion.value = $T('TIPS_NETWORK_ERROR')
const res = await invokeRPC<string>(IRPCActionType.GET_LATEST_VERSION, checkBetaUpdate.value)
if (!res.success) {
latestVersion.value = res.error || $T('TIPS_NETWORK_ERROR')
return
}
latestVersion.value = res.data
}
function confirmCheckVersion () {
@@ -97,8 +97,6 @@ function confirmCustomLink () {
dialogVisible.value = false
sendToMain('updateCustomLink')
updateProps()
} else {
return false
}
})
}
@@ -74,7 +74,8 @@
import { T as $T } from '@/i18n'
import { saveConfig } from '@/utils/dataSender'
import { useVModel } from '@/hooks/useVModel'
import { openFile, showNotification } from '@/utils/common'
import { openFile } from '@/utils/common'
import { showNotification } from '@/utils/notification'
import { ElMessage as $message } from 'element-plus'
import { useVModelValues } from '@/hooks/useVModelValues'
@@ -121,7 +122,10 @@ function confirmLogLevelSetting () {
'settings.logLevel': form.logLevel,
'settings.logFileSizeLimit': form.logFileSizeLimit
})
showNotification($T('SETTINGS_SET_LOG_FILE'), $T('TIPS_SET_SUCCEED'))
showNotification({
title: $T('SETTINGS_SET_LOG_FILE'),
body: $T('TIPS_SET_SUCCEED')
})
updateProps()
dialogVisible.value = false
}
@@ -61,7 +61,7 @@ import { T as $T } from '@/i18n'
import { useVModel } from '@/hooks/useVModel'
import { saveConfig } from '@/utils/dataSender'
import { useVModelValues } from '@/hooks/useVModelValues'
import { showNotification } from '@/utils/common'
import { showNotification } from '@/utils/notification'
interface IProps {
modelValue: boolean
@@ -82,7 +82,10 @@ function confirmProxy () {
'settings.npmProxy': form.npmProxy,
'settings.npmRegistry': form.npmRegistry
})
showNotification($T('SETTINGS_SET_PROXY_AND_MIRROR'), $T('TIPS_SET_SUCCEED'))
showNotification({
title: $T('SETTINGS_SET_PROXY_AND_MIRROR'),
body: $T('TIPS_SET_SUCCEED')
})
updateProps()
}

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