diff --git a/.changeset/.prettierrc.json b/.changeset/.prettierrc.json new file mode 100644 index 00000000..544138be --- /dev/null +++ b/.changeset/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/.changeset/fancy-ears-walk.md b/.changeset/fancy-ears-walk.md new file mode 100644 index 00000000..f73cb596 --- /dev/null +++ b/.changeset/fancy-ears-walk.md @@ -0,0 +1,7 @@ +--- +'@vben/styles': patch +'@vben-core/form-ui': patch +'@vben/web-naive': patch +--- + +feat(@core/form-ui): 新增 useVbenForm 数组编辑器 VbenFormFieldArray diff --git a/.cursor/rules/Code-Standards.mdc b/.cursor/rules/Code-Standards.mdc index cdd0c377..5400dfa9 100644 --- a/.cursor/rules/Code-Standards.mdc +++ b/.cursor/rules/Code-Standards.mdc @@ -10,6 +10,7 @@ alwaysApply: true 3. 有封装好的方法、组件需要复用,不要重复造轮子 4. 小程序端的抽屉全部需要用 page-container 来防止用户意外退出页面(记得使用 v-if 而不是 v-show) 5. 数据库的(created_at、updated_at、deleted_at)统一使用时间戳,不要使用字符串,并且我在查询器中一级格式化成字符串了,无需再次格式化 +6. 卡片强调样式:**禁止**用左侧竖色条(`border-left: Npx solid 色值` / 左侧色块)做卡片强调或分类标识;统一用「1px 同色系边框 + 微光」——`border: 1px solid hsl(var(--主题色) / 30%)` 配 `box-shadow: 0 0 6px hsl(var(--主题色) / 18%)`;历史页面暂不强制回改,新增/改动的卡片必须遵守 # 全局架构规范 diff --git a/.gitattributes b/.gitattributes index d4e5bd3e..775caa34 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,8 +4,18 @@ * text=auto eol=lf # Declare files that will always have CRLF line endings on checkout. -*.{cmd,[cC][mM][dD]} text eol=crlf -*.{bat,[bB][aA][tT]} text eol=crlf +*.cmd text eol=crlf +*.[cC][mM][dD] text eol=crlf +*.bat text eol=crlf +*.[bB][aA][tT] text eol=crlf # Denote all files that are truly binary and should not be modified. -*.{ico,png,jpg,jpeg,gif,webp,svg,woff,woff2} binary \ No newline at end of file +*.ico binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.svg binary +*.woff binary +*.woff2 binary \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9fd86547..b95ff946 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,14 +1,14 @@ # default onwer -* anncwb@126.com vince292007@gmail.com netfan@foxmail.com +* anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com # vben core onwer -/.github/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com -/.vscode/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com -/packages/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com -/packages/@core/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com -/internal/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com -/scripts/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com +/.github/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com +/.vscode/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com +/packages/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com +/packages/@core/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com +/internal/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com +/scripts/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com # vben team onwer -apps/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com @vbenjs/team-v5 -docs/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com @vbenjs/team-v5 +apps/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com @vbenjs/team-v5 jinmao88@qq.com +docs/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com @vbenjs/team-v5 jinmao88@qq.com diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml index 35fa41c8..445f30be 100644 --- a/.github/actions/setup-node/action.yml +++ b/.github/actions/setup-node/action.yml @@ -6,10 +6,10 @@ runs: using: 'composite' steps: - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 - name: Install Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .node-version cache: 'pnpm' diff --git a/.github/config.yml b/.github/config.yml index 52454840..248da6a5 100644 --- a/.github/config.yml +++ b/.github/config.yml @@ -21,12 +21,10 @@ newPRWelcomeComment: | firstPRMergeComment: > Thanks for your contribution! 🎉🎉🎉 - # Comment to be posted to on first time issues newIssueWelcomeComment: > Thanks for opening your first issue! Be sure to follow the issue template and provide every bit of information to help the developers! - # *OPTIONAL* default titles to check against for lack of descriptiveness # MUST BE ALL LOWERCASE requestInfoDefaultTitles: @@ -36,4 +34,3 @@ requestInfoDefaultTitles: # *Required* Comment to reply with requestInfoReplyComment: > Thanks for filing this issue/PR! It would be much appreciated if you could provide us with more information so we can effectively analyze the situation in context. - diff --git a/.github/contributing.md b/.github/contributing.md index f22370f3..304c5192 100644 --- a/.github/contributing.md +++ b/.github/contributing.md @@ -19,11 +19,9 @@ Project maintainers have the right and responsibility to remove, edit, or reject - Checkout a topic branch from the relevant branch, e.g. main, and merge back against that branch. - If adding a new feature: - - Provide a convincing reason to add this feature. Ideally, you should open a suggestion issue first and have it approved before working on it. - If fixing bug: - - Provide a detailed description of the bug in the PR. Live demo preferred. - It's OK to have multiple small commits as you work on the PR - GitHub can automatically squash them before merging. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d9f48e4a..b81bc4ad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,7 +30,7 @@ jobs: - windows-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/changeset-version.yml b/.github/workflows/changeset-version.yml index 373d9af2..32421e26 100644 --- a/.github/workflows/changeset-version.yml +++ b/.github/workflows/changeset-version.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00750949..254ffd8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,12 +28,12 @@ jobs: timeout-minutes: 20 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: run_install: false @@ -67,7 +67,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -90,7 +90,7 @@ jobs: - windows-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 69a990f0..4824318e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -57,11 +57,11 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -89,6 +89,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 533a87c3..8c12d408 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -30,7 +30,7 @@ jobs: run: pnpm build:play - name: Sync Playground files - uses: SamKirkland/FTP-Deploy-Action@v4.3.5 + uses: SamKirkland/FTP-Deploy-Action@v4.4.0 with: server: ${{ secrets.PRO_FTP_HOST }} username: ${{ secrets.WEB_PLAYGROUND_FTP_ACCOUNT }} @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -54,7 +54,7 @@ jobs: run: pnpm build:docs - name: Sync Docs files - uses: SamKirkland/FTP-Deploy-Action@v4.3.5 + uses: SamKirkland/FTP-Deploy-Action@v4.4.0 with: server: ${{ secrets.PRO_FTP_HOST }} username: ${{ secrets.WEBSITE_FTP_ACCOUNT }} @@ -67,7 +67,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -85,7 +85,7 @@ jobs: run: pnpm run build:antd - name: Sync files - uses: SamKirkland/FTP-Deploy-Action@v4.3.5 + uses: SamKirkland/FTP-Deploy-Action@v4.4.0 with: server: ${{ secrets.PRO_FTP_HOST }} username: ${{ secrets.WEB_ANTD_FTP_ACCOUNT }} @@ -98,7 +98,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -116,7 +116,7 @@ jobs: run: pnpm run build:ele - name: Sync files - uses: SamKirkland/FTP-Deploy-Action@v4.3.5 + uses: SamKirkland/FTP-Deploy-Action@v4.4.0 with: server: ${{ secrets.PRO_FTP_HOST }} username: ${{ secrets.WEB_ELE_FTP_ACCOUNT }} @@ -129,7 +129,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -147,9 +147,26 @@ jobs: run: pnpm run build:naive - name: Sync files - uses: SamKirkland/FTP-Deploy-Action@v4.3.5 + uses: SamKirkland/FTP-Deploy-Action@v4.4.0 with: server: ${{ secrets.PRO_FTP_HOST }} username: ${{ secrets.WEB_NAIVE_FTP_ACCOUNT }} password: ${{ secrets.WEB_NAIVE_FTP_PASSWORD }} local-dir: ./apps/web-naive/dist/ + + rerun-on-failure: + name: Rerun on failure + needs: + - deploy-playground-ftp + - deploy-docs-ftp + - deploy-antd-ftp + - deploy-ele-ftp + - deploy-naive-ftp + if: failure() && fromJSON(github.run_attempt) < 10 + runs-on: ubuntu-latest + steps: + - name: Retry ${{ fromJSON(github.run_attempt) }} of 10 + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: gh workflow run rerun.yml -F run_id=${{ github.run_id }} diff --git a/.github/workflows/draft.yml b/.github/workflows/draft.yml index 20b4148a..babca2d3 100644 --- a/.github/workflows/draft.yml +++ b/.github/workflows/draft.yml @@ -20,6 +20,6 @@ jobs: if: github.repository == 'vbenjs/vue-vben-admin' runs-on: ubuntu-latest steps: - - uses: release-drafter/release-drafter@v6 + - uses: release-drafter/release-drafter@v7 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/issue-close-require.yml b/.github/workflows/issue-close-require.yml index 18c007bf..794292f4 100644 --- a/.github/workflows/issue-close-require.yml +++ b/.github/workflows/issue-close-require.yml @@ -19,7 +19,7 @@ jobs: steps: # 关闭未活动的 Issues - name: Close Inactive Issues - uses: actions/stale@v9 + uses: actions/stale@v11 with: days-before-stale: -1 # Issues and PR will never be flagged stale automatically. stale-issue-label: needs-reproduction # Label that flags an issue as stale. diff --git a/.github/workflows/issue-labeled.yml b/.github/workflows/issue-labeled.yml index 2feda4ef..7a461e3b 100644 --- a/.github/workflows/issue-labeled.yml +++ b/.github/workflows/issue-labeled.yml @@ -18,7 +18,7 @@ jobs: steps: - name: remove enhancement pending if: github.event.label.name == 'enhancement' - uses: actions-cool/issues-helper@v3 + uses: actions-cool/issues-helper-backup@d65454423c6fbbd20026b9b499d403f79422ac69 with: actions: 'remove-labels' token: ${{ secrets.GITHUB_TOKEN }} @@ -27,7 +27,7 @@ jobs: - name: remove bug pending if: github.event.label.name == 'bug' - uses: actions-cool/issues-helper@v3 + uses: actions-cool/issues-helper-backup@d65454423c6fbbd20026b9b499d403f79422ac69 with: actions: 'remove-labels' token: ${{ secrets.GITHUB_TOKEN }} @@ -36,7 +36,7 @@ jobs: - name: needs reproduction if: github.event.label.name == 'needs reproduction' - uses: actions-cool/issues-helper@v3 + uses: actions-cool/issues-helper-backup@d65454423c6fbbd20026b9b499d403f79422ac69 with: actions: 'create-comment, remove-labels' token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index bd73d533..a12227ef 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -14,7 +14,7 @@ jobs: if: github.repository == 'vbenjs/vue-vben-admin' runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v5 + - uses: dessant/lock-threads@v6 with: github-token: ${{ secrets.GITHUB_TOKEN }} issue-inactive-days: '14' diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 7f524f90..d87a7011 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -3,78 +3,48 @@ name: Create Release Tag on: push: tags: - - 'v*.*.*' # Push events to matching v*, i.e. v1.0, v20.15.10 - -env: - HUSKY: '0' + - 'v*.*.*' + workflow_dispatch: + inputs: + tag: + description: 'Tag to create (e.g. v1.2.3)' + required: true + type: string permissions: - pull-requests: write contents: write jobs: - build: + release: name: Create Release if: github.repository == 'vbenjs/vue-vben-admin' runs-on: ubuntu-latest - strategy: - matrix: - node-version: [20] steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - # - name: Checkout code - # uses: actions/checkout@v4 - # with: - # fetch-depth: 0 - - # - name: Install pnpm - # uses: pnpm/action-setup@v4 - - # - name: Use Node.js ${{ matrix.node-version }} - # uses: actions/setup-node@v4 - # with: - # node-version: ${{ matrix.node-version }} - # cache: "pnpm" - - # - name: Install dependencies - # run: pnpm install --frozen-lockfile - - # - name: Test and Build - # run: | - # pnpm run test - # pnpm run build - - - name: version + - name: Extract version id: version run: | - tag=${GITHUB_REF/refs\/tags\//} - version=${tag#v} - major=${version%%.*} - echo "tag=${tag}" >> $GITHUB_OUTPUT - echo "version=${version}" >> $GITHUB_OUTPUT - echo "major=${major}" >> $GITHUB_OUTPUT + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + raw_tag="${{ inputs.tag }}" + else + raw_tag="${GITHUB_REF_NAME}" + fi + # Normalize: ensure v prefix + tag="${raw_tag}" + [[ "${tag:0:1}" != "v" ]] && tag="v${tag}" + version="${tag#v}" + major="${version%%.*}" + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + echo "version=${version}" >> "${GITHUB_OUTPUT}" + echo "major=${major}" >> "${GITHUB_OUTPUT}" - - uses: release-drafter/release-drafter@v6 + - uses: release-drafter/release-drafter@v7 with: version: ${{ steps.version.outputs.version }} publish: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # - name: force update major tag - # run: | - # git tag v${{ steps.version.outputs.major }} ${{ steps.version.outputs.tag }} -f - # git push origin refs/tags/v${{ steps.version.outputs.major }} -f - - # - name: Create Release for Tag - # id: release_tag - # uses: ncipollo/release-action@v1 - # with: - # token: ${{ secrets.GITHUB_TOKEN }} - # generateReleaseNotes: "true" - # body: | - # > Please refer to [CHANGELOG.md](https://github.com/vbenjs/vue-vben-admin/blob/main/CHANGELOG.md) for details. diff --git a/.github/workflows/rerun.yml b/.github/workflows/rerun.yml new file mode 100644 index 00000000..2b462551 --- /dev/null +++ b/.github/workflows/rerun.yml @@ -0,0 +1,19 @@ +name: Rerun workflow + +on: + workflow_dispatch: + inputs: + run_id: + description: The workflow id to relanch + required: true +jobs: + rerun: + runs-on: ubuntu-latest + steps: + - name: rerun ${{ inputs.run_id }} + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: | + gh run watch ${{ inputs.run_id }} > /dev/null 2>&1 + gh run rerun ${{ inputs.run_id }} --failed diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index db8e9e09..02db08de 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Validate PR title - uses: amannn/action-semantic-pull-request@v5 + uses: amannn/action-semantic-pull-request@v6 with: wip: true subjectPattern: ^(?![A-Z]).+$ diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 991e1331..a6fd1c0e 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -9,7 +9,7 @@ jobs: if: github.repository == 'vbenjs/vue-vben-admin' runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@v11 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days' diff --git a/.gitignore b/.gitignore index f73c2ad7..14538a4d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,8 @@ yarn.lock package-lock.json .VSCodeCounter **/backend-mock/data - +.omx +.pnpm-store # local env files .env.local .env.*.local @@ -51,3 +52,14 @@ vite.config.ts.* .history /.mimocode/ /.cursor/skills/ +.cursor + +# AI +.agent +.agents +.claude +.codex +skills-lock.json +.atomcode +datalog +.playwright-mcp diff --git a/.gitpod.yml b/.gitpod.yml index fb75b433..5fda2cf7 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -2,5 +2,5 @@ ports: - port: 5555 onOpen: open-preview tasks: - - init: corepack enable && pnpm install + - init: npm i -g corepack && pnpm install command: pnpm run dev:play diff --git a/.husky/commit-msg b/.husky/commit-msg deleted file mode 100755 index 270ebb8c..00000000 --- a/.husky/commit-msg +++ /dev/null @@ -1,6 +0,0 @@ -echo Start running commit-msg hook... - -# Check whether the git commit information is standardized -pnpm exec commitlint --edit "$1" - -echo Run commit-msg hook done. diff --git a/.husky/post-merge b/.husky/post-merge deleted file mode 100644 index 83fa775d..00000000 --- a/.husky/post-merge +++ /dev/null @@ -1,3 +0,0 @@ -# 每次 git pull 之后, 安装依赖 - -pnpm install diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs deleted file mode 100644 index e68d8a3e..00000000 --- a/.lintstagedrc.mjs +++ /dev/null @@ -1,20 +0,0 @@ -export default { - '*.{js,jsx,ts,tsx}': [ - 'prettier --cache --ignore-unknown --write', - 'eslint --cache --fix', - ], - '*.{scss,less,styl,html,vue,css}': [ - 'prettier --cache --ignore-unknown --write', - 'stylelint --fix --allow-empty-input', - ], - '*.md': ['prettier --cache --ignore-unknown --write'], - '*.vue': [ - 'prettier --write', - 'eslint --cache --fix', - 'stylelint --fix --allow-empty-input', - ], - '{!(package)*.json,*.code-snippets,.!(browserslist)*rc}': [ - 'prettier --cache --write--parser json', - ], - 'package.json': ['prettier --cache --write'], -}; diff --git a/.node-version b/.node-version index 48b14e6b..b832e400 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20.14.0 +24.16.0 diff --git a/.npmrc b/.npmrc index f4a1ad48..ea2d746e 100644 --- a/.npmrc +++ b/.npmrc @@ -1,13 +1,4 @@ -registry = "https://registry.npmmirror.com" -public-hoist-pattern[]=husky -public-hoist-pattern[]=eslint -public-hoist-pattern[]=prettier -public-hoist-pattern[]=prettier-plugin-tailwindcss -public-hoist-pattern[]=stylelint -public-hoist-pattern[]=*postcss* -public-hoist-pattern[]=@commitlint/* -public-hoist-pattern[]=czg - -strict-peer-dependencies=false -auto-install-peers=true -dedupe-peer-dependents=true +registry=https://registry.npmmirror.com +# Electron 二进制与 electron-builder 打包工具链的国内镜像(不配置在国内网络下大概率下载超时失败) +electron_mirror=https://npmmirror.com/mirrors/electron/ +electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index d0b0ca13..00000000 --- a/.prettierignore +++ /dev/null @@ -1,18 +0,0 @@ -dist -dev-dist -.local -.output.js -node_modules -.nvmrc -coverage -CODEOWNERS -.nitro -.output - - -**/*.svg -**/*.sh - -public -.npmrc -*-lock.yaml diff --git a/.prettierrc.mjs b/.prettierrc.mjs deleted file mode 100644 index 3e25d2cf..00000000 --- a/.prettierrc.mjs +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@vben/prettier-config'; diff --git a/.stylelintignore b/.stylelintignore index f4b2db2c..3adb33b2 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -2,3 +2,7 @@ dist public __tests__ coverage +.codex +.claude +.agent +.agents diff --git a/apps/backend-mock/api/demo/bigint.ts b/apps/backend-mock/api/demo/bigint.ts new file mode 100644 index 00000000..00d6c28c --- /dev/null +++ b/apps/backend-mock/api/demo/bigint.ts @@ -0,0 +1,32 @@ +import { eventHandler, setHeader } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { unAuthorizedResponse } from '~/utils/response'; + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + const data = ` + { + "code": 0, + "message": "success", + "data": [ + { + "id": 123456789012345678901234567890123456789012345678901234567890, + "name": "John Doe", + "age": 30, + "email": "john-doe@demo.com" + }, + { + "id": 987654321098765432109876543210987654321098765432109876543210, + "name": "Jane Smith", + "age": 25, + "email": "jane@demo.com" + } + ] + } + `; + setHeader(event, 'Content-Type', 'application/json'); + return data; +}); diff --git a/apps/backend-mock/api/system/dept/.post.ts b/apps/backend-mock/api/system/dept/.post.ts new file mode 100644 index 00000000..9a4896af --- /dev/null +++ b/apps/backend-mock/api/system/dept/.post.ts @@ -0,0 +1,16 @@ +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { + sleep, + unAuthorizedResponse, + useResponseSuccess, +} from '~/utils/response'; + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + await sleep(600); + return useResponseSuccess(null); +}); diff --git a/apps/backend-mock/api/system/dept/[id].delete.ts b/apps/backend-mock/api/system/dept/[id].delete.ts new file mode 100644 index 00000000..eac0f584 --- /dev/null +++ b/apps/backend-mock/api/system/dept/[id].delete.ts @@ -0,0 +1,16 @@ +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { + sleep, + unAuthorizedResponse, + useResponseSuccess, +} from '~/utils/response'; + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + await sleep(1000); + return useResponseSuccess(null); +}); diff --git a/apps/backend-mock/api/system/dept/[id].put.ts b/apps/backend-mock/api/system/dept/[id].put.ts new file mode 100644 index 00000000..6805e139 --- /dev/null +++ b/apps/backend-mock/api/system/dept/[id].put.ts @@ -0,0 +1,16 @@ +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { + sleep, + unAuthorizedResponse, + useResponseSuccess, +} from '~/utils/response'; + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + await sleep(2000); + return useResponseSuccess(null); +}); diff --git a/apps/backend-mock/api/system/dept/list.ts b/apps/backend-mock/api/system/dept/list.ts new file mode 100644 index 00000000..a649a0d2 --- /dev/null +++ b/apps/backend-mock/api/system/dept/list.ts @@ -0,0 +1,62 @@ +import { faker } from '@faker-js/faker'; +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; + +const formatterCN = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}); + +function generateMockDataList(count: number) { + const dataList = []; + + for (let i = 0; i < count; i++) { + const dataItem: Record = { + id: faker.string.uuid(), + pid: 0, + name: faker.commerce.department(), + status: faker.helpers.arrayElement([0, 1]), + createTime: formatterCN.format( + faker.date.between({ from: '2021-01-01', to: '2022-12-31' }), + ), + remark: faker.lorem.sentence(), + }; + if (faker.datatype.boolean()) { + dataItem.children = Array.from( + { length: faker.number.int({ min: 1, max: 5 }) }, + () => ({ + id: faker.string.uuid(), + pid: dataItem.id, + name: faker.commerce.department(), + status: faker.helpers.arrayElement([0, 1]), + createTime: formatterCN.format( + faker.date.between({ from: '2023-01-01', to: '2023-12-31' }), + ), + remark: faker.lorem.sentence(), + }), + ); + } + dataList.push(dataItem); + } + + return dataList; +} + +const mockData = generateMockDataList(10); + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + + const listData = structuredClone(mockData); + + return useResponseSuccess(listData); +}); diff --git a/apps/backend-mock/api/system/menu/list.ts b/apps/backend-mock/api/system/menu/list.ts new file mode 100644 index 00000000..ce96bb14 --- /dev/null +++ b/apps/backend-mock/api/system/menu/list.ts @@ -0,0 +1,13 @@ +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { MOCK_MENU_LIST } from '~/utils/mock-data'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + + return useResponseSuccess(MOCK_MENU_LIST); +}); diff --git a/apps/backend-mock/api/system/menu/name-exists.ts b/apps/backend-mock/api/system/menu/name-exists.ts new file mode 100644 index 00000000..7d5551b3 --- /dev/null +++ b/apps/backend-mock/api/system/menu/name-exists.ts @@ -0,0 +1,29 @@ +import { eventHandler, getQuery } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { MOCK_MENU_LIST } from '~/utils/mock-data'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; + +const namesMap: Record = {}; + +function getNames(menus: any[]) { + menus.forEach((menu) => { + namesMap[menu.name] = String(menu.id); + if (menu.children) { + getNames(menu.children); + } + }); +} +getNames(MOCK_MENU_LIST); + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + const { id, name } = getQuery(event); + + return (name as string) in namesMap && + (!id || namesMap[name as string] !== String(id)) + ? useResponseSuccess(true) + : useResponseSuccess(false); +}); diff --git a/apps/backend-mock/api/system/menu/path-exists.ts b/apps/backend-mock/api/system/menu/path-exists.ts new file mode 100644 index 00000000..f3c3be99 --- /dev/null +++ b/apps/backend-mock/api/system/menu/path-exists.ts @@ -0,0 +1,29 @@ +import { eventHandler, getQuery } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { MOCK_MENU_LIST } from '~/utils/mock-data'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; + +const pathMap: Record = { '/': 0 }; + +function getPaths(menus: any[]) { + menus.forEach((menu) => { + pathMap[menu.path] = String(menu.id); + if (menu.children) { + getPaths(menu.children); + } + }); +} +getPaths(MOCK_MENU_LIST); + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + const { id, path } = getQuery(event); + + return (path as string) in pathMap && + (!id || pathMap[path as string] !== String(id)) + ? useResponseSuccess(true) + : useResponseSuccess(false); +}); diff --git a/apps/backend-mock/api/system/role/list.ts b/apps/backend-mock/api/system/role/list.ts new file mode 100644 index 00000000..bad29a51 --- /dev/null +++ b/apps/backend-mock/api/system/role/list.ts @@ -0,0 +1,84 @@ +import { faker } from '@faker-js/faker'; +import { eventHandler, getQuery } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { getMenuIds, MOCK_MENU_LIST } from '~/utils/mock-data'; +import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response'; + +const formatterCN = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}); + +const menuIds = getMenuIds(MOCK_MENU_LIST); + +function generateMockDataList(count: number) { + const dataList = []; + + for (let i = 0; i < count; i++) { + const dataItem: Record = { + id: faker.string.uuid(), + name: faker.commerce.product(), + status: faker.helpers.arrayElement([0, 1]), + createTime: formatterCN.format( + faker.date.between({ from: '2022-01-01', to: '2025-01-01' }), + ), + permissions: faker.helpers.arrayElements(menuIds), + remark: faker.lorem.sentence(), + }; + + dataList.push(dataItem); + } + + return dataList; +} + +const mockData = generateMockDataList(100); + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + + const { + page = 1, + pageSize = 20, + name, + id, + remark, + startTime, + endTime, + status, + } = getQuery(event); + let listData = structuredClone(mockData); + if (name) { + listData = listData.filter((item) => + item.name.toLowerCase().includes(String(name).toLowerCase()), + ); + } + if (id) { + listData = listData.filter((item) => + item.id.toLowerCase().includes(String(id).toLowerCase()), + ); + } + if (remark) { + listData = listData.filter((item) => + item.remark?.toLowerCase()?.includes(String(remark).toLowerCase()), + ); + } + if (startTime) { + listData = listData.filter((item) => item.createTime >= startTime); + } + if (endTime) { + listData = listData.filter((item) => item.createTime <= endTime); + } + if (['0', '1'].includes(status as string)) { + listData = listData.filter((item) => item.status === Number(status)); + } + return usePageResponseSuccess(page as string, pageSize as string, listData); +}); diff --git a/apps/backend-mock/api/system/user/list.ts b/apps/backend-mock/api/system/user/list.ts new file mode 100644 index 00000000..21d44c8b --- /dev/null +++ b/apps/backend-mock/api/system/user/list.ts @@ -0,0 +1,85 @@ +import { faker } from '@faker-js/faker'; +import { eventHandler, getQuery } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response'; + +const formatterCN = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}); + +function generateMockDataList(count: number) { + const dataList = []; + + for (let i = 0; i < count; i++) { + const dataItem: Record = { + id: faker.string.uuid(), + name: faker.commerce.product(), + status: faker.helpers.arrayElement([0, 1]), + createTime: formatterCN.format( + faker.date.between({ from: '2022-01-01', to: '2025-01-01' }), + ), + deptId: faker.string.uuid(), + remark: faker.lorem.sentence(), + }; + + dataList.push(dataItem); + } + + return dataList; +} + +const mockData = generateMockDataList(100); + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + + const { + page = 1, + pageSize = 20, + name, + id, + remark, + startTime, + endTime, + deptId, + status, + } = getQuery(event); + let listData = structuredClone(mockData); + if (name) { + listData = listData.filter((item) => + item.name.toLowerCase().includes(String(name).toLowerCase()), + ); + } + if (id) { + listData = listData.filter((item) => + item.id.toLowerCase().includes(String(id).toLowerCase()), + ); + } + if (remark) { + listData = listData.filter((item) => + item.remark?.toLowerCase()?.includes(String(remark).toLowerCase()), + ); + } + if (startTime) { + listData = listData.filter((item) => item.createTime >= startTime); + } + if (endTime) { + listData = listData.filter((item) => item.createTime <= endTime); + } + if (['0', '1'].includes(status as string)) { + listData = listData.filter((item) => item.status === Number(status)); + } + if (deptId) { + listData = listData.filter((item) => item.deptId === deptId); + } + return usePageResponseSuccess(page as string, pageSize as string, listData); +}); diff --git a/apps/backend-mock/api/timezone/getTimezone.ts b/apps/backend-mock/api/timezone/getTimezone.ts new file mode 100644 index 00000000..0cbcb6ec --- /dev/null +++ b/apps/backend-mock/api/timezone/getTimezone.ts @@ -0,0 +1,12 @@ +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; +import { getTimezone } from '~/utils/timezone-utils'; + +export default eventHandler((event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + return useResponseSuccess(getTimezone()); +}); diff --git a/apps/backend-mock/api/timezone/getTimezoneOptions.ts b/apps/backend-mock/api/timezone/getTimezoneOptions.ts new file mode 100644 index 00000000..6c241864 --- /dev/null +++ b/apps/backend-mock/api/timezone/getTimezoneOptions.ts @@ -0,0 +1,11 @@ +import { eventHandler } from 'h3'; +import { TIME_ZONE_OPTIONS } from '~/utils/mock-data'; +import { useResponseSuccess } from '~/utils/response'; + +export default eventHandler(() => { + const data = TIME_ZONE_OPTIONS.map((o) => ({ + label: `${o.timezone} (GMT${o.offset >= 0 ? `+${o.offset}` : o.offset})`, + value: o.timezone, + })); + return useResponseSuccess(data); +}); diff --git a/apps/backend-mock/api/timezone/setTimezone.ts b/apps/backend-mock/api/timezone/setTimezone.ts new file mode 100644 index 00000000..851e4351 --- /dev/null +++ b/apps/backend-mock/api/timezone/setTimezone.ts @@ -0,0 +1,26 @@ +import { eventHandler, readBody, setResponseStatus } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { TIME_ZONE_OPTIONS } from '~/utils/mock-data'; +import { + unAuthorizedResponse, + useResponseError, + useResponseSuccess, +} from '~/utils/response'; +import { setTimezone } from '~/utils/timezone-utils'; + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + const body = await readBody<{ timezone?: unknown }>(event); + const timezone = + typeof body?.timezone === 'string' ? body.timezone : undefined; + const allowed = TIME_ZONE_OPTIONS.some((o) => o.timezone === timezone); + if (!timezone || !allowed) { + setResponseStatus(event, 400); + return useResponseError('Bad Request', 'Invalid timezone'); + } + setTimezone(timezone); + return useResponseSuccess({}); +}); diff --git a/apps/backend-mock/api/upload.ts b/apps/backend-mock/api/upload.ts new file mode 100644 index 00000000..436b63cb --- /dev/null +++ b/apps/backend-mock/api/upload.ts @@ -0,0 +1,14 @@ +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; + +export default eventHandler((event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + return useResponseSuccess({ + url: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp', + }); + // return useResponseError("test") +}); diff --git a/apps/backend-mock/utils/timezone-utils.ts b/apps/backend-mock/utils/timezone-utils.ts new file mode 100644 index 00000000..da35f920 --- /dev/null +++ b/apps/backend-mock/utils/timezone-utils.ts @@ -0,0 +1,9 @@ +let mockTimeZone: null | string = null; + +export const setTimezone = (timeZone: string) => { + mockTimeZone = timeZone; +}; + +export const getTimezone = () => { + return mockTimeZone; +}; diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore new file mode 100644 index 00000000..dcfe6825 --- /dev/null +++ b/apps/desktop/.gitignore @@ -0,0 +1,2 @@ +out/ +release/ diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 00000000..294d9789 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,46 @@ +# @vben/desktop — 萧康云医 PC 桌面端 + +Electron 壳工程:打包加载 `apps/web-antd` 的生产构建产物,提供桌面通知、开机自启、系统托盘、未读角标、静默打印、全局快捷键与自动更新能力。 + +## 常用命令(在仓库根目录执行) + +```bash +# 开发:并行启动 web-antd dev server(:5666)与 Electron 壳(壳会自动等待/重试连接 dev server) +pnpm dev:desktop + +# 打包:turbo 先构建 web-antd dist,再构建主进程并出安装包(产物在 apps/desktop/release/) +pnpm build:desktop +``` + +- Windows 机器出 NSIS 安装包;mac 包必须在 macOS 机器上执行同一命令(electron-builder 不支持交叉编译 mac 包)。 +- 不需要打桌面包的同事 / CI,可设置环境变量 `ELECTRON_SKIP_BINARY_DOWNLOAD=1` 跳过 Electron 二进制下载,不影响 web 开发。 + +## 环境切换(dev 走本地,打包走生产) + +| 模式 | 页面来源 | API 走向 | +|---|---|---| +| `pnpm dev:desktop` | `http://localhost:5666`(web-antd vite dev server) | vite 代理转发到**本地后端**:`/api/*` → `localhost:18001`、`/im-api/*` → `localhost:12080`(见 `apps/web-antd/vite.config.ts`) | +| 打包后的安装包 | `app://local/`(内置 web-antd 生产 dist) | 主进程协议处理器转发到**生产上游**:`/api/admin/*` → `api.xiaokang88.com`、`/im-api/*` → `api.ws.g.xiaokang88.com/api`(见 `src/main/config.ts`,可用环境变量覆盖) | + +即:开发调试自动对接本地后端(与网页开发完全一致),只有 `pnpm build:desktop` 出的安装包才请求生产环境,无需手动切换。 + +## 环境变量 + +| 变量 | 用途 | +|---|---| +| `XK_DESKTOP_API_UPSTREAM` | 覆盖 `/api/admin` 转发上游(联调测试环境用) | +| `XK_DESKTOP_IM_UPSTREAM` | 覆盖 `/im-api` 转发上游 | +| `XK_DESKTOP_DEV_URL` | 覆盖开发模式加载的 dev server 地址(默认 `http://localhost:5666`) | +| `CSC_LINK` / `CSC_KEY_PASSWORD` | 代码签名证书;**未配置时自动跳过签名,出未签名包,构建不报错** | +| `APPLE_ID` / `APPLE_APP_SPECIFIC_PASSWORD` / `APPLE_TEAM_ID` | mac 公证三件套;三者齐全才启用公证 | + +未签名 mac 包可内部分发:首次打开需右键「打开」,且 mac 自动更新不可用(应用内会降级为提示前往下载页手动安装)。 + +## 发布自动更新 + +`release/` 里的以下产物上传到静态托管地址(`electron-builder.config.cjs` 的 `publish.url`): + +- Windows:`latest.yml` + `xk-desktop-*.exe` + `*.blockmap` +- mac:`latest-mac.yml` + `*.dmg` + `*.zip` + `*.blockmap`(zip 是 mac 自动更新必需格式) + +前端每次发版后跟着跑一次 `pnpm build:desktop` 并上传产物,桌面端即可自动更新。 diff --git a/apps/desktop/electron-builder.config.cjs b/apps/desktop/electron-builder.config.cjs new file mode 100644 index 00000000..879364d4 --- /dev/null +++ b/apps/desktop/electron-builder.config.cjs @@ -0,0 +1,78 @@ +/** + * electron-builder 打包配置 + * 用 JS 配置而不是 yml:mac 公证需要按「环境变量是否配齐」动态开关。 + * + * 签名策略(按需求做成条件化,未配置证书时出未签名包且构建不报错): + * - Windows / mac 代码签名:配置了 CSC_LINK + CSC_KEY_PASSWORD(或 mac 钥匙串里有 + * Developer ID 证书)时 electron-builder 自动签名;否则自动跳过,只输出告警 + * - mac 公证:APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID 三者齐全才启用 + * - 未签名 mac 包仍可内部分发:首次打开需右键「打开」,且自动更新会降级为提示手动下载 + */ +const canNotarize = Boolean( + process.env.APPLE_ID && + process.env.APPLE_APP_SPECIFIC_PASSWORD && + process.env.APPLE_TEAM_ID, +); + +/** @type {import('electron-builder').Configuration} */ +const config = { + // appId 必须与主进程 setAppUserModelId 一致,否则 Windows 系统通知不显示 + appId: 'com.xiaokang.xkadmin', + productName: '萧康云医', + // 安装包文件名用 ASCII,避免中文名在静态托管/下载链接上出编码问题 + artifactName: 'xk-desktop-${version}-${os}-${arch}.${ext}', + directories: { + output: 'release', + buildResources: 'resources', + }, + files: ['out/**'], + extraResources: [ + // web-antd 生产构建产物:放 resources/web(不进 asar),为后续渲染层热更新留路 + { from: '../web-antd/dist', to: 'web' }, + // 应用图标供运行时使用(托盘、Windows 窗口图标) + { from: 'resources/icon.png', to: 'icon.png' }, + ], + asar: true, + compression: 'normal', + win: { + // TODO(待确认):正式图标待提供,当前用 web-antd logo 预生成的 ico 占位 + // 直接给 .ico 让 builder 跳过 png 转换(本机 builder 的 wasm 图标工具会报内存错误) + icon: 'resources/icon.ico', + target: [{ target: 'nsis', arch: ['x64'] }], + }, + nsis: { + // 非一键安装:允许用户选择安装目录,符合企业内部软件分发习惯 + oneClick: false, + perMachine: false, + allowToChangeInstallationDirectory: true, + createDesktopShortcut: true, + createStartMenuShortcut: true, + shortcutName: '萧康云医', + }, + mac: { + icon: 'resources/icon.png', + category: 'public.app-category.medical', + // dmg 给用户安装,zip 是 electron-updater 在 mac 自动更新的必需格式,缺一不可 + target: [ + { target: 'dmg', arch: ['x64', 'arm64'] }, + { target: 'zip', arch: ['x64', 'arm64'] }, + ], + hardenedRuntime: true, + gatekeeperAssess: false, + entitlements: 'resources/entitlements.mac.plist', + entitlementsInherit: 'resources/entitlements.mac.plist', + // IM 音视频通话必需的隐私用途描述,缺失时 mac 上调用摄像头/麦克风直接崩溃 + extendInfo: { + NSCameraUsageDescription: '视频问诊需要使用摄像头', + NSMicrophoneUsageDescription: '语音/视频通话需要使用麦克风', + }, + notarize: canNotarize, + }, + publish: { + provider: 'generic', + // TODO(待确认):更新包静态托管地址,需与主进程 config.ts 的 DOWNLOAD_PAGE_URL 保持一致 + url: 'https://static.xiaokang88.com/xk-desktop/', + }, +}; + +module.exports = config; diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts new file mode 100644 index 00000000..162224df --- /dev/null +++ b/apps/desktop/electron.vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; + +/** + * electron-vite 构建配置 + * 只构建主进程与预加载脚本,不配置 renderer: + * - 开发模式渲染层直接加载 web-antd 的 vite dev server(http://localhost:5666) + * - 打包模式渲染层加载 app://local/(web-antd 的生产 dist,由主进程自定义协议提供服务) + * externalizeDepsPlugin:dependencies(electron-updater / electron-log)保持外部化, + * 由 electron-builder 按生产依赖收集进安装包,避免打包器转译这类含运行时文件读取的库出问题 + */ +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + build: { + outDir: 'out/main', + lib: { entry: 'src/main/index.ts' }, + }, + }, + preload: { + plugins: [externalizeDepsPlugin()], + build: { + outDir: 'out/preload', + lib: { entry: 'src/preload/index.ts' }, + }, + }, +}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 00000000..6bf74c20 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,27 @@ +{ + "name": "@vben/desktop", + "version": "1.0.0", + "private": true, + "description": "萧康云医 PC 桌面端(Electron 壳,打包加载 web-antd 生产构建产物)", + "author": "萧康云医", + "main": "out/main/index.js", + "scripts": { + "dev": "electron-vite dev", + "build": "electron-vite build", + "pack": "electron-builder --config electron-builder.config.cjs", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "electron-log": "catalog:", + "electron-updater": "^6.8.9" + }, + "devDependencies": { + "@types/node": "catalog:", + "@vben/web-antd": "workspace:*", + "electron": "^43.4.0", + "electron-builder": "catalog:", + "electron-vite": "catalog:", + "typescript": "catalog:", + "vite": "^7.3.6" + } +} diff --git a/apps/desktop/resources/entitlements.mac.plist b/apps/desktop/resources/entitlements.mac.plist new file mode 100644 index 00000000..f3950fd2 --- /dev/null +++ b/apps/desktop/resources/entitlements.mac.plist @@ -0,0 +1,18 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + + com.apple.security.device.camera + + com.apple.security.device.audio-input + + + diff --git a/apps/desktop/resources/icon.ico b/apps/desktop/resources/icon.ico new file mode 100644 index 00000000..dddf3784 Binary files /dev/null and b/apps/desktop/resources/icon.ico differ diff --git a/apps/desktop/resources/icon.png b/apps/desktop/resources/icon.png new file mode 100644 index 00000000..8d7f47ae Binary files /dev/null and b/apps/desktop/resources/icon.png differ diff --git a/apps/desktop/src/main/auto-launch.ts b/apps/desktop/src/main/auto-launch.ts new file mode 100644 index 00000000..ddde864c --- /dev/null +++ b/apps/desktop/src/main/auto-launch.ts @@ -0,0 +1,30 @@ +import { app } from 'electron'; +import log from 'electron-log/main'; + +/** + * 开机自启:基于系统登录项实现(Windows 写 HKCU\...\Run 注册表、mac 写「系统设置 → 登录项」), + * 无需管理员权限;系统登录项本身就是持久化存储,无需额外落盘记录状态。 + */ + +/** 读取当前开机自启状态(直接以系统登录项为准,避免自维护状态与系统不同步) */ +export function getAutoLaunchEnabled(): boolean { + // 开发模式下登录项指向 electron 开发二进制,无意义,统一视为关闭 + if (!app.isPackaged) return false; + return app.getLoginItemSettings().openAtLogin; +} + +/** + * 设置开机自启 + * --hidden 参数让开机自启时静默进托盘(主进程入口据此不弹主窗口),避免开机打扰 + */ +export function setAutoLaunchEnabled(enable: boolean): boolean { + if (!app.isPackaged) { + log.warn('开发模式不支持设置开机自启(登录项会错误指向开发二进制)'); + return false; + } + app.setLoginItemSettings({ + openAtLogin: enable, + args: enable ? ['--hidden'] : [], + }); + return getAutoLaunchEnabled(); +} diff --git a/apps/desktop/src/main/badge.ts b/apps/desktop/src/main/badge.ts new file mode 100644 index 00000000..86de4d3b --- /dev/null +++ b/apps/desktop/src/main/badge.ts @@ -0,0 +1,24 @@ +import { app, nativeImage } from 'electron'; + +import { getMainWindow } from './window'; + +/** + * 未读角标:按平台分支实现 + * - mac:Dock 图标原生支持文字角标,直接 setBadge + * - Windows:任务栏无原生数字角标,用「覆盖图标」实现(图由 preload 侧 canvas 绘制成 dataURL 传来, + * 因为主进程没有 DOM/canvas,在渲染侧画图是社区标准做法) + */ +export function applyBadge(count: number, image?: string): void { + if (process.platform === 'darwin') { + app.dock?.setBadge(count > 0 ? String(Math.min(count, 99)) : ''); + return; + } + const win = getMainWindow(); + if (!win) return; + if (count > 0 && image) { + win.setOverlayIcon(nativeImage.createFromDataURL(image), `${count} 条未读消息`); + } else { + // 数量归零或没有角标图时清除覆盖图标 + win.setOverlayIcon(null, ''); + } +} diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts new file mode 100644 index 00000000..d2eef74a --- /dev/null +++ b/apps/desktop/src/main/config.ts @@ -0,0 +1,54 @@ +import { app } from 'electron'; +import { join } from 'node:path'; + +/** + * 桌面端全局配置 + * 打包模式下渲染层加载的是本地 dist,所有「按环境调整」的地址都收敛在这里, + * 并支持环境变量覆盖,便于联调测试环境时不改代码切换上游。 + */ + +/** 应用标识:必须与 electron-builder 的 appId 一致,否则 Windows 系统通知不显示 */ +export const APP_ID = 'com.xiaokang.xkadmin'; + +/** 自定义协议,注册为特权协议后 app://local/ 等价一个「本地 https 站点」(安全上下文) */ +export const APP_SCHEME = 'app'; +export const APP_HOST = 'local'; +export const APP_ORIGIN = `${APP_SCHEME}://${APP_HOST}`; +export const APP_INDEX_URL = `${APP_ORIGIN}/`; + +/** 开发模式加载 web-antd 的 vite dev server(端口对齐 apps/web-antd/.env.development 的 VITE_PORT=5666) */ +export const DEV_SERVER_URL = process.env.XK_DESKTOP_DEV_URL ?? 'http://localhost:5666'; + +/** + * /api/admin/* 的转发上游(仅打包后的 app:// 协议使用;dev 模式加载 vite dev server, + * 由 vite 代理转发到本地后端 localhost:18001,不经过这里) + * 地址来源:apps/web-antd/vite.config.ts 代理注释中的生产目标 https://api.xiaokang88.com/api/admin/ + */ +export const API_UPSTREAM = process.env.XK_DESKTOP_API_UPSTREAM ?? 'https://api.xiaokang88.com'; + +/** + * /im-api/* 的转发上游(转发规则对齐开发代理:/im-api/* → 上游 /api/*;同样仅打包后生效, + * dev 模式由 vite 代理转发到本地 IM localhost:12080) + * 地址来源:vite.config.ts 代理注释的生产目标 https://api.ws.g.xiaokang88.com/api/,与生产 WS 同主机 + */ +export const IM_UPSTREAM = process.env.XK_DESKTOP_IM_UPSTREAM ?? 'https://api.ws.g.xiaokang88.com'; + +/** + * 更新包下载页地址(与 electron-builder.config.cjs 的 publish.url 保持一致) + * 兼作 mac 未签名时「自动更新不可用 → 手动下载」的跳转页 + * TODO(待确认):更新包静态托管地址 + */ +export const DOWNLOAD_PAGE_URL = 'https://static.xiaokang88.com/xk-desktop/'; + +/** 全局快捷键:唤起/隐藏主窗口(CommandOrControl 在 Windows 映射 Ctrl、mac 映射 Cmd) */ +export const MAIN_WINDOW_SHORTCUT = 'CommandOrControl+Alt+X'; + +/** + * 解析运行时资源路径(图标等) + * 打包后 extraResources 位于 process.resourcesPath,开发时直接读工程 resources 目录 + */ +export function getResourcePath(name: string): string { + return app.isPackaged + ? join(process.resourcesPath, name) + : join(app.getAppPath(), 'resources', name); +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts new file mode 100644 index 00000000..c5cd6a46 --- /dev/null +++ b/apps/desktop/src/main/index.ts @@ -0,0 +1,81 @@ +import { app, Menu } from 'electron'; +import log from 'electron-log/main'; + +import { APP_ID } from './config'; +import { registerIpcHandlers } from './ipc'; +import { registerAppProtocol, registerSchemePrivileges } from './protocol'; +import { registerGlobalShortcut, unregisterGlobalShortcut } from './shortcut'; +import { createTray } from './tray'; +import { initUpdater } from './updater'; +import { createMainWindow, getMainWindow, markQuitting, showMainWindow } from './window'; + +/** + * 主进程入口:负责应用生命周期编排 + * 启动顺序:单实例锁 → 特权协议注册(必须在 ready 前)→ ready 后依次初始化 + * 协议服务 / IPC / 主窗口 / 托盘 / 全局快捷键 / 自动更新 + */ + +// 显式设置应用名:userData / 日志目录会随之落到 %APPDATA%\萧康云医, +// 不设置的话 Electron 会取 package.json 的 name(@vben/desktop),生成嵌套目录不利于运维排查 +app.setName('萧康云医'); + +// Windows 通知归属与任务栏分组标识,必须与 electron-builder 的 appId 一致, +// 否则系统通知不显示或显示为「electron.app.Electron」 +app.setAppUserModelId(APP_ID); + +// 单实例锁:避免多开导致托盘、自动更新、本地设置互相干扰 +if (app.requestSingleInstanceLock()) { + // 特权协议必须在 app ready 之前注册,否则 app:// 无法成为安全上下文 + registerSchemePrivileges(); + + // 重复启动(用户再次双击图标)时唤起已有窗口 + app.on('second-instance', () => showMainWindow()); + + app.whenReady().then(() => { + // electron-log 初始化:主进程日志落 userData/logs,便于排查线上问题 + log.initialize(); + + // mac 必须保留应用菜单,否则 Cmd+C/V/Q 等系统快捷键失效;Windows 隐藏菜单栏 + if (process.platform === 'darwin') { + Menu.setApplicationMenu( + Menu.buildFromTemplate([ + { role: 'appMenu' }, + { role: 'editMenu' }, + { role: 'viewMenu' }, + { role: 'windowMenu' }, + ]), + ); + } else { + Menu.setApplicationMenu(null); + } + + registerAppProtocol(); + registerIpcHandlers(); + + // 开机自启带 --hidden 参数:静默启动进托盘,不弹主窗口打扰用户 + const startHidden = process.argv.includes('--hidden'); + createMainWindow({ startHidden }); + + createTray(); + registerGlobalShortcut(); + initUpdater(); + }); + + // mac 点击 Dock 图标恢复窗口(关窗只是隐藏,符合平台惯例) + app.on('activate', () => { + if (getMainWindow()) showMainWindow(); + }); + + // 常驻托盘模式:窗口全部关闭也不退出(close 已被拦截为隐藏,这里是兜底),退出走托盘菜单/Cmd+Q + app.on('window-all-closed', () => { + // 故意留空:不调用 app.quit() + }); + + // 任何退出路径(托盘退出 / Cmd+Q / 自动更新安装)都先放行窗口 close 拦截 + app.on('before-quit', () => markQuitting()); + + // 退出前注销全局快捷键,避免残留占用 + app.on('will-quit', () => unregisterGlobalShortcut()); +} else { + app.quit(); +} diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts new file mode 100644 index 00000000..11cbd4af --- /dev/null +++ b/apps/desktop/src/main/ipc.ts @@ -0,0 +1,117 @@ +import { app, ipcMain, Notification } from 'electron'; + +import { getAutoLaunchEnabled, setAutoLaunchEnabled } from './auto-launch'; +import { applyBadge } from './badge'; +import { printHtml, type PrintHtmlOptions } from './printer'; +import { getSettings, updateSettings, type DesktopSettings } from './settings'; +import { refreshTrayMenu } from './tray'; +import { checkForUpdates } from './updater'; +import { getMainWindow, isTrustedPageUrl, showMainWindow } from './window'; + +/** + * IPC 汇总:preload 暴露的 desktopAPI 全部经由这里进入主进程 + * 所有 handler 先校验调用方来源(app:// 或开发服务器), + * 防止页面内嵌第三方 iframe 等场景越权调用桌面能力 + */ + +/** 校验 IPC 调用来源,不可信来源直接抛错拒绝 */ +function assertTrustedSender(event: Electron.IpcMainInvokeEvent): void { + const url = event.senderFrame?.url ?? ''; + if (!isTrustedPageUrl(url)) { + throw new Error(`拒绝来自不可信来源的 IPC 调用: ${url}`); + } +} + +/** 系统通知参数(route 用于点击通知后让渲染端跳转对应页面) */ +interface NotifyPayload { + title: string; + body: string; + route?: string; +} + +export function registerIpcHandlers(): void { + // 应用版本号(渲染端「关于/设置」展示用) + ipcMain.handle('desktop:get-version', (event) => { + assertTrustedSender(event); + return app.getVersion(); + }); + + // 系统通知:点击后唤起主窗口,并把目标路由回传渲染端执行 router.push + ipcMain.handle('desktop:notify', (event, payload: NotifyPayload) => { + assertTrustedSender(event); + if (!Notification.isSupported()) return false; + const notification = new Notification({ + title: payload.title, + body: payload.body, + }); + notification.on('click', () => { + showMainWindow(); + if (payload.route) { + getMainWindow()?.webContents.send('desktop:navigate', payload.route); + } + }); + notification.show(); + return true; + }); + + // 未读角标(mac Dock 数字 / Windows 任务栏覆盖图标,图由 preload canvas 绘制) + ipcMain.handle('desktop:set-badge', (event, payload: { count: number; image?: string }) => { + assertTrustedSender(event); + applyBadge(payload.count, payload.image); + }); + + // 任务栏闪烁提醒:仅窗口未聚焦时触发(聚焦状态下闪烁是干扰),聚焦后由窗口 focus 事件自动清除 + ipcMain.handle('desktop:flash-frame', (event) => { + assertTrustedSender(event); + const win = getMainWindow(); + if (win && !win.isFocused()) win.flashFrame(true); + }); + + // 开机自启读取/设置(设置后同步刷新托盘菜单勾选态) + ipcMain.handle('desktop:get-auto-launch', (event) => { + assertTrustedSender(event); + return getAutoLaunchEnabled(); + }); + ipcMain.handle('desktop:set-auto-launch', (event, enable: boolean) => { + assertTrustedSender(event); + const result = setAutoLaunchEnabled(enable); + refreshTrayMenu(); + return result; + }); + + // 打印机列表(渲染端打印设置下拉用) + ipcMain.handle('desktop:get-printers', async (event) => { + assertTrustedSender(event); + const win = getMainWindow(); + if (!win) return []; + return await win.webContents.getPrintersAsync(); + }); + + // 静默打印:渲染端拼好完整 HTML 传入,主进程隐藏窗口出纸 + ipcMain.handle('desktop:print-html', (event, options: PrintHtmlOptions) => { + assertTrustedSender(event); + return printHtml(options); + }); + + // 桌面端设置(打印机选择、静默开关等)读取/增量更新 + ipcMain.handle('desktop:get-settings', (event) => { + assertTrustedSender(event); + return getSettings(); + }); + ipcMain.handle('desktop:set-settings', (event, patch: Partial) => { + assertTrustedSender(event); + return updateSettings(patch); + }); + + // 手动检查更新(渲染端设置页/关于入口) + ipcMain.handle('desktop:check-update', (event) => { + assertTrustedSender(event); + checkForUpdates(true); + }); + + // 渲染端主动唤起主窗口(如页内提醒的「查看」动作) + ipcMain.handle('desktop:focus-window', (event) => { + assertTrustedSender(event); + showMainWindow(); + }); +} diff --git a/apps/desktop/src/main/printer.ts b/apps/desktop/src/main/printer.ts new file mode 100644 index 00000000..8dfcd29d --- /dev/null +++ b/apps/desktop/src/main/printer.ts @@ -0,0 +1,75 @@ +import { unlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { app, BrowserWindow } from 'electron'; +import log from 'electron-log/main'; + +/** + * 静默打印:隐藏窗口加载 HTML → webContents.print 直接出纸 + * 为什么用隐藏窗口:渲染层的处方打印本来就是拼好的完整 HTML(含内联样式), + * 加载到独立隐藏窗口打印可以完全复用现有模板,且不阻塞/不干扰主窗口。 + * 为什么落临时文件而不是 data: URL:处方 HTML 可能较大,data URL 有长度上限风险, + * 临时文件 loadFile 对任意大小都稳定,打完即删。 + */ + +export interface PrintHtmlOptions { + /** 完整的打印 HTML(含样式),由渲染层拼好传入 */ + html: string; + /** 目标打印机名称;不传时使用系统默认打印机 */ + deviceName?: string; + /** 是否静默打印(true 不弹系统打印对话框) */ + silent?: boolean; +} + +export interface PrintResult { + ok: boolean; + message?: string; +} + +/** + * 执行打印,返回是否成功与失败原因(渲染层据此提示用户) + */ +export async function printHtml(options: PrintHtmlOptions): Promise { + const { html, deviceName, silent = true } = options; + // 临时文件带时间戳防并发打印互相覆盖 + const tempFile = join(app.getPath('temp'), `xk-print-${Date.now()}-${Math.random().toString(36).slice(2)}.html`); + await writeFile(tempFile, html, 'utf8'); + + return new Promise((resolve) => { + let settled = false; + const win = new BrowserWindow({ + show: false, + webPreferences: { sandbox: true }, + }); + + /** 统一收尾:销毁隐藏窗口、清理临时文件、只 resolve 一次 */ + const finish = (ok: boolean, message?: string) => { + if (settled) return; + settled = true; + if (!win.isDestroyed()) win.destroy(); + unlink(tempFile).catch(() => {}); + if (!ok) log.warn(`静默打印失败: ${message}`); + resolve({ ok, message }); + }; + + win.webContents.once('did-finish-load', () => { + win.webContents.print( + { + silent, + deviceName: deviceName || undefined, + printBackground: true, + }, + (success, failureReason) => finish(success, success ? undefined : failureReason), + ); + }); + + win.webContents.once('did-fail-load', (_event, _code, desc) => finish(false, desc)); + + // 兜底超时:防止打印机脱机等场景下隐藏窗口泄漏 + setTimeout(() => finish(false, '打印超时(请检查打印机状态)'), 60_000); + + win.loadFile(tempFile).catch((error: unknown) => { + finish(false, error instanceof Error ? error.message : String(error)); + }); + }); +} diff --git a/apps/desktop/src/main/protocol.ts b/apps/desktop/src/main/protocol.ts new file mode 100644 index 00000000..c06cb8eb --- /dev/null +++ b/apps/desktop/src/main/protocol.ts @@ -0,0 +1,108 @@ +import { existsSync, statSync } from 'node:fs'; +import { join, normalize } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { app, net, protocol } from 'electron'; + +import { API_UPSTREAM, APP_SCHEME, IM_UPSTREAM } from './config'; + +/** + * app:// 自定义协议:桌面端的「本地 nginx」 + * 1. 静态文件服务:把 web-antd 生产 dist 按 URL 路径映射到本地文件,带 SPA fallback + * (等价 nginx 的 try_files $uri /index.html),因此无需把前端路由改成 hash 模式 + * 2. 反向代理:/api/admin/* 与 /im-api/* 用 net.fetch 转发到线上上游 —— + * 渲染层看到的是同源请求,天然规避 CORS,后端与 nginx 都不需要任何改动 + */ + +/** Electron net.fetch 支持但标准 RequestInit 类型缺失的扩展字段 */ +type ElectronFetchInit = RequestInit & { + /** 转发出去的请求不再进入自定义协议处理器,防止死循环 */ + bypassCustomProtocolHandlers?: boolean; + /** 携带流式 body 时 fetch 规范要求显式声明半双工 */ + duplex?: 'half'; +}; + +/** + * 注册特权协议,必须在 app ready 之前调用 + * standard/secure 让 app:// 成为「安全上下文」,navigator.clipboard、getUserMedia、 + * IndexedDB、crypto.subtle 等浏览器能力才可用;stream 支持流式响应(音频等大文件) + */ +export function registerSchemePrivileges(): void { + protocol.registerSchemesAsPrivileged([ + { + scheme: APP_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + stream: true, + corsEnabled: true, + }, + }, + ]); +} + +/** + * 解析 web dist 根目录 + * 打包后位于 resources/web(electron-builder extraResources 拷入,不进 asar); + * 未打包(本地验证生产包行为)时兜底读仓库内 web-antd/dist + */ +export function getWebRoot(): string { + return app.isPackaged + ? join(process.resourcesPath, 'web') + : join(__dirname, '../../../web-antd/dist'); +} + +/** + * 把渲染层的同源请求转发到远端上游(等价 nginx proxy_pass) + * 保留原请求的方法 / 头 / 流式请求体,Bearer token 请求头原样透传 + */ +function proxyRequest(target: string, request: Request): Promise { + const init: ElectronFetchInit = { + method: request.method, + headers: request.headers, + body: request.body, + bypassCustomProtocolHandlers: true, + }; + if (request.body) init.duplex = 'half'; + return net.fetch(target, init); +} + +/** + * 静态文件解析:带路径穿越防护 + SPA fallback + * 命中不了真实文件的路径(前端路由地址、不存在的资源)统一回 index.html 交给前端路由 + */ +function resolveStaticFile(webRoot: string, pathname: string): string { + const indexHtml = join(webRoot, 'index.html'); + try { + const filePath = normalize(join(webRoot, decodeURIComponent(pathname))); + // 防路径穿越:解析结果必须仍在 webRoot 目录内 + if (!filePath.startsWith(normalize(webRoot))) return indexHtml; + if (existsSync(filePath) && statSync(filePath).isFile()) return filePath; + } catch { + // decode 失败等异常场景直接回 index.html,不让协议处理器抛错 + } + return indexHtml; +} + +/** + * 注册 app:// 协议处理器,在 app ready 之后调用 + */ +export function registerAppProtocol(): void { + const webRoot = getWebRoot(); + protocol.handle(APP_SCHEME, (request) => { + const url = new URL(request.url); + // 反代 /api/admin/*:生产构建的 VITE_GLOB_API_URL=/api/admin,路径直接透传 + if (url.pathname.startsWith('/api/')) { + return proxyRequest(`${API_UPSTREAM}${url.pathname}${url.search}`, request); + } + // 反代 /im-api/*:对齐开发代理规则,剥掉 /im-api 前缀后拼到上游 /api/* + if (url.pathname.startsWith('/im-api/')) { + const stripped = url.pathname.slice('/im-api'.length); + return proxyRequest(`${IM_UPSTREAM}/api${stripped}${url.search}`, request); + } + // 其余全部按静态资源处理(net.fetch file:// 会按扩展名自动带上正确的 Content-Type) + const fileUrl = pathToFileURL(resolveStaticFile(webRoot, url.pathname)).toString(); + return net.fetch(fileUrl, { bypassCustomProtocolHandlers: true } as ElectronFetchInit); + }); +} diff --git a/apps/desktop/src/main/settings.ts b/apps/desktop/src/main/settings.ts new file mode 100644 index 00000000..d93a6609 --- /dev/null +++ b/apps/desktop/src/main/settings.ts @@ -0,0 +1,67 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { app } from 'electron'; +import log from 'electron-log/main'; + +/** + * 桌面端本地设置持久化(userData/desktop-settings.json) + * 为什么不用 electron-store:目前只需存打印机等少量键值,直接读写 JSON 文件即可, + * 避免引入额外依赖(electron-store 新版为纯 ESM,与 CJS 主进程产物不兼容,还会拖入 ajv 等重依赖)。 + */ + +/** 桌面端设置项定义 */ +export interface DesktopSettings { + /** 静默打印使用的打印机名称,空串表示未选择 */ + printerName: string; + /** 是否启用静默打印(true 时打印处方不弹系统打印对话框) */ + silentPrint: boolean; +} + +const DEFAULT_SETTINGS: DesktopSettings = { + printerName: '', + silentPrint: false, +}; + +/** 内存缓存,避免每次 IPC 调用都读磁盘 */ +let cache: DesktopSettings | null = null; + +/** 设置文件落在 userData 目录,随应用卸载数据目录一起清理 */ +function getSettingsFilePath(): string { + return join(app.getPath('userData'), 'desktop-settings.json'); +} + +/** + * 读取设置:文件不存在或解析失败时回退默认值,保证任何情况下不影响应用启动 + */ +export function getSettings(): DesktopSettings { + if (cache) return cache; + try { + const filePath = getSettingsFilePath(); + if (existsSync(filePath)) { + // JSON.parse 返回 any,显式收窄成 Partial 再与默认值合并,保证字段完整 + const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Partial; + const merged: DesktopSettings = { ...DEFAULT_SETTINGS, ...parsed }; + cache = merged; + return merged; + } + } catch (error) { + log.warn('读取桌面端设置失败,回退默认值', error); + } + cache = { ...DEFAULT_SETTINGS }; + return cache; +} + +/** + * 增量更新设置并写盘:写盘失败只告警(内存态仍生效),下次成功写入时自动恢复 + */ +export function updateSettings(patch: Partial): DesktopSettings { + const next = { ...getSettings(), ...patch }; + cache = next; + try { + writeFileSync(getSettingsFilePath(), JSON.stringify(next, null, 2), 'utf8'); + } catch (error) { + log.warn('写入桌面端设置失败', error); + } + return next; +} diff --git a/apps/desktop/src/main/shortcut.ts b/apps/desktop/src/main/shortcut.ts new file mode 100644 index 00000000..f4d37bfe --- /dev/null +++ b/apps/desktop/src/main/shortcut.ts @@ -0,0 +1,21 @@ +import { globalShortcut } from 'electron'; +import log from 'electron-log/main'; + +import { MAIN_WINDOW_SHORTCUT } from './config'; +import { toggleMainWindow } from './window'; + +/** + * 全局快捷键:任何界面下按 CommandOrControl+Alt+X 快速唤起/隐藏主窗口 + * 注册失败(快捷键被其他应用占用)只记录警告,不影响应用启动 + */ +export function registerGlobalShortcut(): void { + const ok = globalShortcut.register(MAIN_WINDOW_SHORTCUT, () => toggleMainWindow()); + if (!ok) { + log.warn(`全局快捷键 ${MAIN_WINDOW_SHORTCUT} 注册失败(可能被其他应用占用)`); + } +} + +/** 应用退出前注销全部快捷键,避免残留占用 */ +export function unregisterGlobalShortcut(): void { + globalShortcut.unregisterAll(); +} diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts new file mode 100644 index 00000000..7fc1b0a5 --- /dev/null +++ b/apps/desktop/src/main/tray.ts @@ -0,0 +1,78 @@ +import { app, Menu, nativeImage, Tray } from 'electron'; +import log from 'electron-log/main'; + +import { getAutoLaunchEnabled, setAutoLaunchEnabled } from './auto-launch'; +import { getResourcePath } from './config'; +import { checkForUpdates } from './updater'; +import { markQuitting, showMainWindow } from './window'; + +/** + * 系统托盘:应用常驻入口 + * - Windows 单击托盘图标恢复主窗口;mac 单击弹出菜单(跟随平台惯例) + * - 菜单:显示主窗口 / 开机自启开关 / 检查更新 / 退出 + * 注意:托盘实例必须保持模块级引用,否则会被 GC 导致图标消失(Electron 经典坑) + */ + +let tray: Tray | null = null; + +/** 构建托盘菜单:开机自启是动态勾选项,每次状态变化后需重建菜单 */ +function buildTrayMenu(): Menu { + return Menu.buildFromTemplate([ + { + label: '显示主窗口', + click: () => showMainWindow(), + }, + { type: 'separator' }, + { + label: '开机自启', + type: 'checkbox', + checked: getAutoLaunchEnabled(), + // 开发模式登录项无意义,置灰避免误操作 + enabled: app.isPackaged, + click: (menuItem) => { + setAutoLaunchEnabled(menuItem.checked); + refreshTrayMenu(); + }, + }, + { + label: '检查更新', + click: () => checkForUpdates(true), + }, + { type: 'separator' }, + { + label: '退出', + click: () => { + // 先标记退出,放行主窗口 close 拦截,再走正常退出流程 + markQuitting(); + app.quit(); + }, + }, + ]); +} + +/** 刷新托盘菜单(开机自启状态从渲染端 IPC 修改后也要同步勾选态) */ +export function refreshTrayMenu(): void { + tray?.setContextMenu(buildTrayMenu()); +} + +/** + * 创建系统托盘 + * 图标用打包的应用图标缩放(mac 菜单栏建议 18px、Windows 托盘 16px 的倍数由系统处理) + * TODO(待确认):正式图标到位后,mac 建议换单色 Template 图以适配深浅菜单栏 + */ +export function createTray(): void { + try { + const icon = nativeImage.createFromPath(getResourcePath('icon.png')); + const trayIcon = process.platform === 'darwin' ? icon.resize({ width: 18, height: 18 }) : icon.resize({ width: 16, height: 16 }); + tray = new Tray(trayIcon); + tray.setToolTip('萧康云医'); + tray.setContextMenu(buildTrayMenu()); + // Windows 单击直接唤起主窗口;mac 上设置了 contextMenu 后单击默认弹菜单,不重复绑定 + if (process.platform === 'win32') { + tray.on('click', () => showMainWindow()); + } + } catch (error) { + // 托盘创建失败(图标缺失等)不阻塞应用启动,仅损失常驻入口 + log.error('创建系统托盘失败', error); + } +} diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts new file mode 100644 index 00000000..dc5a3ad3 --- /dev/null +++ b/apps/desktop/src/main/updater.ts @@ -0,0 +1,139 @@ +import { app, dialog, shell } from 'electron'; +import log from 'electron-log/main'; +import { autoUpdater } from 'electron-updater'; + +import { DOWNLOAD_PAGE_URL } from './config'; +import { getMainWindow, markQuitting, showMainWindow } from './window'; + +/** + * 自动更新(electron-updater + generic 静态托管) + * - 启动 10 秒后自动检查一次 + 每 4 小时轮询 + 托盘/渲染端手动触发 + * - autoDownload:发现新版本自动后台下载,进度事件转发渲染端展示 + * - 下载完成弹窗询问「立即重启安装 / 退出时自动安装」 + * - mac 未签名包 electron-updater 签名校验会报错:捕获后降级为「打开下载页手动安装」 + */ + +/** 手动检查标记:手动触发时才提示「已是最新版本」,自动轮询保持安静 */ +let isManualCheck = false; +/** 检查中防重入 */ +let isChecking = false; + +/** 把更新事件转发给渲染端(web 侧在 basic.vue 里做轻量提示) */ +function sendToRenderer(type: string, data?: unknown): void { + getMainWindow()?.webContents.send('desktop:update-event', { type, data }); +} + +/** 判断是否 mac 未签名导致的更新失败(这类错误重试无意义,直接引导手动下载) */ +function isMacSignatureError(message: string): boolean { + return ( + process.platform === 'darwin' && + /code signature|not signed|codesign|could not get code signature/i.test(message) + ); +} + +/** + * 初始化自动更新:仅打包后生效(开发模式没有 app-update.yml,检查必然失败) + */ +export function initUpdater(): void { + if (!app.isPackaged) return; + + autoUpdater.logger = log; + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + + autoUpdater.on('update-available', (info) => { + log.info(`发现新版本: ${info.version}`); + sendToRenderer('available', { version: info.version }); + }); + + autoUpdater.on('update-not-available', () => { + sendToRenderer('not-available'); + if (isManualCheck) { + dialog.showMessageBox({ + type: 'info', + title: '检查更新', + message: `当前已是最新版本(v${app.getVersion()})`, + }); + } + isManualCheck = false; + }); + + autoUpdater.on('download-progress', (progress) => { + sendToRenderer('progress', { percent: Math.round(progress.percent) }); + }); + + autoUpdater.on('update-downloaded', async (info) => { + sendToRenderer('downloaded', { version: info.version }); + // 唤起窗口再弹对话框,避免用户看不到安装确认 + showMainWindow(); + const { response } = await dialog.showMessageBox({ + type: 'question', + title: '发现新版本', + message: `新版本 v${info.version} 已下载完成`, + detail: '立即重启安装,或在退出应用时自动安装。', + buttons: ['立即重启安装', '退出时自动安装'], + defaultId: 0, + cancelId: 1, + }); + if (response === 0) { + // 放行窗口 close 拦截,否则 quitAndInstall 会被「关闭进托盘」逻辑挡住 + markQuitting(); + autoUpdater.quitAndInstall(); + } + }); + + autoUpdater.on('error', async (error) => { + const message = error?.message ?? String(error); + log.error('自动更新失败', message); + sendToRenderer('error', { message }); + // mac 未签名:自动更新走不通,引导用户手动下载安装 + if (isMacSignatureError(message)) { + const { response } = await dialog.showMessageBox({ + type: 'info', + title: '检查更新', + message: '当前安装包不支持自动更新', + detail: '请前往下载页获取最新版本手动安装。', + buttons: ['打开下载页', '取消'], + defaultId: 0, + cancelId: 1, + }); + if (response === 0) shell.openExternal(DOWNLOAD_PAGE_URL); + } else if (isManualCheck) { + dialog.showMessageBox({ + type: 'warning', + title: '检查更新', + message: '检查更新失败,请稍后重试', + detail: message, + }); + } + isManualCheck = false; + isChecking = false; + }); + + // 启动延时自动检查一次 + 每 4 小时轮询(内部工具常开着,轮询保证及时收到发版) + setTimeout(() => checkForUpdates(false), 10_000); + setInterval(() => checkForUpdates(false), 4 * 60 * 60 * 1000); +} + +/** + * 触发一次更新检查 + * @param manual 是否手动触发(手动时「已是最新/失败」都会弹窗反馈,自动轮询保持安静) + */ +export async function checkForUpdates(manual: boolean): Promise { + if (!app.isPackaged) { + if (manual) { + dialog.showMessageBox({ type: 'info', title: '检查更新', message: '开发模式不支持检查更新' }); + } + return; + } + if (isChecking) return; + isChecking = true; + isManualCheck = manual; + try { + await autoUpdater.checkForUpdates(); + } catch { + // 错误已在 error 事件里统一处理 + } finally { + isChecking = false; + } +} diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts new file mode 100644 index 00000000..6691c2c8 --- /dev/null +++ b/apps/desktop/src/main/window.ts @@ -0,0 +1,212 @@ +import { join } from 'node:path'; + +import { app, BrowserWindow, Notification, session, shell, systemPreferences } from 'electron'; +import log from 'electron-log/main'; + +import { APP_INDEX_URL, APP_ORIGIN, DEV_SERVER_URL, getResourcePath } from './config'; + +/** + * 主窗口管理 + * - 加载目标:开发模式加载 web-antd 的 vite dev server(未起服务时自动重试);打包模式加载 app://local/ + * - 安全基线:contextIsolation + sandbox 开启,webSecurity 不关闭;window.open 一律交给系统浏览器 + * - 关闭行为:双平台都是「关窗隐藏、应用常驻」(Windows 走托盘、mac 走 Dock),真正退出走托盘菜单/Cmd+Q + * - 权限控制:只放行业务需要的媒体(IM 音视频)/ 通知 / 剪贴板 / 全屏,其余默认拒绝 + */ + +let mainWindow: BrowserWindow | null = null; +/** 标记「真正退出」:常驻模式下 close 只是隐藏,只有 quit 流程才放行关闭 */ +let isQuitting = false; +/** Windows 首次关窗提示「已最小化到托盘」,只提示一次避免打扰 */ +let hasShownHideTip = false; +/** 加载失败重试定时器,避免叠加多个重试 */ +let retryTimer: NodeJS.Timeout | null = null; + +/** 主窗口要加载的页面地址:打包后走自定义协议,开发走 dev server */ +const PAGE_URL = app.isPackaged ? APP_INDEX_URL : DEV_SERVER_URL; + +export function markQuitting(): void { + isQuitting = true; +} + +export function getMainWindow(): BrowserWindow | null { + return mainWindow; +} + +/** 判断 URL 是否属于信任的页面来源(本地协议或开发服务器),IPC 与权限校验共用 */ +export function isTrustedPageUrl(url: string): boolean { + return url.startsWith(APP_ORIGIN) || url.startsWith(DEV_SERVER_URL); +} + +/** + * 会话级权限控制:白名单内且来源可信才放行 + * media 供 IM 音视频通话;notifications 供 HTML5 Notification(业务主要走主进程通知,这里兜底) + */ +function setupPermissionHandlers(): void { + const allowedPermissions = new Set([ + 'media', + 'notifications', + 'clipboard-read', + 'clipboard-sanitized-write', + 'fullscreen', + ]); + + session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => { + const trusted = isTrustedPageUrl(webContents.getURL()); + if (!trusted || !allowedPermissions.has(permission)) { + callback(false); + return; + } + // mac 首次使用摄像头/麦克风必须先过系统 TCC 授权弹窗,否则页面侧拿不到设备 + if (permission === 'media' && process.platform === 'darwin') { + Promise.all([ + systemPreferences.askForMediaAccess('microphone'), + systemPreferences.askForMediaAccess('camera'), + ]) + .then(() => callback(true)) + // TCC 弹窗异常时仍放行,让页面侧走自己的错误提示 + .catch(() => callback(true)); + return; + } + callback(true); + }); + + session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => { + return allowedPermissions.has(permission) && isTrustedPageUrl(requestingOrigin); + }); +} + +/** 加载失败时的内置错误页(数据 URL 形式,不依赖任何本地文件) */ +function buildErrorPageUrl(reason: string): string { + const html = ` + + + + 加载失败 + + + +
+
页面加载失败,正在自动重试…
+
请检查网络连接(${reason})
+
+ +`; + return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; +} + +/** + * 创建主窗口 + * @param options.startHidden 开机自启(--hidden)场景下静默启动,不弹窗打扰用户 + */ +export function createMainWindow(options: { startHidden: boolean }): BrowserWindow { + setupPermissionHandlers(); + + mainWindow = new BrowserWindow({ + width: 1440, + height: 900, + minWidth: 1100, + minHeight: 700, + show: false, + autoHideMenuBar: true, + title: '萧康云医', + // Windows/Linux 窗口与任务栏图标;mac 使用打包进 app 的 icns,无需在此指定 + icon: process.platform === 'darwin' ? undefined : getResourcePath('icon.png'), + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + spellcheck: false, + }, + }); + + // 首次渲染完成再显示,避免白屏闪烁;静默启动场景保持隐藏(用户从托盘/快捷键唤起) + mainWindow.once('ready-to-show', () => { + if (!options.startHidden) mainWindow?.show(); + }); + + // window.open / target=_blank:外链一律交给系统默认浏览器,不在应用内开新窗(安全 + 体验) + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('http://') || url.startsWith('https://')) { + shell.openExternal(url); + } + return { action: 'deny' }; + }); + + // 页面内导航限制在信任来源内,跳外部地址时转交系统浏览器 + mainWindow.webContents.on('will-navigate', (event, url) => { + if (isTrustedPageUrl(url)) return; + event.preventDefault(); + if (url.startsWith('http://') || url.startsWith('https://')) { + shell.openExternal(url); + } + }); + + // 加载失败(dev server 未启动 / 断网等)时展示内置错误页并定时重试 + mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDesc, _url, isMainFrame) => { + if (!isMainFrame) return; + // ERR_ABORTED(-3)是导航被主动取消(如重复 loadURL),不算失败 + if (errorCode === -3) return; + log.warn(`主窗口加载失败: ${errorCode} ${errorDesc}`); + mainWindow?.loadURL(buildErrorPageUrl(errorDesc || String(errorCode))); + if (retryTimer) clearTimeout(retryTimer); + // 开发模式快速重试等 vite 起服务;打包模式放慢节奏等网络恢复 + retryTimer = setTimeout(() => mainWindow?.loadURL(PAGE_URL), app.isPackaged ? 5000 : 1500); + }); + + // 窗口聚焦即清除任务栏闪烁提醒(配合新消息 flashFrame) + mainWindow.on('focus', () => mainWindow?.flashFrame(false)); + + // F12 切换 DevTools:内部管理系统保留排障入口 + mainWindow.webContents.on('before-input-event', (_event, input) => { + if (input.type === 'keyDown' && input.key === 'F12') { + mainWindow?.webContents.toggleDevTools(); + } + }); + + // 关闭行为:双平台都拦截为隐藏(Windows 常驻托盘 / mac 常驻 Dock),退出流程放行 + mainWindow.on('close', (event) => { + if (isQuitting) return; + event.preventDefault(); + mainWindow?.hide(); + // Windows 首次隐藏时提示托盘位置,避免用户以为应用被关闭 + if (process.platform === 'win32' && !hasShownHideTip && Notification.isSupported()) { + hasShownHideTip = true; + new Notification({ + title: '萧康云医仍在运行', + body: '应用已最小化到系统托盘,点击托盘图标可重新打开窗口', + }).show(); + } + }); + + mainWindow.on('closed', () => { + mainWindow = null; + }); + + mainWindow.loadURL(PAGE_URL); + return mainWindow; +} + +/** 唤起主窗口:还原最小化、显示并聚焦(通知点击 / 托盘 / 第二实例 / 快捷键共用) */ +export function showMainWindow(): void { + if (!mainWindow) return; + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); +} + +/** 切换主窗口显隐(全局快捷键使用) */ +export function toggleMainWindow(): void { + if (!mainWindow) return; + if (mainWindow.isVisible() && mainWindow.isFocused()) { + mainWindow.hide(); + } else { + showMainWindow(); + } +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts new file mode 100644 index 00000000..dcfb3446 --- /dev/null +++ b/apps/desktop/src/preload/index.ts @@ -0,0 +1,102 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +/** + * 预加载脚本:通过 contextBridge 向渲染层暴露受控的 desktopAPI + * - 渲染层(web-antd)通过 window.desktopAPI 使用桌面能力;浏览器环境没有该对象, + * 前端封装层(apps/web-antd/src/util/desktop.ts)会自动 no-op 降级 + * - sandbox 模式下本文件必须是 CJS 产物(electron-vite 已按包配置输出 CJS), + * 且只能使用 Electron 提供的受限 API(contextBridge / ipcRenderer 等) + */ + +/** + * Windows 任务栏角标图:用 canvas 画「红底白字」数字返回 dataURL + * 为什么在 preload 画:主进程没有 DOM/canvas;preload 运行在渲染进程内有 DOM, + * 由主进程 setOverlayIcon 消费,这是 Windows 数字角标的社区标准做法 + */ +function drawBadgeImage(count: number): string | undefined { + try { + const size = 32; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) return undefined; + // 红色圆底 + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); + ctx.fillStyle = '#f5222d'; + ctx.fill(); + // 白色数字,超过 99 显示 99+ + const text = count > 99 ? '99+' : String(count); + ctx.fillStyle = '#ffffff'; + ctx.font = `bold ${text.length > 2 ? 13 : 18}px Arial`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, size / 2, size / 2 + 1); + return canvas.toDataURL('image/png'); + } catch { + // canvas 不可用时退化为无图(主进程会清除角标),不影响业务 + return undefined; + } +} + +/** 暴露给渲染层的桌面能力集合,字段语义与 web 侧 desktop.ts 封装一一对应 */ +const desktopAPI = { + /** 渲染层判断是否运行在桌面端的唯一标记 */ + isElectron: true as const, + /** 当前平台:win32 / darwin / linux */ + platform: process.platform, + /** 获取桌面端版本号(关于/设置展示用) */ + getVersion: (): Promise => ipcRenderer.invoke('desktop:get-version'), + /** 发送系统通知;route 为点击通知后要跳转的前端路由 */ + notify: (options: { title: string; body: string; route?: string }): Promise => + ipcRenderer.invoke('desktop:notify', options), + /** 设置未读角标数量(0 表示清除);Windows 角标图在此侧 canvas 生成 */ + setBadge: (count: number): Promise => + ipcRenderer.invoke('desktop:set-badge', { + count, + image: count > 0 && process.platform === 'win32' ? drawBadgeImage(count) : undefined, + }), + /** 任务栏闪烁提醒(窗口未聚焦时生效,聚焦后自动清除) */ + flashFrame: (): Promise => ipcRenderer.invoke('desktop:flash-frame'), + /** 读取开机自启状态 */ + getAutoLaunch: (): Promise => ipcRenderer.invoke('desktop:get-auto-launch'), + /** 设置开机自启,返回设置后的实际状态 */ + setAutoLaunch: (enable: boolean): Promise => + ipcRenderer.invoke('desktop:set-auto-launch', enable), + /** 获取系统打印机列表(打印设置下拉用) */ + getPrinters: (): Promise> => + ipcRenderer.invoke('desktop:get-printers'), + /** 静默打印:传入拼好的完整 HTML,主进程隐藏窗口出纸 */ + printHTML: (options: { + html: string; + deviceName?: string; + silent?: boolean; + }): Promise<{ ok: boolean; message?: string }> => + ipcRenderer.invoke('desktop:print-html', options), + /** 读取桌面端设置(打印机名称、静默打印开关等) */ + getSettings: (): Promise<{ printerName: string; silentPrint: boolean }> => + ipcRenderer.invoke('desktop:get-settings'), + /** 增量更新桌面端设置,返回更新后的完整设置 */ + setSettings: ( + patch: Partial<{ printerName: string; silentPrint: boolean }>, + ): Promise<{ printerName: string; silentPrint: boolean }> => + ipcRenderer.invoke('desktop:set-settings', patch), + /** 手动检查更新(结果由主进程弹窗反馈) */ + checkUpdate: (): Promise => ipcRenderer.invoke('desktop:check-update'), + /** 唤起并聚焦主窗口 */ + focusWindow: (): Promise => ipcRenderer.invoke('desktop:focus-window'), + /** 监听「通知点击跳转」事件:主进程通知被点击后回传目标路由,渲染层执行 router.push */ + onNavigate: (callback: (route: string) => void): void => { + ipcRenderer.on('desktop:navigate', (_event, route: string) => callback(route)); + }, + /** 监听更新事件(available / progress / downloaded / not-available / error),渲染层做轻量提示 */ + onUpdateEvent: (callback: (payload: { type: string; data?: unknown }) => void): void => { + ipcRenderer.on('desktop:update-event', (_event, payload) => callback(payload)); + }, +}; + +contextBridge.exposeInMainWorld('desktopAPI', desktopAPI); + +/** 供 web 侧类型声明引用(apps/web-antd/src/types/desktop.d.ts 与此保持同步) */ +export type DesktopAPI = typeof desktopAPI; diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 00000000..b49951dc --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "useUnknownInCatchVariables": true + }, + "include": ["src", "electron.vite.config.ts"] +} diff --git a/apps/web-antd/index.html b/apps/web-antd/index.html index 480eb84d..e3b9ae5c 100644 --- a/apps/web-antd/index.html +++ b/apps/web-antd/index.html @@ -12,7 +12,7 @@ content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0" /> - <%= VITE_APP_TITLE %> + %VITE_APP_TITLE% - - - - - - - \ No newline at end of file + + + + + + + + diff --git a/apps/web-antd/src/components/form/components/editor.vue b/apps/web-antd/src/components/form/components/editor.vue index d928227c..93debba7 100644 --- a/apps/web-antd/src/components/form/components/editor.vue +++ b/apps/web-antd/src/components/form/components/editor.vue @@ -1,9 +1,9 @@ + + diff --git a/apps/web-antd/src/components/form/components/store-multi-search.vue b/apps/web-antd/src/components/form/components/store-multi-search.vue index 8890a9fc..3f0b1cd1 100644 --- a/apps/web-antd/src/components/form/components/store-multi-search.vue +++ b/apps/web-antd/src/components/form/components/store-multi-search.vue @@ -1,12 +1,20 @@ - -
- - -
-
- {{ item.name }}【{{ item.id }}】 -
-
- 首拼:{{ item.shouzimu }} -
+ +
+
+ ↑↓ 循环切换 · Enter 选用 · Esc 关闭 · 可连续多选 + + 剩 {{ visibleOptions.length }} 条可选 +
- -
+
+ + +
+ +
+
+
+
+ -
- {{ item.name }}【{{ item.id }}】 + + + {{ item.name }}【{{ item.id }}】 +
+ + diff --git a/apps/web-antd/src/components/loading/PrescriptionLoading.vue b/apps/web-antd/src/components/loading/PrescriptionLoading.vue index 4996776f..232e3b06 100644 --- a/apps/web-antd/src/components/loading/PrescriptionLoading.vue +++ b/apps/web-antd/src/components/loading/PrescriptionLoading.vue @@ -666,12 +666,12 @@ onUnmounted(() => { width: 100%; height: 100%; min-height: 220px; - //background: #ffffff; + /* background: #ffffff; */ border-radius: 12px; overflow: hidden; } .loading-wrapper--dark { - //background: #1e2227; + /* background: #1e2227; */ } .loading-canvas { display: block; diff --git a/apps/web-antd/src/components/stat-islands/index.ts b/apps/web-antd/src/components/stat-islands/index.ts new file mode 100644 index 00000000..611ceb84 --- /dev/null +++ b/apps/web-antd/src/components/stat-islands/index.ts @@ -0,0 +1,5 @@ +/** + * 通用统计岛组件导出:紧凑玻璃质感统计小卡(设计对齐提现管理账户卡统计岛) + */ +export { default as StatIslands } from './stat-islands.vue'; +export type { StatIslandItem } from './typing'; diff --git a/apps/web-antd/src/components/stat-islands/stat-islands.vue b/apps/web-antd/src/components/stat-islands/stat-islands.vue new file mode 100644 index 00000000..6e765707 --- /dev/null +++ b/apps/web-antd/src/components/stat-islands/stat-islands.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/apps/web-antd/src/components/stat-islands/typing.ts b/apps/web-antd/src/components/stat-islands/typing.ts new file mode 100644 index 00000000..f32cc0b7 --- /dev/null +++ b/apps/web-antd/src/components/stat-islands/typing.ts @@ -0,0 +1,19 @@ +/** + * 统计岛组件类型定义(独立文件避免 index ↔ 组件循环引用) + */ + +/** 统计岛单项配置 */ +export interface StatIslandItem { + /** 唯一 key */ + key: string; + /** 指标名(小灰字) */ + label: string; + /** 指标值(数字,内部格式化两位小数) */ + value: number | string; + /** iconify 图标名(右上角,规范:SVG 图标不用 emoji) */ + icon: string; + /** 是否强调(主色字 + 更强荧光) */ + emphasize?: boolean; + /** 值前缀,默认 ¥;传空串则不显示前缀 */ + prefix?: string; +} diff --git a/apps/web-antd/src/components/table-action/card-actions.vue b/apps/web-antd/src/components/table-action/card-actions.vue new file mode 100644 index 00000000..f7193758 --- /dev/null +++ b/apps/web-antd/src/components/table-action/card-actions.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/apps/web-antd/src/components/table-action/index.ts b/apps/web-antd/src/components/table-action/index.ts index 00b7e6b6..39d9991a 100644 --- a/apps/web-antd/src/components/table-action/index.ts +++ b/apps/web-antd/src/components/table-action/index.ts @@ -1,2 +1,3 @@ +export { default as CardActions } from './card-actions.vue'; export { default as TableAction } from './table-action.vue'; export type * from './types.d.ts'; diff --git a/apps/web-antd/src/components/view-mode-switch/index.ts b/apps/web-antd/src/components/view-mode-switch/index.ts new file mode 100644 index 00000000..fe39309b --- /dev/null +++ b/apps/web-antd/src/components/view-mode-switch/index.ts @@ -0,0 +1,3 @@ +export { useViewMode } from './use-view-mode'; +export type { ViewMode } from './use-view-mode'; +export { default as ViewModeSwitch } from './view-mode-switch.vue'; diff --git a/apps/web-antd/src/components/view-mode-switch/use-view-mode.ts b/apps/web-antd/src/components/view-mode-switch/use-view-mode.ts new file mode 100644 index 00000000..0ce529ef --- /dev/null +++ b/apps/web-antd/src/components/view-mode-switch/use-view-mode.ts @@ -0,0 +1,49 @@ +import type { Ref } from 'vue'; + +import { ref, watch } from 'vue'; + +/** + * 通用「列表/卡片」视图模式类型 + * list=表格列表视图,card=卡片看板视图 + */ +export type ViewMode = 'card' | 'list'; + +/** + * 视图模式记忆 Hook + * + * 为什么封装:商品订单/处方订单/挂号订单等多个列表页都需要「列表/卡片」切换, + * 且要求刷新后记住用户上次选择。统一在这里读写 localStorage, + * 各页面只需传入唯一 storageKey,避免每页复制一份存取逻辑。 + * + * @param storageKey localStorage 键名(建议 `<模块>-view-mode`,如 `product-order-view-mode`) + * @param defaultMode 无记忆时的默认视图,默认列表 + * @returns 响应式视图模式 ref,直接 v-model 给 ViewModeSwitch 组件即可 + */ +export function useViewMode( + storageKey: string, + defaultMode: ViewMode = 'list', +): Ref { + // 初始化时同步读取本地记忆,避免先渲染默认视图再闪切 + const readStored = (): ViewMode => { + try { + const raw = localStorage.getItem(storageKey); + if (raw === 'list' || raw === 'card') return raw; + } catch { + // localStorage 不可用(隐私模式等)时静默回退默认值 + } + return defaultMode; + }; + + const viewMode = ref(readStored()); + + // 切换后立即写回,下次进入页面直接还原 + watch(viewMode, (mode) => { + try { + localStorage.setItem(storageKey, mode); + } catch { + // 写入失败不影响功能,忽略 + } + }); + + return viewMode; +} diff --git a/apps/web-antd/src/components/view-mode-switch/view-mode-switch.vue b/apps/web-antd/src/components/view-mode-switch/view-mode-switch.vue new file mode 100644 index 00000000..4d32e1fb --- /dev/null +++ b/apps/web-antd/src/components/view-mode-switch/view-mode-switch.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/apps/web-antd/src/components/vip/HeaderVipBadge.vue b/apps/web-antd/src/components/vip/HeaderVipBadge.vue index 1fe6d3a9..5969be6f 100644 --- a/apps/web-antd/src/components/vip/HeaderVipBadge.vue +++ b/apps/web-antd/src/components/vip/HeaderVipBadge.vue @@ -145,8 +145,9 @@ function openRecords() { >